Skip to content

Commit f5fedad

Browse files
committed
fix(auth): 修复 Windows 打开登录 URL 时 & 被截断,并补充缺少 API Key 的中英文提示
- 新增 buildOpenInvocation,在 Windows 上正确打开带 & 的 URL。 - runConsoleLogin 在只保存了控制台会话、没有 API Key 时给出本地化提示。 - makeAuthUiBridge 及相关函数支持本地化。 - 为新的 URL 处理和无 API Key 提示补充测试。
1 parent 2303e98 commit f5fedad

11 files changed

Lines changed: 367 additions & 23 deletions

File tree

‎docs/agents/auth-change.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,4 @@ pnpm -F bailian-cli exec tsx src/main.ts usage stats --dry-run --output json --w
175175
- ✗ token mask 显示完整 token,日志泄漏
176176
- ✗ `auth: "console"` 命令误用 `apiKey` 域,config 只有 API key 时会把 `sk-...` 发到网关
177177
- ✗ 新增 core resolver hint 时写死产品命令,导致 `kscli` 等入口提示错误
178+
- ✗ Windows 上用 `cmd /c start` 打开带 `&` 的 URL 时没把整段目标括起来。`auth login --console` 的 `needapikey=true` 会被 cmd 当成下一条命令,浏览器只拿到控制台会话。打开参数走 `packages/commands/src/commands/shared/local-server.ts` 的 `buildOpenInvocation`(dsh 插件里有一份同步实现)

‎packages/bailian-kb-dsh/src/console-login.ts‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,16 +160,53 @@ async function extractCredentials(
160160
return pickCallbackCredentials(Object.fromEntries(url.searchParams), body);
161161
}
162162

