diff --git a/packages/tool-bailian-kb/README.md b/packages/tool-bailian-kb/README.md index 6d0309d..30eb0aa 100644 --- a/packages/tool-bailian-kb/README.md +++ b/packages/tool-bailian-kb/README.md @@ -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 文档内,面板不可编辑)记账已消费/已由用户管理的字段,用户主动清空的值永不会被重新填回。 diff --git a/packages/tool-bailian-kb/package.json b/packages/tool-bailian-kb/package.json index 56b29db..6a3f8a4 100644 --- a/packages/tool-bailian-kb/package.json +++ b/packages/tool-bailian-kb/package.json @@ -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", diff --git a/packages/tool-bailian-kb/src/bl-cli.ts b/packages/tool-bailian-kb/src/bl-cli.ts index ae10a68..e601d90 100644 --- a/packages/tool-bailian-kb/src/bl-cli.ts +++ b/packages/tool-bailian-kb/src/bl-cli.ts @@ -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 | 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 { - 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 }) - }) -} diff --git a/packages/tool-bailian-kb/src/console-login.ts b/packages/tool-bailian-kb/src/console-login.ts new file mode 100644 index 0000000..a5324b0 --- /dev/null +++ b/packages/tool-bailian-kb/src/console-login.ts @@ -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-login?notice=127.0.0.1:?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 = { + 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, ...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 { + 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 { + 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 + 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, ...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, + body: Record, +): 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 { + 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 { + 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 { + 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 +}): Promise { + 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' } +} diff --git a/packages/tool-bailian-kb/src/index.ts b/packages/tool-bailian-kb/src/index.ts index ca584b8..2db08ef 100644 --- a/packages/tool-bailian-kb/src/index.ts +++ b/packages/tool-bailian-kb/src/index.ts @@ -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') }) diff --git a/packages/tool-bailian-kb/src/web/BailianCard.tsx b/packages/tool-bailian-kb/src/web/BailianCard.tsx index b99e52d..a3be523 100644 --- a/packages/tool-bailian-kb/src/web/BailianCard.tsx +++ b/packages/tool-bailian-kb/src/web/BailianCard.tsx @@ -47,8 +47,7 @@ const FIELDS: readonly FieldView[] = [ /** Result-notice locale key per settled autofill outcome. */ const AUTOFILL_NOTICES: Partial> = { 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 (
@@ -74,13 +75,20 @@ export function BailianCard(props: BailianCardProps) { {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 !== '' + ? <> + {' '} + {t('autofillOpenUrl')} + + : null}
diff --git a/packages/tool-bailian-kb/src/web/bailian-card-controller.ts b/packages/tool-bailian-kb/src/web/bailian-card-controller.ts index 96b7096..3da14ad 100644 --- a/packages/tool-bailian-kb/src/web/bailian-card-controller.ts +++ b/packages/tool-bailian-kb/src/web/bailian-card-controller.ts @@ -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 - /** 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 } @@ -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 { - 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 { + 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 { + 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 { + private async postAutofill(action: 'login' | 'loginStatus'): Promise { const resp = await fetch('/bailian-kb/autofill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/packages/tool-bailian-kb/src/web/locales.ts b/packages/tool-bailian-kb/src/web/locales.ts index b1f151b..60becf4 100644 --- a/packages/tool-bailian-kb/src/web/locales.ts +++ b/packages/tool-bailian-kb/src/web/locales.ts @@ -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 = { @@ -53,13 +53,13 @@ export const en: Record = { 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 = { 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: '自动获取失败——凭据可能被环境变量锁定、宿主拒绝了写入,或登录未完成。', } diff --git a/packages/tool-bailian-kb/tests/console-login.test.ts b/packages/tool-bailian-kb/tests/console-login.test.ts new file mode 100644 index 0000000..bbf9946 --- /dev/null +++ b/packages/tool-bailian-kb/tests/console-login.test.ts @@ -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' }) + }) +})