Skip to content

Commit bb88591

Browse files
Merge pull request #298 from modelstudioai/feat/aliyun-bl-integration
feat(cli): integrate aliyun bl invocation and authentication
2 parents a7cb810 + 6b70b99 commit bb88591

118 files changed

Lines changed: 2642 additions & 503 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/agents/auth-change.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
5353
- 显式 `auth login --config <name>` 在凭证落盘成功后自动激活目标 Profile;未传
5454
`--config` 时继续写当前激活项,失败和 dry-run 不切换
5555
- `resolveConsole()` — `auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认
56-
- `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_*`,新写入只写短字段
56+
- `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。
57+
- 上述 AK/SK 参数优先级仅适用于开放这些参数的入口(如 `auth: "openapi"` 命令)。Console 业务命令不开放临时 AK/SK 参数,只使用提前准备好的凭证:百炼环境变量 > 当前百炼配置 > 阿里云环境变量。
5758
- `describeAuthState()` — `auth status` / banner / telemetry 使用的只读快照
5859

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

6869
### Console token 签发与刷新
6970

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

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

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

164170
## 完成后自查
165171

‎docs/agents/error-hint-change.md‎

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

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

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

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

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

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

105117
### D. 文案一致性
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { CliOptions } from "bailian-cli-runtime";
2+
3+
/** Decode the existing Aliyun subprocess protocol at the product boundary. */
4+
export function resolveAliyunIntegration(
5+
environment: NodeJS.ProcessEnv,
6+
): Pick<CliOptions, "commandInvocation"> {
7+
if (environment.ALIBABA_CLOUD_BL_COMPAT_MODE?.trim() !== "aliyun bl") return {};
8+
return {
9+
commandInvocation: { bin: "aliyun", path: ["bl"] },
10+
};
11+
}

‎packages/cli/src/main.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createCli } from "bailian-cli-runtime";
22
import { commandAliases, commands } from "./commands.ts";
33
import { commandPackPolicy } from "./command-pack-policy.ts";
44
import pkg from "../package.json" with { type: "json" };
5+
import { resolveAliyunIntegration } from "./integrations/aliyun.ts";
56