163-
/** Open a URL with the OS default handler; never routed through a shell. */
163+
/**
164+
* Quote a value for `cmd.exe`. `%` is doubled because cmd expands variables even
165+
* inside quotes; embedded `"` becomes `""`.
166+
* Keep in sync with `buildOpenInvocation` in
167+
* `packages/commands/src/commands/shared/local-server.ts`.
168+
*/
169+
function quoteForCmd(value: string): string {
170+
return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`;
171+
}
172+
173+
/**
174+
* Command that opens `target` with the OS default handler.
175+
* On Windows, `cmd.exe` splits an unquoted `&`, so `needapikey=true` never
176+
* reaches the browser. See `buildOpenInvocation` in the commands package.
177+
*/
178+
export function buildOpenInvocation(
179+
target: string,
180+
platform = process.platform,
181+
): { command: string; args: string[]; windowsVerbatimArguments?: boolean } {
182+
if (platform === "win32") {
183+
const inner = `start "" ${quoteForCmd(target)}`;
184+
return {
185+
command: "cmd",
186+
args: ["/d", "/v:off", "/s", "/c", `"${inner}"`],
187+
windowsVerbatimArguments: true,
188+
};
189+
}
190+
if (platform === "darwin") return { command: "open", args: [target] };
191+
return { command: "xdg-open", args: [target] };
192+
}
193+
194+
/** Open a URL with the OS default handler. */
164195
function openInBrowser(url: string): Promise<void> {
165-
const cmd =
166-
process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
167-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
196+
const invocation = buildOpenInvocation(url);
168197
return new Promise((resolve, reject) => {
169-
execFile(cmd, args, { windowsHide: true }, (err) => {
170-
if (err) reject(err);
171-
else resolve();
172-
});
198+
execFile(
199+
invocation.command,
200+
invocation.args,
201+
{
202+
windowsHide: true,
203+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
204+
},
205+
(err) => {
206+
if (err) reject(err);
207+
else resolve();
208+
},
209+
);
173210
});
174211
}
175212

‎packages/bailian-kb-dsh/tests/console-login.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vite-plus/test";
22
import {
3+
buildOpenInvocation,
34
consoleLoginState,
45
parseCallbackBody,
56
pickCallbackCredentials,
@@ -68,3 +69,14 @@ describe("consoleLoginState", () => {
6869
expect(consoleLoginState()).toEqual({ phase: "idle" });
6970
});
7071
});
72+
73+
describe("buildOpenInvocation", () => {
74+
it("quotes a Windows start target so & stays inside the URL", () => {
75+
const url =
76+
"https://bailian.console.aliyun.com/console-login?notice=127.0.0.1:1?state=abc&needapikey=true";
77+
const invocation = buildOpenInvocation(url, "win32");
78+
const command = invocation.args.at(-1) ?? "";
79+
expect(invocation.windowsVerbatimArguments).toBe(true);
80+
expect(command.slice(1, -1)).toBe(`start "" "${url}"`);
81+
});
82+
});

‎packages/commands/src/commands/auth/console-ui.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core";
1+
import {
2+
maskToken,
3+
type AuthStore,
4+
type Identity,
5+
type LocalizedText,
6+
type Settings,
7+
} from "bailian-cli-core";
28
import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts";
39

410
/** Read-only auth snapshot the config UI account widget renders. bl stores no
@@ -32,8 +38,9 @@ export function makeAuthUiBridge(ctx: {
3238
identity: Identity;
3339
settings: Settings;
3440
authStore: AuthStore;
41+
localize?: (text: LocalizedText) => string;
3542
}): AuthUiBridge {
36-
const { identity, settings, authStore } = ctx;
43+
const { identity, settings, authStore, localize } = ctx;
3744
return {
3845
status() {
3946
const a = authStore.describe();
@@ -63,7 +70,7 @@ export function makeAuthUiBridge(ctx: {
6370
// endpoint to detect completion. Errors are logged, not surfaced.
6471
void runConsoleLogin(
6572
origin,
66-
{ identity, settings, authStore },
73+
{ identity, settings, authStore, localize },
6774
{
6875
needApiKey: !hasApiKey,
6976
},

‎packages/commands/src/commands/auth/login-console.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type AuthStore,
99
type ConfigFile,
1010
type Identity,
11+
type LocalizedText,
1112
type Settings,
1213
} from "bailian-cli-core";
1314
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
@@ -18,6 +19,13 @@ export interface LoginDeps {
1819
identity: Identity;
1920
settings: Settings;
2021
authStore: AuthStore;
22+
/** 缺省时用户可见新文案回退到 en-US。 */
23+
localize?: (text: LocalizedText) => string;
24+
}
25+
26+
function localizeLogin(deps: LoginDeps, text: LocalizedText): string {
27+
if (typeof text === "string") return text;
28+
return deps.localize ? deps.localize(text) : text["en-US"];
2129
}
2230

2331
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
@@ -371,6 +379,7 @@ export async function runConsoleLogin(
371379
): Promise<void> {
372380
const state = randomBytes(16).toString("hex");
373381
let callbackError: unknown;
382+
let savedSessionWithoutApiKey = false;
374383
const server = http.createServer(async (req, res) => {
375384
try {
376385
if (req.method === "OPTIONS") {
@@ -423,6 +432,7 @@ export async function runConsoleLogin(
423432
...callbackPatch,
424433
base_url: baseUrl || undefined,
425434
});
435+
savedSessionWithoutApiKey = true;
426436
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
427437
}
428438
} catch (err: unknown) {
@@ -506,4 +516,14 @@ export async function runConsoleLogin(
506516
if (callbackError) {
507517
throw callbackError;
508518
}
519+
520+
if (opts?.needApiKey && savedSessionWithoutApiKey) {
521+
const binName = deps.identity.binName;
522+
process.stderr.write(
523+
`${localizeLogin(deps, {
524+
"en-US": `Saved the console session only. Model commands still need an API key: ${binName} auth login --console, or ${binName} auth login --api-key <key>`,
525+
"zh-CN": `仅保存了控制台会话。模型命令仍需要 API Key:${binName} auth login --console,或 ${binName} auth login --api-key <key>`,
526+
})}\n`,
527+
);
528+
}
509529
}

‎packages/commands/src/commands/auth/login.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export default defineCommand({
119119
async run(ctx) {
120120
const { identity, settings, flags } = ctx;
121121
const store = ctx.authStore;
122-
const deps = { identity, settings, authStore: store };
122+
const deps = { identity, settings, authStore: store, localize: ctx.localize };
123123
const key = flags.apiKey;
124124
const baseUrl = flags.baseUrl ? normalizeModelBaseUrl(flags.baseUrl) : undefined;
125125

‎packages/commands/src/commands/auth/status.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default defineCommand({
1010
auth: "none",
1111
exampleArgs: ["", "--output json"],
1212
async run(ctx) {
13-
const { identity, settings } = ctx;
13+
const { identity, settings, localize } = ctx;
1414
const format = detectOutputFormat(settings.output);
1515
const auth = ctx.authStore.describe();
1616

@@ -40,6 +40,12 @@ export default defineCommand({
4040
const authenticated = !!(apiKey || consoleCred || openapi);
4141
const configName = settings.configName ?? "default";
4242
const configFile = ctx.authStore.path;
43+
const modelKeyHint = apiKey
44+
? undefined
45+
: localize({
46+
"en-US": `No model API key is configured. Model commands need: ${identity.binName} auth login --console, or ${identity.binName} auth login --api-key <key>`,
47+
"zh-CN": `尚未配置模型 API Key。模型命令需要:${identity.binName} auth login --console,或 ${identity.binName} auth login --api-key <key>`,
48+
});
4349

4450
if (!authenticated) {
4551
emitResult(
@@ -69,6 +75,7 @@ export default defineCommand({
6975
api_key: apiKey,
7076
console: consoleCred,
7177
openapi,
78+
...(modelKeyHint ? { hint: modelKeyHint } : {}),
7279
},
7380
format,
7481
);
@@ -82,6 +89,7 @@ export default defineCommand({
8289
emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`);
8390
} else {
8491
emitBare(" API key (model): not configured");
92+
if (modelKeyHint) emitBare(` ${modelKeyHint}`);
8593
}
8694
if (consoleCred) {
8795
emitBare(

‎packages/commands/src/commands/shared/local-server.ts‎

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,65 @@ export function listenLocalServer(server: http.Server, port = 0): Promise<number
2222
});
2323
}
2424

