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
12 changes: 9 additions & 3 deletions docs/agents/auth-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
- 显式 `auth login --config <name>` 在凭证落盘成功后自动激活目标 Profile;未传
`--config` 时继续写当前激活项,失败和 dry-run 不切换
- `resolveConsole()` — `auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认
- `resolveOpenApi()` — `auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段
- `resolveOpenApi()` — AK/SK 统一解析链:显式参数 > `BAILIAN_ACCESS_KEY_ID` / `BAILIAN_ACCESS_KEY_SECRET` > 当前 config `access_key_*` > `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`。各层使用同组 `security_token` / `BAILIAN_SECURITY_TOKEN` / `ALIBABA_CLOUD_SECURITY_TOKEN`,缺少整组时继续查找,部分配置则报错,不跨来源拼接。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段。Aliyun 宿主凭证按标准环境变量处理,不读取或切换 Aliyun config。
- 上述 AK/SK 参数优先级仅适用于开放这些参数的入口(如 `auth: "openapi"` 命令)。Console 业务命令不开放临时 AK/SK 参数,只使用提前准备好的凭证:百炼环境变量 > 当前百炼配置 > 阿里云环境变量。
- `describeAuthState()` — `auth status` / banner / telemetry 使用的只读快照

命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。
Expand All @@ -67,9 +68,14 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx

### Console token 签发与刷新

- `packages/core/src/client/console-session.ts` 集中管理 Console 凭证、换票、重试、并发协调及落盘;`Client.console()` 委托给该内部模块,对外调用方式不变。
- `resolveCLIAccessTokenTarget()` 优先采用有效 `consoleRegion`;地域缺省时识别国内/新加坡 DashScope 和对应 `*.maas.aliyuncs.com` 域名(含 Token Plan、Workspace)。未知或不支持的地域报错,不静默回退北京;`consoleSite` 与地域独立,不能把新加坡自动等同国际站。
- `Client.console()` 收到 `NotLogined` 后,以本次实际 Console credential 的 region/site/switchAgent 签发并重试一次。只有原请求重试成功才保存新 token,并更新 Client 内存凭证;失败保留磁盘旧 token。
- `refreshAccessToken()` 独立调用时默认用只读 `loginInfo` 验证;调用方提供 `validateToken` 时由其完成验证或原请求重试,回调成功后才落盘。缺少当前 Profile 的 AK/SK 返回 `null`,不跨 Profile 借用凭证。
- Console token 为空时,authStage 先通过统一 AK/SK 链校验凭证是否可用;首次 `Client.console()` 调用时换票,并直接执行原请求。token 失效时也复用该 AK/SK 链与签发接口;每次请求至多换票一次,新 token 再次失效不循环刷新。有效 token 不要求额外提供 AK/SK。
- 同一 Client 的并发请求共享在途换票,但每个业务请求独立执行和报错;任一业务请求成功后才更新 Client 凭证,符合落盘条件时只保存一次。旧 token 的失效响应晚到时,直接用该 Client 已刷新的 token 重试,不重复签发。换票失败或所有等待请求结束后清理在途状态,后续调用可重新尝试;不同 Client(包括 `withConsoleSwitchAgent()` 创建的实例)不共享换票状态。
- 新增自动获取 token,以及使用环境变量凭证的刷新,只更新 Client 内存;不保存 AK/SK、STS 或 token。已有本地 token 使用本地 config AK/SK 刷新时,保留“原请求重试成功才保存 token”的规则。
- `auth status` / AuthStore 快照仅展示当前凭证,不换票、不在线验证、不增加“可换票”等推断状态。静态 dry-run 不换票;需要 Console 只读请求的 prepare/dry-run 按原规则鉴权。模型 API Key 链、登录/退出、根帮助与配置 UI 不随此次换票接入改写。
- `refreshAccessToken()` 独立调用时默认用只读 `loginInfo` 验证;调用方提供 `validateToken` 时由其完成验证,允许持久化时在回调成功后落盘。Client 的共享换票使用 `persist: false`,由各业务请求验证,并在符合条件的首次成功后保存。未传入已解析凭证且当前 Profile 缺少 AK/SK 时返回 `null`,不跨 Profile 借用凭证。
- `auth generate-access-token` 只签发并输出,不做 Console 验证、不持久化;首次登录的校验直接走 `callConsoleGateway`,不得递归进入刷新流程。

### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入
Expand Down Expand Up @@ -159,7 +165,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx

- [ ] `packages/commands/tests/e2e/auth.e2e.test.ts` 增加新方式的 happy / failure 路径;浏览器回调契约在 `packages/commands/tests/login-console.test.ts` 覆盖
- [ ] mask token 的输出格式不变(避免泄漏)
- [ ] 如调整 resolver 优先级,补 core/runtime 单测覆盖 flag > env > file
- [ ] 如调整 resolver 优先级,补 core/runtime 单测覆盖对应凭证链;AK/SK 为 flag > 百炼 env > file > 阿里云 env,并验证不跨来源拼接

## 完成后自查

Expand Down
22 changes: 17 additions & 5 deletions docs/agents/error-hint-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,23 @@ process.exit(err.exitCode)
- ❌ 新增/改动时不写控制台 URL 或 region
- ❌ 新增/改动时不写渠道追踪参数(`source_channel=xxx`)
- ✅ 只描述抽象做法(如 `"Set DASHSCOPE_API_KEY environment variable, or pass --api-key."`)
- 当前遗留:`packages/core/src/auth/resolver.ts` 仍含 `bl auth login` hint;触碰鉴权错误时迁到 runtime `errorHint`
- 本地凭证错误用 `CredentialError.reason` 表达失败事实;resolver 的纯提示不含产品命令,runtime `errorHint` 保留原有登录指引。

### 4. runtime / 产品层可以使用入口名 + URL

- `packages/runtime/src/error-handler.ts` 通过本次调用的 `binName` 渲染 `bl` / `kscli` 等入口名,不要硬编码
- 用 `formatCommand(identity, "<path> [args]")` 生成用户操作提示;`identity.binName`
保持产品身份,用于渠道、安装包及独立入口默认名;宿主可执行信息另行声明。不要全局替换输出或服务端文本。
- 可执行命令引用用 `buildCommandInvocation(identity, path, args)` 生成 `{ bin, path, args }`;
`identity.commandInvocation` 显式声明宿主 executable/固定子命令,不拆分展示字符串。
消费者使用 `spawn(bin, [...path, ...args], { shell: false })`,不隐式添加确认参数。
- 静态 CLI 元数据使用 `commandRef("<path> [args]")` 声明命令引用,由 help、introspect
和 reference 生成器调用 `renderCommandText` 渲染;固定模板不可用于服务端文本或用户输入。
- 动态错误在构造时生成完整字符串;helper 只接收必要的格式化函数或恢复提示。
`BailianError.message/hint/toJSON()` 不携带模板,也不要求调用方理解渲染协议。
- Console 失效恢复提示由 runtime 提供给 client/gateway,在业务包装前完成;普通包装无需传播模板或凭证域状态。
- 前缀适配只改变命令引用,不增删原有操作步骤、不重组鉴权提示、不改写周围文案。

- `packages/runtime/src/error-handler.ts` 通过本次调用的 `commandPrefix` 渲染 `bl` / `kscli` 等入口名,不要硬编码
- 产品入口 / README / E2E 可以写具体入口命令
- shared command 实现不写 `bl` / `kscli` 前缀;`usageArgs` / `exampleArgs` 只写参数片段
- URL 必须从 `packages/runtime/src/urls.ts` import,不能硬编码
Expand All @@ -93,13 +105,13 @@ process.exit(err.exitCode)
### B. runtime 增强(`errorHint`)

