Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_

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

Expand Down
1 change: 1 addition & 0 deletions docs/agents/auth-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 插件里有一份同步实现)
54 changes: 46 additions & 8 deletions packages/bailian-kb-dsh/src/console-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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();
},
);
});
}

Expand Down
19 changes: 19 additions & 0 deletions packages/bailian-kb-dsh/tests/console-login.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
import {
buildOpenInvocation,
consoleLoginState,
parseCallbackBody,
pickCallbackCredentials,
Expand Down Expand Up @@ -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("%%");
});
});
13 changes: 10 additions & 3 deletions packages/commands/src/commands/auth/console-ui.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
},
Expand Down
20 changes: 20 additions & 0 deletions packages/commands/src/commands/auth/login-console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -371,6 +379,7 @@ export async function runConsoleLogin(
): Promise<void> {
const state = randomBytes(16).toString("hex");
let callbackError: unknown;
let savedSessionWithoutApiKey = false;
const server = http.createServer(async (req, res) => {
try {
if (req.method === "OPTIONS") {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <key>`,
"zh-CN": `仅保存了控制台会话。模型命令仍需要 API Key:${binName} auth login --console,或 ${binName} auth login --api-key <key>`,
})}\n`,
);
}
}
2 changes: 1 addition & 1 deletion packages/commands/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
10 changes: 9 additions & 1 deletion packages/commands/src/commands/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 <key>`,
"zh-CN": `尚未配置模型 API Key。模型命令需要:${identity.binName} auth login --console,或 ${identity.binName} auth login --api-key <key>`,
});

if (!authenticated) {
emitResult(
Expand Down Expand Up @@ -69,6 +75,7 @@ export default defineCommand({
api_key: apiKey,
console: consoleCred,
openapi,
...(modelKeyHint ? { hint: modelKeyHint } : {}),
},
format,
);
Expand All @@ -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(
Expand Down
68 changes: 58 additions & 10 deletions packages/commands/src/commands/shared/local-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,69 @@ export function listenLocalServer(server: http.Server, port = 0): Promise<number
});
}

export interface OpenInvocation {
command: string;
args: string[];
/** Windows only: keep the quotes we added for `cmd.exe`. */
windowsVerbatimArguments?: boolean;
}

/**
* Quote a value for `cmd /c`. An embedded `"` is written as `""`, which cmd
* reads as one literal quote.
*
* Do not double `%`. `%%` → `%` is a batch-file rule and does not apply when
* Node starts `cmd /c` directly. Doubling would make `start` open a different
* target (`%20` would stay `%20` with two percent signs).
*/
function quoteForCmd(value: string): string {
return `"${value.replaceAll('"', '""')}"`;
}

/**
* Command that opens `target` with the OS default handler.
*
* On Windows, `start` is a `cmd.exe` builtin. An empty argv slot is not enough:
* cmd re-parses the command line and treats an unquoted `&` as a separator, so
* `...&needapikey=true` never reaches the browser. `start` also consumes the
* first quoted argument as a window title. `/d` skips AutoRun, `/v:off` disables
* delayed expansion, and `/s /c` strips one outer quote pair, leaving:
* `start "" "<target>"`.
*/
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<void> {
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();
},
);
});
}

Expand Down
41 changes: 41 additions & 0 deletions packages/commands/tests/e2e/auth.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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(
Expand Down
Loading
Loading