67
const quickStartTasks = [
78
"帮我创建一个能够生成短片分镜和视频的 Managed Agent。\n Help me create a Managed Agent that can generate short-film storyboards and videos.",
@@ -15,6 +16,7 @@ void createCli(
1516
{ ...commands, ...commandAliases },
1617
{
1718
binName: "bl",
19+
...resolveAliyunIntegration(process.env),
1820
version: pkg.version,
1921
clientName: "bailian-cli",
2022
npmPackage: "bailian-cli",
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { captureRegistryHelp } from "e2e/registry-smoke";
2+
import { buildIsolatedCliEnv } from "e2e/isolation";
3+
import { runInProcess } from "e2e/in-process";
4+
import { defineCommand, buildCommandInvocation, maskToken } from "bailian-cli-core";
5+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6+
import { tmpdir } from "node:os";
7+
import { join } from "node:path";
8+
import { afterAll, expect, expectTypeOf, test } from "vite-plus/test";
9+
import { runCli } from "./helpers.ts";
10+
import {
11+
buildCommandSchema,
12+
CommandRegistry,
13+
createCli,
14+
type CliOptions,
15+
} from "bailian-cli-runtime";
16+
import { commands } from "../../src/commands.ts";
17+
import { resolveAliyunIntegration } from "../../src/integrations/aliyun.ts";
18+
19+
const configDirectory = mkdtempSync(join(tmpdir(), "bl-aliyun-compat-"));
20+
afterAll(() => rmSync(configDirectory, { recursive: true, force: true }));
21+
22+
test("host adapter propagates executable argv through the runtime context", async () => {
23+
expectTypeOf<CliOptions>().not.toHaveProperty("commandPrefix");
24+
const isolated = buildIsolatedCliEnv({
25+
overrides: { NO_UPDATE_NOTIFIER: "1", DO_NOT_TRACK: "1" },
26+
});
27+
const arguments_ = ["probe", "--output", "json"];
28+
const probe = defineCommand({
29+
auth: "none",
30+
description: "fixture",
31+
async run(context) {
32+
context.output.result({
33+
product: context.identity.binName,
34+
command: buildCommandInvocation(
35+
context.identity,
36+
["knowledge", "delete"],
37+
["--index-id", "example"],
38+
),
39+
});
40+
},
41+
});
42+
try {
43+
const result = await runInProcess(
44+
() =>
45+
createCli(
46+
{ probe },
47+
{
48+
binName: "bl",
49+
clientName: "test",
50+
npmPackage: "test",
51+
version: "test",
52+
...resolveAliyunIntegration({ ALIBABA_CLOUD_BL_COMPAT_MODE: "aliyun bl" }),
53+
},
54+
).run(arguments_),
55+
arguments_,
56+
{ env: isolated.env },
57+
);
58+
expect(result.exitCode, result.stderr).toBe(0);
59+
expect(JSON.parse(result.stdout)).toEqual({
60+
product: "bl",
61+
command: {
62+
bin: "aliyun",
63+
path: ["bl", "knowledge", "delete"],
64+
args: ["--index-id", "example"],
65+
},
66+
});
67+
} finally {
68+
isolated.dispose();
69+
}
70+
});
71+
72+
test("all command metadata preserves standalone wording and resolves hosted references", () => {
73+
const identity = {
74+
binName: "bl",
75+
version: "test",
76+
clientName: "test",
77+
npmPackage: "bailian-cli",
78+
};
79+
const standalone = buildCommandSchema(new CommandRegistry(commands, "bl"), identity);
80+
const hosted = buildCommandSchema(new CommandRegistry(commands, "aliyun bl"), {
81+
...identity,
82+
commandInvocation: { bin: "aliyun", path: ["bl"] },
83+
});
84+
const serialized = JSON.stringify(hosted);
85+
expect(serialized).not.toContain("{{commandPrefix}}");
86+
expect(hosted.bin).toBe("aliyun");
87+
expect(hosted.scope).toEqual(["bl"]);
88+
const qualifiedStandalone = {
89+
...standalone,
90+
bin: "aliyun",
91+
scope: ["bl"],
92+
commands: standalone.commands.map((command) => ({ ...command, path: ["bl", ...command.path] })),
93+
};
94+
expect(JSON.parse(serialized.replaceAll("aliyun bl", "bl"))).toEqual(qualifiedStandalone);
95+
const skill = hosted.commands.find((command) => command.path.join(" ") === "bl skill init")!;
96+
expect(JSON.stringify(skill.notes)).toContain("Equivalent to: `aliyun bl skill add --all`");
97+
});
98+
99+
// Real subprocesses below verify the product entrypoint reads the inherited host marker.
100+
function runCompatible(arguments_: string[], language = "en-US", compatibility = "aliyun bl") {
101+
writeFileSync(join(configDirectory, "config.json"), JSON.stringify({ language }));
102+
return runCli(arguments_, {
103+
BAILIAN_CONFIG_DIR: configDirectory,
104+
ALIBABA_CLOUD_BL_COMPAT_MODE: compatibility,
105+
DASHSCOPE_API_KEY: "",
106+
BAILIAN_ACCESS_KEY_ID: "",
107+
BAILIAN_ACCESS_KEY_SECRET: "",
108+
BAILIAN_SECURITY_TOKEN: "",
109+
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
110+
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
111+
ALIBABA_CLOUD_SECURITY_TOKEN: "",
112+
NO_UPDATE_NOTIFIER: "1",
113+
DO_NOT_TRACK: "1",
114+
});
115+
}
116+
117+
test.each(["en-US", "zh-CN"])(
118+
"host help and onboarding use the invocation prefix (%s)",
119+
async (language) => {
120+
const root = await runCompatible(["--help"], language);
121+
expect(root.exitCode, root.stderr).toBe(0);
122+
expect(root.stderr).toContain("aliyun bl <resource> <command> [flags]");
123+
expect(root.stderr).toContain("aliyun bl auth login --console");
124+
},
125+
);
126+
127+
test.each(
128+
["en-US", "zh-CN"].flatMap((language) =>
129+
["app", "app list", "skill init", "managed-agent plan"].map((path) => ({ language, path })),
130+
),
131+
)("host nested help uses the invocation prefix ($language, $path)", ({ language, path }) => {
132+
const registry = new CommandRegistry(commands, "aliyun bl", {
133+
language: language as "en-US" | "zh-CN",
134+
localize: (value) => (typeof value === "string" ? value : value[language as "en-US" | "zh-CN"]),
135+
});
136+
const help = captureRegistryHelp(registry, path.split(" "));
137+
expect(help).toContain(`aliyun bl ${path}`);
138+
expect(help).not.toMatch(/(?<!aliyun )\bbl (?:auth|skill|managed-agent) /);
139+
});
140+
141+
// Real entrypoint verifies inherited Bailian env names without making authentication requests.
142+
test("auth status reads Bailian env before config and host env without adding status fields", async () => {
143+
writeFileSync(
144+
join(configDirectory, "config.json"),
145+
JSON.stringify({
146+
language: "en-US",
147+
access_key_id: "config-ak",
148+
access_key_secret: "config-sk",
149+
}),
150+
);
151+
const result = await runCli(["auth", "status", "--output", "json"], {
152+
BAILIAN_CONFIG_DIR: configDirectory,
153+
ALIBABA_CLOUD_BL_COMPAT_MODE: "aliyun bl",
154+
BAILIAN_ACCESS_KEY_ID: "bailian-env-access-key",
155+
BAILIAN_ACCESS_KEY_SECRET: "bailian-env-secret-key",
156+
BAILIAN_SECURITY_TOKEN: "bailian-env-sts",
157+
ALIBABA_CLOUD_ACCESS_KEY_ID: "host-ak",
158+
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "host-sk",
159+
ALIBABA_CLOUD_SECURITY_TOKEN: "host-sts",
160+
DASHSCOPE_API_KEY: "",
161+
NO_UPDATE_NOTIFIER: "1",
162+
DO_NOT_TRACK: "1",
163+
});
164+
expect(result.exitCode, result.stderr).toBe(0);
165+
const state = JSON.parse(result.stdout);
166+
expect(state.openapi).toEqual({
167+
source: "env",
168+
access_key_id: maskToken("bailian-env-access-key"),
169+
access_key_secret: maskToken("bailian-env-secret-key"),
170+
});
171+
expect(state.console).toBeUndefined();
172+
expect(result.stdout + result.stderr).not.toContain("bailian-env-secret-key");
173+
expect(result.stdout + result.stderr).not.toContain("bailian-env-sts");
174+
});
175+
176+
test("host introspection exports executable paths and examples from the real entrypoint", async () => {
177+
const result = await runCompatible(["auth", "status", "--introspect"]);
178+
expect(result.exitCode, result.stderr).toBe(0);
179+
const schema = JSON.parse(result.stdout);
180+
expect(schema.bin).toBe("aliyun");
181+
expect(schema.scope).toEqual(["bl", "auth", "status"]);
182+
expect(schema.commands[0].path).toEqual(["bl", "auth", "status"]);
183+
expect(schema.commands[0].usage).toMatch(/^aliyun bl auth status/);
184+
expect(schema.commands[0].examples[0]).toBe("aliyun bl auth status");
185+
});
186+
187+
test("host version is display-only and absent or unknown COMPAT keeps standalone", async () => {
188+
const hosted = await runCompatible(["--version"]);
189+
expect(hosted.stdout).toMatch(/^aliyun bl \d+\.\d+\.\d+\n$/);
190+
for (const compatibility of [""]) {
191+
const standalone = await runCompatible(["--version"], "en-US", compatibility);
192+
expect(standalone.stdout).toMatch(/^bl \d+\.\d+\.\d+\n$/);
193+
}
194+
});
195+
196+
test.each(["text", "json"])(
197+
"missing Console credentials use host instructions (%s)",
198+
async (output) => {
199+
const result = await runCompatible(["app", "list", "--output", output]);
200+
expect(result.exitCode, result.stderr).toBe(3);
201+
const diagnostic = output === "json" ? JSON.parse(result.stderr).error.hint : result.stderr;
202+
expect(diagnostic).toContain("aliyun bl auth login --console");
203+
expect(diagnostic).not.toMatch(/(?<!aliyun )\bbl auth login/);
204+
},
205+
);
206+
207+
test("auth status operation hints use the host prefix without changing authentication", async () => {
208+
const result = await runCompatible(["auth", "status", "--output", "json"]);
209+
expect(result.exitCode, result.stderr).toBe(0);
210+
const status = JSON.parse(result.stdout);
211+
expect(status.authenticated).toBe(false);
212+
expect(status.hint).toContain("aliyun bl auth login --api-key");
213+
});
214+
215+
// Marker decoding is pure; unknown/whitespace cases need no additional CLI processes.
216+
test.each(["", " ", "unrecognized-host"])(
217+
"unknown host marker keeps the default invocation (%j)",
218+
(marker) => {
219+
expect(resolveAliyunIntegration({ ALIBABA_CLOUD_BL_COMPAT_MODE: marker })).toEqual({});
220+
},
221+
);
222+
test("recognized host marker permits surrounding whitespace", () => {
223+
expect(resolveAliyunIntegration({ ALIBABA_CLOUD_BL_COMPAT_MODE: " aliyun bl " })).toEqual({
224+
commandInvocation: { bin: "aliyun", path: ["bl"] },
225+
});
226+
});

0 commit comments

Comments
 (0)