- [ ] `packages/runtime/src/error-handler.ts:errorHint`:**当前只为 internal AUTH 增强**(因为 resolver / authStage 等内部位置会发 AUTH)
- [ ] 命令名使用 `binName`,不要硬编码 `bl`
- [ ] 命令名使用 `commandPrefix`,不要硬编码 `bl`
- [ ] URL 必须是 `import { API_KEY_PAGE } from "./urls.ts"`

### C. command / runtime 直接抛错

- [ ] runtime 层抛 BailianError 时,hint 里可以放 `binName` 渲染的入口命令,但 **URL 一律走 `urls.ts` import**
- [ ] `packages/commands` 作为 shared command 库,默认不硬编码产品 bin;如果确需用户操作提示,优先依赖 runtime error handler 或 `ctx.identity.binName`
- [ ] runtime 层抛 BailianError 时,hint 里可以放 `commandPrefix` 渲染的入口命令,但 **URL 一律走 `urls.ts` import**
- [ ] `packages/commands` 作为 shared command 库,默认不硬编码产品 bin;如果确需用户操作提示,优先依赖 runtime error handler 或 `formatCommand(ctx.identity, args)`
- [ ] 抛错位置如果**已经在调用服务端**,catch 时不要替换 message——重新评估是否需要 catch

### D. 文案一致性
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/integrations/aliyun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { CliOptions } from "bailian-cli-runtime";

/** Decode the existing Aliyun subprocess protocol at the product boundary. */
export function resolveAliyunIntegration(
environment: NodeJS.ProcessEnv,
): Pick<CliOptions, "commandInvocation"> {
if (environment.ALIBABA_CLOUD_BL_COMPAT_MODE?.trim() !== "aliyun bl") return {};
return {
commandInvocation: { bin: "aliyun", path: ["bl"] },
};
}
2 changes: 2 additions & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createCli } from "bailian-cli-runtime";
import { commandAliases, commands } from "./commands.ts";
import { commandPackPolicy } from "./command-pack-policy.ts";
import pkg from "../package.json" with { type: "json" };
import { resolveAliyunIntegration } from "./integrations/aliyun.ts";

const quickStartTasks = [
"帮我创建一个能够生成短片分镜和视频的 Managed Agent。\n Help me create a Managed Agent that can generate short-film storyboards and videos.",
Expand All @@ -15,6 +16,7 @@ void createCli(
{ ...commands, ...commandAliases },
{
binName: "bl",
...resolveAliyunIntegration(process.env),
version: pkg.version,
clientName: "bailian-cli",
npmPackage: "bailian-cli",
Expand Down
226 changes: 226 additions & 0 deletions packages/cli/tests/e2e/aliyun-compat.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { captureRegistryHelp } from "e2e/registry-smoke";
import { buildIsolatedCliEnv } from "e2e/isolation";
import { runInProcess } from "e2e/in-process";
import { defineCommand, buildCommandInvocation, maskToken } from "bailian-cli-core";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, expect, expectTypeOf, test } from "vite-plus/test";
import { runCli } from "./helpers.ts";
import {
buildCommandSchema,
CommandRegistry,
createCli,
type CliOptions,
} from "bailian-cli-runtime";
import { commands } from "../../src/commands.ts";
import { resolveAliyunIntegration } from "../../src/integrations/aliyun.ts";

const configDirectory = mkdtempSync(join(tmpdir(), "bl-aliyun-compat-"));
afterAll(() => rmSync(configDirectory, { recursive: true, force: true }));

test("host adapter propagates executable argv through the runtime context", async () => {
expectTypeOf<CliOptions>().not.toHaveProperty("commandPrefix");
const isolated = buildIsolatedCliEnv({
overrides: { NO_UPDATE_NOTIFIER: "1", DO_NOT_TRACK: "1" },
});
const arguments_ = ["probe", "--output", "json"];
const probe = defineCommand({
auth: "none",
description: "fixture",
async run(context) {
context.output.result({
product: context.identity.binName,
command: buildCommandInvocation(
context.identity,
["knowledge", "delete"],
["--index-id", "example"],
),
});
},
});
try {
const result = await runInProcess(
() =>
createCli(
{ probe },
{
binName: "bl",
clientName: "test",
npmPackage: "test",
version: "test",
...resolveAliyunIntegration({ ALIBABA_CLOUD_BL_COMPAT_MODE: "aliyun bl" }),
},
).run(arguments_),
arguments_,
{ env: isolated.env },
);
expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
product: "bl",
command: {
bin: "aliyun",
path: ["bl", "knowledge", "delete"],
args: ["--index-id", "example"],
},
});
} finally {
isolated.dispose();
}
});

