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
8 changes: 6 additions & 2 deletions docs/agents/auth-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx

`~/.bailian/config.json` 可同时保存 `api_key`、`access_token` 与 `access_key_*`。登录任一种方式不得删除另一种:

- `bl auth login --api-key ...` 只更新 `api_key` / `base_url`
- `bl auth login --api-key ...` 更新 `api_key`;显式 `base_url` 会一并写入,所选命名 Profile 若命中内置套餐预设(当前为 `token-plan`),则在尚未保存 `base_url` 时补写预设地址,并把该预设的默认模型物化写入。API Key 验证成功后,`api_key_capabilities` 保留已有项并追加当前 preset 中缺少的项,不自动删除任何已有能力;无 preset 的自定义 Profile 不做合并。登录仍不得删除其他鉴权域的凭证
- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段
- `bl auth login --open-api ...` 更新 `access_key_id` / `access_key_secret`,同时会调用 OpenAPI 生成 CLI `access_token` 并一并写入;即一次 `--open-api` 登录同时产生 `openapi` 与 `console` 域凭证
- `bl auth logout --console` 只清 `access_token`
Expand All @@ -44,7 +44,11 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx

- `resolveApiKey()` — `auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key`
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin(仅保留协议、host 和显式端口,去除 path、query、fragment)
- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级;内置套餐 Profile(当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值
- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级。对 `auth: "apiKey"` 命令,runtime 会先按叶子命令路径检查所选 Profile 的 `api_key_capabilities`:
- `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一显式连接覆盖存在时,完全跳过自动降级,继续走统一的 flag > env > selected config file > 默认值
- 配置文件显式声明 `api_key_capabilities` 后,命中能力时保留所选 Profile,未命中时仅把 file-backed `api_key` / `base_url` 来源切到顶层 `default`,其他 Settings 仍来自所选 Profile
- 字段缺失时不启用降级,包括命中内置套餐预设的 Profile;preset 只在 API Key 登录验证成功后物化写入,升级 preset 需要重新登录
- fallback 反馈写 stderr:text 模式输出本地化句子,`--output json` 输出两空格缩进的多行 `warning` 对象;若后续鉴权失败,warning 与多行 `error` 对象以空行分隔,stdout 仍只保留命令结果
- 显式 `auth login --config <name>` 在凭证验证并落盘成功后自动激活目标 Profile;未传
`--config` 时继续写当前激活项,失败和 dry-run 不切换
- `resolveConsole()` — `auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认
Expand Down
4 changes: 2 additions & 2 deletions docs/agents/cli-e2e-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {

## 安全与例外

- **禁止破坏真实用户配置**:`auth logout` 默认只用 `--dry-run`;需要验证实际落盘时,必须通过
`BAILIAN_CONFIG_DIR` 指向隔离 fixture;`config set` 只用 `--dry-run`
- **禁止破坏真实用户配置**:`auth logout` 和 `config set` 默认只用 `--dry-run`;只有验证持久化契约时,才允许通过
`BAILIAN_CONFIG_DIR` 指向每个用例独占的临时目录实际落盘,并必须在 `finally` 中清理;禁止写入或复用真实 `~/.bailian`
- **不加 dry-run**:`dryRun` 在 `resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit`、`speech recognize` 带 `--url`)
- **`--list-voices` 等旁路**:先于 `--text` 校验的 flag,缺参用例勿带该 flag
- 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言
Expand Down
3 changes: 3 additions & 0 deletions docs/agents/command-pack.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
- [ ] Command Pack 不能覆盖内置命令、其他 pack 命令或重声明保留 flag。
- [ ] 普通网络请求走 `ctx.client`;基础 Context 提供 `identity/settings/flags/client/output/errors`,不提供原始凭据。
- [ ] `ctx.credentials.apiKey()` 仅限 policy 显式声明 `credentialAccess: ["apiKey"]`,且命令自身为 `auth: "apiKey"`。
- [ ] API Key capability ID 由 host 按实际叶子命令路径生成(例如 `agent credential` → `agent.credential`);Command Pack 不声明额外元数据,Profile allowlist 对 pack 命令同样 fail closed。
- [ ] `ctx.credentials.apiKey()` 必须返回 `authStage` 已注入 `ctx.client` 的实际凭证,不能重新读取原 Profile 绕过 capability fallback。
- [ ] 不向 Command Pack 暴露原始 Console Token、OpenAPI AK/SK、`authStore` 或 `configStore`。
- [ ] 不向 Command Pack 暴露宿主的 `commandPacks` manager,避免 pack 安装或删除其他 pack。
- [ ] 单包失败必须 fail-open:保留内置命令和其他合法 pack。
Expand All @@ -40,6 +42,7 @@
## 测试与文档

- [ ] `packages/runtime/tests/command-packs.test.ts` 覆盖产品 policy、安装目录隔离、协议版本、前缀和导出契约。
- [ ] Command Pack 测试覆盖 capability 格式/鉴权域、adapter 保留字段,以及 raw API Key 委托使用 fallback 后的实际 Base URL。
- [ ] `packages/cli/tests/e2e/command-packs.e2e.test.ts` 覆盖 help、link、执行、output/errors、凭据授权、list、remove。
- [ ] `packages/kscli/tests/e2e/command-packs.e2e.test.ts` 覆盖统一 host 和 runtime 默认空 policy 下不暴露管理命令。
- [ ] fixture 的包名必须在测试白名单内,且构建入口不依赖工作区运行时解析。
Expand Down
4 changes: 4 additions & 0 deletions docs/agents/config-add.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ config 文件 ─┘
- `ConfigFile`(disk 形状,snake_case)加新字段(如果允许写文件)
- `parseConfigFile()` 解析新字段
- 如果是 enum 字段,加校验
- 如果是数组字段,明确“缺失 / 空数组 / 非法值”的不同语义;安全策略字段的非法值必须 fail closed

### B. 加载逻辑

Expand All @@ -51,6 +52,7 @@ config 文件 ─┘
- [ ] 配置展示 / 修改命令同步:
- `packages/commands/src/commands/config/show.ts` 显示新字段
- `packages/commands/src/commands/config/set.ts` 的 `VALID_KEYS` / `KEY_ALIASES` / description 允许 set
- `packages/commands/src/commands/config/ui.ts` / `ui-html.ts` 能按原类型往返数组字段,不能把 `[]` 保存成字段缺失

### E. 文档

Expand All @@ -61,6 +63,7 @@ config 文件 ─┘
- [ ] 单测覆盖优先级:flag > env > file
- [ ] 校验失败抛错(非法值)
- [ ] 默认值正确
- [ ] 数组配置覆盖 CLI 逗号/JSON 输入、Config UI 往返、去重和显式空数组

## 完成后自查

Expand All @@ -82,3 +85,4 @@ cat ~/.bailian/config.json
- ✗ 全局 switch 没标 `type: "switch"`,被当成需要值的 `--xxx <value>`
- ✗ 加了 env var 但 README 表格没更新,用户不知道有这条
- ✗ `config show` 不显示新字段,用户改了无法回查
- ✗ UI 用 `String([])` 把显式空数组渲染为空串,保存后意外关闭安全策略
9 changes: 9 additions & 0 deletions docs/agents/config-profile-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
- 普通命令的显式 `--config` 只覆盖本次选择,不修改持久化激活状态;例外是
`auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。
- 激活状态只选择配置 block,不改变字段优先级;字段仍为 flag > env > selected config > 默认值。
- API Key capability fallback 是窄例外:命名 Profile 显式配置 `api_key_capabilities` 后,不在白名单中的 `auth: "apiKey"` 叶子命令只把 file 层 `api_key` / `base_url` 切到顶层 `default`;所选 Profile 的其他 settings 和 `active_config` 均不变。如果 `--api-key` / `--base-url` 或 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一提供了更高优先级的模型连接参数,则整个 capability fallback 跳过,file 层也不切换;未显式提供的另一部分继续按 flag > env > 所选 Profile 解析。
- Profile 是否启用 capability fallback 只看持久化的 `api_key_capabilities`,与名称无关:字段缺失表示关闭策略,`[]` 表示全部 API Key 命令 fallback。runtime 不注入内置 preset;升级内置 Plan Profile 的 preset 需要重新登录。
- 对命中内置 preset 的 Profile,API Key 登录验证成功后会把当前 preset 中缺少的 capability 追加落盘,同时保留已有项且不做删除;Console/OpenAPI 登录、自定义 Profile、dry-run 和失败登录均不修改该白名单。
- Capability ID 直接使用产品实际叶子命令路径并以 `.` 连接(例如 `video task get` → `video.task.get`);不新增命令元数据。新增或改名后的 API Key 路由未进入白名单时自然 fail closed。
- Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。
- Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。

Expand Down Expand Up @@ -55,6 +59,8 @@

- 旧配置无 `active_config` -> `default`。
- 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。
- 任意名称 Profile 的叶子路由 capability 命中时使用自身 API Key;未命中或空白名单时使用 `default` API Key;`--api-key` / `--base-url` 和 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL` 任一覆盖时跳过 fallback。
- 旧内置 Plan Profile 缺 capability 字段时不启用 fallback;重新登录后使用并持久化当前 preset,显式配置(含 `[]`)按文件值生效。
- 显式命名 `--config` 和 `--config default` 均覆盖激活项且不修改磁盘状态。
- 激活不存在的 Profile 失败且不写盘。
- 悬空 `active_config` 明确失败。
Expand All @@ -63,6 +69,9 @@
- 显式 `auth login --config <name>` 成功后激活该 Profile,失败或 dry-run 不创建、不切换;
`--config default` 成功后切回 `default`。
- Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。
- 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。
- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。
- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。
Expand Down
1 change: 1 addition & 0 deletions docs/agents/error-hint-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ runtime/error-handler.ts:
- 内部 AUTH/USAGE/NETWORK/TIMEOUT → 走 enhanceHint(只 AUTH 还有增强)
- TypeError("fetch failed") → 读 err.cause.code 翻成 NETWORK
- Node fs errno → 翻成 GENERAL + errno hint
- `--output json` → stderr 输出两空格缩进的多行 error JSON;前面已有结构化 warning 时,两个 diagnostics 以空行分隔
- 其它 Error → 默认走 cause 链
process.exit(err.exitCode)
Expand Down
78 changes: 76 additions & 2 deletions packages/cli/tests/e2e/command-packs.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
Expand All @@ -9,7 +9,19 @@ const fixtureRoot = join(fileURLToPath(import.meta.url), "..", "..", "fixtures",
let configDir: string;

function env(): NodeJS.ProcessEnv {
return { BAILIAN_CONFIG_DIR: configDir, DO_NOT_TRACK: "1" };
return {
BAILIAN_CONFIG_DIR: configDir,
DO_NOT_TRACK: "1",
DASHSCOPE_API_KEY: "",
DASHSCOPE_BASE_URL: "",
};
}

function parseStderrJsonDiagnostics(stderr: string): unknown[] {
return stderr
.trim()
.split(/\n\s*\n/)
.map((diagnostic) => JSON.parse(diagnostic) as unknown);
}

describe("e2e: Command Pack", () => {
Expand Down Expand Up @@ -71,6 +83,68 @@ describe("e2e: Command Pack", () => {
expect(executed.exitCode, executed.stderr).toBe(0);
expect(executed.stdout).toContain("command-pack:hello");

await writeFile(
join(configDir, "config.json"),
JSON.stringify(
{
base_url: "https://default.example.com",
active_config: "company-plan",
"company-plan": {
api_key: "sk-plan",
base_url: "https://plan.example.com",
api_key_capabilities: [],
},
},
null,
2,
) + "\n",
);
const fallbackFailure = await runCli(["agent", "credential", "--output", "json"], env());
expect(fallbackFailure.exitCode).toBe(3);
expect(fallbackFailure.stderr).toMatch(/^\{\n {2}"warning": \{/);
expect(fallbackFailure.stderr).toContain("\n\n{\n");
expect(parseStderrJsonDiagnostics(fallbackFailure.stderr)).toEqual([
{
warning: {
code: "PROFILE_API_KEY_FALLBACK",
message:
'Profile "company-plan" does not support command "agent credential"; API Key settings will be read from Profile "default" for this run.',
profile: "company-plan",
command_path: ["agent", "credential"],
fallback_profile: "default",
credential_fields: ["api_key", "base_url"],
},
},
{
error: expect.objectContaining({
code: 3,
message: "No API key found.",
}),
},
]);

await writeFile(
join(configDir, "config.json"),
JSON.stringify(
{
api_key: "sk-default",
base_url: "https://default.example.com",
active_config: "company-plan",
"company-plan": {
api_key: "sk-plan",
base_url: "https://plan.example.com",
api_key_capabilities: [],
},
},
null,
2,
) + "\n",
);
const fallbackCredential = await runCli(["agent", "credential"], env());
expect(fallbackCredential.exitCode, fallbackCredential.stderr).toBe(0);
expect(fallbackCredential.stderr).toContain('command "agent credential"');
expect(fallbackCredential.stdout).toContain("credential-base-url:https://default.example.com");

const credential = await runCli(["agent", "credential", "--api-key", "fixture-key"], env());
expect(credential.exitCode, credential.stderr).toBe(0);
expect(credential.stdout).toContain("credential-source:flag");
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/tests/fixtures/command-pack/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ const credential = {
auth: "apiKey",
async run(ctx) {
const apiKey = ctx.credentials.apiKey();
process.stdout.write(`credential-source:${apiKey.source}\n`);
process.stdout.write(
`credential-source:${apiKey.source} credential-base-url:${apiKey.baseUrl}\n`,
);
},
};

Expand Down
6 changes: 6 additions & 0 deletions packages/commands/src/commands/auth/login-api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ interface ApiKeyLoginProfile {
defaultImageToVideoModel?: string;
defaultReferenceToVideoModel?: string;
defaultImageModel?: string;
defaultSpeechModel?: string;
defaultSpeechRecognitionModel?: string;
apiKeyCapabilities?: readonly string[];
persistPatch?: AuthPersistPatch;
}

Expand Down Expand Up @@ -95,5 +98,8 @@ export async function validateAndPersistApiKey(
default_image_to_video_model: profile.defaultImageToVideoModel,
default_reference_to_video_model: profile.defaultReferenceToVideoModel,
default_image_model: profile.defaultImageModel,
default_speech_model: profile.defaultSpeechModel,
default_speech_recognition_model: profile.defaultSpeechRecognitionModel,
api_key_capabilities: profile.apiKeyCapabilities ? [...profile.apiKeyCapabilities] : undefined,
});
}
9 changes: 8 additions & 1 deletion packages/commands/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,13 @@ export default defineCommand({
return;
}
const profilePreset = getModelProfilePreset(settings.configName);
const storedBaseUrl = store.stored().baseUrl;
const stored = store.stored();
const storedBaseUrl = stored.baseUrl;
const resolvedBaseUrl = baseUrl || store.resolveBaseUrl(profilePreset?.baseUrl);
const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined);
const apiKeyCapabilities = profilePreset
? [...new Set([...(stored.apiKeyCapabilities ?? []), ...profilePreset.apiKeyCapabilities])]
: stored.apiKeyCapabilities;
await validateAndPersistApiKey(deps, key, {
baseUrl: resolvedBaseUrl,
persistBaseUrl,
Expand All @@ -179,6 +183,9 @@ export default defineCommand({
defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel,
defaultReferenceToVideoModel: profilePreset?.defaultReferenceToVideoModel,
defaultImageModel: profilePreset?.defaultImageModel,
defaultSpeechModel: profilePreset?.defaultSpeechModel,
defaultSpeechRecognitionModel: profilePreset?.defaultSpeechRecognitionModel,
apiKeyCapabilities,
});
},
});
5 changes: 3 additions & 2 deletions packages/commands/src/commands/config/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ export default defineCommand({
valueHint: "<key>",
description: {
"en-US":
"Config key (language, base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
"Config key (language, base_url, output, output_dir, timeout, api_key, api_key_capabilities, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
"zh-CN":
"配置项名称(language、base_url、output、output_dir、timeout、api_key、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id)",
"配置项名称(language、base_url、output、output_dir、timeout、api_key、api_key_capabilities、access_token、access_key_id、access_key_secret、security_token、default_*_model、workspace_id)",
},
required: true,
},
Expand All @@ -30,6 +30,7 @@ export default defineCommand({
"--key output --value json",
"--key timeout --value 600",
"--key base_url --value https://dashscope.aliyuncs.com",
"--config company-plan --key api-key-capabilities --value text.chat,image.generate",
],
async run(ctx) {
const { settings, flags } = ctx;
Expand Down
Loading