feat(tool-bailian-kb): 改用自实现的控制台登录协议获取凭据

- 移除对 bl CLI 登录流程 `bl auth login --console` 的依赖
- 实现了自包含的控制台登录流程,直接使用控制台登录回调协议
- 始终要求签发新 API key,避免旧 key 与新 workspaceId 不匹配问题
- 新增本地 loopback HTTP 服务器接收登录回调并持久化凭据
- 变更面板的自动获取流程,改为通过新登录协议驱动登录
- 添加自动获取登录态的轮询状态,支持登录进度反馈
- 优化页面按钮状态及提示,支持登录 URL 手动打开
- 删除对 bl CLI 配置文件的读取与登录
This commit is contained in:
zeyu.fz
2026-08-21 01:49:04 +08:00
parent e1793e1f3a
commit 8714f61496
9 changed files with 496 additions and 120 deletions
+1 -1
View File
@@ -33,7 +33,7 @@
- **DashScope API Key** — write-only,`type=password` 遮罩输入草稿,仅显示 configured/来自环境变量 徽标;写 `~/.dsh/.credentials.yaml`
- **Bailian Workspace ID / 默认检索服务 ID / 默认对话服务 ID** — 回显:读写 `bailian-kb` settings 用户层,预填当前解析值;清空保存 = 移除用户层,回退 entry config → credential
- **自动获取(bl CLI)** — 按钮调 Host 桥接路由 `/bailian-kb/autofill`:宿主机读 `~/.bailian/config.json`(`bl auth login` 的落盘),把 `api_key` 写入凭据存储、`workspace_id` 写入 settings,明文 key 不过浏览器;文件里没有 key 时在宿主机拉起 `bl auth login --console` 浏览器登录,完成后再次点击即可回填
- **自动获取** — 按钮调 Host 桥接路由 `/bailian-kb/autofill`:Host **自己走百炼控制台登录回调协议**(不经 `bl` 命令,也不读 `~/.bailian/config.json`)在宿主机拉起浏览器登录,回调落到本机 loopback 端口后直接把 API 密钥写入凭据存储、工作空间 ID 写入 settings,明文 key 不过浏览器;面板轮询到完成后自动刷新(无需再次点击)。登录 URL 始终带 `needapikey=true`,因此**每次都由本次登录的账号签发新 key**,key 与 workspaceId 必然同账号,切换账号直接点一次即可;`bl auth login --console` 自身做不到这点(它硬编码 `needApiKey: !hasApiKey`,已存 key 时不再签发,会把旧账号的 key 和新账号的 workspaceId 配在一起且无任何提示)
首次接入 seed:启动时若 API key / workspaceId 从未被设置过(settings、credential、env 均无值),自动从 `~/.bailian/config.json` 采纳一次;`seededFields` 字段(settings 文档内,面板不可编辑)记账已消费/已由用户管理的字段,用户主动清空的值永不会被重新填回。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ali/bailian-kb-dsh",
"version": "0.1.7",
"version": "0.1.12",
"description": "Bailian knowledge-base tools for DeepSeek Harness: kb_search and kb_chat over the DashScope RAG API, plus the bl CLI management skill.",
"type": "module",
"main": "lib/index.js",
+5 -30
View File
@@ -5,9 +5,13 @@
* console issues and persists it there in plain JSON — no CLI command ever
* echoes the value back (auth status / config show both mask), so reading the
* file is the only way to obtain it programmatically.
*
* Starting a login is NOT done through the CLI: it hard-codes
* `needApiKey: !hasApiKey` and so refuses to have a fresh key issued once any
* key is stored. See `console-login.ts`, which speaks the callback protocol
* directly and always asks for one.
*/
import { spawn } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
@@ -47,32 +51,3 @@ export function readBlCliConfig(configPath = blCliConfigPath()): BlCliConfig {
return {}
}
}
/** Outcome of asking the host to start a console browser login. */
export type ConsoleLoginStart = 'started' | 'already-running' | 'not-found' | 'failed'
/** The in-flight login child, if any: one browser flow at a time. */
let loginChild: ReturnType<typeof spawn> | undefined
/**
* Start `bl auth login --console` on the host: opens the Bailian console
* login page in the host's default browser; on completion the CLI persists
* the issued api key and workspace id to `~/.bailian/config.json` (the flow
* requests a key only when none is stored yet). Fire-and-forget: the child
* keeps running after this resolves — callers re-read the credential file
* on their next fill attempt.
* @returns whether the flow started, was already running, or the CLI is absent.
*/
export function startConsoleLogin(): Promise<ConsoleLoginStart> {
if (loginChild !== undefined) return Promise.resolve('already-running')
return new Promise((resolve) => {
const child = spawn('bl', ['auth', 'login', '--console'], { stdio: 'ignore' })
loginChild = child
child.once('spawn', () => { resolve('started') })
child.once('error', (err: NodeJS.ErrnoException) => {
loginChild = undefined
resolve(err.code === 'ENOENT' ? 'not-found' : 'failed')
})
child.once('exit', () => { loginChild = undefined })
})
}
@@ -0,0 +1,291 @@
/**
* Self-contained Bailian console login: the plugin speaks the console's
* callback protocol itself instead of shelling out to `bl auth login --console`.
*
* Why not the CLI: it hard-codes `needApiKey: !hasApiKey`, so once any api key
* is stored it never asks the console to issue a fresh one — you end up pairing
* an old account's key with a new account's workspace id, and nothing warns
* you. Driving the flow here lets us always pass `needapikey=true`, so the key
* and the workspace id both come from the account that just signed in, and the
* values land straight in the dsh stores without transiting the CLI's
* `~/.bailian/config.json`.
*
* Protocol (mirrors the CLI's implementation): bind a loopback-only port, open
* `<console>/console-login?notice=127.0.0.1:<port>?state=<state>&needapikey=true`,
* then accept one callback carrying the credentials as query parameters or a
* JSON / form-encoded body. Note the URL shape: `state` is part of the `notice`
* value (separated by `?`), not a sibling query parameter.
*/
import { execFile } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import http from 'node:http'
/** Console web origins by site, keyed as the CLI's `--console-site` values. */
const CONSOLE_ORIGINS: Record<string, string> = {
domestic: 'https://bailian.console.aliyun.com',
international: 'https://modelstudio.console.alibabacloud.com',
}
/** How long the loopback listener waits for the browser callback. */
const LOGIN_TIMEOUT_MS = 15 * 60 * 1000
/** Upper bound on a callback body, matching the CLI's limit. */
const MAX_CALLBACK_BODY = 65536
/** Credentials the console callback can carry. */
export interface ConsoleLoginCredentials {
/** Freshly issued DashScope api key (`needapikey=true` asks for one). */
apiKey?: string
/** Workspace id of the account that signed in. */
workspaceId?: string
}
/**
* Where the login flow stands. Deliberately carries no secret: the plain key
* is handed to the completion callback and never retained here, so polling
* this state from the browser cannot leak it.
*/
export type ConsoleLoginState =
| { phase: 'idle' }
| { phase: 'waiting', loginUrl: string }
| { phase: 'done', fields: string[] }
| { phase: 'failed', reason: string }
/** The single in-flight flow: one browser login at a time. */
let active: { server: http.Server } | undefined
let state: ConsoleLoginState = { phase: 'idle' }
/** Read the current flow state (safe to expose to the panel). */
export function consoleLoginState(): ConsoleLoginState {
return state
}
/** Pick the first non-blank string among the given keys. */
function stringField(source: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = source[key]
if (typeof value === 'string' && value.trim() !== '') return value.trim()
}
return undefined
}
/** Read a bounded UTF-8 request body; an oversized body reads as empty. */
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve) => {
const chunks: Buffer[] = []
let size = 0
req.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > MAX_CALLBACK_BODY) { req.destroy(); resolve(''); return }
chunks.push(chunk)
})
req.on('end', () => { resolve(Buffer.concat(chunks).toString('utf8')) })
req.on('error', () => { resolve('') })
})
}
/**
* Parse a callback body as JSON (optionally wrapped in `data`) or as form
* encoding. Content-type is a hint only — the CLI falls back to trying both,
* and so do we, because the console has shipped both shapes.
* @param raw - the raw request body.
* @returns the flattened fields; an unparseable body yields no fields.
*/
export function parseCallbackBody(raw: string): Record<string, unknown> {
const text = raw.replace(/^\uFEFF/, '').trim()
if (text === '') return {}
let json: unknown
let parsedAsJson = false
try {
json = JSON.parse(text)
parsedAsJson = true
} catch (_notJson) { /* fall through to form parsing */ }
if (parsedAsJson) {
// Valid JSON that is not an object carries no fields. Returning here rather
// than falling through matters: form parsing would turn the whole payload
// into one junk key.
if (json === null || typeof json !== 'object' || Array.isArray(json)) return {}
const record = json as Record<string, unknown>
const inner = record.data
if (inner !== null && typeof inner === 'object' && !Array.isArray(inner)) {
// Merge the envelope's `data` under the top level, top level winning.
return { ...inner as Record<string, unknown>, ...record }
}
return record
}
try {
return Object.fromEntries(new URLSearchParams(text))
} catch (_notForm) {
return {}
}
}
/**
* Pick the api key and workspace id out of a callback's fields, query
* parameters taking priority over the body.
* @param query - the callback URL's query parameters.
* @param body - the parsed callback body.
* @returns the credentials found; fields are absent rather than blank.
*/
export function pickCallbackCredentials(
query: Record<string, unknown>,
body: Record<string, unknown>,
): ConsoleLoginCredentials {
const apiKey = stringField(query, 'api_key', 'apiKey') ?? stringField(body, 'api_key', 'apiKey')
const workspaceId = stringField(query, 'workspace_id', 'workspaceId')
?? stringField(body, 'workspace_id', 'workspaceId')
return {
...(apiKey !== undefined ? { apiKey } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
}
}
/** Extract the credentials from a callback, query parameters taking priority. */
async function extractCredentials(req: http.IncomingMessage, url: URL): Promise<ConsoleLoginCredentials> {
const method = req.method ?? 'GET'
const body = (method === 'POST' || method === 'PUT' || method === 'PATCH')
? parseCallbackBody(await readBody(req))
: {}
return pickCallbackCredentials(Object.fromEntries(url.searchParams), body)
}
/** Open a URL with the OS default handler; never routed through a shell. */
function openInBrowser(url: string): Promise<void> {
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
return new Promise((resolve, reject) => {
execFile(cmd, args, { windowsHide: true }, err => { err ? reject(err) : resolve() })
})
}
/** Bind an http server to a loopback-only port chosen by the OS. */
function listenLoopback(server: http.Server): Promise<number> {
return new Promise((resolve, reject) => {
const onError = (err: Error): void => { reject(err) }
server.once('error', onError)
server.listen({ port: 0, host: '127.0.0.1', exclusive: true }, () => {
server.off('error', onError)
const address = server.address()
if (address === null || typeof address === 'string') {
reject(new Error('expected a TCP address'))
return
}
resolve(address.port)
})
})
}
/** Outcome of asking the host to start a console login. */
export type ConsoleLoginStart =
| { status: 'started', loginUrl: string }
| { status: 'already-running', loginUrl: string }
| { status: 'failed', reason: string }
/**
* Start a console login on the host: binds a loopback listener, opens the
* console login page in the host's default browser, and hands the credentials
* from the callback to `onComplete` (which persists them). Fire-and-forget —
* this resolves once the browser has been opened; poll {@link consoleLoginState}
* for the outcome.
* @param opts.site - console site, `domestic` (default) or `international`.
* @param opts.onComplete - persists the received credentials; its resolved
* field names become the `done` state's `fields`.
* @returns whether the flow started, plus the URL to open manually if needed.
*/
export async function startConsoleLogin(opts: {
site?: string
onComplete: (credentials: ConsoleLoginCredentials) => Promise<string[]>
}): Promise<ConsoleLoginStart> {
if (active !== undefined) {
return {
status: 'already-running',
loginUrl: state.phase === 'waiting' ? state.loginUrl : '',
}
}
const expectedState = randomBytes(16).toString('hex')
let settled = false
const server = http.createServer((req, res) => {
void (async () => {
if (req.method === 'OPTIONS') {
// The console page posts cross-origin; answer its preflight.
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
})
res.end()
return
}
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
if (url.searchParams.get('state') !== expectedState) {
// Not our callback (or a forged one): refuse without ending the flow.
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('bad state\n')
return
}
const credentials = await extractCredentials(req, url)
res.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
'Access-Control-Allow-Origin': '*',
})
res.end('OK\n')
if (credentials.apiKey === undefined && credentials.workspaceId === undefined) {
// A callback with neither value (e.g. a bare probe): keep waiting.
return
}
settled = true
try {
const fields = await opts.onComplete(credentials)
state = { phase: 'done', fields }
} catch (err) {
state = { phase: 'failed', reason: err instanceof Error ? err.message : 'persist failed' }
}
server.close()
})().catch(() => {
res.statusCode = 500
res.end()
})
})
let port: number
try {
port = await listenLoopback(server)
} catch (err) {
const reason = err instanceof Error ? err.message : 'could not bind a local port'
state = { phase: 'failed', reason }
return { status: 'failed', reason }
}
// `state` rides inside the `notice` value, and `needapikey=true` is the whole
// point: it makes the console issue a key for the account signing in.
const origin = (opts.site !== undefined ? CONSOLE_ORIGINS[opts.site] : undefined) ?? CONSOLE_ORIGINS.domestic!
const loginUrl = `${origin}/console-login?notice=127.0.0.1:${port}`
+ `?state=${encodeURIComponent(expectedState)}&needapikey=true`
active = { server }
state = { phase: 'waiting', loginUrl }
const timer = setTimeout(() => { server.close() }, LOGIN_TIMEOUT_MS)
timer.unref?.()
server.once('close', () => {
clearTimeout(timer)
active = undefined
if (!settled && state.phase === 'waiting') {
state = { phase: 'failed', reason: 'the login timed out before the console called back' }
}
})
try {
await openInBrowser(loginUrl)
} catch (_browserRefused) {
// Headless or locked-down host: the panel shows `loginUrl` to open by hand.
}
return { status: 'started', loginUrl }
}
/** Abandon an in-flight login (closes the listener). */
export function cancelConsoleLogin(): void {
active?.server.close()
active = undefined
state = { phase: 'idle' }
}
+32 -38
View File
@@ -9,7 +9,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
import z from '@deepseek-ai/schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { settingsNamespace, SettingsProvider, type SettingsRegisterOptions, type SettingsScope } from '@deepseek-ai/dsh-settings'
import { readBlCliConfig, startConsoleLogin } from './bl-cli.js'
import { readBlCliConfig } from './bl-cli.js'
import { consoleLoginState, startConsoleLogin } from './console-login.js'
import { KbClient } from './client.js'
import { registerSkill } from './skill.js'
import { createKbTools } from './tools.js'
@@ -353,11 +354,10 @@ export function apply(ctx: Context, config: Config): void {
},
}), 'tool-bailian-kb: settings bridge route')
// Autofill bridge: adopt the bl CLI's stored login on demand (panel
// button). `fill` reads `~/.bailian/config.json` on the host and writes
// through the same layers the panel edits — the plain key never rides
// the wire to the browser; `login` starts the console browser flow that
// provisions the file for the next fill.
// Autofill bridge: fetch credentials by signing in to the Bailian console
// (panel button). `login` drives the console's callback protocol on the
// host and persists what comes back — the plain key never rides the wire
// to the browser; `loginStatus` lets the panel poll for the outcome.
wctx.effect(() => wctx.webServer.register({
kind: 'exact',
path: '/bailian-kb/autofill',
@@ -366,43 +366,37 @@ export function apply(ctx: Context, config: Config): void {
sendJson(res, 405, { error: 'use POST' })
return
}
let action = 'fill'
let action = 'login'
try {
const body = await readJsonBody(req)
if (typeof body === 'object' && body !== null && (body as { action?: unknown }).action === 'login') action = 'login'
} catch (_emptyOrMalformedBody) { /* default to fill */ }
if (action === 'login') {
sendJson(res, 200, { status: await startConsoleLogin() })
if (
typeof body === 'object' && body !== null
&& (body as { action?: unknown }).action === 'loginStatus'
) action = 'loginStatus'
} catch (_emptyOrMalformedBody) { /* default to login */ }
if (action === 'loginStatus') {
sendJson(res, 200, consoleLoginState())
return
}
const bl = readBlCliConfig()
const filled: string[] = []
let apiKey: 'filled' | 'missing' | 'failed' = 'missing'
if (bl.apiKey !== undefined) {
try {
await ctx.credentials.set(credentialRef('DASHSCOPE_API_KEY'), bl.apiKey)
apiKey = 'filled'
filled.push('apiKey')
} catch (_readOnlyShadow) {
apiKey = 'failed'
}
}
let workspaceId: 'filled' | 'missing' | 'failed' = 'missing'
if (bl.workspaceId !== undefined) {
if (scope) {
try {
await scope.update({ workspaceId: bl.workspaceId })
workspaceId = 'filled'
filled.push('workspaceId')
} catch (_settingsWriteFailure) {
workspaceId = 'failed'
// Drive the console flow ourselves with `needapikey=true`, so the key
// and the workspace id both belong to the account signing in now.
// Persisting here keeps the plain key on the host.
const started = await startConsoleLogin({
onComplete: async (credentials) => {
const written: string[] = []
if (credentials.apiKey !== undefined) {
await ctx.credentials.set(credentialRef('DASHSCOPE_API_KEY'), credentials.apiKey)
written.push('apiKey')
}
} else {
workspaceId = 'failed'
}
}
if (filled.length > 0) await markSeeded(filled)
sendJson(res, 200, { apiKey, workspaceId })
if (credentials.workspaceId !== undefined && scope) {
await scope.update({ workspaceId: credentials.workspaceId })
written.push('workspaceId')
}
if (written.length > 0) await markSeeded(written)
return written
},
})
sendJson(res, 200, started)
},
}), 'tool-bailian-kb: autofill bridge route')
})
@@ -47,8 +47,7 @@ const FIELDS: readonly FieldView[] = [
/** Result-notice locale key per settled autofill outcome. */
const AUTOFILL_NOTICES: Partial<Record<string, BailianKbLocaleKey>> = {
done: 'autofillDone',
loginStarted: 'autofillLoginStarted',
blMissing: 'autofillBlMissing',
awaitingLogin: 'autofillAwaitingLogin',
failed: 'autofillFailed',
}
@@ -63,6 +62,8 @@ export function BailianCard(props: BailianCardProps) {
const dirty = dirtyOf(state)
const busy = state.saving || state.clearing
const autofillNotice = AUTOFILL_NOTICES[state.autofill]
// The flow spans a browser login, so the button stays disabled until it settles.
const autofillBusy = state.autofill === 'running' || state.autofill === 'awaitingLogin'
return (
<section className={css.section}>
<div className={css.headRow}>
@@ -74,13 +75,20 @@ export function BailianCard(props: BailianCardProps) {
<button
type="button"
className={css.discard}
disabled={busy || state.autofill === 'running'}
disabled={busy || autofillBusy}
onClick={() => { void props.autofill() }}
>
{t(state.autofill === 'running' ? 'autofilling' : 'autofill')}
</button>
<span className={css.hint}>
{autofillNotice !== undefined ? t(autofillNotice) : t('autofillHint')}
{/* The host opens the page itself; this link is the fallback when it cannot. */}
{state.autofillLoginUrl !== undefined && state.autofillLoginUrl !== ''
? <>
{' '}
<a href={state.autofillLoginUrl} target="_blank" rel="noreferrer">{t('autofillOpenUrl')}</a>
</>
: null}
</span>
</div>
<div className={css.form}>
@@ -61,8 +61,8 @@ export interface BailianSettingsView {
values: BailianKbSection
}
/** Where the autofill flow (adopt the bl CLI's stored login) currently stands. */
export type BailianAutofillStatus = 'idle' | 'running' | 'done' | 'loginStarted' | 'blMissing' | 'failed'
/** Where the autofill flow (adopt a Bailian console login) currently stands. */
export type BailianAutofillStatus = 'idle' | 'running' | 'awaitingLogin' | 'done' | 'failed'
/** What the Bailian page renders. */
export interface BailianCardState {
@@ -80,6 +80,8 @@ export interface BailianCardState {
failed: boolean
/** The autofill flow's state; feeds the button label and its result notice. */
autofill: BailianAutofillStatus
/** Console login URL while `awaitingLogin`, shown in case the host could not open a browser. */
autofillLoginUrl?: string
}
/** The registration-side face the page's slot entry injects. */
@@ -96,7 +98,7 @@ export interface BailianCardFace {
discard: () => void
/** Remove the stored default service from every writable layer, then re-read. */
clearDefaultAgent: (key: 'BAILIAN_DEFAULT_RETRIEVE_AGENT_ID' | 'BAILIAN_DEFAULT_CHAT_AGENT_ID') => Promise<void>
/** Adopt the bl CLI's stored login (api key + workspace id) via the Host. */
/** Adopt a Bailian console login (api key + workspace id) via the Host. */
autofill: () => Promise<void>
}
@@ -152,6 +154,7 @@ export class BailianCardController {
clearing: false,
failed: false,
autofill: 'idle',
autofillLoginUrl: undefined,
})
void this.fetchSettings()
void this.read()
@@ -259,39 +262,90 @@ export class BailianCardController {
}
/**
* Adopt the bl CLI's stored login through the Host autofill route. The
* Host reads `~/.bailian/config.json` itself and writes the api key into
* the credential store and the workspace id into the settings section —
* the plain key never rides the wire to this page. When the file has no
* key yet, ask the Host to start `bl auth login --console` (a browser
* flow on the host machine); the user finishes it and clicks again.
* Fetch credentials by signing in to the Bailian console. The Host drives
* the console's callback protocol itself, always asking for a freshly issued
* api key, then persists the key into the credential store and the workspace
* id into the settings section — so both values belong to the account that
* just signed in, and the plain key never rides the wire to this page.
*
* Deliberately does NOT adopt the bl CLI's stored login: reusing a key from
* `~/.bailian/config.json` can pair one account's key with another account's
* workspace id (the CLI refuses to re-issue once any key is stored), and
* nothing would flag the mismatch until a knowledge-base call fails.
*/
async autofill(): Promise<void> {
if (this.store.getSnapshot().autofill === 'running') return
this.store.update(draft => { draft.autofill = 'running' })
let outcome: BailianAutofillStatus = 'failed'
try {
const fill = await this.postAutofill('fill') as { apiKey?: string, workspaceId?: string }
if (fill.apiKey === 'filled') {
outcome = 'done'
} else if (fill.apiKey === 'missing') {
// Nothing to adopt yet: start the console login that provisions the
// CLI's credential file, then have the user retry the button.
const login = await this.postAutofill('login') as { status?: string }
outcome = login.status === 'started' || login.status === 'already-running'
? 'loginStarted'
: login.status === 'not-found' ? 'blMissing' : 'failed'
}
// apiKey === 'failed' (a read-only source shadows the credential):
// fall through as 'failed' even when the workspace id was adopted.
} catch (_autofillFailure) {
outcome = 'failed'
}
this.store.update(draft => { draft.autofill = outcome })
const phase = this.store.getSnapshot().autofill
if (phase === 'running' || phase === 'awaitingLogin') return
this.store.update(draft => {
draft.autofill = 'running'
draft.autofillLoginUrl = undefined
})
await this.runConsoleLogin()
await this.fetchSettings()
await this.read()
}
/**
* Ask the Host to open the console login page, then poll for the outcome.
* The Host persists the credentials itself when the callback lands, always
* asking the console to issue a fresh key — so the key and the workspace id
* both come from the account signing in. (The bl CLI's own login refuses to
* re-issue once any key is stored, which would otherwise pair an old
* account's key with a new account's workspace.)
*/
private async runConsoleLogin(): Promise<void> {
let started: { status?: string, loginUrl?: string }
try {
started = await this.postAutofill('login') as { status?: string, loginUrl?: string }
} catch (_routeFailure) {
this.store.update(draft => { draft.autofill = 'failed' })
return
}
if (started.status !== 'started' && started.status !== 'already-running') {
this.store.update(draft => { draft.autofill = 'failed' })
return
}
this.store.update(draft => {
draft.autofill = 'awaitingLogin'
draft.autofillLoginUrl = started.loginUrl
})
await this.pollConsoleLogin()
}
/**
* Poll the Host until the console login resolves. Bounded so a login the
* user abandons does not leave the button spinning forever; the Host keeps
* its own (longer) timeout, so a late callback still persists and shows up
* on the next page read.
*/
private async pollConsoleLogin(): Promise<void> {
const deadline = Date.now() + 5 * 60 * 1000
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 2000))
let phase: string | undefined
try {
phase = (await this.postAutofill('loginStatus') as { phase?: string }).phase
} catch (_pollFailure) {
continue
}
if (phase === 'done') {
this.store.update(draft => {
draft.autofill = 'done'
draft.autofillLoginUrl = undefined
})
return
}
if (phase === 'failed') {
this.store.update(draft => {
draft.autofill = 'failed'
draft.autofillLoginUrl = undefined
})
return
}
}
this.store.update(draft => { draft.autofill = 'failed' })
}
/**
* Remove the stored default service from every writable layer — the
* settings user layer AND the credential store, so the fallback chain does
@@ -360,10 +414,11 @@ export class BailianCardController {
/**
* Post one autofill action to the Host bridge route.
* @param action - `fill` adopts the CLI file; `login` starts the browser flow.
* @param action - `login` starts the console browser flow; `loginStatus`
* reads that flow's progress.
* @returns the route's JSON answer.
*/
private async postAutofill(action: 'fill' | 'login'): Promise<unknown> {
private async postAutofill(action: 'login' | 'loginStatus'): Promise<unknown> {
const resp = await fetch('/bailian-kb/autofill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+15 -15
View File
@@ -17,7 +17,7 @@ export type BailianKbLocaleKey =
| 'fromEnv' | 'clear' | 'clearing'
| 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed'
| 'autofill' | 'autofilling' | 'autofillHint'
| 'autofillDone' | 'autofillLoginStarted' | 'autofillBlMissing' | 'autofillFailed'
| 'autofillDone' | 'autofillAwaitingLogin' | 'autofillOpenUrl' | 'autofillFailed'
/** English copy. */
export const en: Record<BailianKbLocaleKey, string> = {
@@ -53,13 +53,13 @@ export const en: Record<BailianKbLocaleKey, string> = {
discard: 'Discard',
unsaved: 'Unsaved',
saveFailed: 'The Host did not accept these values; they were left for you to correct.',
autofill: 'Auto-fill from bl CLI',
autofilling: 'Fetching…',
autofillHint: 'Adopt the API key and workspace id stored by the bl CLI (~/.bailian/config.json on the host); starts a console browser login on the host when the CLI has none yet.',
autofillDone: 'Adopted the bl CLI login; the fields below reflect the new values.',
autofillLoginStarted: 'A Bailian console login was opened in a browser on the host machine — finish signing in there, then click again.',
autofillBlMissing: 'The bl CLI was not found on the host; install it with `npm install -g bailian-cli` and retry.',
autofillFailed: 'Auto-fill failed — the credential may be locked by an environment variable, or the Host refused the write.',
autofill: 'Fetch from console login',
autofilling: 'Starting…',
autofillHint: 'Sign in to the Bailian console to fill in that account’s API key and workspace id.',
autofillDone: 'Credentials adopted; the fields below reflect the new values.',
autofillAwaitingLogin: 'Waiting for the Bailian console login to finish in a browser on the host machine…',
autofillOpenUrl: 'Open the login page manually',
autofillFailed: 'Auto-fill failed — the credential may be locked by an environment variable, the Host refused the write, or the login was abandoned.',
}
/** Simplified Chinese copy. */
@@ -96,11 +96,11 @@ export const zh: Record<BailianKbLocaleKey, string> = {
discard: '放弃',
unsaved: '未保存',
saveFailed: '宿主未接受这些值,已保留供你修改。',
autofill: '自动获取(bl CLI)',
autofilling: '获取中…',
autofillHint: '从宿主机 bl CLI 的登录态(~/.bailian/config.json)回填 API 密钥与工作空间 ID;CLI 尚未登录时会在宿主机拉起百炼控制台浏览器登录。',
autofillDone: '已回填 bl CLI 的登录信息,下方字段已更新。',
autofillLoginStarted: '已在宿主机浏览器打开百炼控制台登录页,完成登录后请再次点击。',
autofillBlMissing: '宿主机未安装 bl CLI;请先 `npm install -g bailian-cli` 再重试。',
autofillFailed: '自动获取失败——凭据可能被环境变量锁定,或宿主拒绝了写入。',
autofill: '自动获取',
autofilling: '启动中…',
autofillHint: '登录百炼控制台,自动填入该账号的 API 密钥与工作空间 ID。',
autofillDone: '已回填凭据,下方字段已更新。',
autofillAwaitingLogin: '等待在宿主机浏览器中完成百炼控制台登录…',
autofillOpenUrl: '手动打开登录页',
autofillFailed: '自动获取失败——凭据可能被环境变量锁定、宿主拒绝了写入,或登录未完成。',
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { consoleLoginState, parseCallbackBody, pickCallbackCredentials } from '../src/console-login.js'
describe('parseCallbackBody', () => {
it('reads a plain JSON callback', () => {
expect(parseCallbackBody('{"api_key":"sk-abc","workspace_id":"ws-1"}'))
.toMatchObject({ api_key: 'sk-abc', workspace_id: 'ws-1' })
})
it('flattens a `data` envelope, the top level winning', () => {
const parsed = parseCallbackBody('{"data":{"api_key":"sk-inner","workspace_id":"ws-1"},"api_key":"sk-outer"}')
expect(parsed).toMatchObject({ api_key: 'sk-outer', workspace_id: 'ws-1' })
})
it('reads a form-encoded callback', () => {
expect(parseCallbackBody('api_key=sk-abc&workspace_id=ws-1'))
.toMatchObject({ api_key: 'sk-abc', workspace_id: 'ws-1' })
})
it('tolerates a BOM, surrounding space, and an empty or broken body', () => {
expect(parseCallbackBody('\uFEFF {"api_key":"sk-abc"} ')).toMatchObject({ api_key: 'sk-abc' })
expect(parseCallbackBody('')).toEqual({})
expect(parseCallbackBody(' ')).toEqual({})
// A non-object JSON value carries no fields, and neither does an array.
expect(parseCallbackBody('["sk-abc"]')).toEqual({})
})
})
describe('pickCallbackCredentials', () => {
it('accepts both snake_case and camelCase field names', () => {
expect(pickCallbackCredentials({}, { apiKey: 'sk-abc', workspaceId: 'ws-1' }))
.toEqual({ apiKey: 'sk-abc', workspaceId: 'ws-1' })
expect(pickCallbackCredentials({}, { api_key: 'sk-abc', workspace_id: 'ws-1' }))
.toEqual({ apiKey: 'sk-abc', workspaceId: 'ws-1' })
})
it('lets query parameters win over the body', () => {
expect(pickCallbackCredentials({ api_key: 'sk-query' }, { api_key: 'sk-body' }))
.toEqual({ apiKey: 'sk-query' })
})
it('omits absent, blank, and non-string fields instead of returning empties', () => {
expect(pickCallbackCredentials({}, {})).toEqual({})
expect(pickCallbackCredentials({}, { api_key: ' ', workspace_id: 42 })).toEqual({})
expect(pickCallbackCredentials({}, { api_key: ' sk-abc ' })).toEqual({ apiKey: 'sk-abc' })
})
})
describe('consoleLoginState', () => {
it('starts idle, carrying no secret', () => {
expect(consoleLoginState()).toEqual({ phase: 'idle' })
})
})