test("all command metadata preserves standalone wording and resolves hosted references", () => {
const identity = {
binName: "bl",
version: "test",
clientName: "test",
npmPackage: "bailian-cli",
};
const standalone = buildCommandSchema(new CommandRegistry(commands, "bl"), identity);
const hosted = buildCommandSchema(new CommandRegistry(commands, "aliyun bl"), {
...identity,
commandInvocation: { bin: "aliyun", path: ["bl"] },
});
const serialized = JSON.stringify(hosted);
expect(serialized).not.toContain("{{commandPrefix}}");
expect(hosted.bin).toBe("aliyun");
expect(hosted.scope).toEqual(["bl"]);
const qualifiedStandalone = {
...standalone,
bin: "aliyun",
scope: ["bl"],
commands: standalone.commands.map((command) => ({ ...command, path: ["bl", ...command.path] })),
};
expect(JSON.parse(serialized.replaceAll("aliyun bl", "bl"))).toEqual(qualifiedStandalone);
const skill = hosted.commands.find((command) => command.path.join(" ") === "bl skill init")!;
expect(JSON.stringify(skill.notes)).toContain("Equivalent to: `aliyun bl skill add --all`");
});

// Real subprocesses below verify the product entrypoint reads the inherited host marker.
function runCompatible(arguments_: string[], language = "en-US", compatibility = "aliyun bl") {
writeFileSync(join(configDirectory, "config.json"), JSON.stringify({ language }));
return runCli(arguments_, {
BAILIAN_CONFIG_DIR: configDirectory,
ALIBABA_CLOUD_BL_COMPAT_MODE: compatibility,
DASHSCOPE_API_KEY: "",
BAILIAN_ACCESS_KEY_ID: "",
BAILIAN_ACCESS_KEY_SECRET: "",
BAILIAN_SECURITY_TOKEN: "",
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
ALIBABA_CLOUD_SECURITY_TOKEN: "",
NO_UPDATE_NOTIFIER: "1",
DO_NOT_TRACK: "1",
});
}

test.each(["en-US", "zh-CN"])(
"host help and onboarding use the invocation prefix (%s)",
async (language) => {
const root = await runCompatible(["--help"], language);
expect(root.exitCode, root.stderr).toBe(0);
expect(root.stderr).toContain("aliyun bl <resource> <command> [flags]");
expect(root.stderr).toContain("aliyun bl auth login --console");
},
);

test.each(
["en-US", "zh-CN"].flatMap((language) =>
["app", "app list", "skill init", "managed-agent plan"].map((path) => ({ language, path })),
),
)("host nested help uses the invocation prefix ($language, $path)", ({ language, path }) => {
const registry = new CommandRegistry(commands, "aliyun bl", {
language: language as "en-US" | "zh-CN",
localize: (value) => (typeof value === "string" ? value : value[language as "en-US" | "zh-CN"]),
});
const help = captureRegistryHelp(registry, path.split(" "));
expect(help).toContain(`aliyun bl ${path}`);
expect(help).not.toMatch(/(?<!aliyun )\bbl (?:auth|skill|managed-agent) /);
});

