Skip to content
Open
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
5 changes: 4 additions & 1 deletion docs/agents/config-profile-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 本地配置读取。
Expand All @@ -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 或 `<undeclared>` 等内部值。存在 `--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` 覆盖常见扩展名映射。

Expand Down
14 changes: 12 additions & 2 deletions packages/cli/tests/e2e/config-profile.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<Record<string, unknown>>(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
Expand Down
107 changes: 101 additions & 6 deletions packages/commands/src/commands/config/show.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,127 @@
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<string, unknown> = {
...file,
language: file.language ?? DEFAULT_LANGUAGE,
base_url: client.baseUrl,
output: settings.output,
timeout: settings.timeout,
watermark: settings.watermark,
config: settings.configName ?? "default",
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",
);
},
});
112 changes: 111 additions & 1 deletion packages/commands/tests/e2e/config.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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:[email protected]/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);
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,6 +17,7 @@ export type {
OpenApiCredential,
AuthState,
CredentialSource,
ModelConnectionProvenance,
} from "./types.ts";
export {
generateCLIAccessToken,
Expand Down
46 changes: 45 additions & 1 deletion packages/core/src/auth/resolver.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading
Loading