diff --git a/README.zh.md b/README.zh.md
index 4cdebf186..28e431b65 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -36,7 +36,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
-
+
diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md
index 8be0b17ae..190a606ed 100644
--- a/docs/agents/auth-change.md
+++ b/docs/agents/auth-change.md
@@ -175,3 +175,4 @@ pnpm -F bailian-cli exec tsx src/main.ts usage stats --dry-run --output json --w
- ✗ token mask 显示完整 token,日志泄漏
- ✗ `auth: "console"` 命令误用 `apiKey` 域,config 只有 API key 时会把 `sk-...` 发到网关
- ✗ 新增 core resolver hint 时写死产品命令,导致 `kscli` 等入口提示错误
+- ✗ Windows 上用 `cmd /c start` 打开带 `&` 的 URL 时没把整段目标括起来。`auth login --console` 的 `needapikey=true` 会被 cmd 当成下一条命令,浏览器只拿到控制台会话。打开参数走 `packages/commands/src/commands/shared/local-server.ts` 的 `buildOpenInvocation`(dsh 插件里有一份同步实现)
diff --git a/packages/bailian-kb-dsh/src/console-login.ts b/packages/bailian-kb-dsh/src/console-login.ts
index 62b28f547..89b7e4c97 100644
--- a/packages/bailian-kb-dsh/src/console-login.ts
+++ b/packages/bailian-kb-dsh/src/console-login.ts
@@ -160,16 +160,54 @@ async function extractCredentials(
return pickCallbackCredentials(Object.fromEntries(url.searchParams), body);
}
-/** Open a URL with the OS default handler; never routed through a shell. */
+/**
+ * Quote a value for `cmd /c`. An embedded `"` is written as `""`.
+ * Do not double `%`: that substitution only happens inside batch files, and
+ * `cmd /c` would otherwise open a target that still contains `%%`.
+ * Keep in sync with `buildOpenInvocation` in
+ * `packages/commands/src/commands/shared/local-server.ts`.
+ */
+function quoteForCmd(value: string): string {
+ return `"${value.replaceAll('"', '""')}"`;
+}
+
+/**
+ * Command that opens `target` with the OS default handler.
+ * On Windows, `cmd.exe` splits an unquoted `&`, so `needapikey=true` never
+ * reaches the browser. See `buildOpenInvocation` in the commands package.
+ */
+export function buildOpenInvocation(
+ target: string,
+ platform = process.platform,
+): { command: string; args: string[]; windowsVerbatimArguments?: boolean } {
+ if (platform === "win32") {
+ const inner = `start "" ${quoteForCmd(target)}`;
+ return {
+ command: "cmd",
+ args: ["/d", "/v:off", "/s", "/c", `"${inner}"`],
+ windowsVerbatimArguments: true,
+ };
+ }
+ if (platform === "darwin") return { command: "open", args: [target] };
+ return { command: "xdg-open", args: [target] };
+}
+
+/** Open a URL with the OS default handler. */
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];
+ const invocation = buildOpenInvocation(url);
return new Promise((resolve, reject) => {
- execFile(cmd, args, { windowsHide: true }, (err) => {
- if (err) reject(err);
- else resolve();
- });
+ execFile(
+ invocation.command,
+ invocation.args,
+ {
+ windowsHide: true,
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
+ },
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ },
+ );
});
}
diff --git a/packages/bailian-kb-dsh/tests/console-login.test.ts b/packages/bailian-kb-dsh/tests/console-login.test.ts
index 4637177d2..b6ec18e26 100644
--- a/packages/bailian-kb-dsh/tests/console-login.test.ts
+++ b/packages/bailian-kb-dsh/tests/console-login.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
import {
+ buildOpenInvocation,
consoleLoginState,
parseCallbackBody,
pickCallbackCredentials,
@@ -68,3 +69,21 @@ describe("consoleLoginState", () => {
expect(consoleLoginState()).toEqual({ phase: "idle" });
});
});
+
+describe("buildOpenInvocation", () => {
+ it("quotes a Windows start target so & stays inside the URL", () => {
+ const url =
+ "https://bailian.console.aliyun.com/console-login?notice=127.0.0.1:1?state=abc&needapikey=true";
+ const invocation = buildOpenInvocation(url, "win32");
+ const command = invocation.args.at(-1) ?? "";
+ expect(invocation.windowsVerbatimArguments).toBe(true);
+ expect(command.slice(1, -1)).toBe(`start "" "${url}"`);
+ });
+
+ it("leaves percent-encoded characters unchanged for cmd /c", () => {
+ const url = "https://example.com/a%20b";
+ const command = buildOpenInvocation(url, "win32").args.at(-1) ?? "";
+ expect(command).toContain("%20");
+ expect(command).not.toContain("%%");
+ });
+});
diff --git a/packages/commands/src/commands/auth/console-ui.ts b/packages/commands/src/commands/auth/console-ui.ts
index dd5a498cc..7acb48745 100644
--- a/packages/commands/src/commands/auth/console-ui.ts
+++ b/packages/commands/src/commands/auth/console-ui.ts
@@ -1,4 +1,10 @@
-import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core";
+import {
+ maskToken,
+ type AuthStore,
+ type Identity,
+ type LocalizedText,
+ type Settings,
+} from "bailian-cli-core";
import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts";
/** Read-only auth snapshot the config UI account widget renders. bl stores no
@@ -32,8 +38,9 @@ export function makeAuthUiBridge(ctx: {
identity: Identity;
settings: Settings;
authStore: AuthStore;
+ localize?: (text: LocalizedText) => string;
}): AuthUiBridge {
- const { identity, settings, authStore } = ctx;
+ const { identity, settings, authStore, localize } = ctx;
return {
status() {
const a = authStore.describe();
@@ -63,7 +70,7 @@ export function makeAuthUiBridge(ctx: {
// endpoint to detect completion. Errors are logged, not surfaced.
void runConsoleLogin(
origin,
- { identity, settings, authStore },
+ { identity, settings, authStore, localize },
{
needApiKey: !hasApiKey,
},
diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts
index c37d3d8ab..5c4313970 100644
--- a/packages/commands/src/commands/auth/login-console.ts
+++ b/packages/commands/src/commands/auth/login-console.ts
@@ -8,6 +8,7 @@ import {
type AuthStore,
type ConfigFile,
type Identity,
+ type LocalizedText,
type Settings,
} from "bailian-cli-core";
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
@@ -18,6 +19,13 @@ export interface LoginDeps {
identity: Identity;
settings: Settings;
authStore: AuthStore;
+ /** 缺省时用户可见新文案回退到 en-US。 */
+ localize?: (text: LocalizedText) => string;
+}
+
+function localizeLogin(deps: LoginDeps, text: LocalizedText): string {
+ if (typeof text === "string") return text;
+ return deps.localize ? deps.localize(text) : text["en-US"];
}
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
@@ -371,6 +379,7 @@ export async function runConsoleLogin(
): Promise {
const state = randomBytes(16).toString("hex");
let callbackError: unknown;
+ let savedSessionWithoutApiKey = false;
const server = http.createServer(async (req, res) => {
try {
if (req.method === "OPTIONS") {
@@ -423,6 +432,7 @@ export async function runConsoleLogin(
...callbackPatch,
base_url: baseUrl || undefined,
});
+ savedSessionWithoutApiKey = true;
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
}
} catch (err: unknown) {
@@ -506,4 +516,14 @@ export async function runConsoleLogin(
if (callbackError) {
throw callbackError;
}
+
+ if (opts?.needApiKey && savedSessionWithoutApiKey) {
+ const binName = deps.identity.binName;
+ process.stderr.write(
+ `${localizeLogin(deps, {
+ "en-US": `Saved the console session only. Model commands still need an API key: ${binName} auth login --console, or ${binName} auth login --api-key `,
+ "zh-CN": `仅保存了控制台会话。模型命令仍需要 API Key:${binName} auth login --console,或 ${binName} auth login --api-key `,
+ })}\n`,
+ );
+ }
}
diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts
index b4c9554c7..805a5deb7 100644
--- a/packages/commands/src/commands/auth/login.ts
+++ b/packages/commands/src/commands/auth/login.ts
@@ -119,7 +119,7 @@ export default defineCommand({
async run(ctx) {
const { identity, settings, flags } = ctx;
const store = ctx.authStore;
- const deps = { identity, settings, authStore: store };
+ const deps = { identity, settings, authStore: store, localize: ctx.localize };
const key = flags.apiKey;
const baseUrl = flags.baseUrl ? normalizeModelBaseUrl(flags.baseUrl) : undefined;
diff --git a/packages/commands/src/commands/auth/status.ts b/packages/commands/src/commands/auth/status.ts
index 893d6554e..bce37cfa8 100644
--- a/packages/commands/src/commands/auth/status.ts
+++ b/packages/commands/src/commands/auth/status.ts
@@ -10,7 +10,7 @@ export default defineCommand({
auth: "none",
exampleArgs: ["", "--output json"],
async run(ctx) {
- const { identity, settings } = ctx;
+ const { identity, settings, localize } = ctx;
const format = detectOutputFormat(settings.output);
const auth = ctx.authStore.describe();
@@ -40,6 +40,12 @@ export default defineCommand({
const authenticated = !!(apiKey || consoleCred || openapi);
const configName = settings.configName ?? "default";
const configFile = ctx.authStore.path;
+ const modelKeyHint = apiKey
+ ? undefined
+ : localize({
+ "en-US": `No model API key is configured. Model commands need: ${identity.binName} auth login --console, or ${identity.binName} auth login --api-key `,
+ "zh-CN": `尚未配置模型 API Key。模型命令需要:${identity.binName} auth login --console,或 ${identity.binName} auth login --api-key `,
+ });
if (!authenticated) {
emitResult(
@@ -69,6 +75,7 @@ export default defineCommand({
api_key: apiKey,
console: consoleCred,
openapi,
+ ...(modelKeyHint ? { hint: modelKeyHint } : {}),
},
format,
);
@@ -82,6 +89,7 @@ export default defineCommand({
emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`);
} else {
emitBare(" API key (model): not configured");
+ if (modelKeyHint) emitBare(` ${modelKeyHint}`);
}
if (consoleCred) {
emitBare(
diff --git a/packages/commands/src/commands/shared/local-server.ts b/packages/commands/src/commands/shared/local-server.ts
index 102247d53..f2c6fa0c1 100644
--- a/packages/commands/src/commands/shared/local-server.ts
+++ b/packages/commands/src/commands/shared/local-server.ts
@@ -22,21 +22,69 @@ export function listenLocalServer(server: http.Server, port = 0): Promise"`.
+ */
+export function buildOpenInvocation(target: string, platform = process.platform): OpenInvocation {
+ if (platform === "win32") {
+ const inner = `start "" ${quoteForCmd(target)}`;
+ return {
+ command: "cmd",
+ args: ["/d", "/v:off", "/s", "/c", `"${inner}"`],
+ windowsVerbatimArguments: true,
+ };
+ }
+ if (platform === "darwin") {
+ return { command: "open", args: [target] };
+ }
+ return { command: "xdg-open", args: [target] };
+}
+
/**
* Open a local file, directory, or URL with the OS default handler
- * (best-effort, cross-platform). Arguments are passed to `execFile` as an array
- * so the target is never interpreted by a shell.
+ * (best-effort, cross-platform).
*/
export function openPath(target: string): Promise {
- const platform = process.platform;
- const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
- const args = platform === "win32" ? ["/c", "start", "", target] : [target];
-
+ const invocation = buildOpenInvocation(target);
return new Promise((resolve, reject) => {
- execFile(cmd, args, { windowsHide: true }, (err) => {
- if (err) reject(err);
- else resolve();
- });
+ execFile(
+ invocation.command,
+ invocation.args,
+ {
+ windowsHide: true,
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
+ },
+ (err) => {
+ if (err) reject(err);
+ else resolve();
+ },
+ );
});
}
diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts
index b9f6dadb9..2e1aec80c 100644
--- a/packages/commands/tests/e2e/auth.e2e.test.ts
+++ b/packages/commands/tests/e2e/auth.e2e.test.ts
@@ -681,6 +681,47 @@ describe("e2e: auth", () => {
expect(stderr).not.toContain("Cleared api_key");
});
+ test("auth status 在只有控制台会话时标明模型 API Key 仍未配置", async () => {
+ const configDir = makeE2eOutputDir("auth-status-console-only");
+ writeFileSync(
+ join(configDir, "config.json"),
+ JSON.stringify({
+ language: "zh-CN",
+ access_token: "console-token-placeholder",
+ console_region: "cn-beijing",
+ console_site: "domestic",
+ }),
+ );
+ const env = {
+ BAILIAN_CONFIG_DIR: configDir,
+ DASHSCOPE_API_KEY: undefined,
+ DASHSCOPE_BASE_URL: undefined,
+ ALIBABA_CLOUD_ACCESS_KEY_ID: undefined,
+ ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined,
+ ALIBABA_CLOUD_SECURITY_TOKEN: undefined,
+ };
+
+ const json = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
+ expect(json.exitCode, json.stderr).toBe(0);
+ const data = parseStdoutJson<{
+ authenticated?: boolean;
+ api_key?: unknown;
+ console?: { region?: string };
+ hint?: string;
+ }>(json.stdout);
+ expect(data.authenticated).toBe(true);
+ expect(data.api_key).toBeUndefined();
+ expect(data.console?.region).toBe("cn-beijing");
+ expect(data.hint).toContain("尚未配置模型 API Key");
+ expect(data.hint).toContain("bl auth login --api-key ");
+ expect(json.stdout).not.toContain("console-token-placeholder");
+
+ const text = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "text"], env);
+ expect(text.exitCode, text.stderr).toBe(0);
+ expect(text.stdout).toContain("API key (model): not configured");
+ expect(text.stdout).toContain("尚未配置模型 API Key");
+ });
+
test("auth logout 清除当前 Config 的全部凭证和 Base URL,保留普通配置", async () => {
const configDir = makeE2eOutputDir("auth-logout-all");
writeFileSync(
diff --git a/packages/commands/tests/local-server-open.test.ts b/packages/commands/tests/local-server-open.test.ts
new file mode 100644
index 000000000..f43bc694d
--- /dev/null
+++ b/packages/commands/tests/local-server-open.test.ts
@@ -0,0 +1,80 @@
+import { expect, test } from "vite-plus/test";
+import { buildOpenInvocation } from "../src/commands/shared/local-server.ts";
+
+const LOGIN_URL =
+ "https://bailian.console.aliyun.com/console-login?notice=127.0.0.1:54675?state=abc&needapikey=true";
+
+/** Command line `cmd.exe` runs after `/s` strips one outer quote pair. */
+function commandExecutedByCmd(args: string[]): string {
+ const switchIndex = args.lastIndexOf("/c");
+ const remainder = args[switchIndex + 1];
+ if (!remainder || switchIndex < 0) throw new Error("missing /c command");
+ expect(remainder.startsWith('"')).toBe(true);
+ expect(remainder.endsWith('"')).toBe(true);
+ return remainder.slice(1, -1);
+}
+
+/**
+ * Target `start` receives after cmd quote parsing: one outer quote pair is
+ * removed, and each `""` inside it is one literal `"`. `%` is not rewritten.
+ */
+function targetReceivedByStart(executed: string): string {
+ const prefix = 'start "" ';
+ if (!executed.startsWith(prefix)) throw new Error("not a start command");
+ const quoted = executed.slice(prefix.length);
+ if (!quoted.startsWith('"') || !quoted.endsWith('"')) throw new Error("target not quoted");
+ return quoted.slice(1, -1).replaceAll('""', '"');
+}
+
+/** Split on `&` that is not inside double quotes, the way `cmd.exe` does. */
+function splitUnquotedAmpersand(command: string): string[] {
+ const parts: string[] = [];
+ let current = "";
+ let quoted = false;
+ for (const character of command) {
+ if (character === '"') {
+ quoted = !quoted;
+ current += character;
+ continue;
+ }
+ if (character === "&" && !quoted) {
+ parts.push(current);
+ current = "";
+ continue;
+ }
+ current += character;
+ }
+ parts.push(current);
+ return parts;
+}
+
+test("win32 打开带 & 的登录 URL 时,needapikey 留在同一条 start 命令里", () => {
+ const invocation = buildOpenInvocation(LOGIN_URL, "win32");
+ expect(invocation.command).toBe("cmd");
+ expect(invocation.windowsVerbatimArguments).toBe(true);
+ expect(invocation.args.slice(0, 4)).toEqual(["/d", "/v:off", "/s", "/c"]);
+
+ const executed = commandExecutedByCmd(invocation.args);
+ expect(executed).toBe(`start "" "${LOGIN_URL}"`);
+ expect(splitUnquotedAmpersand(executed)).toEqual([executed]);
+ expect(targetReceivedByStart(executed)).toBe(LOGIN_URL);
+});
+
+test("win32 传给 start 的目标保持原样,% 不会被写成 %%", () => {
+ const target = 'https://example.com/a%20b?"q"&x=1';
+ const executed = commandExecutedByCmd(buildOpenInvocation(target, "win32").args);
+ expect(executed).not.toContain("%%");
+ expect(targetReceivedByStart(executed)).toBe(target);
+ expect(splitUnquotedAmpersand(executed)).toEqual([executed]);
+});
+
+test("darwin / linux 直接把目标交给系统打开命令", () => {
+ expect(buildOpenInvocation(LOGIN_URL, "darwin")).toEqual({
+ command: "open",
+ args: [LOGIN_URL],
+ });
+ expect(buildOpenInvocation(LOGIN_URL, "linux")).toEqual({
+ command: "xdg-open",
+ args: [LOGIN_URL],
+ });
+});
diff --git a/packages/commands/tests/login-console.test.ts b/packages/commands/tests/login-console.test.ts
new file mode 100644
index 000000000..0726dcb03
--- /dev/null
+++ b/packages/commands/tests/login-console.test.ts
@@ -0,0 +1,108 @@
+import http from "node:http";
+import { expect, test, vi } from "vite-plus/test";
+import type { AuthStore, Identity, Settings } from "bailian-cli-core";
+
+const openInBrowser = vi.hoisted(() => vi.fn<(url: string) => Promise>());
+
+vi.mock("../src/commands/shared/local-server.ts", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ openInBrowser: (url: string) => openInBrowser(url),
+ };
+});
+
+import { runConsoleLogin } from "../src/commands/auth/login-console.ts";
+
+function callbackTarget(loginUrl: string): { port: number; state: string } {
+ const notice = new URL(loginUrl).searchParams.get("notice");
+ if (!notice) throw new Error("login URL is missing notice");
+ const callback = new URL(`http://${notice}`);
+ const state = callback.searchParams.get("state");
+ if (!callback.port || !state) throw new Error("login URL is missing callback state");
+ return { port: Number(callback.port), state };
+}
+
+function postCallback(port: number, state: string, body: Record): Promise {
+ const payload = JSON.stringify(body);
+ return new Promise((resolve, reject) => {
+ const request = http.request(
+ {
+ host: "127.0.0.1",
+ port,
+ path: `/?state=${encodeURIComponent(state)}`,
+ method: "POST",
+ agent: false,
+ headers: {
+ "content-type": "application/json",
+ "content-length": Buffer.byteLength(payload),
+ connection: "close",
+ },
+ },
+ (response) => {
+ response.resume();
+ response.on("end", () => resolve(response.statusCode ?? 0));
+ },
+ );
+ request.on("error", reject);
+ request.end(payload);
+ });
+}
+
+test("控制台回调只有会话、没有 API Key 时提示模型命令仍不可用", async () => {
+ const stderr: string[] = [];
+ const write = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
+ stderr.push(String(chunk));
+ return true;
+ });
+ const saved: unknown[] = [];
+ let loginUrl = "";
+ openInBrowser.mockReset();
+ openInBrowser.mockImplementation(async (url) => {
+ loginUrl = url;
+ });
+
+ const pending = runConsoleLogin(
+ "https://bailian.console.aliyun.com",
+ {
+ identity: { binName: "bl" } as Identity,
+ settings: {} as Settings,
+ authStore: {
+ path: "/tmp/bailian/config.json",
+ login: async (patch: unknown) => {
+ saved.push(patch);
+ },
+ } as unknown as AuthStore,
+ localize: (text) => (typeof text === "string" ? text : text["zh-CN"]),
+ },
+ { needApiKey: true },
+ );
+
+ try {
+ await vi.waitFor(() => {
+ if (!loginUrl) throw new Error("waiting for login URL");
+ });
+ expect(loginUrl).toContain("&needapikey=true");
+ const target = callbackTarget(loginUrl);
+ const status = await postCallback(target.port, target.state, {
+ access_token: "console-token",
+ console_region: "cn-beijing",
+ console_site: "domestic",
+ });
+ expect(status).toBe(200);
+ await pending;
+ } finally {
+ write.mockRestore();
+ }
+
+ expect(saved).toEqual([
+ expect.objectContaining({
+ access_token: "console-token",
+ console_region: "cn-beijing",
+ console_site: "domestic",
+ }),
+ ]);
+ expect(stderr.join("")).toContain("仅保存了控制台会话");
+ expect(stderr.join("")).toContain("bl auth login --api-key ");
+ expect(stderr.join("")).not.toContain("console-token");
+});