// Real entrypoint verifies inherited Bailian env names without making authentication requests.
test("auth status reads Bailian env before config and host env without adding status fields", async () => {
writeFileSync(
join(configDirectory, "config.json"),
JSON.stringify({
language: "en-US",
access_key_id: "config-ak",
access_key_secret: "config-sk",
}),
);
const result = await runCli(["auth", "status", "--output", "json"], {
BAILIAN_CONFIG_DIR: configDirectory,
ALIBABA_CLOUD_BL_COMPAT_MODE: "aliyun bl",
BAILIAN_ACCESS_KEY_ID: "bailian-env-access-key",
BAILIAN_ACCESS_KEY_SECRET: "bailian-env-secret-key",
BAILIAN_SECURITY_TOKEN: "bailian-env-sts",
ALIBABA_CLOUD_ACCESS_KEY_ID: "host-ak",
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "host-sk",
ALIBABA_CLOUD_SECURITY_TOKEN: "host-sts",
DASHSCOPE_API_KEY: "",
NO_UPDATE_NOTIFIER: "1",
DO_NOT_TRACK: "1",
});
expect(result.exitCode, result.stderr).toBe(0);
const state = JSON.parse(result.stdout);
expect(state.openapi).toEqual({
source: "env",
access_key_id: maskToken("bailian-env-access-key"),
access_key_secret: maskToken("bailian-env-secret-key"),
});
expect(state.console).toBeUndefined();
expect(result.stdout + result.stderr).not.toContain("bailian-env-secret-key");
expect(result.stdout + result.stderr).not.toContain("bailian-env-sts");
});

test("host introspection exports executable paths and examples from the real entrypoint", async () => {
const result = await runCompatible(["auth", "status", "--introspect"]);
expect(result.exitCode, result.stderr).toBe(0);
const schema = JSON.parse(result.stdout);
expect(schema.bin).toBe("aliyun");
expect(schema.scope).toEqual(["bl", "auth", "status"]);
expect(schema.commands[0].path).toEqual(["bl", "auth", "status"]);
expect(schema.commands[0].usage).toMatch(/^aliyun bl auth status/);
expect(schema.commands[0].examples[0]).toBe("aliyun bl auth status");
});

test("host version is display-only and absent or unknown COMPAT keeps standalone", async () => {
const hosted = await runCompatible(["--version"]);
expect(hosted.stdout).toMatch(/^aliyun bl \d+\.\d+\.\d+\n$/);
for (const compatibility of [""]) {
const standalone = await runCompatible(["--version"], "en-US", compatibility);
expect(standalone.stdout).toMatch(/^bl \d+\.\d+\.\d+\n$/);
}
});

test.each(["text", "json"])(
"missing Console credentials use host instructions (%s)",
async (output) => {
const result = await runCompatible(["app", "list", "--output", output]);
expect(result.exitCode, result.stderr).toBe(3);
const diagnostic = output === "json" ? JSON.parse(result.stderr).error.hint : result.stderr;
expect(diagnostic).toContain("aliyun bl auth login --console");
expect(diagnostic).not.toMatch(/(?<!aliyun )\bbl auth login/);
},
);

test("auth status operation hints use the host prefix without changing authentication", async () => {
const result = await runCompatible(["auth", "status", "--output", "json"]);
expect(result.exitCode, result.stderr).toBe(0);
const status = JSON.parse(result.stdout);
expect(status.authenticated).toBe(false);
expect(status.hint).toContain("aliyun bl auth login --api-key");
});

// Marker decoding is pure; unknown/whitespace cases need no additional CLI processes.
test.each(["", " ", "unrecognized-host"])(
"unknown host marker keeps the default invocation (%j)",
(marker) => {
expect(resolveAliyunIntegration({ ALIBABA_CLOUD_BL_COMPAT_MODE: marker })).toEqual({});
},
);
test("recognized host marker permits surrounding whitespace", () => {
expect(resolveAliyunIntegration({ ALIBABA_CLOUD_BL_COMPAT_MODE: " aliyun bl " })).toEqual({
commandInvocation: { bin: "aliyun", path: ["bl"] },
});
});
Loading
Loading