Skip to content

Commit 5569484

Browse files
committed
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
1 parent e89c331 commit 5569484

12 files changed

Lines changed: 564 additions & 16 deletions

File tree

‎docs/agents/config-profile-change.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
- 新增/重命名命令时同步 `packages/commands/src/index.ts` 和产品入口 `packages/cli/src/commands.ts`。
5050
- `config list` 标识所有 Profile 与当前激活项。
5151
- `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。
52+
- `config show` 额外展示 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL`(有值才输出,key 脱敏);JSON 字段值保持干净,另用 `active: { api_key?, base_url? }` 标生效源;text 模式才在生效层值后追加 `(active)` / `(active)`。`base_url` 展示文件层或内置默认,不再被 client 生效值覆盖来源语义。
53+
- 模型域 `api_key` 与显式 `base_url` 来自不同优先级层级时,仅 `auth: "apiKey"` 命令在 `authStage` 向 stderr 告警一次(`--quiet` 抑制;`--output json` 为 `API_KEY_BASE_URL_SOURCE_MISMATCH`);同源或仅一端显式配置不告。排查建议指向 `{binName} config show`。
5254
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。
5355
- `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*`、`telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`。UI 未管理的顶层元数据(如 `active_config`)不进入 Profile block,仍由写盘逻辑单独保留。
5456
- `config ui` 只读展示本地 agent 生态:Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源;MCP、Agents 从各 agent 本地配置读取。
@@ -72,7 +74,8 @@
7274
- Console/OpenAPI/none 命令不参与 API Key capability fallback;fallback 后的 Client 特殊端点行为必须跟随最终解析的 `base_url`,不能根据原 `settings.configName` 推断端点类型。
7375
- Fallback 反馈只描述 CLI 能权威确认的本地行为:当前 Profile 不支持空格分隔的用户可见叶子命令,本次将从 `default` 读取 API Key 配置;不得声称整个 Profile 已切换,也不得展示 capability ID 或 `<undeclared>` 等内部值。存在 `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式覆盖时必须跳过 fallback 且不输出反馈。
7476
- Fallback 反馈写 stderr,`--quiet` 抑制;text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象。dry-run 和后续鉴权失败仍保留反馈;JSON 模式下多个 diagnostics 以空行分隔,任何模式都不得输出凭证值。
75-
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
77+
- 跨源告警覆盖:env key + file base_url、flag/env 与另一层组合;同源与「仅 key + 默认 base_url」不告;quiet / json 形态与 fallback 一致。
78+
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E;`config show` 覆盖 env 脱敏与 active 标记。
7679
- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。
7780
- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。
7881

‎packages/cli/tests/e2e/config-profile.e2e.test.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ describe("e2e: named config", () => {
153153
});
154154
} else {
155155
expect(leadingData.base_url).toBe("https://work.example.test");
156+
expect(leadingData.api_key).toBeDefined();
157+
expect(String(leadingData.api_key)).not.toMatch(/active/);
158+
expect(leadingData.active).toEqual({ api_key: "config", base_url: "config" });
156159
expect(leadingData.workspace_id).toBe("work-space-review");
157160
}
158161
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"))).toEqual(
@@ -227,13 +230,20 @@ describe("e2e: named config", () => {
227230
});
228231
const { stdout, stderr, exitCode } = await runCli(
229232
["config", "show", "--config", "default", "--output", "json"],
230-
{ BAILIAN_CONFIG_DIR: dir },
233+
{
234+
BAILIAN_CONFIG_DIR: dir,
235+
DASHSCOPE_API_KEY: "",
236+
DASHSCOPE_BASE_URL: "",
237+
},
231238
);
232239
expect(exitCode, stderr).toBe(0);
233240
const data = parseStdoutJson<Record<string, unknown>>(stdout);
234241
expect(data.config).toBe("default");
235-
expect(data.active).toBeUndefined();
242+
// `active` is credential-source provenance, not profile activation metadata.
243+
expect(data.active).toEqual({ api_key: "config" });
244+
expect(data).not.toHaveProperty("active_config");
236245
expect(data.api_key).toBeDefined();
246+
expect(String(data.api_key)).not.toMatch(/active/);
237247
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
238248
string,
239249
unknown
Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,127 @@
1-
import { DEFAULT_LANGUAGE, defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core";
2-
import { emitResult } from "bailian-cli-runtime";
1+
import {
2+
DEFAULT_LANGUAGE,
3+
defineCommand,
4+
detectOutputFormat,
5+
formatOutput,
6+
maskToken,
7+
normalizeModelBaseUrl,
8+
REGIONS,
9+
resolveModelConnectionProvenance,
10+
type CredentialSource,
11+
type ResolutionSources,
12+
} from "bailian-cli-core";
13+
import { ansi } from "bailian-cli-runtime";
314
import { SECRET_KEYS } from "./shared.ts";
415

16+
function markActive(value: string, isActive: boolean, suffix: string): string {
17+
return isActive ? `${value}${suffix}` : value;
18+
}
19+
520
export default defineCommand({
621
description: { "en-US": "Display current configuration", "zh-CN": "显示当前配置" },
722
auth: "none",
823
exampleArgs: ["", "--output json"],
924
async run(ctx) {
10-
const { settings, client } = ctx;
25+
const { settings, localize } = ctx;
1126
const store = ctx.configStore;
1227
const file = store.read();
1328
const format = detectOutputFormat(settings.output);
29+
const activeSuffix = localize({
30+
"en-US": " (active)",
31+
"zh-CN": "(active)",
32+
});
33+
34+
// auth:none — no credential flags; file + env match the dispatch resolution chain.
35+
const sources: ResolutionSources = {
36+
flags: {},
37+
file,
38+
env: process.env,
39+
configName: settings.configName,
40+
configPath: store.path,
41+
};
42+
const provenance = resolveModelConnectionProvenance(sources);
43+
44+
const envApiKey = process.env.DASHSCOPE_API_KEY?.trim();
45+
const envBaseUrl = process.env.DASHSCOPE_BASE_URL?.trim();
1446

1547
const result: Record<string, unknown> = {
1648
...file,
1749
language: file.language ?? DEFAULT_LANGUAGE,
18-
base_url: client.baseUrl,
1950
output: settings.output,
2051
timeout: settings.timeout,
2152
watermark: settings.watermark,
2253
config: settings.configName ?? "default",
2354
config_file: store.path,
2455
};
2556

57+
// Keep field values machine-clean; decorate only for text output below.
58+
if (typeof file.api_key === "string") {
59+
result.api_key = maskToken(file.api_key);
60+
}
61+
62+
if (envApiKey) {
63+
result.DASHSCOPE_API_KEY = maskToken(envApiKey);
64+
}
65+
66+
if (typeof file.base_url === "string") {
67+
result.base_url = file.base_url;
68+
} else {
69+
// Always show the built-in default when the file omits base_url.
70+
result.base_url = REGIONS.cn;
71+
}
72+
73+
if (envBaseUrl) {
74+
// Match the request path: origin only, so userinfo/query/fragment are not printed.
75+
result.DASHSCOPE_BASE_URL = normalizeModelBaseUrl(envBaseUrl);
76+
}
77+
2678
for (const key of SECRET_KEYS) {
27-
if (typeof result[key] === "string") result[key] = maskToken(result[key]);
79+
if (key === "api_key") continue;
80+
if (typeof result[key] === "string") result[key] = maskToken(result[key] as string);
81+
}
82+
83+
const active: { api_key?: CredentialSource; base_url?: CredentialSource } = {};
84+
if (provenance.apiKeySource) active.api_key = provenance.apiKeySource;
85+
if (provenance.baseUrlSource) active.base_url = provenance.baseUrlSource;
86+
87+
if (format === "json") {
88+
result.active = active;
89+
process.stdout.write(formatOutput(result, format) + "\n");
90+
return;
91+
}
92+
93+
// Text: append (active) to the winning layer's displayed value.
94+
if (typeof result.api_key === "string") {
95+
result.api_key = markActive(
96+
result.api_key,
97+
provenance.apiKeySource === "config",
98+
activeSuffix,
99+
);
100+
}
101+
if (typeof result.DASHSCOPE_API_KEY === "string") {
102+
result.DASHSCOPE_API_KEY = markActive(
103+
result.DASHSCOPE_API_KEY,
104+
provenance.apiKeySource === "env",
105+
activeSuffix,
106+
);
107+
}
108+
if (typeof result.base_url === "string") {
109+
const baseUrlActive =
110+
provenance.baseUrlSource === "config" || provenance.baseUrlSource === undefined;
111+
result.base_url = markActive(result.base_url, baseUrlActive, activeSuffix);
112+
}
113+
if (typeof result.DASHSCOPE_BASE_URL === "string") {
114+
result.DASHSCOPE_BASE_URL = markActive(
115+
result.DASHSCOPE_BASE_URL,
116+
provenance.baseUrlSource === "env",
117+
activeSuffix,
118+
);
28119
}
29120

30-
emitResult(result, format);
121+
// Color after render so YAML does not escape ANSI into values.
122+
const rendered = formatOutput(result, format);
123+
process.stdout.write(
124+
rendered.replaceAll(activeSuffix, ansi(process.stdout).cyan(activeSuffix)) + "\n",
125+
);
31126
},
32127
});

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

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,13 @@ describe("e2e: config", () => {
6363
const data = parseStdoutJson<{
6464
config_file?: string;
6565
base_url?: string;
66+
DASHSCOPE_BASE_URL?: string;
6667
timeout?: number;
6768
watermark?: boolean;
6869
}>(stdout);
6970
expect(data.config_file).toBeDefined();
70-
expect(data.base_url).toBeDefined();
71+
// 生效 base_url 可能来自文件/默认(base_url)或 env(DASHSCOPE_BASE_URL)
72+
expect(data.base_url ?? data.DASHSCOPE_BASE_URL).toBeDefined();
7173
expect(data.timeout).toBeDefined();
7274
expect(data.watermark).toBe(true);
7375
});
@@ -147,6 +149,114 @@ describe("e2e: config", () => {
147149
}
148150
});
149151

152+
test("config show 脱敏 DASHSCOPE_*,JSON 用 active 标出生效层且值保持干净", async () => {
153+
const configDir = mkdtempSync(join(tmpdir(), "bl-config-show-env-"));
154+
try {
155+
writeFileSync(
156+
join(configDir, "config.json"),
157+
JSON.stringify(
158+
{
159+
api_key: "sk-file-secret-key",
160+
base_url: "https://file.example.com",
161+
},
162+
null,
163+
2,
164+
) + "\n",
165+
);
166+
167+
const { stdout, stderr, exitCode } = await runCommandE2e(
168+
CONFIG_ROUTES,
169+
["config", "show", "--output", "json"],
170+
{
171+
BAILIAN_CONFIG_DIR: configDir,
172+
DASHSCOPE_API_KEY: "sk-env-secret-key",
173+
DASHSCOPE_BASE_URL: "https://env.example.com",
174+
},
175+
);
176+
expect(exitCode, stderr).toBe(0);
177+
const data = parseStdoutJson<{
178+
api_key?: string;
179+
base_url?: string;
180+
DASHSCOPE_API_KEY?: string;
181+
DASHSCOPE_BASE_URL?: string;
182+
active?: { api_key?: string; base_url?: string };
183+
}>(stdout);
184+
expect(data.DASHSCOPE_API_KEY).toBeDefined();
185+
expect(data.DASHSCOPE_API_KEY).not.toContain("sk-env-secret-key");
186+
expect(data.DASHSCOPE_API_KEY).not.toMatch(/active/);
187+
expect(data.DASHSCOPE_BASE_URL).toBe("https://env.example.com");
188+
expect(data.api_key).toBeDefined();
189+
expect(data.api_key).not.toMatch(/active/);
190+
expect(data.api_key).not.toContain("sk-file-secret-key");
191+
expect(data.base_url).toBe("https://file.example.com");
192+
expect(data.active).toEqual({ api_key: "env", base_url: "env" });
193+
expect(stdout).not.toContain("sk-env-secret-key");
194+
expect(stdout).not.toContain("sk-file-secret-key");
195+
} finally {
196+
rmSync(configDir, { recursive: true, force: true });
197+
}
198+
});
199+
200+
test("config show 在仅有 DASHSCOPE_BASE_URL 时仍输出文件层默认 base_url", async () => {
201+
const configDir = mkdtempSync(join(tmpdir(), "bl-config-show-env-base-only-"));
202+
try {
203+
writeFileSync(
204+
join(configDir, "config.json"),
205+
JSON.stringify({ api_key: "sk-file" }, null, 2) + "\n",
206+
);
207+
208+
const { stdout, stderr, exitCode } = await runCommandE2e(
209+
CONFIG_ROUTES,
210+
["config", "show", "--output", "json"],
211+
{
212+
BAILIAN_CONFIG_DIR: configDir,
213+
DASHSCOPE_API_KEY: "",
214+
DASHSCOPE_BASE_URL: "https://env.example.com",
215+
},
216+
);
217+
expect(exitCode, stderr).toBe(0);
218+
const data = parseStdoutJson<{
219+
base_url?: string;
220+
DASHSCOPE_BASE_URL?: string;
221+
active?: { api_key?: string; base_url?: string };
222+
}>(stdout);
223+
expect(data.base_url).toBe("https://dashscope.aliyuncs.com");
224+
expect(data.DASHSCOPE_BASE_URL).toBe("https://env.example.com");
225+
expect(data.active).toEqual({ api_key: "config", base_url: "env" });
226+
} finally {
227+
rmSync(configDir, { recursive: true, force: true });
228+
}
229+
});
230+
231+
test("config show 对 DASHSCOPE_BASE_URL 只展示 origin,不泄露 userinfo/query", async () => {
232+
const configDir = mkdtempSync(join(tmpdir(), "bl-config-show-env-url-secret-"));
233+
const rawEnvBaseUrl = "https://user:pass@example.com/path?token=super-secret#frag";
234+
try {
235+
writeFileSync(join(configDir, "config.json"), "{}\n");
236+
237+
const { stdout, stderr, exitCode } = await runCommandE2e(
238+
CONFIG_ROUTES,
239+
["config", "show", "--output", "json"],
240+
{
241+
BAILIAN_CONFIG_DIR: configDir,
242+
DASHSCOPE_BASE_URL: rawEnvBaseUrl,
243+
},
244+
);
245+
expect(exitCode, stderr).toBe(0);
246+
const data = parseStdoutJson<{
247+
DASHSCOPE_BASE_URL?: string;
248+
active?: { base_url?: string };
249+
}>(stdout);
250+
expect(data.DASHSCOPE_BASE_URL).toBe("https://example.com");
251+
expect(data.active?.base_url).toBe("env");
252+
expect(stdout).not.toContain("user:pass");
253+
expect(stdout).not.toContain("super-secret");
254+
expect(stdout).not.toContain(rawEnvBaseUrl);
255+
} finally {
256+
rmSync(configDir, { recursive: true, force: true });
257+
}
258+
});
259+
150260
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
151261
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]);
152262
expect(exitCode, stderr).toBe(2);

‎packages/core/src/auth/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
export {
22
resolveApiKey,
3+
resolveApiKeySource,
34
resolveConsole,
45
resolveOpenApi,
56
describeAuthState,
67
resolveModelBaseUrl,
8+
resolveModelBaseUrlSource,
79
resolveModelBaseUrlState,
10+
resolveModelConnectionProvenance,
11+
isModelConnectionSourceMismatch,
812
} from "./resolver.ts";
913
export { makeAuthStore, type AuthStore, type AuthPersistPatch } from "./store.ts";
1014
export type {
@@ -13,6 +17,7 @@ export type {
1317
OpenApiCredential,
1418
AuthState,
1519
CredentialSource,
20+
ModelConnectionProvenance,
1621
} from "./types.ts";
1722
export {
1823
generateCLIAccessToken,

‎packages/core/src/auth/resolver.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,57 @@
11
import { REGIONS } from "../config/schema.ts";
22
import { normalizeModelBaseUrl } from "../config/model-base-url.ts";
33
import type { ResolutionSources } from "../config/loader.ts";
4-
import type { ApiKeyCredential, ConsoleCredential, OpenApiCredential, AuthState } from "./types.ts";
4+
import type {
5+
ApiKeyCredential,
6+
ConsoleCredential,
7+
CredentialSource,
8+
ModelConnectionProvenance,
9+
OpenApiCredential,
10+
AuthState,
11+
} from "./types.ts";
512
import { BailianError } from "../errors/base.ts";
613
import { ExitCode } from "../errors/codes.ts";
714

815
// Resolve the credential for a command's declared domain (model = api-key,
916
// console = access-token), by priority, or throw. Read only from sources.
1017

18+
/** Winning api_key source (flag > env > file), or undefined if none. */
19+
export function resolveApiKeySource(sources: ResolutionSources): CredentialSource | undefined {
20+
if (sources.flags.apiKey) return "flag";
21+
if (sources.env.DASHSCOPE_API_KEY?.trim()) return "env";
22+
if (sources.file.api_key) return "config";
23+
return undefined;
24+
}
25+
26+
/**
27+
* Winning explicit base_url source (flag > env > file).
28+
* Undefined means the built-in REGIONS.cn default will be used.
29+
*/
30+
export function resolveModelBaseUrlSource(
31+
sources: ResolutionSources,
32+
): CredentialSource | undefined {
33+
if (sources.flags.baseUrl) return "flag";
34+
if (sources.env.DASHSCOPE_BASE_URL?.trim()) return "env";
35+
if (sources.file.base_url) return "config";
36+
return undefined;
37+
}
38+
39+
/** Provenance for the model connection pair (no secrets). */
40+
export function resolveModelConnectionProvenance(
41+
sources: ResolutionSources,
42+
): ModelConnectionProvenance {
43+
return {
44+
apiKeySource: resolveApiKeySource(sources),
45+
baseUrlSource: resolveModelBaseUrlSource(sources),
46+
};
47+
}
48+
49+
/** True only when both sides are explicit and disagree. */
50+
export function isModelConnectionSourceMismatch(provenance: ModelConnectionProvenance): boolean {
51+
const { apiKeySource, baseUrlSource } = provenance;
52+
return Boolean(apiKeySource && baseUrlSource && apiKeySource !== baseUrlSource);
53+
}
54+
1155
/** Preserve whether the shared base URL chain selected a configured origin or its default. */
1256
export function resolveModelBaseUrlState(
1357
sources: ResolutionSources,

0 commit comments

Comments
 (0)