From 3de532091fc16c92fb953852e08cd86cdd40723b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Thu, 22 Oct 2026 14:30:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E7=99=BB=E5=BD=95=E4=B8=8E=20token=20?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=B9=B6=E5=8D=87=E7=BA=A7=E8=87=B3=202.4.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 + README.zh.md | 16 + docs/agents/auth-change.md | 18 +- packages/cli/README.md | 16 + packages/cli/README.zh.md | 16 + packages/cli/package.json | 2 +- packages/commands/package.json | 2 +- .../src/commands/auth/login-console.ts | 314 +++++++++++++----- packages/commands/src/commands/auth/login.ts | 92 ++++- .../commands/src/commands/workspace/init.ts | 4 +- packages/commands/tests/e2e/auth.e2e.test.ts | 292 +++++++++++++++- .../e2e/console-flags-dry-run.e2e.test.ts | 8 +- packages/commands/tests/login-console.test.ts | 269 ++++++++++++++- packages/core/package.json | 2 +- packages/core/src/auth/index.ts | 7 +- packages/core/src/auth/refresh-token.ts | 97 +++++- packages/core/src/client/client.ts | 25 +- packages/core/tests/config-store.test.ts | 186 ++++++++++- packages/kscli/package.json | 2 +- packages/runtime/package.json | 2 +- skills/bailian-cli/SKILL.md | 2 +- skills/bailian-cli/reference/auth.md | 41 ++- skills/bailian-finetune/SKILL.md | 2 +- skills/bailian-gen/SKILL.md | 2 +- skills/bailian-managed-agent/SKILL.md | 2 +- skills/bailian-protocol/SKILL.md | 2 +- skills/bailian-sandbox/SKILL.md | 2 +- skills/bailian-web-search/SKILL.md | 2 +- 28 files changed, 1279 insertions(+), 162 deletions(-) diff --git a/README.md b/README.md index b35b055b0..1f9dcb6fd 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,22 @@ bl auth login --console Use `--console-site international` for the international console. +The CLI always prints the login URL. To choose a browser yourself, keep the command running and open the URL on the **same machine**: + +```bash +bl auth login --console --no-browser --console-site international +``` + +If the browser blocks the local callback, check the console site's local-network permission or try Firefox. The PNA compatibility header does not bypass Chrome's local-network permission or Safari's mixed-content restrictions. No callback within 15 minutes is a failure (exit code 5), not a successful login. + +If you already have a valid console access token, import it via stdin without a browser or local callback server. Specify the token's actual region and site; for example, in a POSIX shell: + +```bash +bl auth login --console --token-stdin --console-site international --console-region ap-southeast-1 < /path/to/console-token.txt +``` + +Input must be one raw token (up to 64 KiB), not JSON or `Bearer ...`; interactive terminal input is rejected to avoid echoing secrets. Use a protected file or trusted pipe, never put the token in command arguments or shell history, and remove temporary secret files after use. The CLI validates the token before saving; failure leaves existing credentials and the active Profile unchanged. Add `--config ` to save and activate a specific Profile on success. This imports only the console session, not a model API key. + ### API Key To use an existing ordinary API key or a Token Plan subscription key, run the same command: diff --git a/README.zh.md b/README.zh.md index 4cdebf186..8efe243c8 100644 --- a/README.zh.md +++ b/README.zh.md @@ -137,6 +137,22 @@ bl auth login --console 国际站请使用 `--console-site international`。 +CLI 始终打印登录地址。若要自行选择浏览器,请保持命令运行,并在**同一台机器**的浏览器中打开地址: + +```bash +bl auth login --console --no-browser --console-site international +``` + +浏览器拦截本地回调时,请检查控制台站点的本地网络权限,或尝试 Firefox。PNA 兼容响应头不能绕过 Chrome 的本地网络权限或 Safari 的混合内容限制。15 分钟内未收到回调会登录失败(退出码 5),不会视为成功。 + +如果已有有效的控制台 access token,可通过 stdin 导入,无需浏览器或本地回调服务。必须指定 token 实际所属的地域与站点;以下为 POSIX shell 示例: + +```bash +bl auth login --console --token-stdin --console-site international --console-region ap-southeast-1 < /path/to/console-token.txt +``` + +输入只能是一个原始 token(不超过 64 KiB),不接受 JSON 或 `Bearer ...`;为避免凭证回显,不接受终端交互输入。请使用受保护的文件或可信管道,不要把 token 放进命令参数或 shell 历史,使用后清理临时凭证文件。CLI 校验通过才保存;失败时保留原凭证和激活的 Profile。加上 `--config ` 可在成功后写入并激活指定 Profile。此方式仅导入控制台会话,不创建模型 API Key。 + ### API Key 使用已有的普通 API Key 或 Token Plan 订阅 Key,都执行同一条命令: diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index 26d1a79a5..642004d12 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -36,7 +36,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - `bl auth login --api-key ...` 先通过只读 `GET /models` 校验 Key,校验成功后才原子写入 `api_key` 与实际匹配的 `base_url`。显式 `--base-url` 只校验该站点,不自动改写;未显式指定时,普通 `sk-*` / `sk-ws-*` 按“当前 Profile 已保存的同类站点 → 基于 `workspace_id` 构造的各地域 Workspace 专属站点 → 公共地域”顺序选择首个成功结果,`sk-sp-*` 并行探测国内站与新加坡 Token Plan,其他格式同时尝试两类候选。所有候选明确拒绝时返回 `AUTH`;网络、超时、非 JSON 或 5xx 视为校验无法确定并透传原错误。任一失败都不落盘、不激活 Profile - 未显式传 `--config` 时,`sk-sp-*` 自动写入并激活 `token-plan`;普通 `sk-*` / `sk-ws-*` 仅在当前为 `token-plan` 时改写并激活 `default`,否则保留当前 Profile;其他格式不自动切换。显式 `--config` 始终优先。`sk-sp-*` 无论写入哪个 Profile 都会携带 Token Plan 的预设默认模型,并在保留已有项的基础上补齐 `api_key_capabilities`,不自动删除任何已有能力。登录仍不得删除其他鉴权域的凭证 - `bl auth login --console` 更新 `access_token` 以及回调携带的 console 作用域字段;若所选 Profile 没有模型 API Key,会要求控制台页面创建并回传一个普通 API Key 与 Base URL 一并保存。该回调结果不重复执行 `/models` 校验,且不会创建或配置 Token Plan -- `bl auth login --open-api ...` 更新 `access_key_id` / `access_key_secret`,同时会调用 OpenAPI 生成 CLI `access_token` 并一并写入;即一次 `--open-api` 登录同时产生 `openapi` 与 `console` 域凭证 +- `bl auth login --open-api ...` 调用 OpenAPI 生成 CLI `access_token`,通过目标 Console 网关的只读 `loginInfo` 校验后,才一并写入 `access_key_id` / `access_key_secret` / `access_token` 和实际 `console_region` / `console_site`;失败不覆盖原凭证、不激活 Profile。一次成功登录同时产生 `openapi` 与 `console` 域凭证 - `bl auth logout --console` 只清 `access_token` - `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token` - `bl auth logout` 清 `api_key` + `base_url` + `access_token` + `access_key_*` @@ -58,6 +58,20 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx 命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。 +### Console 浏览器回调与人工导入 + +- 浏览器回调只监听 `127.0.0.1`,校验随机 `state`,只对本次控制台 Origin 返回 CORS 头;仅可信 Origin 明确请求 PNA 时返回 `Access-Control-Allow-Private-Network: true`。错误响应也必须可被可信页面读取,不能返回通配 Origin。 +- 登录地址始终输出;`--no-browser` 只禁止自动打开浏览器,仍等待本机回调。必须在打开浏览器前注册完成事件,浏览器启动失败或挂起不阻止回调完成。无 token 的回调不能视为成功;15 分钟未收到凭证返回 `TIMEOUT`,保存失败返回错误,保存期间客户端断开也必须完成清理。 +- `--console --token-stdin` 与 `--no-browser` 互斥,必须显式指定 `--console-region`;只接受非 TTY stdin 的单个原始 token(最多 64 KiB),拒绝空输入、JSON、Bearer 前缀及多 token 输入。此分支不启动浏览器或回调服务,经目标网关 `loginInfo` 验证成功才保存 token/site/region 并激活指定 Profile;保留其他凭证,失败和 dry-run 不写入、不激活,dry-run 不读取 stdin。 +- PNA 响应头只是兼容措施,不能宣称绕过 Chrome LNA 权限或 Safari 混合内容限制。回归覆盖真实 loopback 请求、超时、浏览器启动竞态、stdin 失败不改配置及输出不泄漏 token。 + +### Console token 签发与刷新 + +- `resolveCLIAccessTokenTarget()` 优先采用有效 `consoleRegion`;地域缺省时识别国内/新加坡 DashScope 和对应 `*.maas.aliyuncs.com` 域名(含 Token Plan、Workspace)。未知或不支持的地域报错,不静默回退北京;`consoleSite` 与地域独立,不能把新加坡自动等同国际站。 +- `Client.console()` 收到 `NotLogined` 后,以本次实际 Console credential 的 region/site/switchAgent 签发并重试一次。只有原请求重试成功才保存新 token,并更新 Client 内存凭证;失败保留磁盘旧 token。 +- `refreshAccessToken()` 独立调用时默认用只读 `loginInfo` 验证;调用方提供 `validateToken` 时由其完成验证或原请求重试,回调成功后才落盘。缺少当前 Profile 的 AK/SK 返回 `null`,不跨 Profile 借用凭证。 +- `auth generate-access-token` 只签发并输出,不做 Console 验证、不持久化;首次登录的校验直接走 `callConsoleGateway`,不得递归进入刷新流程。 + ### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入 `bl managed-agent *` 按调用链分两层: @@ -143,7 +157,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx ### E. 测试 -- [ ] `packages/cli/tests/e2e/auth.e2e.test.ts` 增加新方式的 happy / failure 路径 +- [ ] `packages/commands/tests/e2e/auth.e2e.test.ts` 增加新方式的 happy / failure 路径;浏览器回调契约在 `packages/commands/tests/login-console.test.ts` 覆盖 - [ ] mask token 的输出格式不变(避免泄漏) - [ ] 如调整 resolver 优先级,补 core/runtime 单测覆盖 flag > env > file diff --git a/packages/cli/README.md b/packages/cli/README.md index b35b055b0..1f9dcb6fd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -138,6 +138,22 @@ bl auth login --console Use `--console-site international` for the international console. +The CLI always prints the login URL. To choose a browser yourself, keep the command running and open the URL on the **same machine**: + +```bash +bl auth login --console --no-browser --console-site international +``` + +If the browser blocks the local callback, check the console site's local-network permission or try Firefox. The PNA compatibility header does not bypass Chrome's local-network permission or Safari's mixed-content restrictions. No callback within 15 minutes is a failure (exit code 5), not a successful login. + +If you already have a valid console access token, import it via stdin without a browser or local callback server. Specify the token's actual region and site; for example, in a POSIX shell: + +```bash +bl auth login --console --token-stdin --console-site international --console-region ap-southeast-1 < /path/to/console-token.txt +``` + +Input must be one raw token (up to 64 KiB), not JSON or `Bearer ...`; interactive terminal input is rejected to avoid echoing secrets. Use a protected file or trusted pipe, never put the token in command arguments or shell history, and remove temporary secret files after use. The CLI validates the token before saving; failure leaves existing credentials and the active Profile unchanged. Add `--config ` to save and activate a specific Profile on success. This imports only the console session, not a model API key. + ### API Key To use an existing ordinary API key or a Token Plan subscription key, run the same command: diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index 4cdebf186..8efe243c8 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -137,6 +137,22 @@ bl auth login --console 国际站请使用 `--console-site international`。 +CLI 始终打印登录地址。若要自行选择浏览器,请保持命令运行,并在**同一台机器**的浏览器中打开地址: + +```bash +bl auth login --console --no-browser --console-site international +``` + +浏览器拦截本地回调时,请检查控制台站点的本地网络权限,或尝试 Firefox。PNA 兼容响应头不能绕过 Chrome 的本地网络权限或 Safari 的混合内容限制。15 分钟内未收到回调会登录失败(退出码 5),不会视为成功。 + +如果已有有效的控制台 access token,可通过 stdin 导入,无需浏览器或本地回调服务。必须指定 token 实际所属的地域与站点;以下为 POSIX shell 示例: + +```bash +bl auth login --console --token-stdin --console-site international --console-region ap-southeast-1 < /path/to/console-token.txt +``` + +输入只能是一个原始 token(不超过 64 KiB),不接受 JSON 或 `Bearer ...`;为避免凭证回显,不接受终端交互输入。请使用受保护的文件或可信管道,不要把 token 放进命令参数或 shell 历史,使用后清理临时凭证文件。CLI 校验通过才保存;失败时保留原凭证和激活的 Profile。加上 `--config ` 可在成功后写入并激活指定 Profile。此方式仅导入控制台会话,不创建模型 API Key。 + ### API Key 使用已有的普通 API Key 或 Token Plan 订阅 Key,都执行同一条命令: diff --git a/packages/cli/package.json b/packages/cli/package.json index 0ebc7917c..323bcbf45 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli", - "version": "2.4.0", + "version": "2.4.1", "description": "CLI for Aliyun Model Studio (DashScope) AI Platform.", "keywords": [ "agent", diff --git a/packages/commands/package.json b/packages/commands/package.json index 785ad393b..c060570cd 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-commands", - "version": "2.4.0", + "version": "2.4.1", "description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts index 5c4313970..f8f499b8f 100644 --- a/packages/commands/src/commands/auth/login-console.ts +++ b/packages/commands/src/commands/auth/login-console.ts @@ -4,6 +4,8 @@ import http from "node:http"; import { BailianError, ExitCode, + validateConsoleAccessToken, + type ConsoleGatewayTarget, type AuthPersistPatch, type AuthStore, type ConfigFile, @@ -372,19 +374,97 @@ function listenServerOnFreeLocalPort(server: http.Server): Promise { return listenLocalServer(server); } +/** 人工导入只接受管道中的原始 token,不把凭证放进 argv 或终端回显。 */ +export async function runConsoleTokenLogin( + deps: LoginDeps, + target: Omit, +): Promise { + const input = process.stdin; + if (input.isTTY) { + throw new BailianError( + localizeLogin(deps, { + "en-US": + "Pipe a raw console access token to --token-stdin; interactive input is disabled to avoid echoing secrets.", + "zh-CN": + "请通过管道向 --token-stdin 输入原始控制台 access token;为避免凭证回显,不接受交互式输入。", + }), + ExitCode.USAGE, + ); + } + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of input) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + size += bytes.length; + if (size > MAX_AUTH_CALLBACK_BODY) { + throw new BailianError( + localizeLogin(deps, { + "en-US": "Console token input exceeds 64 KiB.", + "zh-CN": "控制台 token 输入超过 64 KiB。", + }), + ExitCode.USAGE, + ); + } + chunks.push(bytes); + } + const token = Buffer.concat(chunks).toString("utf8").trim(); + if (!token || /[\s\p{Cc}]/u.test(token) || token.startsWith("{") || token.startsWith("[")) { + throw new BailianError( + localizeLogin(deps, { + "en-US": + "stdin must contain one raw console access token, without JSON or a Bearer prefix.", + "zh-CN": "stdin 必须包含一个原始控制台 access token,不接受 JSON 或 Bearer 前缀。", + }), + ExitCode.USAGE, + ); + } + await validateConsoleAccessToken({ ...target, token }, deps.settings); + await deps.authStore.login({ + access_token: token, + console_site: target.site, + console_region: target.region, + }); + process.stderr.write( + `${localizeLogin(deps, { + "en-US": `Console token validated and saved to ${deps.authStore.path}`, + "zh-CN": `控制台 token 已校验并保存至 ${deps.authStore.path}`, + })}\n`, + ); +} + export async function runConsoleLogin( consoleOrigin: string, deps: LoginDeps, - opts?: { needApiKey?: boolean }, + opts?: { needApiKey?: boolean; noBrowser?: boolean }, ): Promise { const state = randomBytes(16).toString("hex"); let callbackError: unknown; let savedSessionWithoutApiKey = false; + let savedCredentials = false; + let savingCredentials = false; + let finished = false; + let timer: ReturnType | undefined; const server = http.createServer(async (req, res) => { try { + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Vary", "Origin, Access-Control-Request-Private-Network"); + const origin = req.headers.origin; + if (origin !== undefined && origin !== consoleOrigin) { + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end( + localizeLogin(deps, { "en-US": "Origin not allowed\n", "zh-CN": "不允许的来源\n" }), + ); + return; + } + if (origin === consoleOrigin) res.setHeader("Access-Control-Allow-Origin", consoleOrigin); if (req.method === "OPTIONS") { + if ( + origin === consoleOrigin && + req.headers["access-control-request-private-network"] === "true" + ) { + res.setHeader("Access-Control-Allow-Private-Network", "true"); + } res.writeHead(204, { - "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }); @@ -395,7 +475,7 @@ export async function runConsoleLogin( const u = new URL(req.url ?? "/", "http://127.0.0.1"); if (u.searchParams.get("state") !== state) { res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("bad state\n"); + res.end(localizeLogin(deps, { "en-US": "bad state\n", "zh-CN": "无效的 state\n" })); return; } @@ -409,50 +489,82 @@ export async function runConsoleLogin( workspaceId, } = await extractCredentialsFromRequest(req); - const hasConfig = - accessToken || baseUrl || consoleSite || consoleRegion || consoleSwitchAgent || workspaceId; - - if (hasConfig || apiKey) { - try { - const callbackPatch: AuthPersistPatch = { - access_token: accessToken || undefined, - console_site: (consoleSite || undefined) as ConfigFile["console_site"], - console_region: consoleRegion || undefined, - console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined, - workspace_id: workspaceId || undefined, - }; - if (apiKey) { - await persistApiKey(deps, apiKey, { - persistBaseUrl: baseUrl || undefined, - persistPatch: callbackPatch, - }); - process.stderr.write(`Config saved to ${deps.authStore.path}\n`); - } else if (hasConfig) { - await deps.authStore.login({ - ...callbackPatch, - base_url: baseUrl || undefined, - }); - savedSessionWithoutApiKey = true; - process.stderr.write(`Config saved to ${deps.authStore.path}\n`); - } - } catch (err: unknown) { - callbackError = err; - res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Failed to save credentials\n"); - server.close(); - return; - } + if (finished || savingCredentials) { + res.writeHead(409); + res.end( + localizeLogin(deps, { + "en-US": "Login is no longer waiting for credentials\n", + "zh-CN": "登录已不再等待凭证\n", + }), + ); + return; + } + if (!accessToken) { + res.writeHead(400); + res.end( + localizeLogin(deps, { + "en-US": "Missing access_token\n", + "zh-CN": "缺少 access_token\n", + }), + ); + return; } - res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", - "Access-Control-Allow-Origin": "*", - }); - res.end("OK\n"); - - if (hasConfig || apiKey) { - server.close(); + savingCredentials = true; + clearTimeout(timer); + try { + const callbackPatch: AuthPersistPatch = { + access_token: accessToken || undefined, + console_site: (consoleSite || undefined) as ConfigFile["console_site"], + console_region: consoleRegion || undefined, + console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined, + workspace_id: workspaceId || undefined, + }; + if (apiKey) { + await persistApiKey(deps, apiKey, { + persistBaseUrl: baseUrl || undefined, + persistPatch: callbackPatch, + }); + process.stderr.write( + `${localizeLogin(deps, { + "en-US": `Config saved to ${deps.authStore.path}`, + "zh-CN": `配置已保存至 ${deps.authStore.path}`, + })}\n`, + ); + } else { + await deps.authStore.login({ + ...callbackPatch, + base_url: baseUrl || undefined, + }); + savedSessionWithoutApiKey = true; + process.stderr.write( + `${localizeLogin(deps, { + "en-US": `Config saved to ${deps.authStore.path}`, + "zh-CN": `配置已保存至 ${deps.authStore.path}`, + })}\n`, + ); + } + savedCredentials = true; + } catch (error: unknown) { + callbackError = error; } + finished = true; + server.close(); + if (res.destroyed) { + server.closeAllConnections(); + return; + } + res.once("finish", () => server.closeAllConnections()); + res.once("close", () => server.closeAllConnections()); + res.writeHead(callbackError ? 500 : 200, { "Content-Type": "text/plain; charset=utf-8" }); + res.end( + callbackError + ? localizeLogin(deps, { + "en-US": "Failed to save credentials\n", + "zh-CN": "凭证保存失败\n", + }) + : "OK\n", + ); } catch { res.statusCode = 500; res.end(); @@ -462,10 +574,13 @@ export async function runConsoleLogin( let port: number; try { port = await listenServerOnFreeLocalPort(server); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); throw new BailianError( - `Could not bind to 127.0.0.1 (no free port or permission denied): ${msg}`, + localizeLogin(deps, { + "en-US": `Could not bind to 127.0.0.1 (no free port or permission denied): ${message}`, + "zh-CN": `无法监听 127.0.0.1(无可用端口或权限被拒绝):${message}`, + }), ExitCode.USAGE, ); } @@ -475,46 +590,79 @@ export async function runConsoleLogin( loginUrl += "&needapikey=true"; } - try { - await openInBrowser(loginUrl); - process.stderr.write( - "Opened the login page in your default browser. This process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n", - ); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write( - `Could not open the default browser (${msg}). Open this URL manually:\n\n`, - ); - process.stdout.write(`${loginUrl}\n`); - process.stderr.write( - "\nThis process keeps the local port open for the console; press Ctrl+C when finished (or wait for idle timeout).\n", - ); - } - - await new Promise((resolve, reject) => { - let finished = false; - const done = () => { - if (finished) return; + // 先注册完成事件,再打开浏览器,避免快速回调或打开程序挂起造成漏报。 + const completion = new Promise((resolve, reject) => { + server.once("close", resolve); + server.once("error", reject); + timer = setTimeout(() => { finished = true; - clearTimeout(timer); - resolve(); - }; - const timer = setTimeout(() => { + callbackError = new BailianError( + localizeLogin(deps, { + "en-US": "Console login timed out: no credentials were received from the browser.", + "zh-CN": "控制台登录超时:未收到浏览器回传的凭证。", + }), + ExitCode.TIMEOUT, + localizeLogin(deps, { + "en-US": + "Check the site's local network permission, retry with --console --no-browser and open the URL in Firefox, or import a token with --console --token-stdin --console-region .", + "zh-CN": + "请检查站点的本地网络权限,使用 --console --no-browser 重试并在 Firefox 打开地址,或使用 --console --token-stdin --console-region 导入 token。", + }), + ); server.close(); + server.closeAllConnections(); }, CONSOLE_LOGIN_TIMEOUT_MS); - - server.once("close", done); - server.once("error", (err) => { - clearTimeout(timer); - if (!finished) { - finished = true; - reject(err); - } - }); }); - - if (callbackError) { - throw callbackError; + process.stderr.write( + `${localizeLogin(deps, { + "en-US": "Open this login URL in a browser on this machine:", + "zh-CN": "请在本机浏览器中打开以下登录地址:", + })}\n${loginUrl}\n${localizeLogin(deps, { + "en-US": + "Waiting up to 15 minutes for credentials. If blocked, check local network permission or open the same URL in Firefox. Press Ctrl+C to cancel.", + "zh-CN": + "最多等待 15 分钟接收凭证。如被拦截,请检查本地网络权限或在 Firefox 打开同一地址。按 Ctrl+C 取消。", + })}\n`, + ); + if (!opts?.noBrowser) { + void openInBrowser(loginUrl).then( + () => { + if (!finished) + process.stderr.write( + `${localizeLogin(deps, { + "en-US": "Opened the login page in your default browser.", + "zh-CN": "已在默认浏览器打开登录页面。", + })}\n`, + ); + }, + () => { + if (!finished) + process.stderr.write( + `${localizeLogin(deps, { + "en-US": "Could not open the default browser. Open the URL above manually.", + "zh-CN": "无法打开默认浏览器,请手动打开上方地址。", + })}\n`, + ); + }, + ); + } + try { + await completion; + } finally { + finished = true; + clearTimeout(timer); + server.close(); + server.closeAllConnections(); + } + if (callbackError) throw callbackError; + if (!savedCredentials) { + throw new BailianError( + localizeLogin(deps, { + "en-US": "Console login ended without saving credentials.", + "zh-CN": "控制台登录已结束,但未保存凭证。", + }), + ExitCode.GENERAL, + ); } if (opts?.needApiKey && savedSessionWithoutApiKey) { diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index 805a5deb7..216ab2cad 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -1,12 +1,15 @@ import { defineCommand, + CONSOLE_AUTH_FLAGS, generateCLIAccessToken, + resolveCLIAccessTokenTarget, + validateConsoleAccessToken, getModelProfilePreset, normalizeModelBaseUrl, } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; import { persistApiKey, validateApiKey } from "./login-api-key.ts"; -import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts"; +import { resolveConsoleOrigin, runConsoleLogin, runConsoleTokenLogin } from "./login-console.ts"; const LOGIN_MODE_HINT = "Choose exactly one login mode: --api-key, --console, or --open-api"; @@ -22,7 +25,7 @@ export default defineCommand({ }, auth: "none", usageArgs: - "--api-key | --console | --open-api --access-key-id --access-key-secret ", + "--api-key | --console [--no-browser | --token-stdin --console-region ] | --open-api --access-key-id --access-key-secret ", flags: { apiKey: { type: "string", @@ -49,9 +52,30 @@ export default defineCommand({ "通过浏览器登录(默认中国站;国际站使用 --console-site international)。需要时会创建普通 API Key;订阅计划必须使用 --api-key 登录", }, }, + noBrowser: { + type: "switch", + description: { + "en-US": "With --console, print the login URL without opening a browser", + "zh-CN": "与 --console 一起使用,只打印登录地址,不自动打开浏览器", + }, + }, + tokenStdin: { + type: "switch", + description: { + "en-US": + "With --console, read one raw access token from piped stdin and validate before saving; requires --console-region, no browser or local server", + "zh-CN": + "与 --console 一起使用,从管道 stdin 读取原始 access token,校验后保存;必须指定 --console-region,不启动浏览器或本地服务", + }, + }, + consoleRegion: { + ...CONSOLE_AUTH_FLAGS.consoleRegion, + choices: ["cn-beijing", "ap-southeast-1"], + }, consoleSite: { type: "string", valueHint: "", + choices: ["domestic", "international"], description: { "en-US": "Console site: domestic, international", "zh-CN": "控制台站点:domestic、international", @@ -83,23 +107,38 @@ export default defineCommand({ }, exampleArgs: [ "--console", + "--console --no-browser --console-site international", + "--console --token-stdin --console-site international --console-region ap-southeast-1", "--api-key sk-xxxxx", "--open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx", ], - validate: (f) => { - const apiKeyMode = hasValue(f.apiKey); - const consoleMode = f.console === true; - const openApiMode = f.openApi === true; + validate: (flags) => { + const apiKeyMode = hasValue(flags.apiKey); + const consoleMode = flags.console === true; + const openApiMode = flags.openApi === true; + + if (!consoleMode && (flags.noBrowser || flags.tokenStdin || hasValue(flags.consoleRegion))) { + return "Use --no-browser, --token-stdin and --console-region only with --console. / --no-browser、--token-stdin 和 --console-region 只能与 --console 一起使用。"; + } + if (flags.noBrowser && flags.tokenStdin) { + return "Choose either --no-browser or --token-stdin. / --no-browser 与 --token-stdin 不能同时使用。"; + } + if (flags.tokenStdin && !hasValue(flags.consoleRegion)) { + return "Provide --console-region with --token-stdin. / 使用 --token-stdin 时必须指定 --console-region。"; + } + if (!flags.tokenStdin && hasValue(flags.consoleRegion)) { + return "Use --console-region only with --console --token-stdin. / --console-region 只能与 --console --token-stdin 一起使用。"; + } // Mode-specific options must not imply a login mode by themselves; this keeps // `auth login` explicit and avoids silently ignoring flags from another mode. - if (!apiKeyMode && hasValue(f.baseUrl)) { + if (!apiKeyMode && hasValue(flags.baseUrl)) { return "Use --base-url only with --api-key"; } - if (!consoleMode && hasValue(f.consoleSite)) { + if (!consoleMode && hasValue(flags.consoleSite)) { return "Use --console-site only with --console"; } - if (!openApiMode && (hasValue(f.accessKeyId) || hasValue(f.accessKeySecret))) { + if (!openApiMode && (hasValue(flags.accessKeyId) || hasValue(flags.accessKeySecret))) { return "Use --open-api with --access-key-id and --access-key-secret"; } @@ -110,7 +149,7 @@ export default defineCommand({ // AK/SK are a credential pair. `auth login --open-api` persists exactly what // the user typed and does not fill the missing half from env/config. - if (openApiMode && (!hasValue(f.accessKeyId) || !hasValue(f.accessKeySecret))) { + if (openApiMode && (!hasValue(flags.accessKeyId) || !hasValue(flags.accessKeySecret))) { return "Provide --access-key-id and --access-key-secret with --open-api"; } @@ -126,15 +165,42 @@ export default defineCommand({ if (flags.console) { if (settings.dryRun) { emitBare( - "Would bind a free port on 127.0.0.1 and open the console login URL in your browser.", + ctx.localize( + flags.tokenStdin + ? { + "en-US": + "Would read a console token from stdin, validate it and save it without opening a browser or local server.", + "zh-CN": "将从 stdin 读取控制台 token,校验后保存,不启动浏览器或本地服务。", + } + : flags.noBrowser + ? { + "en-US": + "Would bind a free port on 127.0.0.1 and print the console login URL without opening a browser.", + "zh-CN": "将在 127.0.0.1 监听空闲端口并打印控制台登录地址,不自动打开浏览器。", + } + : { + "en-US": + "Would bind a free port on 127.0.0.1 and open the console login URL in your browser.", + "zh-CN": "将在 127.0.0.1 监听空闲端口并在浏览器中打开控制台登录地址。", + }, + ), ); return; } const hasApiKey = !!(key || store.stored().apiKey); // 本次登录站点:参数缺省时用配置默认(file 里上次登录存的站点)。 const site = flags.consoleSite || settings.consoleSite || "domestic"; + if (flags.tokenStdin) { + await runConsoleTokenLogin(deps, { + site: site as "domestic" | "international", + region: flags.consoleRegion!, + switchAgent: settings.consoleSwitchAgent, + }); + return; + } await runConsoleLogin(resolveConsoleOrigin(site), deps, { needApiKey: !hasApiKey, + noBrowser: flags.noBrowser, }); return; } @@ -145,6 +211,7 @@ export default defineCommand({ return; } const resolvedBaseUrl = store.resolveBaseUrl(); + const target = resolveCLIAccessTokenTarget(settings, resolvedBaseUrl); process.stderr.write("Generating CLI access token... "); const resp = await generateCLIAccessToken({ identity, @@ -154,10 +221,13 @@ export default defineCommand({ accessKeySecret: flags.accessKeySecret!, }); const accessToken = resp.cliAccessToken; + await validateConsoleAccessToken({ ...target, token: accessToken }, settings); await store.login({ access_key_id: flags.accessKeyId, access_key_secret: flags.accessKeySecret, access_token: accessToken, + console_region: target.region, + console_site: target.site, }); process.stderr.write(`OpenAPI credentials saved to ${store.path}\n`); return; diff --git a/packages/commands/src/commands/workspace/init.ts b/packages/commands/src/commands/workspace/init.ts index f773378c7..e6e7a38a6 100644 --- a/packages/commands/src/commands/workspace/init.ts +++ b/packages/commands/src/commands/workspace/init.ts @@ -155,9 +155,10 @@ export default defineCommand({ const securityToken = flags.securityToken || undefined; // Step 0: Exchange AK/SK for a temporary CLI access token used by console calls. + const gateway = effectiveConsoleGatewayConfig(settings); const tokenResp = await generateCLIAccessToken({ identity: ctx.identity, - settings, + settings: { ...settings, consoleRegion: gateway.consoleRegion }, baseUrl: ctx.client.baseUrl, accessKeyId, accessKeySecret, @@ -168,7 +169,6 @@ export default defineCommand({ throw new BailianError("Failed to generate CLI access token from AK/SK.", ExitCode.GENERAL); } - const gateway = effectiveConsoleGatewayConfig(settings); const target: ConsoleGatewayTarget = { region: gateway.consoleRegion, site: gateway.consoleSite, diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 2e1aec80c..15bf4e0f4 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -2,7 +2,12 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs import { createServer, type Server } from "http"; import { tmpdir } from "os"; import { join } from "path"; -import { afterAll, beforeAll, describe, expect, test } from "vite-plus/test"; +import { Readable } from "node:stream"; +import { execFile } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vite-plus/test"; +import { readConfigFile, readConfigProfiles, writeConfigFile } from "bailian-cli-core"; +import { buildIsolatedCliEnv, withIsolatedHome } from "e2e/isolation"; import { isDashScopeE2EReady, isOpenApiE2EReady, @@ -10,6 +15,7 @@ import { parseStdoutJson, runCommandHelp, runCommandE2e, + runCommandInProcess, } from "./helpers.ts"; import { AUTH_ROUTES } from "./topic-routes.ts"; @@ -51,6 +57,9 @@ describe("e2e: auth", () => { expect(stderr).toMatch(/--console-site.*ordinary API key/i); expect(stderr).toMatch(/subscription plans.*--api-key/i); expect(stderr).toMatch(/--open-api/); + expect(stderr).toContain("--no-browser"); + expect(stderr).toContain("--token-stdin"); + expect(stderr).toContain("--console-region"); expect(stderr).toContain("bl auth login --console\n"); expect(stderr).not.toContain("bl auth login --console --console-site domestic"); }); @@ -140,6 +149,287 @@ describe("e2e: auth", () => { } }); + test.each(["success", "NotLogined", "network", "invalid-response", "empty-token"])( + "auth login --open-api 校验 %s 后才写入并激活 Profile", + async (outcome) => { + await withIsolatedHome(async (home, configDir) => { + await writeConfigFile({ access_token: "default-token" }); + const previous = { + access_token: "browser-token", + access_key_id: "old-ak", + access_key_secret: "old-secret", + base_url: "https://token-plan.ap-southeast-1.maas.aliyuncs.com", + console_site: "international" as const, + console_switch_agent: 123, + }; + await writeConfigFile(previous, "token-plan"); + const originalFetch = globalThis.fetch; + const requests: string[] = []; + globalThis.fetch = async (input, init) => { + const endpoint = new URL(input instanceof Request ? input.url : input); + requests.push(endpoint.hostname); + expect(readConfigFile("token-plan")).toEqual(previous); + expect(readConfigProfiles().active).toBe("default"); + if (endpoint.pathname === "/modelstudio/cli/generateAccessToken") { + return new Response( + JSON.stringify({ cliAccessToken: outcome === "empty-token" ? " " : "new-token" }), + ); + } + expect(endpoint.searchParams.get("api")).toBe( + "zeldaEasy.cornerstone-portal.cs-console.loginInfo", + ); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer new-token"); + const payload = JSON.parse(new URLSearchParams(init?.body as string).get("params")!); + expect(payload.Data.cornerstoneParam.switchAgent).toBe(123); + if (outcome === "network") throw new TypeError("fetch failed"); + if (outcome === "NotLogined") { + return new Response( + JSON.stringify({ + data: { success: false, errorCode: "BailianGateway.Login.NotLogined" }, + }), + ); + } + if (outcome === "invalid-response") return new Response("{}"); + return new Response( + JSON.stringify({ + data: { success: true, DataV2: { data: { data: { aliyun: { uid: "test-user" } } } } }, + }), + ); + }; + try { + const result = await runCommandInProcess( + AUTH_ROUTES, + [ + "auth", + "login", + "--open-api", + "--access-key-id", + "mock-ak", + "--access-key-secret", + "mock-secret", + "--config", + "token-plan", + ], + { + HOME: home, + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_BASE_URL: "", + NODE_OPTIONS: "", + HTTP_PROXY: "", + HTTPS_PROXY: "", + http_proxy: "", + https_proxy: "", + }, + ); + if (outcome === "success") { + expect(result.exitCode, result.stderr).toBe(0); + expect(readConfigFile("token-plan")).toMatchObject({ + ...previous, + access_token: "new-token", + access_key_id: "mock-ak", + access_key_secret: "mock-secret", + console_region: "ap-southeast-1", + }); + expect(readConfigProfiles().active).toBe("token-plan"); + } else { + expect(result.exitCode, result.stderr).not.toBe(0); + expect(readConfigFile("token-plan")).toEqual(previous); + expect(readConfigProfiles().active).toBe("default"); + } + expect(readConfigFile().access_token).toBe("default-token"); + expect(requests).toEqual( + outcome === "empty-token" + ? ["modelstudio.ap-southeast-1.aliyuncs.com"] + : [ + "modelstudio.ap-southeast-1.aliyuncs.com", + "bailian-singapore-cs.alibabacloud.com", + ], + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + }, + ); + + test.each([ + ["--no-browser"], + ["--token-stdin"], + ["--console", "--token-stdin"], + ["--console", "--token-stdin", "--console-region", "unknown-region"], + ["--console", "--console-region", "ap-southeast-1"], + ["--console", "--token-stdin", "--no-browser", "--console-region", "ap-southeast-1"], + ])("Console 兜底参数组合 %j 返回用法错误", async (...flags) => { + const result = await runCommandInProcess(AUTH_ROUTES, ["auth", "login", ...flags, "--dry-run"]); + expect(result.exitCode, result.stderr).toBe(2); + expect(result.stderr).not.toContain("Unknown flag"); + }); + + test.each(["--no-browser", "--token-stdin"])( + "Console %s dry-run 不读 stdin、不打开浏览器、不写配置", + async (flag) => { + await withIsolatedHome(async (home, configDir) => { + const extra = flag === "--token-stdin" ? ["--console-region", "ap-southeast-1"] : []; + const previous = readFileSync(join(configDir, "config.json"), "utf8"); + const result = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--console", flag, ...extra, "--dry-run"], + { HOME: home, BAILIAN_CONFIG_DIR: configDir }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain( + flag === "--token-stdin" ? "stdin" : "without opening a browser", + ); + expect(readFileSync(join(configDir, "config.json"), "utf8")).toBe(previous); + }); + }, + ); + + test("Console stdin 子进程读取真实管道并拒绝非原始 token,不回显或写配置", async () => { + await withIsolatedHome(async (home, configDir) => { + const previous = readFileSync(join(configDir, "config.json"), "utf8"); + const isolated = buildIsolatedCliEnv({ + overrides: { + HOME: home, + BAILIAN_CONFIG_DIR: configDir, + DO_NOT_TRACK: "1", + NODE_NO_WARNINGS: "1", + NODE_OPTIONS: "", + BAILIAN_E2E_ROUTES: JSON.stringify( + Object.entries(AUTH_ROUTES).map(([path, exportName]) => ({ path, export: exportName })), + ), + }, + }); + try { + const result = await new Promise<{ code: number | string | undefined; output: string }>( + (resolve) => { + const child = execFile( + process.execPath, + [ + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("./harness/main.ts", import.meta.url)), + "auth", + "login", + "--console", + "--token-stdin", + "--console-region", + "ap-southeast-1", + "--console-site", + "international", + ], + { env: isolated.env, timeout: 10_000 }, + (error, stdout, stderr) => { + resolve({ code: error?.code, output: stdout + stderr }); + }, + ); + child.stdin!.on("error", () => {}); + child.stdin!.end("Bearer pipe-secret-placeholder\n"); + }, + ); + expect(result.code, result.output).toBe(2); + expect(result.output).toContain("stdin must contain one raw console access token"); + expect(result.output).not.toContain("pipe-secret-placeholder"); + expect(readFileSync(join(configDir, "config.json"), "utf8")).toBe(previous); + } finally { + isolated.dispose(); + } + }); + }); + + test.each(["success", "rejected", "network", "empty", "oversized"])( + "Console stdin 导入 %s:验证成功才保存并激活 Profile", + async (outcome) => { + await withIsolatedHome(async (home, configDir) => { + await writeConfigFile({ access_token: "default-token" }); + const previous = { access_token: "old-token", api_key: "sk-kept", access_key_id: "old-ak" }; + await writeConfigFile(previous, "imported"); + const input = Readable.from([ + outcome === "empty" + ? " \n" + : outcome === "oversized" + ? "x".repeat(65537) + : " pasted-console-token\n", + ]); + const stdin = vi + .spyOn(process, "stdin", "get") + .mockReturnValue(input as typeof process.stdin); + const originalFetch = globalThis.fetch; + let validations = 0; + globalThis.fetch = async (url, init) => { + const endpoint = new URL(url instanceof Request ? url.url : url); + expect(endpoint.hostname).toBe("bailian-singapore-cs.alibabacloud.com"); + expect(endpoint.searchParams.get("api")).toBe( + "zeldaEasy.cornerstone-portal.cs-console.loginInfo", + ); + expect(new Headers(init?.headers).get("authorization")).toBe( + "Bearer pasted-console-token", + ); + expect(readConfigFile("imported")).toEqual(previous); + expect(readConfigProfiles().active).toBe("default"); + validations++; + if (outcome === "network") throw new TypeError("fetch failed"); + return new Response( + JSON.stringify({ + data: + outcome === "rejected" + ? { success: false, errorCode: "BailianGateway.Login.NotLogined" } + : { success: true }, + }), + ); + }; + try { + const result = await runCommandInProcess( + AUTH_ROUTES, + [ + "auth", + "login", + "--console", + "--token-stdin", + "--console-site", + "international", + "--console-region", + "ap-southeast-1", + "--config", + "imported", + "--verbose", + ], + { + HOME: home, + BAILIAN_CONFIG_DIR: configDir, + NODE_OPTIONS: "", + HTTP_PROXY: "", + HTTPS_PROXY: "", + http_proxy: "", + https_proxy: "", + }, + ); + expect(result.stdout + result.stderr).not.toContain("pasted-console-token"); + expect(validations).toBe(["empty", "oversized"].includes(outcome) ? 0 : 1); + if (outcome === "success") { + expect(result.exitCode, result.stderr).toBe(0); + expect(readConfigFile("imported")).toMatchObject({ + ...previous, + access_token: "pasted-console-token", + console_site: "international", + console_region: "ap-southeast-1", + }); + expect(readConfigProfiles().active).toBe("imported"); + } else { + expect(result.exitCode, result.stderr).not.toBe(0); + expect(readConfigFile("imported")).toEqual(previous); + expect(readConfigProfiles().active).toBe("default"); + } + expect(readConfigFile().access_token).toBe("default-token"); + } finally { + stdin.mockRestore(); + input.destroy(); + globalThis.fetch = originalFetch; + } + }); + }, + ); + test("auth logout --help 正常退出", async () => { const { stderr, exitCode } = await runCommandHelp(AUTH_ROUTES, ["auth", "logout", "--help"]); expect(exitCode, stderr).toBe(0); diff --git a/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts index ad9661f30..a60c39a82 100644 --- a/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts +++ b/packages/commands/tests/e2e/console-flags-dry-run.e2e.test.ts @@ -23,7 +23,7 @@ describe("e2e: console global flags (dry-run)", () => { expect(stderr).toMatch(/--console-region cn-beijing/); }); - test("auth login --help:自有 flag 含 --console-site,不含其余 console 域 flag", async () => { + test("auth login --help:自有 flag 含站点和 stdin 导入地域,不注入其余 console 域 flag", async () => { const { stderr, exitCode } = await runCommandHelp(CONSOLE_FLAGS_DRY_RUN_ROUTES, [ "auth", "login", @@ -32,8 +32,10 @@ describe("e2e: console global flags (dry-run)", () => { expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/--api-key /); expect(stderr).toMatch(/--base-url /); - expect(stderr).toMatch(/--console-site /); - expect(stderr).not.toMatch(/--console-region/); + expect(stderr).toContain("--console-site "); + expect(stderr).toContain("--console-region "); + expect(stderr).toContain("--token-stdin"); + expect(stderr).not.toMatch(/--console-switch-agent/); expect(stderr).not.toMatch(/--workspace-id/); }); diff --git a/packages/commands/tests/login-console.test.ts b/packages/commands/tests/login-console.test.ts index 0726dcb03..1f4eb6a82 100644 --- a/packages/commands/tests/login-console.test.ts +++ b/packages/commands/tests/login-console.test.ts @@ -1,6 +1,7 @@ import http from "node:http"; +import { Readable } from "node:stream"; import { expect, test, vi } from "vite-plus/test"; -import type { AuthStore, Identity, Settings } from "bailian-cli-core"; +import { ExitCode, type AuthStore, type Identity, type Settings } from "bailian-cli-core"; const openInBrowser = vi.hoisted(() => vi.fn<(url: string) => Promise>()); @@ -12,7 +13,11 @@ vi.mock("../src/commands/shared/local-server.ts", async (importOriginal) => { }; }); -import { runConsoleLogin } from "../src/commands/auth/login-console.ts"; +import { + runConsoleLogin, + runConsoleTokenLogin, + type LoginDeps, +} from "../src/commands/auth/login-console.ts"; function callbackTarget(loginUrl: string): { port: number; state: string } { const notice = new URL(loginUrl).searchParams.get("notice"); @@ -106,3 +111,263 @@ test("控制台回调只有会话、没有 API Key 时提示模型命令仍不 expect(stderr.join("")).toContain("bl auth login --api-key "); expect(stderr.join("")).not.toContain("console-token"); }); + +async function startLoginProbe( + options: { noBrowser?: boolean } = {}, + browser: "normal" | "failed" | "fast" | "pending" = "normal", +) { + const output: string[] = []; + const stderr = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + const save = vi.fn<(patch: unknown) => Promise>().mockResolvedValue(undefined); + let openedUrl = ""; + let settled = false; + openInBrowser.mockReset(); + openInBrowser.mockImplementation(async (url) => { + openedUrl = url; + if (browser === "failed") throw new Error("browser unavailable"); + if (browser === "fast") { + const target = callbackTarget(url); + await postCallback(target.port, target.state, { access_token: "fast-token" }); + } + if (browser === "pending") await new Promise(() => {}); + }); + const pending = runConsoleLogin( + "https://modelstudio.console.alibabacloud.com", + { + identity: { binName: "bl" } as Identity, + settings: {} as Settings, + authStore: { path: "/unused/config.json", login: save } as unknown as AuthStore, + localize: (text) => (typeof text === "string" ? text : text["zh-CN"]), + }, + options, + ).then( + () => { + settled = true; + return undefined; + }, + (error: unknown) => { + settled = true; + return error; + }, + ); + const getUrl = () => + openedUrl || output.join("").match(/https:\/\/\S+\/console-login\?\S+/)?.[0] || ""; + await vi.waitFor(() => expect(getUrl()).not.toBe("")); + const target = callbackTarget(getUrl()); + const callbackUrl = `http://127.0.0.1:${target.port}/?state=${target.state}`; + return { + output, + save, + pending, + callbackUrl, + async cleanup() { + if (!settled) { + await postCallback(target.port, target.state, { access_token: "cleanup-token" }).catch( + () => {}, + ); + } + await pending; + stderr.mockRestore(); + }, + }; +} + +const INTERNATIONAL_ORIGIN = "https://modelstudio.console.alibabacloud.com"; + +test("回调只允许登录站点的 Origin,按需响应 PNA,并保留 state 校验", async () => { + const probe = await startLoginProbe(); + try { + const preflight = await fetch(probe.callbackUrl, { + method: "OPTIONS", + headers: { + Origin: INTERNATIONAL_ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Private-Network": "true", + }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-origin")).toBe(INTERNATIONAL_ORIGIN); + expect(preflight.headers.get("access-control-allow-private-network")).toBe("true"); + expect(preflight.headers.get("vary")).toContain("Origin"); + const ordinary = await fetch(probe.callbackUrl, { + method: "OPTIONS", + headers: { Origin: INTERNATIONAL_ORIGIN }, + }); + expect(ordinary.headers.has("access-control-allow-private-network")).toBe(false); + for (const method of ["OPTIONS", "POST"]) { + const rejected = await fetch(probe.callbackUrl, { + method, + headers: { Origin: "https://untrusted.example" }, + ...(method === "POST" ? { body: JSON.stringify({ access_token: "forged-token" }) } : {}), + }); + expect(rejected.status).toBe(403); + expect(rejected.headers.has("access-control-allow-origin")).toBe(false); + } + const badState = await fetch(probe.callbackUrl.replace(/state=.*/, "state=wrong"), { + method: "POST", + headers: { Origin: INTERNATIONAL_ORIGIN }, + body: JSON.stringify({ access_token: "forged-token" }), + }); + expect(badState.status).toBe(400); + expect(badState.headers.get("access-control-allow-origin")).toBe(INTERNATIONAL_ORIGIN); + const noCredential = await fetch(probe.callbackUrl, { + method: "POST", + body: JSON.stringify({ console_site: "international" }), + }); + expect(noCredential.status).toBe(400); + expect(probe.save).not.toHaveBeenCalled(); + const valid = await fetch(probe.callbackUrl, { + method: "POST", + headers: { Origin: INTERNATIONAL_ORIGIN, "Content-Type": "application/json" }, + body: JSON.stringify({ access_token: "valid-token", console_site: "international" }), + }); + expect(valid.status).toBe(200); + expect(valid.headers.get("access-control-allow-origin")).toBe(INTERNATIONAL_ORIGIN); + expect(await probe.pending).toBeUndefined(); + expect(probe.save).toHaveBeenCalledTimes(1); + expect(probe.output.join("")).not.toContain("valid-token"); + expect(probe.output.join("")).toContain("/console-login?notice="); + } finally { + await probe.cleanup(); + } +}); + +test("--no-browser 打印登录地址但不启动浏览器,仍接受回调", async () => { + const probe = await startLoginProbe({ noBrowser: true }); + try { + expect(openInBrowser).not.toHaveBeenCalled(); + expect(probe.output.join("")).toContain("/console-login?notice="); + const response = await fetch(probe.callbackUrl, { + method: "POST", + body: JSON.stringify({ access_token: "manual-browser-token" }), + }); + expect(response.status).toBe(200); + expect(await probe.pending).toBeUndefined(); + } finally { + await probe.cleanup(); + } +}); + +test("没有回调时超时失败,不写凭证,并关闭监听端口", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const probe = await startLoginProbe({ noBrowser: true }); + try { + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + expect(await probe.pending).toMatchObject({ exitCode: ExitCode.TIMEOUT }); + expect(probe.save).not.toHaveBeenCalled(); + await expect(fetch(probe.callbackUrl)).rejects.toThrow(); + } finally { + vi.useRealTimers(); + await probe.cleanup(); + } +}); + +test("保存失败向可信页面返回可读错误,登录失败且不泄漏凭证", async () => { + const probe = await startLoginProbe(); + const failure = new Error("disk unavailable"); + probe.save.mockRejectedValue(failure); + try { + const response = await fetch(probe.callbackUrl, { + method: "POST", + headers: { Origin: INTERNATIONAL_ORIGIN }, + body: JSON.stringify({ access_token: "failed-token" }), + }); + expect(response.status).toBe(500); + expect(response.headers.get("access-control-allow-origin")).toBe(INTERNATIONAL_ORIGIN); + expect(await probe.pending).toBe(failure); + expect(probe.output.join("")).not.toContain("failed-token"); + } finally { + await probe.cleanup(); + } +}); + +test("回调在保存期间断开,保存结束后登录仍能完成并关闭端口", async () => { + // 保留真实 HTTP 服务;只记录实例以在回归失败时清理监听资源。 + const servers = vi.spyOn(http, "createServer"); + const probe = await startLoginProbe(); + const server = servers.mock.results[0]!.value as http.Server; + let finishSave!: () => void; + const saving = new Promise((resolve) => { + finishSave = resolve; + }); + probe.save.mockImplementation(() => saving); + const disconnected = new Promise((resolve) => { + server.once("connection", (socket) => socket.once("close", resolve)); + }); + const request = http.request(probe.callbackUrl, { method: "POST", agent: false }); + request.on("error", () => {}); + request.end(JSON.stringify({ access_token: "disconnected-token" })); + let completed = false; + void probe.pending.then(() => { + completed = true; + }); + try { + await vi.waitFor(() => expect(probe.save).toHaveBeenCalledTimes(1)); + request.destroy(); + await disconnected; + finishSave(); + await vi.waitFor(() => expect(completed).toBe(true)); + expect(await probe.pending).toBeUndefined(); + await expect(fetch(probe.callbackUrl)).rejects.toThrow(); + } finally { + finishSave(); + request.destroy(); + server.close(); + server.closeAllConnections(); + await probe.cleanup(); + servers.mockRestore(); + } +}); + +test.each(["failed", "fast", "pending"] as const)( + "浏览器 %s 不阻止收到回调后完成登录", + async (browser) => { + const probe = await startLoginProbe({}, browser); + try { + expect(probe.output.join("")).toContain("/console-login?notice="); + if (browser !== "fast") { + const response = await fetch(probe.callbackUrl, { + method: "POST", + body: JSON.stringify({ access_token: "browser-token" }), + }); + expect(response.status).toBe(200); + } + expect(await probe.pending).toBeUndefined(); + expect(probe.save).toHaveBeenCalledTimes(1); + } finally { + await probe.cleanup(); + } + }, +); + +test.each(["tty", "json", "bearer", "multiple"])( + "stdin 拒绝 %s 输入,不读取交互凭证或调用远端", + async (kind) => { + const input = Readable.from([ + kind === "json" + ? '{"access_token":"secret"}' + : kind === "bearer" + ? "Bearer secret" + : "first\nsecond", + ]); + Object.defineProperty(input, "isTTY", { configurable: true, value: kind === "tty" }); + const stdin = vi.spyOn(process, "stdin", "get").mockReturnValue(input as typeof process.stdin); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + try { + await expect( + runConsoleTokenLogin({} as LoginDeps, { + site: "international", + region: "ap-southeast-1", + }), + ).rejects.toMatchObject({ exitCode: ExitCode.USAGE }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + stdin.mockRestore(); + fetchSpy.mockRestore(); + input.destroy(); + } + }, +); diff --git a/packages/core/package.json b/packages/core/package.json index 5b38a3288..6e4c6f649 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-core", - "version": "2.4.0", + "version": "2.4.1", "description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 3d46316ee..f63fc6281 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -14,4 +14,9 @@ export type { AuthState, CredentialSource, } from "./types.ts"; -export { generateCLIAccessToken, refreshAccessToken } from "./refresh-token.ts"; +export { + generateCLIAccessToken, + refreshAccessToken, + resolveCLIAccessTokenTarget, + validateConsoleAccessToken, +} from "./refresh-token.ts"; diff --git a/packages/core/src/auth/refresh-token.ts b/packages/core/src/auth/refresh-token.ts index 30644c5da..bbee06ff5 100644 --- a/packages/core/src/auth/refresh-token.ts +++ b/packages/core/src/auth/refresh-token.ts @@ -1,5 +1,7 @@ -import { REGIONS, type Region } from "../config/schema.ts"; import type { Identity, Settings } from "../config/schema.ts"; +import { BailianError } from "../errors/base.ts"; +import { ExitCode } from "../errors/codes.ts"; +import { callConsoleGateway, type ConsoleGatewayTarget } from "../console/gateway.ts"; import { readConfigFile, writeConfigFile } from "../config/loader.ts"; import { Client } from "../client/client.ts"; @@ -7,21 +9,64 @@ const API_VERSION = "2026-02-10"; const API_ACTION = "GenerateCLIAccessToken"; const API_PATH = "/modelstudio/cli/generateAccessToken"; -const MODEL_STUDIO_HOSTS: Partial> = { - cn: "modelstudio.cn-beijing.aliyuncs.com", - intl: "modelstudio.ap-southeast-1.aliyuncs.com", -}; +const MODEL_STUDIO_HOSTS = new Map([ + ["cn-beijing", "modelstudio.cn-beijing.aliyuncs.com"], + ["ap-southeast-1", "modelstudio.ap-southeast-1.aliyuncs.com"], +]); -function resolveRegion(baseUrl: string): Region { - for (const [region, url] of Object.entries(REGIONS) as Array<[Region, string]>) { - if (baseUrl === url || baseUrl.startsWith(`${url}/`)) return region; +/** Console 目标优先;仅在地域缺省时从已知模型域名推导,不猜测未知站点。 */ +export function resolveCLIAccessTokenTarget( + settings: Pick, + baseUrl: string, +): Omit { + let region = settings.consoleRegion; + if (!region) { + let hostname: string | undefined; + try { + hostname = new URL(baseUrl).hostname; + } catch { + // 无法识别的地址统一走下方地域校验。 + } + if (hostname === "dashscope.aliyuncs.com") region = "cn-beijing"; + else if (hostname === "dashscope-intl.aliyuncs.com") region = "ap-southeast-1"; + else + region = hostname?.match( + /^[a-z0-9-]+\.(cn-beijing|ap-southeast-1)\.maas\.aliyuncs\.com$/, + )?.[1]; } - return "cn"; + if (!region || !MODEL_STUDIO_HOSTS.has(region)) { + throw new BailianError( + "Cannot resolve a supported CLI token region. / 无法确定受支持的 CLI token 地域。", + ExitCode.USAGE, + "Set console_region to cn-beijing or ap-southeast-1. / 请配置 console_region 为 cn-beijing 或 ap-southeast-1。", + ); + } + return { + region, + site: settings.consoleSite ?? "domestic", + switchAgent: settings.consoleSwitchAgent, + }; } -function modelStudioHost(baseUrl: string): string { - const region = resolveRegion(baseUrl); - return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn!; +/** 首次登录只读验证;直接调用网关,避免错误触发递归刷新。 */ +export async function validateConsoleAccessToken( + target: ConsoleGatewayTarget, + settings: Settings, +): Promise { + const response = (await callConsoleGateway( + target, + settings.timeout, + { api: "zeldaEasy.cornerstone-portal.cs-console.loginInfo", data: {} }, + settings, + )) as { data?: { success?: boolean } } | null; + if (response?.data?.success !== true) { + throw new BailianError( + "Console token validation did not return success. / 控制台 token 校验未返回成功结果。", + ExitCode.GENERAL, + undefined, + { rawResponse: JSON.stringify(response) }, + ); + } } export async function generateCLIAccessToken(opts: { @@ -31,7 +76,7 @@ export async function generateCLIAccessToken(opts: { accessKeyId: string; accessKeySecret: string; securityToken?: string; -}): Promise { +}) { const { identity, settings, baseUrl, accessKeyId, accessKeySecret, securityToken } = opts; const client = new Client({ @@ -41,9 +86,10 @@ export async function generateCLIAccessToken(opts: { openApiCred: { accessKeyId, accessKeySecret, securityToken, source: "flag" }, }); - const host = modelStudioHost(baseUrl); + const target = resolveCLIAccessTokenTarget(settings, baseUrl); + const host = MODEL_STUDIO_HOSTS.get(target.region)!; - return client.openApiQueryJson({ + const response = await client.openApiQueryJson<{ cliAccessToken?: unknown; Success?: boolean }>({ host, path: API_PATH, action: API_ACTION, @@ -51,6 +97,13 @@ export async function generateCLIAccessToken(opts: { method: "POST", queryParams: {}, }); + if (typeof response.cliAccessToken !== "string" || !response.cliAccessToken.trim()) { + throw new BailianError( + "CLI token response is missing cliAccessToken. / CLI token 响应缺少有效的 cliAccessToken。", + ExitCode.GENERAL, + ); + } + return { ...response, cliAccessToken: response.cliAccessToken.trim() }; } /** @@ -61,6 +114,8 @@ export async function refreshAccessToken(opts: { identity: Identity; settings: Settings; baseUrl: string; + /** 重试原请求作为验证;独立调用时默认通过只读 loginInfo 校验。 */ + validateToken?: (token: string) => Promise; }): Promise { const configName = opts.settings.configName; const config = readConfigFile(configName); @@ -78,10 +133,18 @@ export async function refreshAccessToken(opts: { baseUrl: opts.baseUrl, accessKeyId, accessKeySecret, + securityToken: config.security_token, }); - const token: string | undefined = resp.cliAccessToken; - if (!token) return null; + const token = resp.cliAccessToken; + if (opts.validateToken) { + await opts.validateToken(token); + } else { + await validateConsoleAccessToken( + { ...resolveCLIAccessTokenTarget(opts.settings, opts.baseUrl), token }, + opts.settings, + ); + } const existing = readConfigFile(configName) as Record; existing.access_token = token; diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 2a549c89f..acc2b41d8 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -229,18 +229,29 @@ export class Client { ) { throw err; } + const credential = this.deps.consoleCred; + let retryResult: unknown; const newToken = await refreshAccessToken({ identity: this.deps.identity, - settings: this.deps.settings, + settings: { + ...this.deps.settings, + consoleRegion: credential.region, + consoleSite: credential.site, + consoleSwitchAgent: credential.switchAgent, + }, baseUrl: this.deps.baseUrl, + validateToken: async (token) => { + retryResult = await callConsoleGateway( + { ...credential, token }, + timeout, + gwOpts, + this.deps.settings, + ); + }, }); if (!newToken) throw err; - return (await callConsoleGateway( - { ...this.deps.consoleCred, token: newToken }, - timeout, - gwOpts, - this.deps.settings, - )) as T; + this.deps.consoleCred = { ...credential, token: newToken }; + return retryResult as T; } } diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts index 9d95c9aff..c62330e09 100644 --- a/packages/core/tests/config-store.test.ts +++ b/packages/core/tests/config-store.test.ts @@ -5,7 +5,9 @@ import { expect, test } from "vite-plus/test"; import { makeConfigStore } from "../src/config/store.ts"; import { makeAuthStore } from "../src/auth/store.ts"; import { resolveApiKey } from "../src/auth/resolver.ts"; -import { refreshAccessToken } from "../src/auth/refresh-token.ts"; +import { generateCLIAccessToken, refreshAccessToken } from "../src/auth/refresh-token.ts"; +import { Client } from "../src/client/client.ts"; +import { withIsolatedHome } from "./helpers/isolation.ts"; import { buildSettings, buildSources, @@ -236,11 +238,24 @@ test("Console access token 自动刷新写回当前选中 Config", async () => { await activateConfigProfile("dev"); const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - new Response(JSON.stringify({ cliAccessToken: "refreshed-dev" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + globalThis.fetch = async (input, init) => { + const isGeneration = + new URL(input instanceof Request ? input.url : input).pathname === + "/modelstudio/cli/generateAccessToken"; + if (!isGeneration) { + expect(readConfigFile("dev").access_token).toBe("expired-dev"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer refreshed-dev"); + } + return new Response( + JSON.stringify( + isGeneration ? { cliAccessToken: "refreshed-dev" } : { data: { success: true } }, + ), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }; try { const sources = buildSources({}); const refreshed = await refreshAccessToken({ @@ -263,6 +278,165 @@ test("Console access token 自动刷新写回当前选中 Config", async () => { }); }); +const tokenTestIdentity = { + binName: "bl", + version: "test", + npmPackage: "bailian-cli", + clientName: "bailian-cli-test", +}; + +test.each([ + { baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com", expected: "ap-southeast-1" }, + { baseUrl: "https://workspace.ap-southeast-1.maas.aliyuncs.com", expected: "ap-southeast-1" }, + { baseUrl: "https://dashscope-intl.aliyuncs.com/api/v1", expected: "ap-southeast-1" }, + { baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", expected: "cn-beijing" }, + { baseUrl: "https://dashscope.aliyuncs.com", expected: "cn-beijing" }, + { + baseUrl: "https://proxy.example.com", + consoleRegion: "ap-southeast-1", + expected: "ap-southeast-1", + }, + { + baseUrl: "https://dashscope-intl.aliyuncs.com", + consoleRegion: "cn-beijing", + expected: "cn-beijing", + }, +])( + "CLI token 地域解析:$baseUrl / $consoleRegion", + async ({ baseUrl, consoleRegion, expected }) => { + const originalFetch = globalThis.fetch; + const hosts: string[] = []; + globalThis.fetch = async (input) => { + hosts.push(new URL(input instanceof Request ? input.url : input).hostname); + return new Response(JSON.stringify({ cliAccessToken: "candidate" })); + }; + try { + await generateCLIAccessToken({ + identity: tokenTestIdentity, + settings: buildSettings({ flags: { consoleRegion }, file: {}, env: {} }), + baseUrl, + accessKeyId: "mock-ak", + accessKeySecret: "mock-secret", + }); + expect(hosts).toEqual([`modelstudio.${expected}.aliyuncs.com`]); + } finally { + globalThis.fetch = originalFetch; + } + }, +); + +test.each([ + { baseUrl: "https://unknown.example.com" }, + { baseUrl: "https://dashscope-us.aliyuncs.com" }, + { baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com.evil.test" }, + { baseUrl: "https://dashscope.aliyuncs.com", consoleRegion: "unsupported-region" }, +])("CLI token 无法解析地域时不请求北京:$baseUrl", async ({ baseUrl, consoleRegion }) => { + const originalFetch = globalThis.fetch; + let requests = 0; + globalThis.fetch = async () => { + requests++; + return new Response(JSON.stringify({ cliAccessToken: "candidate" })); + }; + try { + await expect( + generateCLIAccessToken({ + identity: tokenTestIdentity, + settings: buildSettings({ flags: { consoleRegion }, file: {}, env: {} }), + baseUrl, + accessKeyId: "mock-ak", + accessKeySecret: "mock-secret", + }), + ).rejects.toMatchObject({ exitCode: 2 }); + expect(requests).toBe(0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test.each(["success", "NotLogined", "network"])( + "Console 刷新后重试 %s:成功才落盘", + async (outcome) => { + await withIsolatedHome(async () => { + await writeConfigFile({ access_token: "default-token" }); + await writeConfigFile( + { + access_token: "old-token", + access_key_id: "mock-ak", + access_key_secret: "mock-secret", + console_region: "ap-southeast-1", + console_site: "international", + }, + "token-plan", + ); + const settings = buildSettings({ flags: {}, file: {}, env: {}, configName: "token-plan" }); + const client = new Client({ + identity: tokenTestIdentity, + settings, + baseUrl: "https://proxy.example.com", + consoleCred: { + token: "old-token", + region: "ap-southeast-1", + site: "international", + switchAgent: 123, + source: "config", + }, + }); + const originalFetch = globalThis.fetch; + const authorizations: string[] = []; + let generations = 0; + globalThis.fetch = async (input, init) => { + const endpoint = new URL(input instanceof Request ? input.url : input); + if (endpoint.pathname === "/modelstudio/cli/generateAccessToken") { + generations++; + expect(endpoint.hostname).toBe("modelstudio.ap-southeast-1.aliyuncs.com"); + return new Response(JSON.stringify({ cliAccessToken: "new-token" })); + } + expect(endpoint.hostname).toBe("bailian-singapore-cs.alibabacloud.com"); + const authorization = new Headers(init?.headers).get("authorization")!; + authorizations.push(authorization); + const payload = JSON.parse(new URLSearchParams(init?.body as string).get("params")!); + expect(payload.Data.cornerstoneParam.switchAgent).toBe(123); + if (authorizations.length <= 2) { + expect(readConfigFile("token-plan").access_token).toBe("old-token"); + } + if (authorization === "Bearer old-token" || outcome === "NotLogined") { + return new Response( + JSON.stringify({ + data: { success: false, errorCode: "BailianGateway.Login.NotLogined" }, + }), + ); + } + if (outcome === "network") throw new TypeError("fetch failed"); + return new Response(JSON.stringify({ data: { success: true, value: "ok" } })); + }; + try { + if (outcome === "success") { + await expect(client.console("test.api", {})).resolves.toMatchObject({ + data: { value: "ok" }, + }); + expect(readConfigFile("token-plan").access_token).toBe("new-token"); + await client.console("test.api", {}); + expect(authorizations).toEqual([ + "Bearer old-token", + "Bearer new-token", + "Bearer new-token", + ]); + } else { + await expect(client.console("test.api", {})).rejects.toThrow( + outcome === "network" ? "fetch failed" : "not logged in", + ); + expect(readConfigFile("token-plan").access_token).toBe("old-token"); + expect(authorizations).toEqual(["Bearer old-token", "Bearer new-token"]); + } + expect(generations).toBe(1); + expect(readConfigFile().access_token).toBe("default-token"); + } finally { + globalThis.fetch = originalFetch; + } + }); + }, +); + test("ConfigStore:命名 config 与默认配置隔离且写入保留其它 block", async () => { await inTempConfigDir(async () => { await writeConfigFile({ api_key: "sk-default", output: "json" }); diff --git a/packages/kscli/package.json b/packages/kscli/package.json index aadcbeae4..1e9970c27 100644 --- a/packages/kscli/package.json +++ b/packages/kscli/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-studio-cli", - "version": "2.4.0", + "version": "2.4.1", "description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.", "keywords": [ "alibaba-cloud", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 224b9d42d..cd8dca28c 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "bailian-cli-runtime", - "version": "2.4.0", + "version": "2.4.1", "description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.", "homepage": "https://bailian.console.aliyun.com/cli", "bugs": { diff --git a/skills/bailian-cli/SKILL.md b/skills/bailian-cli/SKILL.md index 7c18e894d..0c561b49c 100644 --- a/skills/bailian-cli/SKILL.md +++ b/skills/bailian-cli/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-cli metadata: - version: "2.4.0" + version: "2.4.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-cli/reference/auth.md b/skills/bailian-cli/reference/auth.md index c111f93de..e9e000da1 100644 --- a/skills/bailian-cli/reference/auth.md +++ b/skills/bailian-cli/reference/auth.md @@ -41,24 +41,27 @@ bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxx ### `bl auth login` -| Field | Value | -| ------------------ | ------------------------------------------------------------------------------------------------------------ | -| **Name** | `auth login` | -| **Description** | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | -| **Authentication** | No Auth | -| **Usage** | `bl auth login --api-key \| --console \| --open-api --access-key-id --access-key-secret ` | +| Field | Value | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Name** | `auth login` | +| **Description** | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | +| **Authentication** | No Auth | +| **Usage** | `bl auth login --api-key \| --console [--no-browser \| --token-stdin --console-region ] \| --open-api --access-key-id --access-key-secret ` | #### Flags -| Flag | Type | Required | Description | -| ------------------------------ | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--api-key ` | string | no | Model API key to validate and store | -| `--base-url ` | string | no | Model API base URL to validate and store with --api-key | -| `--console` | switch | no | Sign in via browser (China by default; use --console-site international for the international site). Creates an ordinary API key if needed; subscription plans require --api-key | -| `--console-site ` | string | no | Console site: domestic, international | -| `--open-api` | switch | no | Store Alibaba Cloud OpenAPI AK/SK credentials | -| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID to store | -| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret to store | +| Flag | Type | Required | Description | +| ----------------------------------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--api-key ` | string | no | Model API key to validate and store | +| `--base-url ` | string | no | Model API base URL to validate and store with --api-key | +| `--console` | switch | no | Sign in via browser (China by default; use --console-site international for the international site). Creates an ordinary API key if needed; subscription plans require --api-key | +| `--no-browser` | switch | no | With --console, print the login URL without opening a browser | +| `--token-stdin` | switch | no | With --console, read one raw access token from piped stdin and validate before saving; requires --console-region, no browser or local server | +| `--console-region ` | string | no | Console gateway region (e.g. cn-beijing, ap-southeast-1) | +| `--console-site ` | string | no | Console site: domestic, international | +| `--open-api` | switch | no | Store Alibaba Cloud OpenAPI AK/SK credentials | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID to store | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret to store | #### Examples @@ -66,6 +69,14 @@ bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxx bl auth login --console ``` +```bash +bl auth login --console --no-browser --console-site international +``` + +```bash +bl auth login --console --token-stdin --console-site international --console-region ap-southeast-1 +``` + ```bash bl auth login --api-key sk-xxxxx ``` diff --git a/skills/bailian-finetune/SKILL.md b/skills/bailian-finetune/SKILL.md index e1162aa80..84d6b7710 100644 --- a/skills/bailian-finetune/SKILL.md +++ b/skills/bailian-finetune/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-finetune metadata: - version: "2.4.0" + version: "2.4.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index 77f980168..cefbd9726 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-gen metadata: - version: "2.4.0" + version: "2.4.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-managed-agent/SKILL.md b/skills/bailian-managed-agent/SKILL.md index e5513f580..a0406ae18 100644 --- a/skills/bailian-managed-agent/SKILL.md +++ b/skills/bailian-managed-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-managed-agent metadata: - version: "2.4.0" + version: "2.4.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-protocol/SKILL.md b/skills/bailian-protocol/SKILL.md index bf8dda0e8..bcba50499 100644 --- a/skills/bailian-protocol/SKILL.md +++ b/skills/bailian-protocol/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-protocol metadata: - version: "2.4.0" + version: "2.4.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-sandbox/SKILL.md b/skills/bailian-sandbox/SKILL.md index 30b4c2d1e..1b9558a94 100644 --- a/skills/bailian-sandbox/SKILL.md +++ b/skills/bailian-sandbox/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-sandbox metadata: - version: "2.4.0" + version: "2.4.1" requires: bins: ["bl"] description: >- diff --git a/skills/bailian-web-search/SKILL.md b/skills/bailian-web-search/SKILL.md index 93a38a119..7441d2e59 100644 --- a/skills/bailian-web-search/SKILL.md +++ b/skills/bailian-web-search/SKILL.md @@ -1,7 +1,7 @@ --- name: bailian-web-search metadata: - version: "2.4.0" + version: "2.4.1" requires: bins: ["bl"] description: >- From d1116553dac41ecbc96e8461c9f241a11cc70ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Thu, 22 Oct 2026 15:04:51 +0800 Subject: [PATCH 2/2] fix(test): handle nullable execFile error codes in auth e2e --- packages/commands/tests/e2e/auth.e2e.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 15bf4e0f4..f815e9c23 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -3,7 +3,7 @@ import { createServer, type Server } from "http"; import { tmpdir } from "os"; import { join } from "path"; import { Readable } from "node:stream"; -import { execFile } from "node:child_process"; +import { execFile, type ExecFileException } from "node:child_process"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, test, vi } from "vite-plus/test"; import { readConfigFile, readConfigProfiles, writeConfigFile } from "bailian-cli-core"; @@ -301,7 +301,7 @@ describe("e2e: auth", () => { }, }); try { - const result = await new Promise<{ code: number | string | undefined; output: string }>( + const result = await new Promise<{ code: ExecFileException["code"]; output: string }>( (resolve) => { const child = execFile( process.execPath,