Skip to content

Commit 8714f61

Browse files
committed
feat(tool-bailian-kb): 改用自实现的控制台登录协议获取凭据
- 移除对 bl CLI 登录流程 `bl auth login --console` 的依赖 - 实现了自包含的控制台登录流程,直接使用控制台登录回调协议 - 始终要求签发新 API key,避免旧 key 与新 workspaceId 不匹配问题 - 新增本地 loopback HTTP 服务器接收登录回调并持久化凭据 - 变更面板的自动获取流程,改为通过新登录协议驱动登录 - 添加自动获取登录态的轮询状态,支持登录进度反馈 - 优化页面按钮状态及提示,支持登录 URL 手动打开 - 删除对 bl CLI 配置文件的读取与登录
1 parent e1793e1 commit 8714f61

9 files changed

Lines changed: 495 additions & 119 deletions

File tree

packages/tool-bailian-kb/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
- **DashScope API Key** — write-only,`type=password` 遮罩输入草稿,仅显示 configured/来自环境变量 徽标;写 `~/.dsh/.credentials.yaml`
3535
- **Bailian Workspace ID / 默认检索服务 ID / 默认对话服务 ID** — 回显:读写 `bailian-kb` settings 用户层,预填当前解析值;清空保存 = 移除用户层,回退 entry config → credential
36-
- **自动获取(bl CLI)** — 按钮调 Host 桥接路由 `/bailian-kb/autofill`:宿主机读 `~/.bailian/config.json`(`bl auth login` 的落盘),把 `api_key` 写入凭据存储、`workspace_id` 写入 settings,明文 key 不过浏览器;文件里没有 key 时在宿主机拉起 `bl auth login --console` 浏览器登录,完成后再次点击即可回填
36+
- **自动获取** — 按钮调 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 配在一起且无任何提示)
3737

3838
首次接入 seed:启动时若 API key / workspaceId 从未被设置过(settings、credential、env 均无值),自动从 `~/.bailian/config.json` 采纳一次;`seededFields` 字段(settings 文档内,面板不可编辑)记账已消费/已由用户管理的字段,用户主动清空的值永不会被重新填回。
3939

packages/tool-bailian-kb/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ali/bailian-kb-dsh",
3-
"version": "0.1.7",
3+
"version": "0.1.12",
44
"description": "Bailian knowledge-base tools for DeepSeek Harness: kb_search and kb_chat over the DashScope RAG API, plus the bl CLI management skill.",
55
"type": "module",
66
"main": "lib/index.js",

packages/tool-bailian-kb/src/bl-cli.ts

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
* console issues and persists it there in plain JSON — no CLI command ever
66
* echoes the value back (auth status / config show both mask), so reading the
77
* file is the only way to obtain it programmatically.
8+
*
9+
* Starting a login is NOT done through the CLI: it hard-codes
10+
* `needApiKey: !hasApiKey` and so refuses to have a fresh key issued once any
11+
* key is stored. See `console-login.ts`, which speaks the callback protocol
12+
* directly and always asks for one.
813
*/
914

