Skip to content

Commit 01cbe77

Browse files
Merge pull request #266 from modelstudioai/fix/console-needapikey-miss
fix(auth): 修复 Windows 打开登录 URL 时 & 被截断,并补充缺少 API Key 的中英文提示
2 parents 395449d + 6449c60 commit 01cbe77

12 files changed

Lines changed: 394 additions & 24 deletions

File tree

‎README.zh.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
3636

3737
<p align="center">
3838
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
39-
<img src="https://img.alicdn.com/imgextra/i1/O1CN01Q5052k232Hd36NodG_!!6000000007197-0-tps-2940-1656.jpg" alt="点击播放演示视频" width="720" />
39+
<img src="https://img.alicdn.com/imgextra/i4/O1CN01YRqEiQMN7tJ2b87U_!!6000000004549-0-videocover-1280-744.jpg" alt="点击播放演示视频" width="720" />
4040
</a>
4141
</p>
4242

‎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: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,16 +160,54 @@ 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 /c`. An embedded `"` is written as `""`.
165+
* Do not double `%`: that substitution only happens inside batch files, and
166+
* `cmd /c` would otherwise open a target that still contains `%%`.
167+
* Keep in sync with `buildOpenInvocation` in
168+
* `packages/commands/src/commands/shared/local-server.ts`.
169+
*/
170+
function quoteForCmd(value: string): string {
171+
return `"${value.replaceAll('"', '""')}"`;
172+
}
173+
174+
/**
175+
* Command that opens `target` with the OS default handler.
176+
* On Windows, `cmd.exe` splits an unquoted `&`, so `needapikey=true` never
177+
* reaches the browser. See `buildOpenInvocation` in the commands package.
178+
*/
179+
export function buildOpenInvocation(
180+
target: string,
181+
platform = process.platform,
182+
): { command: string; args: string[]; windowsVerbatimArguments?: boolean } {
183+
if (platform === "win32") {
184+
const inner = `start "" ${quoteForCmd(target)}`;
185+
return {
186+
command: "cmd",
187+
args: ["/d", "/v:off", "/s", "/c", `"${inner}"`],
188+
windowsVerbatimArguments: true,
189+
};
190+
}
191+
if (platform === "darwin") return { command: "open", args: [target] };
192+
return { command: "xdg-open", args: [target] };
193+
}
194+
195+
/** Open a URL with the OS default handler. */
164196
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];
197+
const invocation = buildOpenInvocation(url);
168198
return new Promise((resolve, reject) => {
169-
execFile(cmd, args, { windowsHide: true }, (err) => {
170-
if (err) reject(err);
171-
else resolve();
172-
});
199+
execFile(
200+
invocation.command,
201+
invocation.args,
202+
{
203+
windowsHide: true,
204+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
205+
},
206+
(err) => {
207+
if (err) reject(err);
208+
else resolve();
209+
},
210+
);
173211
});
174212
}
175213

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

Lines changed: 19 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,21 @@ 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+
83+
it("leaves percent-encoded characters unchanged for cmd /c", () => {
84+
const url = "https://example.com/a%20b";
85+
const command = buildOpenInvocation(url, "win32").args.at(-1) ?? "";
86+
expect(command).toContain("%20");
87+
expect(command).not.toContain("%%");
88+
});
89+
});

‎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: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,69 @@ 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 /c`. An embedded `"` is written as `""`, which cmd
34+
* reads as one literal quote.
35+
*
36+
* Do not double `%`. `%%` → `%` is a batch-file rule and does not apply when
37+
* Node starts `cmd /c` directly. Doubling would make `start` open a different
38+
* target (`%20` would stay `%20` with two percent signs).
39+
*/
40+
function quoteForCmd(value: string): string {
41+
return `"${value.replaceAll('"', '""')}"`;
42+
}
43+
44+
/**
45+
* Command that opens `target` with the OS default handler.
46+
*
47+
* On Windows, `start` is a `cmd.exe` builtin. An empty argv slot is not enough:
48+
* cmd re-parses the command line and treats an unquoted `&` as a separator, so
49+
* `...&needapikey=true` never reaches the browser. `start` also consumes the
50+
* first quoted argument as a window title. `/d` skips AutoRun, `/v:off` disables
51+
* delayed expansion, and `/s /c` strips one outer quote pair, leaving:
52+
* `start "" "<target>"`.
53+
*/
54+
export function buildOpenInvocation(target: string, platform = process.platform): OpenInvocation {
55+
if (platform === "win32") {
56+
const inner = `start "" ${quoteForCmd(target)}`;
57+
return {
58+
command: "cmd",
59+
args: ["/d", "/v:off", "/s", "/c", `"${inner}"`],
60+
windowsVerbatimArguments: true,
61+
};
62+
}
63+
if (platform === "darwin") {
64+
return { command: "open", args: [target] };
65+
}
66+
return { command: "xdg-open", args: [target] };
67+
}
68+
2569
/**
2670
* 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.
71+
* (best-effort, cross-platform).
2972
*/
3073
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-
74+
const invocation = buildOpenInvocation(target);
3575
return new Promise((resolve, reject) => {
36-
execFile(cmd, args, { windowsHide: true }, (err) => {
37-
if (err) reject(err);
38-
else resolve();
39-
});
76+
execFile(
77+
invocation.command,
78+
invocation.args,
79+
{
80+
windowsHide: true,
81+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
82+
},
83+
(err) => {
84+
if (err) reject(err);
85+
else resolve();
86+
},
87+
);
4088
});
4189
}
4290

‎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)