25+
export interface OpenInvocation {
26+
command: string;
27+
args: string[];
28+
/** Windows only: keep the quotes we added for `cmd.exe`. */
29+
windowsVerbatimArguments?: boolean;
30+
}
31+
32+
/**
33+
* Quote a value for `cmd.exe`. `%` is doubled because cmd expands variables even
34+
* inside quotes; embedded `"` becomes `""`.
35+
*/
36+
function quoteForCmd(value: string): string {
37+
return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`;
38+
}
39+
40+
/**
41+
* Command that opens `target` with the OS default handler.
42+
*
43+
* On Windows, `start` is a `cmd.exe` builtin. An empty argv slot is not enough:
44+
* cmd re-parses the command line and treats an unquoted `&` as a separator, so
45+
* `...&needapikey=true` never reaches the browser. `start` also consumes the
46+
* first quoted argument as a window title. `/d` skips AutoRun, `/v:off` disables
47+
* delayed expansion, and `/s /c` strips one outer quote pair, leaving:
48+
* `start "" "<target>"`.
49+
*/
50+
export function buildOpenInvocation(target: string, platform = process.platform): OpenInvocation {
51+
if (platform === "win32") {
52+
const inner = `start "" ${quoteForCmd(target)}`;
53+
return {
54+
command: "cmd",
55+
args: ["/d", "/v:off", "/s", "/c", `"${inner}"`],
56+
windowsVerbatimArguments: true,
57+
};
58+
}
59+
if (platform === "darwin") {
60+
return { command: "open", args: [target] };
61+
}
62+
return { command: "xdg-open", args: [target] };
63+
}
64+
2565
/**
2666
* Open a local file, directory, or URL with the OS default handler
27-
* (best-effort, cross-platform). Arguments are passed to `execFile` as an array
28-
* so the target is never interpreted by a shell.
67+
* (best-effort, cross-platform).
2968
*/
3069
export function openPath(target: string): Promise<void> {
31-
const platform = process.platform;
32-
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
33-
const args = platform === "win32" ? ["/c", "start", "", target] : [target];
34-
70+
const invocation = buildOpenInvocation(target);
3571
return new Promise((resolve, reject) => {
36-
execFile(cmd, args, { windowsHide: true }, (err) => {
37-
if (err) reject(err);
38-
else resolve();
39-
});
72+
execFile(
73+
invocation.command,
74+
invocation.args,
75+
{
76+
windowsHide: true,
77+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
78+
},
79+
(err) => {
80+
if (err) reject(err);
81+
else resolve();
82+
},
83+
);
4084
});
4185
}
4286

‎packages/commands/tests/e2e/auth.e2e.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,47 @@ describe("e2e: auth", () => {
681681
expect(stderr).not.toContain("Cleared api_key");
682682
});
683683

684+
test("auth status 在只有控制台会话时标明模型 API Key 仍未配置", async () => {
685+
const configDir = makeE2eOutputDir("auth-status-console-only");
686+
writeFileSync(
687+
join(configDir, "config.json"),
688+
JSON.stringify({
689+
language: "zh-CN",
690+
access_token: "console-token-placeholder",
691+
console_region: "cn-beijing",
692+
console_site: "domestic",
693+
}),
694+
);
695+
const env = {
696+
BAILIAN_CONFIG_DIR: configDir,
697+
DASHSCOPE_API_KEY: undefined,
698+
DASHSCOPE_BASE_URL: undefined,
699+
ALIBABA_CLOUD_ACCESS_KEY_ID: undefined,
700+
ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined,
701+
ALIBABA_CLOUD_SECURITY_TOKEN: undefined,
702+
};
703+
704+
const json = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
705+
expect(json.exitCode, json.stderr).toBe(0);
706+
const data = parseStdoutJson<{
707+
authenticated?: boolean;
708+
api_key?: unknown;
709+
console?: { region?: string };
710+
hint?: string;
711+
}>(json.stdout);
712+
expect(data.authenticated).toBe(true);
713+
expect(data.api_key).toBeUndefined();
714+
expect(data.console?.region).toBe("cn-beijing");
715+
expect(data.hint).toContain("尚未配置模型 API Key");
716+
expect(data.hint).toContain("bl auth login --api-key <key>");
717+
expect(json.stdout).not.toContain("console-token-placeholder");
718+
719+
const text = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "text"], env);
720+
expect(text.exitCode, text.stderr).toBe(0);
721+
expect(text.stdout).toContain("API key (model): not configured");
722+
expect(text.stdout).toContain("尚未配置模型 API Key");
723+
});
724+
684725
test("auth logout 清除当前 Config 的全部凭证和 Base URL,保留普通配置", async () => {
685726
const configDir = makeE2eOutputDir("auth-logout-all");
686727
writeFileSync(

0 commit comments

Comments
 (0)