From 55694843b5790f0151cb3ea4016fef3cc9222200 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 23 Oct 2026 18:22:36 +0800 Subject: [PATCH] feat(config): surface API key and base URL source mismatches - show environment overrides and active sources in `config show` - mask API keys while preserving source information - warn API key commands when credentials and base URLs come from different sources - support localized text and structured JSON warnings - add provenance resolution and regression coverage --- docs/agents/config-profile-change.md | 5 +- .../cli/tests/e2e/config-profile.e2e.test.ts | 14 ++- packages/commands/src/commands/config/show.ts | 107 ++++++++++++++++- .../commands/tests/e2e/config.e2e.test.ts | 112 +++++++++++++++++- packages/core/src/auth/index.ts | 5 + packages/core/src/auth/resolver.ts | 46 ++++++- packages/core/src/auth/types.ts | 9 ++ packages/core/tests/config-priority.test.ts | 65 ++++++++++ packages/core/tests/model-base-url.test.ts | 6 + packages/runtime/src/middleware.ts | 92 ++++++++++++++ packages/runtime/src/output/notice-box.ts | 29 +++++ .../tests/auth-profile-fallback.test.ts | 90 +++++++++++++- 12 files changed, 564 insertions(+), 16 deletions(-) create mode 100644 packages/runtime/src/output/notice-box.ts diff --git a/docs/agents/config-profile-change.md b/docs/agents/config-profile-change.md index 116e087ee..836ad584a 100644 --- a/docs/agents/config-profile-change.md +++ b/docs/agents/config-profile-change.md @@ -49,6 +49,8 @@ - 新增/重命名命令时同步 `packages/commands/src/index.ts` 和产品入口 `packages/cli/src/commands.ts`。 - `config list` 标识所有 Profile 与当前激活项。 - `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。 +- `config show` 额外展示 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL`(有值才输出,key 脱敏);JSON 字段值保持干净,另用 `active: { api_key?, base_url? }` 标生效源;text 模式才在生效层值后追加 `(active)` / `(active)`。`base_url` 展示文件层或内置默认,不再被 client 生效值覆盖来源语义。 +- 模型域 `api_key` 与显式 `base_url` 来自不同优先级层级时,仅 `auth: "apiKey"` 命令在 `authStage` 向 stderr 告警一次(`--quiet` 抑制;`--output json` 为 `API_KEY_BASE_URL_SOURCE_MISMATCH`);同源或仅一端显式配置不告。排查建议指向 `{binName} config show`。 - `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。 - `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*`、`telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`。UI 未管理的顶层元数据(如 `active_config`)不进入 Profile block,仍由写盘逻辑单独保留。 - `config ui` 只读展示本地 agent 生态:Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源;MCP、Agents 从各 agent 本地配置读取。 @@ -72,7 +74,8 @@ - Console/OpenAPI/none 命令不参与 API Key capability fallback;fallback 后的 Client 特殊端点行为必须跟随最终解析的 `base_url`,不能根据原 `settings.configName` 推断端点类型。 - Fallback 反馈只描述 CLI 能权威确认的本地行为:当前 Profile 不支持空格分隔的用户可见叶子命令,本次将从 `default` 读取 API Key 配置;不得声称整个 Profile 已切换,也不得展示 capability ID 或 `` 等内部值。存在 `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式覆盖时必须跳过 fallback 且不输出反馈。 - Fallback 反馈写 stderr,`--quiet` 抑制;text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象。dry-run 和后续鉴权失败仍保留反馈;JSON 模式下多个 diagnostics 以空行分隔,任何模式都不得输出凭证值。 -- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。 +- 跨源告警覆盖:env key + file base_url、flag/env 与另一层组合;同源与「仅 key + 默认 base_url」不告;quiet / json 形态与 fallback 一致。 +- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E;`config show` 覆盖 env 脱敏与 active 标记。 - `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。 - Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。 diff --git a/packages/cli/tests/e2e/config-profile.e2e.test.ts b/packages/cli/tests/e2e/config-profile.e2e.test.ts index f4f9e1a56..32ea12ae4 100644 --- a/packages/cli/tests/e2e/config-profile.e2e.test.ts +++ b/packages/cli/tests/e2e/config-profile.e2e.test.ts @@ -153,6 +153,9 @@ describe("e2e: named config", () => { }); } else { expect(leadingData.base_url).toBe("https://work.example.test"); + expect(leadingData.api_key).toBeDefined(); + expect(String(leadingData.api_key)).not.toMatch(/active/); + expect(leadingData.active).toEqual({ api_key: "config", base_url: "config" }); expect(leadingData.workspace_id).toBe("work-space-review"); } expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"))).toEqual( @@ -227,13 +230,20 @@ describe("e2e: named config", () => { }); const { stdout, stderr, exitCode } = await runCli( ["config", "show", "--config", "default", "--output", "json"], - { BAILIAN_CONFIG_DIR: dir }, + { + BAILIAN_CONFIG_DIR: dir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, ); expect(exitCode, stderr).toBe(0); const data = parseStdoutJson>(stdout); expect(data.config).toBe("default"); - expect(data.active).toBeUndefined(); + // `active` is credential-source provenance, not profile activation metadata. + expect(data.active).toEqual({ api_key: "config" }); + expect(data).not.toHaveProperty("active_config"); expect(data.api_key).toBeDefined(); + expect(String(data.api_key)).not.toMatch(/active/); const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record< string, unknown diff --git a/packages/commands/src/commands/config/show.ts b/packages/commands/src/commands/config/show.ts index c8d41f7ee..ed7289a36 100644 --- a/packages/commands/src/commands/config/show.ts +++ b/packages/commands/src/commands/config/show.ts @@ -1,21 +1,52 @@ -import { DEFAULT_LANGUAGE, defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core"; -import { emitResult } from "bailian-cli-runtime"; +import { + DEFAULT_LANGUAGE, + defineCommand, + detectOutputFormat, + formatOutput, + maskToken, + normalizeModelBaseUrl, + REGIONS, + resolveModelConnectionProvenance, + type CredentialSource, + type ResolutionSources, +} from "bailian-cli-core"; +import { ansi } from "bailian-cli-runtime"; import { SECRET_KEYS } from "./shared.ts"; +function markActive(value: string, isActive: boolean, suffix: string): string { + return isActive ? `${value}${suffix}` : value; +} + export default defineCommand({ description: { "en-US": "Display current configuration", "zh-CN": "显示当前配置" }, auth: "none", exampleArgs: ["", "--output json"], async run(ctx) { - const { settings, client } = ctx; + const { settings, localize } = ctx; const store = ctx.configStore; const file = store.read(); const format = detectOutputFormat(settings.output); + const activeSuffix = localize({ + "en-US": " (active)", + "zh-CN": "(active)", + }); + + // auth:none — no credential flags; file + env match the dispatch resolution chain. + const sources: ResolutionSources = { + flags: {}, + file, + env: process.env, + configName: settings.configName, + configPath: store.path, + }; + const provenance = resolveModelConnectionProvenance(sources); + + const envApiKey = process.env.DASHSCOPE_API_KEY?.trim(); + const envBaseUrl = process.env.DASHSCOPE_BASE_URL?.trim(); const result: Record = { ...file, language: file.language ?? DEFAULT_LANGUAGE, - base_url: client.baseUrl, output: settings.output, timeout: settings.timeout, watermark: settings.watermark, @@ -23,10 +54,74 @@ export default defineCommand({ config_file: store.path, }; + // Keep field values machine-clean; decorate only for text output below. + if (typeof file.api_key === "string") { + result.api_key = maskToken(file.api_key); + } + + if (envApiKey) { + result.DASHSCOPE_API_KEY = maskToken(envApiKey); + } + + if (typeof file.base_url === "string") { + result.base_url = file.base_url; + } else { + // Always show the built-in default when the file omits base_url. + result.base_url = REGIONS.cn; + } + + if (envBaseUrl) { + // Match the request path: origin only, so userinfo/query/fragment are not printed. + result.DASHSCOPE_BASE_URL = normalizeModelBaseUrl(envBaseUrl); + } + for (const key of SECRET_KEYS) { - if (typeof result[key] === "string") result[key] = maskToken(result[key]); + if (key === "api_key") continue; + if (typeof result[key] === "string") result[key] = maskToken(result[key] as string); + } + + const active: { api_key?: CredentialSource; base_url?: CredentialSource } = {}; + if (provenance.apiKeySource) active.api_key = provenance.apiKeySource; + if (provenance.baseUrlSource) active.base_url = provenance.baseUrlSource; + + if (format === "json") { + result.active = active; + process.stdout.write(formatOutput(result, format) + "\n"); + return; + } + + // Text: append (active) to the winning layer's displayed value. + if (typeof result.api_key === "string") { + result.api_key = markActive( + result.api_key, + provenance.apiKeySource === "config", + activeSuffix, + ); + } + if (typeof result.DASHSCOPE_API_KEY === "string") { + result.DASHSCOPE_API_KEY = markActive( + result.DASHSCOPE_API_KEY, + provenance.apiKeySource === "env", + activeSuffix, + ); + } + if (typeof result.base_url === "string") { + const baseUrlActive = + provenance.baseUrlSource === "config" || provenance.baseUrlSource === undefined; + result.base_url = markActive(result.base_url, baseUrlActive, activeSuffix); + } + if (typeof result.DASHSCOPE_BASE_URL === "string") { + result.DASHSCOPE_BASE_URL = markActive( + result.DASHSCOPE_BASE_URL, + provenance.baseUrlSource === "env", + activeSuffix, + ); } - emitResult(result, format); + // Color after render so YAML does not escape ANSI into values. + const rendered = formatOutput(result, format); + process.stdout.write( + rendered.replaceAll(activeSuffix, ansi(process.stdout).cyan(activeSuffix)) + "\n", + ); }, }); diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index 0c7314056..86299dbf9 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -63,11 +63,13 @@ describe("e2e: config", () => { const data = parseStdoutJson<{ config_file?: string; base_url?: string; + DASHSCOPE_BASE_URL?: string; timeout?: number; watermark?: boolean; }>(stdout); expect(data.config_file).toBeDefined(); - expect(data.base_url).toBeDefined(); + // 生效 base_url 可能来自文件/默认(base_url)或 env(DASHSCOPE_BASE_URL) + expect(data.base_url ?? data.DASHSCOPE_BASE_URL).toBeDefined(); expect(data.timeout).toBeDefined(); expect(data.watermark).toBe(true); }); @@ -147,6 +149,114 @@ describe("e2e: config", () => { } }); + test("config show 脱敏 DASHSCOPE_*,JSON 用 active 标出生效层且值保持干净", async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-config-show-env-")); + try { + writeFileSync( + join(configDir, "config.json"), + JSON.stringify( + { + api_key: "sk-file-secret-key", + base_url: "https://file.example.com", + }, + null, + 2, + ) + "\n", + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + CONFIG_ROUTES, + ["config", "show", "--output", "json"], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "sk-env-secret-key", + DASHSCOPE_BASE_URL: "https://env.example.com", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + api_key?: string; + base_url?: string; + DASHSCOPE_API_KEY?: string; + DASHSCOPE_BASE_URL?: string; + active?: { api_key?: string; base_url?: string }; + }>(stdout); + expect(data.DASHSCOPE_API_KEY).toBeDefined(); + expect(data.DASHSCOPE_API_KEY).not.toContain("sk-env-secret-key"); + expect(data.DASHSCOPE_API_KEY).not.toMatch(/active/); + expect(data.DASHSCOPE_BASE_URL).toBe("https://env.example.com"); + expect(data.api_key).toBeDefined(); + expect(data.api_key).not.toMatch(/active/); + expect(data.api_key).not.toContain("sk-file-secret-key"); + expect(data.base_url).toBe("https://file.example.com"); + expect(data.active).toEqual({ api_key: "env", base_url: "env" }); + expect(stdout).not.toContain("sk-env-secret-key"); + expect(stdout).not.toContain("sk-file-secret-key"); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); + + test("config show 在仅有 DASHSCOPE_BASE_URL 时仍输出文件层默认 base_url", async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-config-show-env-base-only-")); + try { + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ api_key: "sk-file" }, null, 2) + "\n", + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + CONFIG_ROUTES, + ["config", "show", "--output", "json"], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "https://env.example.com", + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + base_url?: string; + DASHSCOPE_BASE_URL?: string; + active?: { api_key?: string; base_url?: string }; + }>(stdout); + expect(data.base_url).toBe("https://dashscope.aliyuncs.com"); + expect(data.DASHSCOPE_BASE_URL).toBe("https://env.example.com"); + expect(data.active).toEqual({ api_key: "config", base_url: "env" }); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); + + test("config show 对 DASHSCOPE_BASE_URL 只展示 origin,不泄露 userinfo/query", async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-config-show-env-url-secret-")); + const rawEnvBaseUrl = "https://user:pass@example.com/path?token=super-secret#frag"; + try { + writeFileSync(join(configDir, "config.json"), "{}\n"); + + const { stdout, stderr, exitCode } = await runCommandE2e( + CONFIG_ROUTES, + ["config", "show", "--output", "json"], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_BASE_URL: rawEnvBaseUrl, + }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + DASHSCOPE_BASE_URL?: string; + active?: { base_url?: string }; + }>(stdout); + expect(data.DASHSCOPE_BASE_URL).toBe("https://example.com"); + expect(data.active?.base_url).toBe("env"); + expect(stdout).not.toContain("user:pass"); + expect(stdout).not.toContain("super-secret"); + expect(stdout).not.toContain(rawEnvBaseUrl); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); + test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => { const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]); expect(exitCode, stderr).toBe(2); diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index f63fc6281..af7a48860 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -1,10 +1,14 @@ export { resolveApiKey, + resolveApiKeySource, resolveConsole, resolveOpenApi, describeAuthState, resolveModelBaseUrl, + resolveModelBaseUrlSource, resolveModelBaseUrlState, + resolveModelConnectionProvenance, + isModelConnectionSourceMismatch, } from "./resolver.ts"; export { makeAuthStore, type AuthStore, type AuthPersistPatch } from "./store.ts"; export type { @@ -13,6 +17,7 @@ export type { OpenApiCredential, AuthState, CredentialSource, + ModelConnectionProvenance, } from "./types.ts"; export { generateCLIAccessToken, diff --git a/packages/core/src/auth/resolver.ts b/packages/core/src/auth/resolver.ts index faf2241f2..110fa773b 100644 --- a/packages/core/src/auth/resolver.ts +++ b/packages/core/src/auth/resolver.ts @@ -1,13 +1,57 @@ import { REGIONS } from "../config/schema.ts"; import { normalizeModelBaseUrl } from "../config/model-base-url.ts"; import type { ResolutionSources } from "../config/loader.ts"; -import type { ApiKeyCredential, ConsoleCredential, OpenApiCredential, AuthState } from "./types.ts"; +import type { + ApiKeyCredential, + ConsoleCredential, + CredentialSource, + ModelConnectionProvenance, + OpenApiCredential, + AuthState, +} from "./types.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; // Resolve the credential for a command's declared domain (model = api-key, // console = access-token), by priority, or throw. Read only from sources. +/** Winning api_key source (flag > env > file), or undefined if none. */ +export function resolveApiKeySource(sources: ResolutionSources): CredentialSource | undefined { + if (sources.flags.apiKey) return "flag"; + if (sources.env.DASHSCOPE_API_KEY?.trim()) return "env"; + if (sources.file.api_key) return "config"; + return undefined; +} + +/** + * Winning explicit base_url source (flag > env > file). + * Undefined means the built-in REGIONS.cn default will be used. + */ +export function resolveModelBaseUrlSource( + sources: ResolutionSources, +): CredentialSource | undefined { + if (sources.flags.baseUrl) return "flag"; + if (sources.env.DASHSCOPE_BASE_URL?.trim()) return "env"; + if (sources.file.base_url) return "config"; + return undefined; +} + +/** Provenance for the model connection pair (no secrets). */ +export function resolveModelConnectionProvenance( + sources: ResolutionSources, +): ModelConnectionProvenance { + return { + apiKeySource: resolveApiKeySource(sources), + baseUrlSource: resolveModelBaseUrlSource(sources), + }; +} + +/** True only when both sides are explicit and disagree. */ +export function isModelConnectionSourceMismatch(provenance: ModelConnectionProvenance): boolean { + const { apiKeySource, baseUrlSource } = provenance; + return Boolean(apiKeySource && baseUrlSource && apiKeySource !== baseUrlSource); +} + /** Preserve whether the shared base URL chain selected a configured origin or its default. */ export function resolveModelBaseUrlState( sources: ResolutionSources, diff --git a/packages/core/src/auth/types.ts b/packages/core/src/auth/types.ts index 5b45a37cf..78ecec533 100644 --- a/packages/core/src/auth/types.ts +++ b/packages/core/src/auth/types.ts @@ -1,6 +1,15 @@ /** Where a resolved credential came from (shown by `bl auth status`). */ export type CredentialSource = "flag" | "env" | "config"; +/** + * Winning sources for the model-domain connection pair. + * Missing `baseUrlSource` means the built-in REGIONS.cn default. + */ +export interface ModelConnectionProvenance { + apiKeySource?: CredentialSource; + baseUrlSource?: CredentialSource; +} + /** Credential for the **model domain** (DashScope data plane). Always an API key. */ export interface ApiKeyCredential { token: string; diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index 901cd3e1a..7678f2e8c 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -2,10 +2,13 @@ import { expect, test } from "vite-plus/test"; import { parseConfigFile, type ConfigFile, type Settings } from "../src/config/schema.ts"; import { buildSettings, type ResolutionSources } from "../src/config/loader.ts"; import { + isModelConnectionSourceMismatch, resolveApiKey, resolveConsole, resolveModelBaseUrl, + resolveModelBaseUrlSource, resolveModelBaseUrlState, + resolveModelConnectionProvenance, resolveOpenApi, } from "../src/auth/resolver.ts"; import { getModelProfilePreset } from "../src/config/profile-presets.ts"; @@ -98,6 +101,68 @@ test("baseUrl:flag > env > file > 默认,所有来源统一归一化", () => { expect(resolveModelBaseUrl(src({}))).toBe("https://dashscope.aliyuncs.com"); }); +test("base_url 来源:flag > env > file;均无则为 undefined(内置默认)", () => { + const flags = { baseUrl: "https://flag.example.com" }; + const env = { DASHSCOPE_BASE_URL: "https://env.example.com" }; + const file: ConfigFile = { base_url: "https://file.example.com" }; + expect(resolveModelBaseUrlSource(src({ flags, env, file }))).toBe("flag"); + expect(resolveModelBaseUrlSource(src({ env, file }))).toBe("env"); + expect(resolveModelBaseUrlSource(src({ file }))).toBe("config"); + expect(resolveModelBaseUrlSource(src({}))).toBeUndefined(); +}); + +test("跨源判定:两端都有显式来源且不同才 mismatch", () => { + expect( + isModelConnectionSourceMismatch( + resolveModelConnectionProvenance( + src({ + env: { DASHSCOPE_API_KEY: "sk-env" }, + file: { base_url: "https://file.example.com" }, + }), + ), + ), + ).toBe(true); + expect( + isModelConnectionSourceMismatch( + resolveModelConnectionProvenance( + src({ + flags: { apiKey: "sk-flag", baseUrl: "https://flag.example.com" }, + }), + ), + ), + ).toBe(false); + expect( + isModelConnectionSourceMismatch( + resolveModelConnectionProvenance( + src({ + env: { DASHSCOPE_API_KEY: "sk-env", DASHSCOPE_BASE_URL: "https://env.example.com" }, + }), + ), + ), + ).toBe(false); + expect( + isModelConnectionSourceMismatch( + resolveModelConnectionProvenance( + src({ + file: { api_key: "sk-file", base_url: "https://file.example.com" }, + }), + ), + ), + ).toBe(false); + // Key only (default base_url) — not a mismatch. + expect( + isModelConnectionSourceMismatch( + resolveModelConnectionProvenance(src({ env: { DASHSCOPE_API_KEY: "sk-env" } })), + ), + ).toBe(false); + // Explicit base_url only — not a mismatch (missing key is AUTH elsewhere). + expect( + isModelConnectionSourceMismatch( + resolveModelConnectionProvenance(src({ file: { base_url: "https://file.example.com" } })), + ), + ).toBe(false); +}); + test("baseUrl:非法 flag/env 在 resolver 边界报 usage error", () => { expect(() => resolveModelBaseUrl(src({ flags: { baseUrl: "not-a-url" } }))).toThrow( /Invalid model base URL/, diff --git a/packages/core/tests/model-base-url.test.ts b/packages/core/tests/model-base-url.test.ts index 1a0877cee..3ee247449 100644 --- a/packages/core/tests/model-base-url.test.ts +++ b/packages/core/tests/model-base-url.test.ts @@ -21,6 +21,12 @@ test("normalizeModelBaseUrl keeps only the URL origin", () => { ).toBe("https://example.com"); }); +test("normalizeModelBaseUrl strips userinfo, query, and fragment", () => { + expect(normalizeModelBaseUrl("https://user:pass@example.com/path?token=super-secret#frag")).toBe( + "https://example.com", + ); +}); + test("normalizeModelBaseUrl preserves explicit ports while removing paths", () => { expect(normalizeModelBaseUrl("http://localhost:8080/bailian/")).toBe("http://localhost:8080"); }); diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index b44271b2a..376ea2e1a 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -5,6 +5,7 @@ import type { ConfigStore, CommandPackManager, ConsoleCredential, + CredentialSource, FlagsDef, Identity, OpenApiCredential, @@ -16,15 +17,18 @@ import type { import { Client, DEFAULT_LANGUAGE, + isModelConnectionSourceMismatch, resolveApiKey, resolveConsole, resolveOpenApi, resolveModelBaseUrlState, + resolveModelConnectionProvenance, selectApiKeyResolutionSources, trackCommandExecution, } from "bailian-cli-core"; import { maybeShowStatusBar } from "./output/status-bar.ts"; import { ansi } from "./output/color.ts"; +import { renderNoticeBox } from "./output/notice-box.ts"; import { createTranslator } from "./i18n.ts"; import { checkForUpdate, @@ -105,6 +109,85 @@ function writeApiKeyFallbackNotice(ctx: RunContext, fallbackFrom: string): void process.stderr.write(`${message}\n`); } +function describeCredentialSourceLabel( + field: "api_key" | "base_url", + source: CredentialSource, + configPath: string, + language: "en-US" | "zh-CN", +): string { + if (source === "flag") { + return field === "api_key" ? "--api-key" : "--base-url"; + } + if (source === "env") { + const envName = field === "api_key" ? "DASHSCOPE_API_KEY" : "DASHSCOPE_BASE_URL"; + return language === "zh-CN" ? `环境变量 (${envName})` : `env (${envName})`; + } + return language === "zh-CN" ? `配置文件 (${configPath})` : `config (${configPath})`; +} + +function formatModelConnectionMismatchNotice( + ctx: RunContext, + apiKeySource: CredentialSource, + baseUrlSource: CredentialSource, +): { title: string; lines: string[]; message: string } { + const language = ctx.sources.file.language ?? DEFAULT_LANGUAGE; + const translator = createTranslator(language); + const configPath = ctx.sources.configPath ?? ctx.settings.configPath ?? "~/.bailian/config.json"; + const apiKeyLabel = describeCredentialSourceLabel("api_key", apiKeySource, configPath, language); + const baseUrlLabel = describeCredentialSourceLabel( + "base_url", + baseUrlSource, + configPath, + language, + ); + const title = translator.localize({ "en-US": "warning", "zh-CN": "警告" }); + const lines = translator + .localize({ + "en-US": [ + "api_key and base_url are resolved from different sources.", + `api_key ← ${apiKeyLabel}`, + `base_url ← ${baseUrlLabel}`, + "These may not match. The request will continue.", + `Set both from the same source, or run ${ctx.identity.binName} config show to view effective configuration.`, + ].join("\n"), + "zh-CN": [ + "api_key 和 base_url 来自不同配置源。", + `api_key ← ${apiKeyLabel}`, + `base_url ← ${baseUrlLabel}`, + "两者可能不匹配,请求仍会继续。", + `建议设为同一来源,或运行 ${ctx.identity.binName} config show 查看生效配置。`, + ].join("\n"), + }) + .split("\n"); + return { title, lines, message: lines.join("\n") }; +} + +function writeModelConnectionMismatchNotice( + ctx: RunContext, + apiKeySource: CredentialSource, + baseUrlSource: CredentialSource, +): void { + const notice = formatModelConnectionMismatchNotice(ctx, apiKeySource, baseUrlSource); + if (ctx.settings.output === "json") { + process.stderr.write( + JSON.stringify( + { + warning: { + code: "API_KEY_BASE_URL_SOURCE_MISMATCH", + message: notice.message, + api_key_source: apiKeySource, + base_url_source: baseUrlSource, + }, + }, + null, + 2, + ) + "\n\n", + ); + return; + } + process.stderr.write(`${renderNoticeBox(notice.title, notice.lines)}\n`); +} + /** Fold a middleware list into a single runnable function. */ export function compose(stack: Middleware[]): (ctx: RunContext) => Promise { return (ctx) => { @@ -137,6 +220,15 @@ export const authStage: Middleware = async (ctx, next) => { if (selection.fallbackFrom && !settings.quiet) { writeApiKeyFallbackNotice(ctx, selection.fallbackFrom); } + const provenance = resolveModelConnectionProvenance(apiSources); + if ( + !settings.quiet && + isModelConnectionSourceMismatch(provenance) && + provenance.apiKeySource && + provenance.baseUrlSource + ) { + writeModelConnectionMismatchNotice(ctx, provenance.apiKeySource, provenance.baseUrlSource); + } let cred: ApiKeyCredential | undefined; try { cred = resolveApiKey(apiSources); diff --git a/packages/runtime/src/output/notice-box.ts b/packages/runtime/src/output/notice-box.ts new file mode 100644 index 000000000..bdc08ca10 --- /dev/null +++ b/packages/runtime/src/output/notice-box.ts @@ -0,0 +1,29 @@ +import { ansi } from "./color.ts"; +import { displayWidth } from "./cjk-width.ts"; + +/** + * Frame a multi-line notice. Yellow border on TTY; plain box otherwise. + * Width uses display columns (CJK counts as two). + */ +export function renderNoticeBox( + title: string, + lines: readonly string[], + out: NodeJS.WriteStream = process.stderr, +): string { + const color = ansi(out); + const padding = 1; + const titleLabel = ` ${title} `; + const contentWidth = Math.max( + displayWidth(titleLabel), + ...lines.map((line) => displayWidth(line)), + 16, + ); + const innerWidth = contentWidth + padding * 2; + const top = `┌${titleLabel}${"─".repeat(Math.max(innerWidth - displayWidth(titleLabel), 0))}┐`; + const bottom = `└${"─".repeat(innerWidth)}┘`; + const body = lines.map((line) => { + const gap = contentWidth - displayWidth(line); + return `│${" ".repeat(padding)}${line}${" ".repeat(Math.max(gap, 0))}${" ".repeat(padding)}│`; + }); + return [top, ...body, bottom].map((line) => color.yellow(line)).join("\n"); +} diff --git a/packages/runtime/tests/auth-profile-fallback.test.ts b/packages/runtime/tests/auth-profile-fallback.test.ts index d102b3a15..79ee653fd 100644 --- a/packages/runtime/tests/auth-profile-fallback.test.ts +++ b/packages/runtime/tests/auth-profile-fallback.test.ts @@ -200,7 +200,10 @@ test("authStage 在 --api-key 覆盖 Profile API Key 时跳过 capability fallba const captured = await captureStderr(() => runAuth(context)); expect(captured.error).toBeUndefined(); - expect(captured.output).toBe(""); + // Cross-source warning expected; capability fallback must still be skipped. + expect(captured.output).toContain("api_key and base_url are resolved from different sources"); + expect(captured.output).not.toContain("PROFILE_API_KEY_FALLBACK"); + expect(captured.output).not.toContain("does not support command"); expect(context.client.exportApiCredential()).toMatchObject({ token: "sk-flag", baseUrl: "https://plan.example.com", @@ -266,7 +269,7 @@ test("authStage 在 JSON 模式输出多行结构化 fallback warning", async () }); }); -test("authStage 在 DASHSCOPE_API_KEY 覆盖 Profile API Key 时不降级也不提示", async () => { +test("authStage 在 DASHSCOPE_API_KEY 覆盖 Profile API Key 时不降级,但跨源会告警", async () => { useTempConfigDir(); await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" }); await writeConfigFile( @@ -287,7 +290,9 @@ test("authStage 在 DASHSCOPE_API_KEY 覆盖 Profile API Key 时不降级也不 const captured = await captureStderr(() => runAuth(context)); expect(captured.error).toBeUndefined(); - expect(captured.output).toBe(""); + expect(captured.output).toContain("api_key and base_url are resolved from different sources"); + expect(captured.output).not.toContain("PROFILE_API_KEY_FALLBACK"); + expect(captured.output).not.toContain("does not support command"); expect(context.client.exportApiCredential()).toMatchObject({ token: "sk-env", source: "env", @@ -308,7 +313,7 @@ test.each([ env: { DASHSCOPE_BASE_URL: "https://env.example.com" }, expectedBaseUrl: "https://env.example.com", }, -])("authStage 在只有 $name 时不降级也不提示", async ({ flags, env, expectedBaseUrl }) => { +])("authStage 在只有 $name 时不降级,但跨源会告警", async ({ flags, env, expectedBaseUrl }) => { useTempConfigDir(); await writeConfigFile({ api_key: "sk-default", base_url: "https://default.example.com" }); await writeConfigFile( @@ -325,7 +330,9 @@ test.each([ const captured = await captureStderr(() => runAuth(context)); expect(captured.error).toBeUndefined(); - expect(captured.output).toBe(""); + expect(captured.output).toContain("api_key and base_url are resolved from different sources"); + expect(captured.output).not.toContain("PROFILE_API_KEY_FALLBACK"); + expect(captured.output).not.toContain("does not support command"); expect(context.client.exportApiCredential()).toMatchObject({ token: "sk-plan", source: "config", @@ -333,6 +340,79 @@ test.each([ }); }); +test("authStage 在 api_key 与 base_url 跨源时输出告警,quiet 可抑制", async () => { + useTempConfigDir(); + await writeConfigFile({ + language: "zh-CN", + api_key: "sk-file", + base_url: "https://file.example.com", + }); + const visibleContext = makeContext([], {}, { DASHSCOPE_API_KEY: "sk-env-key" }); + visibleContext.settings.quiet = false; + const visible = await captureStderr(() => runAuth(visibleContext)); + expect(visible.error).toBeUndefined(); + expect(visible.output).toContain("┌ 警告 "); + expect(visible.output).toContain("api_key 和 base_url 来自不同配置源。"); + expect(visible.output).toContain("└"); + expect(visible.output).toContain("api_key ← 环境变量 (DASHSCOPE_API_KEY)"); + expect(visible.output).toContain("base_url ← 配置文件 ("); + expect(visible.output).toContain("bl config show"); + + const quietContext = makeContext([], {}, { DASHSCOPE_API_KEY: "sk-env-key" }); + const quiet = await captureStderr(() => runAuth(quietContext)); + expect(quiet.error).toBeUndefined(); + expect(quiet.output).toBe(""); +}); + +test("authStage 在 JSON 模式输出跨源结构化 warning", async () => { + useTempConfigDir(); + await writeConfigFile({ + language: "en-US", + api_key: "sk-file", + base_url: "https://file.example.com", + }); + const context = makeContext([], {}, { DASHSCOPE_API_KEY: "sk-env-key" }); + context.settings.output = "json"; + context.settings.quiet = false; + + const captured = await captureStderr(() => runAuth(context)); + + expect(captured.error).toBeUndefined(); + expect(captured.output).toMatch(/^\{\n {2}"warning": \{\n/); + const parsed = JSON.parse(captured.output.trim()) as { + warning: { + code: string; + api_key_source: string; + base_url_source: string; + message: string; + }; + }; + expect(parsed.warning.code).toBe("API_KEY_BASE_URL_SOURCE_MISMATCH"); + expect(parsed.warning.api_key_source).toBe("env"); + expect(parsed.warning.base_url_source).toBe("config"); + expect(parsed.warning.message).toContain("different sources"); + expect(parsed.warning.message).toContain("bl config show"); +}); + +test("authStage 同源或仅 key 时不发出跨源告警", async () => { + useTempConfigDir(); + await writeConfigFile({ api_key: "sk-file", base_url: "https://file.example.com" }); + + const sameSource = makeContext([], {}); + sameSource.settings.quiet = false; + const same = await captureStderr(() => runAuth(sameSource)); + expect(same.error).toBeUndefined(); + expect(same.output).toBe(""); + + await writeConfigFile({ api_key: "sk-file" }); + const keyOnlyFresh = makeContext([], {}, { DASHSCOPE_API_KEY: "sk-env-only" }); + keyOnlyFresh.settings.quiet = false; + const onlyKey = await captureStderr(() => runAuth(keyOnlyFresh)); + expect(onlyKey.error).toBeUndefined(); + expect(onlyKey.output).not.toContain("different sources"); + expect(onlyKey.output).not.toContain("不同配置源"); +}); + test("authStage fallback 展示空格分隔的叶子命令路径", async () => { useTempConfigDir(); await writeConfigFile({ api_key: "sk-default" });