10-
import { spawn } from 'node:child_process'
1115
import { readFileSync } from 'node:fs'
1216
import { homedir } from 'node:os'
1317
import { join } from 'node:path'
@@ -47,32 +51,3 @@ export function readBlCliConfig(configPath = blCliConfigPath()): BlCliConfig {
4751
return {}
4852
}
4953
}
50-
51-
/** Outcome of asking the host to start a console browser login. */
52-
export type ConsoleLoginStart = 'started' | 'already-running' | 'not-found' | 'failed'
53-
54-
/** The in-flight login child, if any: one browser flow at a time. */
55-
let loginChild: ReturnType<typeof spawn> | undefined
56-
57-
/**
58-
* Start `bl auth login --console` on the host: opens the Bailian console
59-
* login page in the host's default browser; on completion the CLI persists
60-
* the issued api key and workspace id to `~/.bailian/config.json` (the flow
61-
* requests a key only when none is stored yet). Fire-and-forget: the child
62-
* keeps running after this resolves — callers re-read the credential file
63-
* on their next fill attempt.
64-
* @returns whether the flow started, was already running, or the CLI is absent.
65-
*/
66-
export function startConsoleLogin(): Promise<ConsoleLoginStart> {
67-
if (loginChild !== undefined) return Promise.resolve('already-running')
68-
return new Promise((resolve) => {
69-
const child = spawn('bl', ['auth', 'login', '--console'], { stdio: 'ignore' })
70-
loginChild = child
71-
child.once('spawn', () => { resolve('started') })
72-
child.once('error', (err: NodeJS.ErrnoException) => {
73-
loginChild = undefined
74-
resolve(err.code === 'ENOENT' ? 'not-found' : 'failed')
75-
})
76-
child.once('exit', () => { loginChild = undefined })
77-
})
78-
}
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
/**
2+
* Self-contained Bailian console login: the plugin speaks the console's
3+
* callback protocol itself instead of shelling out to `bl auth login --console`.
4+
*
5+
* Why not the CLI: it hard-codes `needApiKey: !hasApiKey`, so once any api key
6+
* is stored it never asks the console to issue a fresh one — you end up pairing
7+
* an old account's key with a new account's workspace id, and nothing warns
8+
* you. Driving the flow here lets us always pass `needapikey=true`, so the key
9+
* and the workspace id both come from the account that just signed in, and the
10+
* values land straight in the dsh stores without transiting the CLI's
11+
* `~/.bailian/config.json`.
12+
*
13+
* Protocol (mirrors the CLI's implementation): bind a loopback-only port, open
14+
* `<console>/console-login?notice=127.0.0.1:<port>?state=<state>&needapikey=true`,
15+
* then accept one callback carrying the credentials as query parameters or a
16+
* JSON / form-encoded body. Note the URL shape: `state` is part of the `notice`
17+
* value (separated by `?`), not a sibling query parameter.
18+
*/
19+
20+
import { execFile } from 'node:child_process'
21+
import { randomBytes } from 'node:crypto'
22+
import http from 'node:http'
23+
24+
/** Console web origins by site, keyed as the CLI's `--console-site` values. */
25+
const CONSOLE_ORIGINS: Record<string, string> = {
26+
domestic: 'https://bailian.console.aliyun.com',
27+
international: 'https://modelstudio.console.alibabacloud.com',
28+
}
29+
30+
/** How long the loopback listener waits for the browser callback. */
31+
const LOGIN_TIMEOUT_MS = 15 * 60 * 1000
32+
33+
/** Upper bound on a callback body, matching the CLI's limit. */
34+
const MAX_CALLBACK_BODY = 65536
35+
36+
/** Credentials the console callback can carry. */
37+
export interface ConsoleLoginCredentials {
38+
/** Freshly issued DashScope api key (`needapikey=true` asks for one). */
39+
apiKey?: string
40+
/** Workspace id of the account that signed in. */
41+
workspaceId?: string
42+
}
43+
44+
/**
45+
* Where the login flow stands. Deliberately carries no secret: the plain key
46+
* is handed to the completion callback and never retained here, so polling
47+
* this state from the browser cannot leak it.
48+
*/
49+
export type ConsoleLoginState =
50+
| { phase: 'idle' }
51+
| { phase: 'waiting', loginUrl: string }
52+
| { phase: 'done', fields: string[] }
53+
| { phase: 'failed', reason: string }
54+
55+
/** The single in-flight flow: one browser login at a time. */
56+
let active: { server: http.Server } | undefined
57+
let state: ConsoleLoginState = { phase: 'idle' }
58+
59+
/** Read the current flow state (safe to expose to the panel). */
60+
export function consoleLoginState(): ConsoleLoginState {
61+
return state
62+
}
63+
64+
/** Pick the first non-blank string among the given keys. */
65+
function stringField(source: Record<string, unknown>, ...keys: string[]): string | undefined {
66+
for (const key of keys) {
67+
const value = source[key]
68+
if (typeof value === 'string' && value.trim() !== '') return value.trim()
69+
}
70+
return undefined
71+
}
72+
73+
/** Read a bounded UTF-8 request body; an oversized body reads as empty. */
74+
function readBody(req: http.IncomingMessage): Promise<string> {
75+
return new Promise((resolve) => {
76+
const chunks: Buffer[] = []
77+
let size = 0
78+
req.on('data', (chunk: Buffer) => {
79+
size += chunk.length
80+
if (size > MAX_CALLBACK_BODY) { req.destroy(); resolve(''); return }
81+
chunks.push(chunk)
82+
})
83+
req.on('end', () => { resolve(Buffer.concat(chunks).toString('utf8')) })
84+
req.on('error', () => { resolve('') })
85+
})
86+
}
87+
88+
/**
89+
* Parse a callback body as JSON (optionally wrapped in `data`) or as form
90+
* encoding. Content-type is a hint only — the CLI falls back to trying both,
91+
* and so do we, because the console has shipped both shapes.
92+
* @param raw - the raw request body.
93+
* @returns the flattened fields; an unparseable body yields no fields.
94+
*/
95+
export function parseCallbackBody(raw: string): Record<string, unknown> {
96+
const text = raw.replace(/^\uFEFF/, '').trim()
97+
if (text === '') return {}
98+
let json: unknown
99+
let parsedAsJson = false
100+
try {
101+
json = JSON.parse(text)
102+
parsedAsJson = true
103+
} catch (_notJson) { /* fall through to form parsing */ }
104+
if (parsedAsJson) {
105+
// Valid JSON that is not an object carries no fields. Returning here rather
106+
// than falling through matters: form parsing would turn the whole payload
107+
// into one junk key.
108+
if (json === null || typeof json !== 'object' || Array.isArray(json)) return {}
109+
const record = json as Record<string, unknown>
110+
const inner = record.data
111+
if (inner !== null && typeof inner === 'object' && !Array.isArray(inner)) {
112+
// Merge the envelope's `data` under the top level, top level winning.
113+
return { ...inner as Record<string, unknown>, ...record }
114+
}
115+
return record
116+
}
117+
try {
118+
return Object.fromEntries(new URLSearchParams(text))
119+
} catch (_notForm) {
120+
return {}
121+
}
122+
}
123+
124+
/**
125+
* Pick the api key and workspace id out of a callback's fields, query
126+
* parameters taking priority over the body.
127+
* @param query - the callback URL's query parameters.
128+
* @param body - the parsed callback body.
129+
* @returns the credentials found; fields are absent rather than blank.
130+
*/
131+
export function pickCallbackCredentials(
132+
query: Record<string, unknown>,
133+
body: Record<string, unknown>,
134+
): ConsoleLoginCredentials {
135+
const apiKey = stringField(query, 'api_key', 'apiKey') ?? stringField(body, 'api_key', 'apiKey')
136+
const workspaceId = stringField(query, 'workspace_id', 'workspaceId')
137+
?? stringField(body, 'workspace_id', 'workspaceId')
138+
return {
139+
...(apiKey !== undefined ? { apiKey } : {}),
140+
...(workspaceId !== undefined ? { workspaceId } : {}),
141+
}
142+
}
143+
144+
/** Extract the credentials from a callback, query parameters taking priority. */
145+
async function extractCredentials(req: http.IncomingMessage, url: URL): Promise<ConsoleLoginCredentials> {
146+
const method = req.method ?? 'GET'
147+
const body = (method === 'POST' || method === 'PUT' || method === 'PATCH')
148+
? parseCallbackBody(await readBody(req))
149+
: {}
150+
return pickCallbackCredentials(Object.fromEntries(url.searchParams), body)
151+
}
152+
153+
/** Open a URL with the OS default handler; never routed through a shell. */
154+
function openInBrowser(url: string): Promise<void> {
155+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
156+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
157+
return new Promise((resolve, reject) => {
158+
execFile(cmd, args, { windowsHide: true }, err => { err ? reject(err) : resolve() })
159+
})
160+
}
161+
162+
/** Bind an http server to a loopback-only port chosen by the OS. */
163+
function listenLoopback(server: http.Server): Promise<number> {
164+
return new Promise((resolve, reject) => {
165+
const onError = (err: Error): void => { reject(err) }
166+
server.once('error', onError)
167+
server.listen({ port: 0, host: '127.0.0.1', exclusive: true }, () => {
168+
server.off('error', onError)
169+
const address = server.address()
170+
if (address === null || typeof address === 'string') {
171+
reject(new Error('expected a TCP address'))
172+
return
173+
}
174+
resolve(address.port)
175+
})
176+
})
177+
}
178+
179+
/** Outcome of asking the host to start a console login. */
180+
export type ConsoleLoginStart =
181+
| { status: 'started', loginUrl: string }
182+
| { status: 'already-running', loginUrl: string }
183+
| { status: 'failed', reason: string }
184+
185+
/**
186+
* Start a console login on the host: binds a loopback listener, opens the
187+
* console login page in the host's default browser, and hands the credentials
188+
* from the callback to `onComplete` (which persists them). Fire-and-forget —
189+
* this resolves once the browser has been opened; poll {@link consoleLoginState}
190+
* for the outcome.
191+
* @param opts.site - console site, `domestic` (default) or `international`.
192+
* @param opts.onComplete - persists the received credentials; its resolved
193+
* field names become the `done` state's `fields`.
194+
* @returns whether the flow started, plus the URL to open manually if needed.
195+
*/
196+
export async function startConsoleLogin(opts: {
197+
site?: string
198+
onComplete: (credentials: ConsoleLoginCredentials) => Promise<string[]>
199+
}): Promise<ConsoleLoginStart> {
200+
if (active !== undefined) {
201+
return {
202+
status: 'already-running',
203+
loginUrl: state.phase === 'waiting' ? state.loginUrl : '',
204+
}
205+
}
206+
const expectedState = randomBytes(16).toString('hex')
207+
let settled = false
208+
const server = http.createServer((req, res) => {
209+
void (async () => {
210+
if (req.method === 'OPTIONS') {
211+
// The console page posts cross-origin; answer its preflight.
212+
res.writeHead(204, {
213+
'Access-Control-Allow-Origin': '*',
214+
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, OPTIONS',
215+
'Access-Control-Allow-Headers': 'Content-Type',
216+
})
217+
res.end()
218+
return
219+
}
220+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
221+
if (url.searchParams.get('state') !== expectedState) {
222+
// Not our callback (or a forged one): refuse without ending the flow.
223+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
224+
res.end('bad state\n')
225+
return
226+
}
227+
const credentials = await extractCredentials(req, url)
228+
res.writeHead(200, {
229+
'Content-Type': 'text/plain; charset=utf-8',
230+
'Access-Control-Allow-Origin': '*',
231+
})
232+
res.end('OK\n')
233+
if (credentials.apiKey === undefined && credentials.workspaceId === undefined) {
234+
// A callback with neither value (e.g. a bare probe): keep waiting.
235+
return
236+
}
237+
settled = true
238+
try {
239+
const fields = await opts.onComplete(credentials)
240+
state = { phase: 'done', fields }
241+
} catch (err) {
242+
state = { phase: 'failed', reason: err instanceof Error ? err.message : 'persist failed' }
243+
}
244+
server.close()
245+
})().catch(() => {
246+
res.statusCode = 500
247+
res.end()
248+
})
249+
})
250+
251+
let port: number
252+
try {
253+
port = await listenLoopback(server)
254+
} catch (err) {
255+
const reason = err instanceof Error ? err.message : 'could not bind a local port'
256+
state = { phase: 'failed', reason }
257+
return { status: 'failed', reason }
258+
}
259+
260+
// `state` rides inside the `notice` value, and `needapikey=true` is the whole
261+
// point: it makes the console issue a key for the account signing in.
262+
const origin = (opts.site !== undefined ? CONSOLE_ORIGINS[opts.site] : undefined) ?? CONSOLE_ORIGINS.domestic!
263+
const loginUrl = `${origin}/console-login?notice=127.0.0.1:${port}`
264+
+ `?state=${encodeURIComponent(expectedState)}&needapikey=true`
265+
266+
active = { server }
267+
state = { phase: 'waiting', loginUrl }
268+
const timer = setTimeout(() => { server.close() }, LOGIN_TIMEOUT_MS)
269+
timer.unref?.()
270+
server.once('close', () => {
271+
clearTimeout(timer)
272+
active = undefined
273+
if (!settled && state.phase === 'waiting') {
274+
state = { phase: 'failed', reason: 'the login timed out before the console called back' }
275+
}
276+
})
277+
278+
try {
279+
await openInBrowser(loginUrl)
280+
} catch (_browserRefused) {
281+
// Headless or locked-down host: the panel shows `loginUrl` to open by hand.
282+
}
283+
return { status: 'started', loginUrl }
284+
}
285+
286+
/** Abandon an in-flight login (closes the listener). */
287+
export function cancelConsoleLogin(): void {
288+
active?.server.close()
289+
active = undefined
290+
state = { phase: 'idle' }
291+
}

0 commit comments

Comments
 (0)