Skip to content

Commit 93c9149

Browse files
committed
feat(agent): 鉴权分离线命令和在线命令,仅对bailian provider鉴权
1 parent 6f9e006 commit 93c9149

28 files changed

Lines changed: 424 additions & 128 deletions

docs/agents/auth-change.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,22 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
5353

5454
命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。
5555

56-
### 例外:agent 命令的 SDK 凭证内存注入
56+
### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入
5757

58-
`bl managed-agent *` 的全部命令声明 `auth: "apiKey"`(含纯本地脚手架 `init` —— 统一登录门槛,无例外),bailian 凭证由 authStage 经 `resolveApiKey(sources)` 权威解析(flag > env > active profile config,缺失时抛统一 AUTH 错误)。
58+
`bl managed-agent *` 按调用链分两层,不再全命令硬门禁:
5959

60-
凭证不再以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/`**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道四步:
60+
- **离线命令**`init``validate``state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言(`plan --no-refresh` 同样传 `"none"`
61+
- **provider-aware 命令**`plan`(默认)、`apply``destroy``state import``skill-list`、全部 `session *`:仍声明 `auth: "apiKey"` 但加 `authOptional: true` —— authStage 照常经 `resolveApiKey(sources)` 解析 bailian 凭证(flag > env > active profile config)并注入 `ctx.client`,但缺失不在 authStage 抛;真正的门禁在引擎层 `assertProviderCredentials`,只校验本次运行涉及的 provider(`CredentialScope`:`--provider` / state 地址里的 provider / 配置默认 provider 链)。配了四个 provider 只跑 claude 时,缺 bailian key 不阻塞。
6162

62-
1. `prepareProviderEnv()` — 先 `bootstrapRuntimeCredentialsSync()`(SDK 把 `.env` / `~/.agents/config.json` 灌进 env,服务 claude/ark/qoder 等非 bailian provider),再把全部凭证类 env(`CREDENTIAL_ENV_KEYS`,含别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值不因缺变量抛错
63-
2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值
64-
3. `injectProviderCredentials()` — 用 `ctx.client.exportApiCredential()`(lint 限定 `managed-agent/_engine/**` 可用)覆写内存 config 对象的 bailian 块:`api_key` 无条件覆写;`base_url`(拼 `/api/v1/agentstudio` 后缀)/`workspace_id`(取 `settings.workspaceId`)仅在引用且为空时填充
65-
4. `scrubCredentialEnv()` + `assertProviderCredentials()` — 从 `process.env` 删除全部凭证变量(真实凭证此后只存于 config 对象 → provider adapter 实例内存,不驻留 env / 不被子进程继承);任一已声明 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错)
63+
凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/`**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道五步:
6664

67-
`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。
65+
1. `prepareProviderEnv()` — 先 `bootstrapRuntimeCredentialsSync()`(SDK 把 `.env` / `~/.agents/config.json` 灌进 env,服务 claude/ark/qoder 等非 bailian provider),再把全部凭证类 env(`CREDENTIAL_ENV_KEYS`,含别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值不因缺变量抛错
66+
2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`(避免范围外 provider 在 SDK zod 层报 "received null")
67+
3. `injectProviderCredentials()` — 用 `ctx.client.exportApiCredential()`(lint 限定 `managed-agent/_engine/**` 可用)覆写内存 config 对象的 bailian 块:有凭证时 `api_key` 无条件覆写;`base_url`(拼 `/api/v1/agentstudio` 后缀,无凭证时用 client 默认域名补齐以满足 schema)/`workspace_id`(取 `settings.workspaceId`)仅在引用且为空时填充
68+
4. `scrubCredentialEnv()` — 从 `process.env` 删除全部凭证变量(真实凭证此后只存于 config 对象 → provider adapter 实例内存,不驻留 env / 不被子进程继承)
69+
5. `assertProviderCredentials(providers, required)` — 按 `CredentialScope` 算出的 `required` 范围校验:范围内 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);范围外 provider 允许空 key
70+
71+
`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。
6872

6973
## 必查清单
7074

packages/commands/src/commands/managed-agent/_engine/config-loader.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,32 @@ import {
99
assertProviderCredentials,
1010
type CredentialHost,
1111
injectProviderCredentials,
12+
normalizeInterpolatedProviderBlocks,
1213
prepareProviderEnv,
14+
resolveTargetProviderNames,
1315
scrubCredentialEnv,
1416
} from "./credentials.ts";
1517
import { loadFileState } from "./file-state-manager.ts";
1618
import { type HostContext, installSdkTransport } from "./transport.ts";
1719

18-
export { CREDENTIALS_NOTE } from "./credentials.ts";
20+
export { CREDENTIALS_NOTE, OFFLINE_NOTE } from "./credentials.ts";
21+
22+
/**
23+
* Which providers this run requires a non-empty key for:
24+
* - "targets" (default) — the run's target providers per the config's
25+
* default provider chain (mirrors the SDK's plan/apply targeting)
26+
* - "none" — offline command (local config/state only), skip the check
27+
* - "all" — every configured provider (`--provider all`)
28+
* - any other name — the run was narrowed to that provider
29+
* (`--provider <name>` / a provider-qualified state address)
30+
*/
31+
export type CredentialScope = "targets" | "none" | "all" | (string & {});
1932

2033
interface AgentConfigOptions {
2134
resolveEnv?: boolean;
2235
projectName?: string;
2336
statePath?: string;
37+
credentials?: CredentialScope;
2438
}
2539

2640
/**
@@ -32,7 +46,8 @@ interface AgentConfigOptions {
3246
* 3. override the bailian block with the CLI auth chain's credential (in-memory)
3347
* 4. scrub all credential vars from process.env (real values now live only in
3448
* the config object → provider adapters, never the environment)
35-
* 5. fail with a CLI-authoritative AUTH error if any provider's key is empty
49+
* 5. fail with a CLI-authoritative AUTH error if a provider within this run's
50+
* {@link CredentialScope} has an empty key (offline commands pass "none")
3651
*/
3752
export async function resolveAgentProjectConfig(
3853
host: CredentialHost,
@@ -41,9 +56,20 @@ export async function resolveAgentProjectConfig(
4156
): Promise<LoadedProjectConfig> {
4257
prepareProviderEnv();
4358
const resolved = await resolveProjectConfig(filePath, options);
59+
normalizeInterpolatedProviderBlocks(resolved.config.providers);
4460
injectProviderCredentials(resolved.config.providers, host);
4561
scrubCredentialEnv();
46-
assertProviderCredentials(resolved.config.providers);
62+
const scope = options.credentials ?? "targets";
63+
if (scope !== "none") {
64+
assertProviderCredentials(
65+
resolved.config.providers,
66+
scope === "targets"
67+
? resolveTargetProviderNames(resolved.config)
68+
: scope === "all"
69+
? Object.keys(resolved.config.providers)
70+
: [scope],
71+
);
72+
}
4773
return resolved;
4874
}
4975

packages/commands/src/commands/managed-agent/_engine/credentials.ts

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,18 @@ export interface CredentialHost {
5151
export const CREDENTIALS_NOTE = [
5252
"Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).",
5353
"Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.",
54+
"Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.",
5455
"Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.",
5556
];
5657

58+
/**
59+
* Shared `--help` note for commands that never talk to a provider: they load
60+
* agents.yaml / local state only, so no login or provider key is required.
61+
*/
62+
export const OFFLINE_NOTE = [
63+
"Runs fully offline against local files: no login or provider credentials required.",
64+
];
65+
5766
/**
5867
* Load the SDK's env-based credential sources (`.env`, `~/.agents/config.json`)
5968
* for non-bailian providers, then placeholder every credential var that is still
@@ -72,15 +81,18 @@ export function prepareProviderEnv(): void {
7281
/**
7382
* Override the bailian provider block with bl's authStage-resolved credential, so
7483
* the bailian API key is authoritatively the CLI auth chain's — never a config
75-
* file bare-read or a stale env value. `api_key` is replaced unconditionally;
76-
* `base_url` / `workspace_id` are filled only when the block references them and
77-
* the interpolated value is empty (a literal in agents.yaml is respected).
84+
* file bare-read or a stale env value. `api_key` is replaced unconditionally
85+
* when a credential resolved; `base_url` / `workspace_id` are filled only when
86+
* the block references them and the interpolated value is empty (a literal in
87+
* agents.yaml is respected).
7888
*
7989
* `base_url` carries {@link AGENTSTUDIO_API_PATH} because the SDK appends resource
8090
* paths onto it verbatim; a value already ending in the suffix is left as-is.
81-
* With no credential (only under --dry-run: authStage hard-gates otherwise) the
82-
* bailian block is left untouched. Non-bailian blocks keep their interpolated
83-
* (env-sourced) values.
91+
* It is filled even without a credential — `client.baseUrl` is readable
92+
* credential-less (defaults to the CLI's model-domain base URL) — so offline /
93+
* out-of-scope runs still satisfy the SDK's "workspace_id or base_url" schema.
94+
* With no credential the `api_key` is left untouched: an in-scope empty key is
95+
* rejected by {@link assertProviderCredentials}, out-of-scope ones may stay empty.
8496
*/
8597
export function injectProviderCredentials(
8698
providers: Record<string, unknown>,
@@ -91,16 +103,14 @@ export function injectProviderCredentials(
91103
const block = bailian as Record<string, unknown>;
92104

93105
const cred = host.client.exportApiCredential();
94-
if (cred) {
95-
block.api_key = cred.token;
96-
if ("base_url" in block && !block.base_url) {
97-
// Defensive normalization: the auth chain already normalizes base_url to
98-
// an origin, but never let a trailing slash produce "//api/v1/agentstudio".
99-
const origin = cred.baseUrl.replace(/\/+$/, "");
100-
block.base_url = origin.endsWith(AGENTSTUDIO_API_PATH)
101-
? origin
102-
: `${origin}${AGENTSTUDIO_API_PATH}`;
103-
}
106+
if (cred) block.api_key = cred.token;
107+
if ("base_url" in block && !block.base_url) {
108+
// Defensive normalization: the auth chain already normalizes base_url to
109+
// an origin, but never let a trailing slash produce "//api/v1/agentstudio".
110+
const origin = host.client.baseUrl.replace(/\/+$/, "");
111+
block.base_url = origin.endsWith(AGENTSTUDIO_API_PATH)
112+
? origin
113+
: `${origin}${AGENTSTUDIO_API_PATH}`;
104114
}
105115
if ("workspace_id" in block && !block.workspace_id && host.settings.workspaceId) {
106116
block.workspace_id = host.settings.workspaceId;
@@ -121,15 +131,55 @@ export function scrubCredentialEnv(): void {
121131
}
122132

123133
/**
124-
* After injection, fail with a CLI-authoritative AUTH error if any configured
134+
* The SDK interpolates `${VAR}` into the raw YAML text, so an empty env var
135+
* leaves `api_key:` with nothing after it — YAML parses that as null. Normalize
136+
* every null provider field back to "" so the pipeline stays uniform: an empty
137+
* api_key is caught by {@link assertProviderCredentials} when the provider is
138+
* in scope, and out-of-scope blocks still satisfy the SDK's string schemas
139+
* instead of failing zod with "received null" before the run even starts.
140+
*/
141+
export function normalizeInterpolatedProviderBlocks(providers: Record<string, unknown>): void {
142+
for (const raw of Object.values(providers)) {
143+
if (!raw || typeof raw !== "object") continue;
144+
const block = raw as Record<string, unknown>;
145+
for (const [fieldName, value] of Object.entries(block)) {
146+
if (value === null) block[fieldName] = "";
147+
}
148+
}
149+
}
150+
151+
/**
152+
* The providers a run targets when no explicit `--provider` narrows it: the
153+
* config's default provider, or every configured provider when the default is
154+
* absent or "all". Mirrors the SDK's config-based `resolveTargetProviders`
155+
* (not exported from the SDK's public surface).
156+
*/
157+
export function resolveTargetProviderNames(config: {
158+
providers: Record<string, unknown>;
159+
defaults?: { provider?: string };
160+
}): string[] {
161+
const defaultProvider = config.defaults?.provider;
162+
if (!defaultProvider || defaultProvider === "all") return Object.keys(config.providers);
163+
return [defaultProvider];
164+
}
165+
166+
/**
167+
* After injection, fail with a CLI-authoritative AUTH error if a required
125168
* provider's `api_key` resolved empty (missing env var, or no bl login for
126169
* bailian). Replaces the SDK's raw `Environment variable '...' is not set` /
127-
* zod config error with a clean message plus a provider-specific hint. Validates
128-
* every declared provider, so a project is only runnable once all its providers'
129-
* keys are available.
170+
* zod config error with a clean message plus a provider-specific hint.
171+
* `required` limits the check to the providers this run actually involves
172+
* (← --provider / state address / config default chain); providers outside
173+
* that scope may keep empty keys — a project stays runnable per provider.
174+
* Names without a matching config block are skipped: "provider not
175+
* configured" is the engine's error to raise, not a credential problem.
130176
*/
131-
export function assertProviderCredentials(providers: Record<string, unknown>): void {
132-
for (const [name, raw] of Object.entries(providers)) {
177+
export function assertProviderCredentials(
178+
providers: Record<string, unknown>,
179+
required?: readonly string[],
180+
): void {
181+
for (const name of required ?? Object.keys(providers)) {
182+
const raw = providers[name];
133183
if (!raw || typeof raw !== "object") continue;
134184
const block = raw as Record<string, unknown>;
135185
if (!("api_key" in block)) continue;

packages/commands/src/commands/managed-agent/apply.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ const APPLY_FLAGS = {
4646
export default defineCommand({
4747
description: "Apply planned changes to create/update/delete agent resources",
4848
auth: "apiKey",
49+
// Provider-aware gate: only the providers this apply targets need credentials.
50+
authOptional: true,
4951
usageArgs: "[--file <path>] [--provider <name>] [--yes] [--concurrency <n>]",
5052
flags: APPLY_FLAGS,
5153
exampleArgs: ["--yes", "--provider bailian --yes"],
@@ -73,7 +75,9 @@ export default defineCommand({
7375

7476
const planned = await withAgentErrors(() =>
7577
withStdoutProtected(async () => {
76-
const runtime = await buildAgentRuntime(ctx, file);
78+
const runtime = await buildAgentRuntime(ctx, file, {
79+
credentials: flags.provider ?? "targets",
80+
});
7781
assertProviderConfigured(runtime, flags.provider);
7882
return planProjectContext(runtime, {
7983
provider: flags.provider,

packages/commands/src/commands/managed-agent/destroy.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const DESTROY_FLAGS = {
3131
export default defineCommand({
3232
description: "Destroy all managed agent resources tracked in state",
3333
auth: "apiKey",
34+
// Provider-aware gate: only the run's target providers need credentials.
35+
authOptional: true,
3436
usageArgs: "[--file <path>] [--yes] [--cascade]",
3537
flags: DESTROY_FLAGS,
3638
exampleArgs: ["--yes", "--yes --cascade"],
@@ -54,7 +56,9 @@ export default defineCommand({
5456

5557
const planned = await withAgentErrors(() =>
5658
withStdoutProtected(async () => {
57-
const runtime = await buildAgentRuntime(ctx, file);
59+
const runtime = await buildAgentRuntime(ctx, file, {
60+
credentials: "targets",
61+
});
5862
return planDestroyProjectContext(runtime);
5963
}),
6064
);

packages/commands/src/commands/managed-agent/init.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const INIT_FLAGS = {
9999

100100
export default defineCommand({
101101
description: "Create a new agents.yaml template",
102-
auth: "apiKey",
102+
auth: "none",
103103
usageArgs: "[--provider <name>] [--agent-name <name>] [--file <path>] [--force]",
104104
flags: INIT_FLAGS,
105105
exampleArgs: ["", "--provider bailian --agent-name assistant", "--provider all"],

packages/commands/src/commands/managed-agent/plan.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ const PLAN_FLAGS = {
4141
export default defineCommand({
4242
description: "Show what changes would be applied to agent infrastructure",
4343
auth: "apiKey",
44+
// Provider-aware gate: --no-refresh plans fully offline; a refreshing run
45+
// only needs credentials for the providers it targets (see CredentialScope).
46+
authOptional: true,
4447
usageArgs: "[--file <path>] [--provider <name>] [--no-refresh] [--refresh-only]",
4548
flags: PLAN_FLAGS,
4649
exampleArgs: ["", "--provider bailian", "--no-refresh"],
@@ -52,7 +55,10 @@ export default defineCommand({
5255

5356
const planned = await withAgentErrors(() =>
5457
withStdoutProtected(async () => {
55-
const runtime = await buildAgentRuntime(ctx, file);
58+
// --no-refresh never talks to a provider → no credentials required.
59+
const runtime = await buildAgentRuntime(ctx, file, {
60+
credentials: flags.noRefresh ? "none" : (flags.provider ?? "targets"),
61+
});
5662
assertProviderConfigured(runtime, flags.provider);
5763
return planProjectContext(runtime, {
5864
provider: flags.provider,

packages/commands/src/commands/managed-agent/session-create.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ const SESSION_CREATE_FLAGS = {
4343
export default defineCommand({
4444
description: "Create a new session for an agent",
4545
auth: "apiKey",
46+
// Provider-aware gate: only the session's provider needs credentials.
47+
authOptional: true,
4648
usageArgs: "[--agent <name>] [--environment <name>] [--title <title>] [--file <path>]",
4749
flags: SESSION_CREATE_FLAGS,
4850
exampleArgs: ["", "--agent assistant", "--agent assistant --title 'debug run'"],
@@ -72,7 +74,9 @@ export default defineCommand({
7274

7375
const run = await withAgentErrors(() =>
7476
withStdoutProtected(async () => {
75-
const runtime = await buildAgentRuntime(ctx, file);
77+
const runtime = await buildAgentRuntime(ctx, file, {
78+
credentials: flags.provider ?? "targets",
79+
});
7680
return createSessionForAgent(runtime, {
7781
agent: flags.agent,
7882
provider: flags.provider,

packages/commands/src/commands/managed-agent/session-delete.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const SESSION_DELETE_FLAGS = {
2727
export default defineCommand({
2828
description: "Delete a session",
2929
auth: "apiKey",
30+
// Provider-aware gate: only the session's provider needs credentials.
31+
authOptional: true,
3032
usageArgs: "--session-id <id> [--provider <name>] [--file <path>]",
3133
flags: SESSION_DELETE_FLAGS,
3234
exampleArgs: ["--session-id sess_abc123"],
@@ -50,7 +52,9 @@ export default defineCommand({
5052

5153
await withAgentErrors(() =>
5254
withStdoutProtected(async () => {
53-
const runtime = await buildAgentRuntime(ctx, file);
55+
const runtime = await buildAgentRuntime(ctx, file, {
56+
credentials: flags.provider ?? "targets",
57+
});
5458
await deleteSession(runtime, flags.sessionId, flags.provider);
5559
}),
5660
);

packages/commands/src/commands/managed-agent/session-events.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const SESSION_EVENTS_FLAGS = {
3838
export default defineCommand({
3939
description: "List event history for a session",
4040
auth: "apiKey",
41+
// Provider-aware gate: only the session's provider needs credentials.
42+
authOptional: true,
4143
usageArgs: "--session-id <id> [--limit <n>] [--all] [--file <path>]",
4244
flags: SESSION_EVENTS_FLAGS,
4345
exampleArgs: ["--session-id sess_abc123", "--session-id sess_abc123 --all"],
@@ -49,7 +51,9 @@ export default defineCommand({
4951

5052
const { items: events, hasMore } = await withAgentErrors(() =>
5153
withStdoutProtected(async () => {
52-
const runtime = await buildAgentRuntime(ctx, file);
54+
const runtime = await buildAgentRuntime(ctx, file, {
55+
credentials: flags.provider ?? "targets",
56+
});
5357
return fetchAllPages(async (page) => {
5458
const result = await listSessionEvents(runtime, flags.sessionId, {
5559
provider: flags.provider,

0 commit comments

Comments
 (0)