diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index 573ff8f..cac9ec1 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -53,18 +53,22 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx 命令不要直接解析 token、env 或 config。业务请求统一走 `ctx.client`;登录/配置命令通过 `ctx.authStore` / `ctx.configStore` 的窄接口操作落盘。 -### 例外:agent 命令的 SDK 凭证内存注入 +### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入 -`bl managed-agent *` 的全部命令声明 `auth: "apiKey"`(含纯本地脚手架 `init` —— 统一登录门槛,无例外),bailian 凭证由 authStage 经 `resolveApiKey(sources)` 权威解析(flag > env > active profile config,缺失时抛统一 AUTH 错误)。 +`bl managed-agent *` 按调用链分两层,不再全命令硬门禁: -凭证不再以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/` 的**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道四步: +- **离线命令** — `init`、`validate`、`state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言(`plan --no-refresh` 同样传 `"none"`) +- **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 不阻塞。 -1. `prepareProviderEnv()` — 先 `bootstrapRuntimeCredentialsSync()`(SDK 把 `.env` / `~/.agents/config.json` 灌进 env,服务 claude/ark/qoder 等非 bailian provider),再把全部凭证类 env(`CREDENTIAL_ENV_KEYS`,含别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值不因缺变量抛错 -2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值 -3. `injectProviderCredentials()` — 用 `ctx.client.exportApiCredential()`(lint 限定 `managed-agent/_engine/**` 可用)覆写内存 config 对象的 bailian 块:`api_key` 无条件覆写;`base_url`(拼 `/api/v1/agentstudio` 后缀)/`workspace_id`(取 `settings.workspaceId`)仅在引用且为空时填充 -4. `scrubCredentialEnv()` + `assertProviderCredentials()` — 从 `process.env` 删除全部凭证变量(真实凭证此后只存于 config 对象 → provider adapter 实例内存,不驻留 env / 不被子进程继承);任一已声明 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错) +凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/` 的**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道五步: -`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。 +1. `prepareProviderEnv()` — 先 `bootstrapRuntimeCredentialsSync()`(SDK 把 `.env` / `~/.agents/config.json` 灌进 env,服务 claude/ark/qoder 等非 bailian provider),再把全部凭证类 env(`CREDENTIAL_ENV_KEYS`,含别名)中仍为 undefined 的占位为 `""`,使 agents.yaml 插值不因缺变量抛错 +2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`(避免范围外 provider 在 SDK zod 层报 "received null") +3. `injectProviderCredentials()` — 用 `ctx.client.exportApiCredential()`(lint 限定 `managed-agent/_engine/**` 可用)覆写内存 config 对象的 bailian 块:有凭证时 `api_key` 无条件覆写;`base_url`(拼 `/api/v1/agentstudio` 后缀,无凭证时用 client 默认域名补齐以满足 schema)/`workspace_id`(取 `settings.workspaceId`)仅在引用且为空时填充 +4. `scrubCredentialEnv()` — 从 `process.env` 删除全部凭证变量(真实凭证此后只存于 config 对象 → provider adapter 实例内存,不驻留 env / 不被子进程继承) +5. `assertProviderCredentials(providers, required)` — 按 `CredentialScope` 算出的 `required` 范围校验:范围内 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);范围外 provider 允许空 key + +`bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。 ## 必查清单 diff --git a/packages/commands/src/commands/managed-agent/_engine/config-loader.ts b/packages/commands/src/commands/managed-agent/_engine/config-loader.ts index ae5ffda..0043c69 100644 --- a/packages/commands/src/commands/managed-agent/_engine/config-loader.ts +++ b/packages/commands/src/commands/managed-agent/_engine/config-loader.ts @@ -9,18 +9,32 @@ import { assertProviderCredentials, type CredentialHost, injectProviderCredentials, + normalizeInterpolatedProviderBlocks, prepareProviderEnv, + resolveTargetProviderNames, scrubCredentialEnv, } from "./credentials.ts"; import { loadFileState } from "./file-state-manager.ts"; import { type HostContext, installSdkTransport } from "./transport.ts"; -export { CREDENTIALS_NOTE } from "./credentials.ts"; +export { CREDENTIALS_NOTE, OFFLINE_NOTE } from "./credentials.ts"; + +/** + * Which providers this run requires a non-empty key for: + * - "targets" (default) — the run's target providers per the config's + * default provider chain (mirrors the SDK's plan/apply targeting) + * - "none" — offline command (local config/state only), skip the check + * - "all" — every configured provider (`--provider all`) + * - any other name — the run was narrowed to that provider + * (`--provider ` / a provider-qualified state address) + */ +export type CredentialScope = "targets" | "none" | "all" | (string & {}); interface AgentConfigOptions { resolveEnv?: boolean; projectName?: string; statePath?: string; + credentials?: CredentialScope; } /** @@ -32,7 +46,8 @@ interface AgentConfigOptions { * 3. override the bailian block with the CLI auth chain's credential (in-memory) * 4. scrub all credential vars from process.env (real values now live only in * the config object → provider adapters, never the environment) - * 5. fail with a CLI-authoritative AUTH error if any provider's key is empty + * 5. fail with a CLI-authoritative AUTH error if a provider within this run's + * {@link CredentialScope} has an empty key (offline commands pass "none") */ export async function resolveAgentProjectConfig( host: CredentialHost, @@ -41,9 +56,20 @@ export async function resolveAgentProjectConfig( ): Promise { prepareProviderEnv(); const resolved = await resolveProjectConfig(filePath, options); + normalizeInterpolatedProviderBlocks(resolved.config.providers); injectProviderCredentials(resolved.config.providers, host); scrubCredentialEnv(); - assertProviderCredentials(resolved.config.providers); + const scope = options.credentials ?? "targets"; + if (scope !== "none") { + assertProviderCredentials( + resolved.config.providers, + scope === "targets" + ? resolveTargetProviderNames(resolved.config) + : scope === "all" + ? Object.keys(resolved.config.providers) + : [scope], + ); + } return resolved; } diff --git a/packages/commands/src/commands/managed-agent/_engine/credentials.ts b/packages/commands/src/commands/managed-agent/_engine/credentials.ts index f7a7e06..2dc096a 100644 --- a/packages/commands/src/commands/managed-agent/_engine/credentials.ts +++ b/packages/commands/src/commands/managed-agent/_engine/credentials.ts @@ -51,9 +51,18 @@ export interface CredentialHost { export const CREDENTIALS_NOTE = [ "Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile).", "Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.", + "Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.", "Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.", ]; +/** + * Shared `--help` note for commands that never talk to a provider: they load + * agents.yaml / local state only, so no login or provider key is required. + */ +export const OFFLINE_NOTE = [ + "Runs fully offline against local files: no login or provider credentials required.", +]; + /** * Load the SDK's env-based credential sources (`.env`, `~/.agents/config.json`) * for non-bailian providers, then placeholder every credential var that is still @@ -72,15 +81,18 @@ export function prepareProviderEnv(): void { /** * Override the bailian provider block with bl's authStage-resolved credential, so * the bailian API key is authoritatively the CLI auth chain's — never a config - * file bare-read or a stale env value. `api_key` is replaced unconditionally; - * `base_url` / `workspace_id` are filled only when the block references them and - * the interpolated value is empty (a literal in agents.yaml is respected). + * file bare-read or a stale env value. `api_key` is replaced unconditionally + * when a credential resolved; `base_url` / `workspace_id` are filled only when + * the block references them and the interpolated value is empty (a literal in + * agents.yaml is respected). * * `base_url` carries {@link AGENTSTUDIO_API_PATH} because the SDK appends resource * paths onto it verbatim; a value already ending in the suffix is left as-is. - * With no credential (only under --dry-run: authStage hard-gates otherwise) the - * bailian block is left untouched. Non-bailian blocks keep their interpolated - * (env-sourced) values. + * It is filled even without a credential — `client.baseUrl` is readable + * credential-less (defaults to the CLI's model-domain base URL) — so offline / + * out-of-scope runs still satisfy the SDK's "workspace_id or base_url" schema. + * With no credential the `api_key` is left untouched: an in-scope empty key is + * rejected by {@link assertProviderCredentials}, out-of-scope ones may stay empty. */ export function injectProviderCredentials( providers: Record, @@ -91,16 +103,14 @@ export function injectProviderCredentials( const block = bailian as Record; const cred = host.client.exportApiCredential(); - if (cred) { - block.api_key = cred.token; - if ("base_url" in block && !block.base_url) { - // Defensive normalization: the auth chain already normalizes base_url to - // an origin, but never let a trailing slash produce "//api/v1/agentstudio". - const origin = cred.baseUrl.replace(/\/+$/, ""); - block.base_url = origin.endsWith(AGENTSTUDIO_API_PATH) - ? origin - : `${origin}${AGENTSTUDIO_API_PATH}`; - } + if (cred) block.api_key = cred.token; + if ("base_url" in block && !block.base_url) { + // Defensive normalization: the auth chain already normalizes base_url to + // an origin, but never let a trailing slash produce "//api/v1/agentstudio". + const origin = host.client.baseUrl.replace(/\/+$/, ""); + block.base_url = origin.endsWith(AGENTSTUDIO_API_PATH) + ? origin + : `${origin}${AGENTSTUDIO_API_PATH}`; } if ("workspace_id" in block && !block.workspace_id && host.settings.workspaceId) { block.workspace_id = host.settings.workspaceId; @@ -121,15 +131,55 @@ export function scrubCredentialEnv(): void { } /** - * After injection, fail with a CLI-authoritative AUTH error if any configured + * The SDK interpolates `${VAR}` into the raw YAML text, so an empty env var + * leaves `api_key:` with nothing after it — YAML parses that as null. Normalize + * every null provider field back to "" so the pipeline stays uniform: an empty + * api_key is caught by {@link assertProviderCredentials} when the provider is + * in scope, and out-of-scope blocks still satisfy the SDK's string schemas + * instead of failing zod with "received null" before the run even starts. + */ +export function normalizeInterpolatedProviderBlocks(providers: Record): void { + for (const raw of Object.values(providers)) { + if (!raw || typeof raw !== "object") continue; + const block = raw as Record; + for (const [fieldName, value] of Object.entries(block)) { + if (value === null) block[fieldName] = ""; + } + } +} + +/** + * The providers a run targets when no explicit `--provider` narrows it: the + * config's default provider, or every configured provider when the default is + * absent or "all". Mirrors the SDK's config-based `resolveTargetProviders` + * (not exported from the SDK's public surface). + */ +export function resolveTargetProviderNames(config: { + providers: Record; + defaults?: { provider?: string }; +}): string[] { + const defaultProvider = config.defaults?.provider; + if (!defaultProvider || defaultProvider === "all") return Object.keys(config.providers); + return [defaultProvider]; +} + +/** + * After injection, fail with a CLI-authoritative AUTH error if a required * provider's `api_key` resolved empty (missing env var, or no bl login for * bailian). Replaces the SDK's raw `Environment variable '...' is not set` / - * zod config error with a clean message plus a provider-specific hint. Validates - * every declared provider, so a project is only runnable once all its providers' - * keys are available. + * zod config error with a clean message plus a provider-specific hint. + * `required` limits the check to the providers this run actually involves + * (← --provider / state address / config default chain); providers outside + * that scope may keep empty keys — a project stays runnable per provider. + * Names without a matching config block are skipped: "provider not + * configured" is the engine's error to raise, not a credential problem. */ -export function assertProviderCredentials(providers: Record): void { - for (const [name, raw] of Object.entries(providers)) { +export function assertProviderCredentials( + providers: Record, + required?: readonly string[], +): void { + for (const name of required ?? Object.keys(providers)) { + const raw = providers[name]; if (!raw || typeof raw !== "object") continue; const block = raw as Record; if (!("api_key" in block)) continue; diff --git a/packages/commands/src/commands/managed-agent/apply.ts b/packages/commands/src/commands/managed-agent/apply.ts index a00a166..985a201 100644 --- a/packages/commands/src/commands/managed-agent/apply.ts +++ b/packages/commands/src/commands/managed-agent/apply.ts @@ -46,6 +46,8 @@ const APPLY_FLAGS = { export default defineCommand({ description: "Apply planned changes to create/update/delete agent resources", auth: "apiKey", + // Provider-aware gate: only the providers this apply targets need credentials. + authOptional: true, usageArgs: "[--file ] [--provider ] [--yes] [--concurrency ]", flags: APPLY_FLAGS, exampleArgs: ["--yes", "--provider bailian --yes"], @@ -73,7 +75,9 @@ export default defineCommand({ const planned = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: flags.provider ?? "targets", + }); assertProviderConfigured(runtime, flags.provider); return planProjectContext(runtime, { provider: flags.provider, diff --git a/packages/commands/src/commands/managed-agent/destroy.ts b/packages/commands/src/commands/managed-agent/destroy.ts index 1732067..c784af0 100644 --- a/packages/commands/src/commands/managed-agent/destroy.ts +++ b/packages/commands/src/commands/managed-agent/destroy.ts @@ -31,6 +31,8 @@ const DESTROY_FLAGS = { export default defineCommand({ description: "Destroy all managed agent resources tracked in state", auth: "apiKey", + // Provider-aware gate: only the run's target providers need credentials. + authOptional: true, usageArgs: "[--file ] [--yes] [--cascade]", flags: DESTROY_FLAGS, exampleArgs: ["--yes", "--yes --cascade"], @@ -54,7 +56,9 @@ export default defineCommand({ const planned = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: "targets", + }); return planDestroyProjectContext(runtime); }), ); diff --git a/packages/commands/src/commands/managed-agent/init.ts b/packages/commands/src/commands/managed-agent/init.ts index 1594626..6fd3100 100644 --- a/packages/commands/src/commands/managed-agent/init.ts +++ b/packages/commands/src/commands/managed-agent/init.ts @@ -99,7 +99,7 @@ const INIT_FLAGS = { export default defineCommand({ description: "Create a new agents.yaml template", - auth: "apiKey", + auth: "none", usageArgs: "[--provider ] [--agent-name ] [--file ] [--force]", flags: INIT_FLAGS, exampleArgs: ["", "--provider bailian --agent-name assistant", "--provider all"], diff --git a/packages/commands/src/commands/managed-agent/plan.ts b/packages/commands/src/commands/managed-agent/plan.ts index 24c714d..516fc2e 100644 --- a/packages/commands/src/commands/managed-agent/plan.ts +++ b/packages/commands/src/commands/managed-agent/plan.ts @@ -41,6 +41,9 @@ const PLAN_FLAGS = { export default defineCommand({ description: "Show what changes would be applied to agent infrastructure", auth: "apiKey", + // Provider-aware gate: --no-refresh plans fully offline; a refreshing run + // only needs credentials for the providers it targets (see CredentialScope). + authOptional: true, usageArgs: "[--file ] [--provider ] [--no-refresh] [--refresh-only]", flags: PLAN_FLAGS, exampleArgs: ["", "--provider bailian", "--no-refresh"], @@ -52,7 +55,10 @@ export default defineCommand({ const planned = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + // --no-refresh never talks to a provider → no credentials required. + const runtime = await buildAgentRuntime(ctx, file, { + credentials: flags.noRefresh ? "none" : (flags.provider ?? "targets"), + }); assertProviderConfigured(runtime, flags.provider); return planProjectContext(runtime, { provider: flags.provider, diff --git a/packages/commands/src/commands/managed-agent/session-create.ts b/packages/commands/src/commands/managed-agent/session-create.ts index 1485ab5..90a5c06 100644 --- a/packages/commands/src/commands/managed-agent/session-create.ts +++ b/packages/commands/src/commands/managed-agent/session-create.ts @@ -43,6 +43,8 @@ const SESSION_CREATE_FLAGS = { export default defineCommand({ description: "Create a new session for an agent", auth: "apiKey", + // Provider-aware gate: only the session's provider needs credentials. + authOptional: true, usageArgs: "[--agent ] [--environment ] [--title ] [--file <path>]", flags: SESSION_CREATE_FLAGS, exampleArgs: ["", "--agent assistant", "--agent assistant --title 'debug run'"], @@ -72,7 +74,9 @@ export default defineCommand({ const run = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: flags.provider ?? "targets", + }); return createSessionForAgent(runtime, { agent: flags.agent, provider: flags.provider, diff --git a/packages/commands/src/commands/managed-agent/session-delete.ts b/packages/commands/src/commands/managed-agent/session-delete.ts index 5d463db..9785bc5 100644 --- a/packages/commands/src/commands/managed-agent/session-delete.ts +++ b/packages/commands/src/commands/managed-agent/session-delete.ts @@ -27,6 +27,8 @@ const SESSION_DELETE_FLAGS = { export default defineCommand({ description: "Delete a session", auth: "apiKey", + // Provider-aware gate: only the session's provider needs credentials. + authOptional: true, usageArgs: "--session-id <id> [--provider <name>] [--file <path>]", flags: SESSION_DELETE_FLAGS, exampleArgs: ["--session-id sess_abc123"], @@ -50,7 +52,9 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: flags.provider ?? "targets", + }); await deleteSession(runtime, flags.sessionId, flags.provider); }), ); diff --git a/packages/commands/src/commands/managed-agent/session-events.ts b/packages/commands/src/commands/managed-agent/session-events.ts index 0cdcf33..2de0d39 100644 --- a/packages/commands/src/commands/managed-agent/session-events.ts +++ b/packages/commands/src/commands/managed-agent/session-events.ts @@ -38,6 +38,8 @@ const SESSION_EVENTS_FLAGS = { export default defineCommand({ description: "List event history for a session", auth: "apiKey", + // Provider-aware gate: only the session's provider needs credentials. + authOptional: true, usageArgs: "--session-id <id> [--limit <n>] [--all] [--file <path>]", flags: SESSION_EVENTS_FLAGS, exampleArgs: ["--session-id sess_abc123", "--session-id sess_abc123 --all"], @@ -49,7 +51,9 @@ export default defineCommand({ const { items: events, hasMore } = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: flags.provider ?? "targets", + }); return fetchAllPages(async (page) => { const result = await listSessionEvents(runtime, flags.sessionId, { provider: flags.provider, diff --git a/packages/commands/src/commands/managed-agent/session-get.ts b/packages/commands/src/commands/managed-agent/session-get.ts index 522daad..628dfe0 100644 --- a/packages/commands/src/commands/managed-agent/session-get.ts +++ b/packages/commands/src/commands/managed-agent/session-get.ts @@ -27,6 +27,8 @@ const SESSION_GET_FLAGS = { export default defineCommand({ description: "Get details of a session", auth: "apiKey", + // Provider-aware gate: only the session's provider needs credentials. + authOptional: true, usageArgs: "--session-id <id> [--provider <name>] [--file <path>]", flags: SESSION_GET_FLAGS, exampleArgs: ["--session-id sess_abc123"], @@ -38,7 +40,9 @@ export default defineCommand({ const session = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: flags.provider ?? "targets", + }); return getSession(runtime, flags.sessionId, flags.provider); }), ); diff --git a/packages/commands/src/commands/managed-agent/session-list.ts b/packages/commands/src/commands/managed-agent/session-list.ts index 2694e20..b5a23bb 100644 --- a/packages/commands/src/commands/managed-agent/session-list.ts +++ b/packages/commands/src/commands/managed-agent/session-list.ts @@ -31,6 +31,8 @@ const SESSION_LIST_FLAGS = { export default defineCommand({ description: "List sessions from the provider", auth: "apiKey", + // Provider-aware gate: only the session's provider needs credentials. + authOptional: true, usageArgs: "[--agent <name>] [--all] [--provider <name>] [--file <path>]", flags: SESSION_LIST_FLAGS, exampleArgs: ["", "--agent assistant", "--all"], @@ -42,7 +44,9 @@ export default defineCommand({ const { items: summaries, hasMore } = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: flags.provider ?? "targets", + }); return fetchAllPages(async (page) => { const result = await listSessionSummaries(runtime, { agent: flags.agent, diff --git a/packages/commands/src/commands/managed-agent/session-run.ts b/packages/commands/src/commands/managed-agent/session-run.ts index 5caec10..820ce47 100644 --- a/packages/commands/src/commands/managed-agent/session-run.ts +++ b/packages/commands/src/commands/managed-agent/session-run.ts @@ -57,6 +57,8 @@ const SESSION_RUN_FLAGS = { export default defineCommand({ description: "Create a session, send a message, and stream the response", auth: "apiKey", + // Provider-aware gate: only the session's provider needs credentials. + authOptional: true, usageArgs: "--prompt <text> [--agent <name>] [--no-stream] [--file <path>]", flags: SESSION_RUN_FLAGS, exampleArgs: ['--prompt "hello"', '--agent assistant --prompt "summarize this repo"'], @@ -98,7 +100,9 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: flags.provider ?? "targets", + }); if (flags.noStream) { const run = await startSessionRunPolling(runtime, flags.prompt, runOptions); if (!asJson) process.stderr.write(`Session created: ${run.session.id}\n`); diff --git a/packages/commands/src/commands/managed-agent/session-send.ts b/packages/commands/src/commands/managed-agent/session-send.ts index a6a06e1..c424262 100644 --- a/packages/commands/src/commands/managed-agent/session-send.ts +++ b/packages/commands/src/commands/managed-agent/session-send.ts @@ -38,6 +38,8 @@ const SESSION_SEND_FLAGS = { export default defineCommand({ description: "Send a message to an existing session and stream the response", auth: "apiKey", + // Provider-aware gate: only the session's provider needs credentials. + authOptional: true, usageArgs: "--session-id <id> --message <text> [--no-stream] [--file <path>]", flags: SESSION_SEND_FLAGS, exampleArgs: ['--session-id sess_abc123 --message "continue"'], @@ -66,7 +68,9 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: flags.provider ?? "targets", + }); if (flags.noStream) { const result = await sendSessionMessagePolling(runtime, flags.sessionId, flags.message, { provider: flags.provider, diff --git a/packages/commands/src/commands/managed-agent/skill-list.ts b/packages/commands/src/commands/managed-agent/skill-list.ts index f5cf185..3d35f13 100644 --- a/packages/commands/src/commands/managed-agent/skill-list.ts +++ b/packages/commands/src/commands/managed-agent/skill-list.ts @@ -30,6 +30,8 @@ const SKILL_LIST_FLAGS = { export default defineCommand({ description: "List skills from the provider's skill catalog", auth: "apiKey", + // Provider-aware gate: only the resolved catalog provider needs credentials. + authOptional: true, usageArgs: "[--source custom|official|all] [--provider <name>] [--file <path>]", flags: SKILL_LIST_FLAGS, exampleArgs: [ @@ -56,7 +58,9 @@ export default defineCommand({ const skills = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: flags.provider ?? "targets", + }); if (source !== "all") { return listSkills(runtime, { provider: flags.provider, source }); } diff --git a/packages/commands/src/commands/managed-agent/state-import.ts b/packages/commands/src/commands/managed-agent/state-import.ts index 3eb06bb..e171482 100644 --- a/packages/commands/src/commands/managed-agent/state-import.ts +++ b/packages/commands/src/commands/managed-agent/state-import.ts @@ -33,6 +33,8 @@ const STATE_IMPORT_FLAGS = { export default defineCommand({ description: "Import an existing remote resource into agents state", auth: "apiKey", + // Provider-aware gate: only the address's provider needs credentials. + authOptional: true, usageArgs: "--address <provider.type.name> --remote-id <id> [--resource-version <n>] [--file <path>]", flags: STATE_IMPORT_FLAGS, @@ -62,10 +64,13 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + // Parse first: the address names the one provider this import touches. const parsed = parseStateAddress(flags.address, { requireProvider: true, }); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: parsed.provider ?? "targets", + }); await importResource(runtime, parsed, flags.remoteId, { resourceVersion: flags.resourceVersion, }); diff --git a/packages/commands/src/commands/managed-agent/state-list.ts b/packages/commands/src/commands/managed-agent/state-list.ts index 2d97023..8bc604b 100644 --- a/packages/commands/src/commands/managed-agent/state-list.ts +++ b/packages/commands/src/commands/managed-agent/state-list.ts @@ -1,6 +1,6 @@ import { defineCommand, detectOutputFormat, type FlagsDef } from "bailian-cli-core"; import { emitBare, emitResult, formatTable } from "bailian-cli-runtime"; -import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, OFFLINE_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; @@ -14,11 +14,11 @@ const STATE_LIST_FLAGS = { export default defineCommand({ description: "List resources tracked in agents state", - auth: "apiKey", + auth: "none", usageArgs: "[--file <path>]", flags: STATE_LIST_FLAGS, exampleArgs: ["", "--file agents.yaml"], - notes: CREDENTIALS_NOTE, + notes: OFFLINE_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -26,7 +26,9 @@ export default defineCommand({ const resources = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: "none", + }); return runtime.state.listResources(); }), ); diff --git a/packages/commands/src/commands/managed-agent/state-rm.ts b/packages/commands/src/commands/managed-agent/state-rm.ts index 6c8f8fd..0442807 100644 --- a/packages/commands/src/commands/managed-agent/state-rm.ts +++ b/packages/commands/src/commands/managed-agent/state-rm.ts @@ -7,7 +7,7 @@ import { } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { parseStateAddress } from "@openagentpack/sdk"; -import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, OFFLINE_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; @@ -27,11 +27,11 @@ const STATE_RM_FLAGS = { export default defineCommand({ description: "Remove a resource from state without destroying it remotely", - auth: "apiKey", + auth: "none", usageArgs: "--address <provider.type.name> [--file <path>]", flags: STATE_RM_FLAGS, exampleArgs: ["--address bailian.agent.assistant"], - notes: CREDENTIALS_NOTE, + notes: OFFLINE_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -48,7 +48,9 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: "none", + }); const parsed = parseStateAddress(flags.address, { requireProvider: false, }); diff --git a/packages/commands/src/commands/managed-agent/state-show.ts b/packages/commands/src/commands/managed-agent/state-show.ts index 845de2e..c478782 100644 --- a/packages/commands/src/commands/managed-agent/state-show.ts +++ b/packages/commands/src/commands/managed-agent/state-show.ts @@ -7,7 +7,7 @@ import { } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { parseStateAddress } from "@openagentpack/sdk"; -import { buildAgentRuntime, CREDENTIALS_NOTE } from "./_engine/config-loader.ts"; +import { buildAgentRuntime, OFFLINE_NOTE } from "./_engine/config-loader.ts"; import { withStdoutProtected } from "./_engine/console-capture.ts"; import { withAgentErrors } from "./_engine/errors.ts"; @@ -27,11 +27,11 @@ const STATE_SHOW_FLAGS = { export default defineCommand({ description: "Show details of a resource in agents state", - auth: "apiKey", + auth: "none", usageArgs: "--address <provider.type.name> [--file <path>]", flags: STATE_SHOW_FLAGS, exampleArgs: ["--address bailian.agent.assistant"], - notes: CREDENTIALS_NOTE, + notes: OFFLINE_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -39,7 +39,9 @@ export default defineCommand({ const found = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file); + const runtime = await buildAgentRuntime(ctx, file, { + credentials: "none", + }); const parsed = parseStateAddress(flags.address, { requireProvider: false, }); diff --git a/packages/commands/src/commands/managed-agent/validate.ts b/packages/commands/src/commands/managed-agent/validate.ts index 0e1bf94..ad918a1 100644 --- a/packages/commands/src/commands/managed-agent/validate.ts +++ b/packages/commands/src/commands/managed-agent/validate.ts @@ -7,7 +7,7 @@ import { } from "bailian-cli-core"; import { emitBare, emitResult } from "bailian-cli-runtime"; import { validateProjectConfig } from "@openagentpack/sdk"; -import { CREDENTIALS_NOTE, resolveAgentProjectConfig } from "./_engine/config-loader.ts"; +import { OFFLINE_NOTE, resolveAgentProjectConfig } from "./_engine/config-loader.ts"; import { withAgentErrors } from "./_engine/errors.ts"; const VALIDATE_FLAGS = { @@ -20,18 +20,20 @@ const VALIDATE_FLAGS = { export default defineCommand({ description: "Validate an agents.yaml configuration (offline)", - auth: "apiKey", + auth: "none", usageArgs: "[--file <path>]", flags: VALIDATE_FLAGS, exampleArgs: ["", "--file agents.yaml"], - notes: CREDENTIALS_NOTE, + notes: OFFLINE_NOTE, async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); const file = flags.file ?? "agents.yaml"; const diagnostics = await withAgentErrors(async () => { - const { config } = await resolveAgentProjectConfig(ctx, file); + const { config } = await resolveAgentProjectConfig(ctx, file, { + credentials: "none", + }); return validateProjectConfig(config); }); diff --git a/packages/commands/tests/credentials-bridge.test.ts b/packages/commands/tests/credentials-bridge.test.ts index 37379d7..60c9dda 100644 --- a/packages/commands/tests/credentials-bridge.test.ts +++ b/packages/commands/tests/credentials-bridge.test.ts @@ -13,13 +13,15 @@ import { type CredentialHost, injectProviderCredentials, prepareProviderEnv, + resolveTargetProviderNames, scrubCredentialEnv, } from "../src/commands/managed-agent/_engine/credentials.ts"; /** * 凭证内存注入管道:injectProviderCredentials 把 authStage 解析进 Client 的凭证 * 权威覆写 bailian 配置块(不落 env),scrubCredentialEnv 清空所有凭证 env, - * assertProviderCredentials 对空 key 给 CLI 权威 AUTH 错误。用快照隔离凭证 env。 + * assertProviderCredentials 按本次运行涉及的 provider 范围对空 key 给 CLI 权威 + * AUTH 错误。用快照隔离凭证 env。 */ const TRACKED_ENV = [ "DASHSCOPE_API_KEY", @@ -139,11 +141,11 @@ test("inject:workspace_id 引用且为空时用 settings 填充;有字面量则 expect(literal.bailian.workspace_id).toBe("ws-yaml"); }); -test("inject:无凭证(dry-run)时 bailian 块保持不变", () => { +test("inject:无凭证时 api_key 保持不变,base_url 仍用 client 默认域名补齐(离线/范围外 schema 可用)", () => { const providers = { bailian: { api_key: "", base_url: "" } }; injectProviderCredentials(providers, makeHost({})); expect(providers.bailian.api_key).toBe(""); - expect(providers.bailian.base_url).toBe(""); + expect(providers.bailian.base_url).toBe("https://dashscope.aliyuncs.com/api/v1/agentstudio"); }); test("inject:非 bailian provider 块不被触碰", () => { @@ -190,6 +192,33 @@ test("assert:bailian key 为空(dry-run/未登录)抛 AUTH 且 hint 指向 bl au expect(err.hint).toContain("bl auth login"); }); +test("assert:required 限定范围后,范围外 provider 的空 key 不拦截", () => { + const providers = { + bailian: { api_key: "" }, + claude: { api_key: "sk-ant" }, + }; + // 本次只涉及 claude(如 --provider claude):bailian 未登录不应阻塞 + expect(() => assertProviderCredentials(providers, ["claude"])).not.toThrow(); + // 反向:范围内的空 key 仍拦截 + expect(() => assertProviderCredentials(providers, ["bailian"])).toThrow(); +}); + +test("assert:required 里未配置的 provider 名被跳过(由引擎报未配置错误)", () => { + expect(() => assertProviderCredentials({ bailian: { api_key: "" } }, ["qoder"])).not.toThrow(); +}); + +test("targets:默认 provider 链镜像 SDK —— default 为单个时只涉及它,缺失/all 时为全部", () => { + const providers = { bailian: {}, claude: {} }; + expect(resolveTargetProviderNames({ providers, defaults: { provider: "claude" } })).toEqual([ + "claude", + ]); + expect(resolveTargetProviderNames({ providers })).toEqual(["bailian", "claude"]); + expect(resolveTargetProviderNames({ providers, defaults: { provider: "all" } })).toEqual([ + "bailian", + "claude", + ]); +}); + test("scrub:所有凭证 env 变量被删除", () => { process.env.DASHSCOPE_API_KEY = "x"; process.env.ANTHROPIC_API_KEY = "y"; diff --git a/packages/commands/tests/e2e/fixtures/managed-agent/agents-multi.yaml b/packages/commands/tests/e2e/fixtures/managed-agent/agents-multi.yaml new file mode 100644 index 0000000..2d6c375 --- /dev/null +++ b/packages/commands/tests/e2e/fixtures/managed-agent/agents-multi.yaml @@ -0,0 +1,28 @@ +version: "1" + +providers: + bailian: + api_key: ${DASHSCOPE_API_KEY} + base_url: ${BAILIAN_BASE_URL} + claude: + api_key: ${ANTHROPIC_API_KEY} + +defaults: + provider: all + +environments: + dev: + config: + type: cloud + networking: + type: unrestricted + +agents: + assistant: + description: "E2E multi-provider fixture" + model: + bailian: qwen3.7-max + claude: claude-sonnet-4-6 + instructions: | + You are a helpful assistant. + environment: dev diff --git a/packages/commands/tests/e2e/managed-agent-auth-chain.e2e.test.ts b/packages/commands/tests/e2e/managed-agent-auth-chain.e2e.test.ts index 26fbb48..aace7a0 100644 --- a/packages/commands/tests/e2e/managed-agent-auth-chain.e2e.test.ts +++ b/packages/commands/tests/e2e/managed-agent-auth-chain.e2e.test.ts @@ -8,8 +8,9 @@ import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts"; /** * managed-agent 凭证链 e2e:验证 bl 自有配置体系(config 写入 / 命名 Profile / - * logout)与错误映射如何流入 SDK 引擎。全部离线:`managed-agent validate` 会走 - * authStage 凭证解析 + 引擎注入 + agents.yaml 校验,但不发任何网络请求。 + * logout)与错误映射如何流入 SDK 引擎。全部离线:凭证门禁用 `managed-agent plan` + * 验证(provider-aware:空 state 不发网络请求,但仍按目标 provider 校验凭证); + * `validate` / `state list` / `plan --no-refresh` 属离线命令,无凭证也必须可用。 * 配置一律通过 BAILIAN_CONFIG_DIR 指向临时目录,绝不触碰真实用户配置。 */ @@ -20,6 +21,7 @@ const ROUTES = { const AGENTS_YAML = join(e2eFixturesDir, "managed-agent", "agents.yaml"); const AGENTS_YAML_INVALID = join(e2eFixturesDir, "managed-agent", "agents-invalid.yaml"); +const AGENTS_YAML_MULTI = join(e2eFixturesDir, "managed-agent", "agents-multi.yaml"); const tempDirs: string[] = []; @@ -45,6 +47,16 @@ function validateArgs(file: string): string[] { return ["managed-agent", "validate", "--file", file, "--quiet"]; } +/** plan 是凭证门禁命令:空 state 下不发网络,但仍按目标 provider 校验凭证。 */ +function planArgs(file: string): string[] { + return ["managed-agent", "plan", "--file", file, "--quiet"]; +} + +/** 隔离宿主机的 ~/.agents/config.json,避免它强制覆盖 provider 凭证 env。 */ +function isolatedAgentsConfigEnv(): NodeJS.ProcessEnv { + return { AGENTS_CONFIG_PATH: join(tmpdir(), "bl-e2e-no-agents-config.json") }; +} + /** 分配一个刚释放的本地端口,连接必然 ECONNREFUSED,用于网络错误场景。 */ async function closedPort(): Promise<number> { const server = createServer(); @@ -61,9 +73,9 @@ async function closedPort(): Promise<number> { } describe("e2e: managed-agent 凭证链(config 写入 / Profile / logout / 错误映射)", () => { - test("config.json 写入的 api_key 流入引擎,validate 离线通过", async () => { + test("config.json 写入的 api_key 流入引擎,plan 离线通过", async () => { const env = makeConfigEnv({ api_key: "sk-e2e-config-write" }); - const { stderr, exitCode } = await runCommandE2e(ROUTES, validateArgs(AGENTS_YAML), env); + const { stderr, exitCode } = await runCommandE2e(ROUTES, planArgs(AGENTS_YAML), env); expect(exitCode, stderr).toBe(0); }); @@ -72,7 +84,7 @@ describe("e2e: managed-agent 凭证链(config 写入 / Profile / logout / 错 work: { api_key: "sk-e2e-profile-work" }, active_config: "work", }); - const { stderr, exitCode } = await runCommandE2e(ROUTES, validateArgs(AGENTS_YAML), env); + const { stderr, exitCode } = await runCommandE2e(ROUTES, planArgs(AGENTS_YAML), env); expect(exitCode, stderr).toBe(0); }); @@ -82,27 +94,27 @@ describe("e2e: managed-agent 凭证链(config 写入 / Profile / logout / 错 empty: {}, active_config: "empty", }); - const { stderr, exitCode } = await runCommandE2e(ROUTES, validateArgs(AGENTS_YAML), env); + const { stderr, exitCode } = await runCommandE2e(ROUTES, planArgs(AGENTS_YAML), env); expect(exitCode).toBe(3); expect(stderr).toMatch(/auth login|API key/i); }); - test("auth logout 清除凭证后 validate 报 AUTH,而非用残留凭证", async () => { + test("auth logout 清除凭证后 plan 报 AUTH,而非用残留凭证", async () => { const env = makeConfigEnv({ api_key: "sk-e2e-before-logout" }); - const before = await runCommandE2e(ROUTES, validateArgs(AGENTS_YAML), env); + const before = await runCommandE2e(ROUTES, planArgs(AGENTS_YAML), env); expect(before.exitCode, before.stderr).toBe(0); const logout = await runCommandE2e(ROUTES, ["auth", "logout"], env); expect(logout.exitCode, logout.stderr).toBe(0); - const after = await runCommandE2e(ROUTES, validateArgs(AGENTS_YAML), env); + const after = await runCommandE2e(ROUTES, planArgs(AGENTS_YAML), env); expect(after.exitCode).toBe(3); expect(after.stderr).toMatch(/auth login|API key/i); }); test("agents.yaml schema 错误映射为 USAGE (2),不透传原始 zod dump", async () => { - const env = makeConfigEnv({ api_key: "sk-e2e-config-write" }); + const env = makeConfigEnv({}); const { stderr, exitCode } = await runCommandE2e( ROUTES, validateArgs(AGENTS_YAML_INVALID), @@ -142,3 +154,64 @@ describe("e2e: managed-agent 凭证链(config 写入 / Profile / logout / 错 expect(stderr).toMatch(/ECONNREFUSED|refused/i); }); }); + +describe("e2e: managed-agent 鉴权分层(离线命令免登录 / provider-aware 按需校验)", () => { + test("validate 无任何凭证也离线通过 (0)", async () => { + const env = makeConfigEnv({}); + const { stderr, exitCode } = await runCommandE2e(ROUTES, validateArgs(AGENTS_YAML), env); + expect(exitCode, stderr).toBe(0); + }); + + test("state list 无任何凭证也离线通过 (0),stdout 为合法 JSON", async () => { + const env = makeConfigEnv({}); + const { stdout, stderr, exitCode } = await runCommandE2e( + ROUTES, + ["managed-agent", "state", "list", "--file", AGENTS_YAML, "--output", "json"], + env, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ resources?: unknown[] }>(stdout); + expect(Array.isArray(data.resources)).toBe(true); + }); + + test("plan --no-refresh 无任何凭证也离线通过 (0)", async () => { + const env = makeConfigEnv({}); + const { stderr, exitCode } = await runCommandE2e( + ROUTES, + [...planArgs(AGENTS_YAML), "--no-refresh"], + env, + ); + expect(exitCode, stderr).toBe(0); + }); + + test("多 provider 下 plan --provider claude 只需 claude 凭证,bailian 未登录不阻塞 (0)", async () => { + const env = { + ...makeConfigEnv({}), + ...isolatedAgentsConfigEnv(), + ANTHROPIC_API_KEY: "sk-ant-e2e-scope", + CLAUDE_API_KEY: "", + }; + const { stderr, exitCode } = await runCommandE2e( + ROUTES, + [...planArgs(AGENTS_YAML_MULTI), "--provider", "claude"], + env, + ); + expect(exitCode, stderr).toBe(0); + }); + + test("plan --provider claude 缺 claude key 时报 AUTH (3),hint 指向 ANTHROPIC_API_KEY", async () => { + const env = { + ...makeConfigEnv({ api_key: "sk-e2e-bailian-present" }), + ...isolatedAgentsConfigEnv(), + ANTHROPIC_API_KEY: "", + CLAUDE_API_KEY: "", + }; + const { stderr, exitCode } = await runCommandE2e( + ROUTES, + [...planArgs(AGENTS_YAML_MULTI), "--provider", "claude"], + env, + ); + expect(exitCode).toBe(3); + expect(stderr).toMatch(/ANTHROPIC_API_KEY/); + }); +}); diff --git a/packages/commands/tests/e2e/managed-agent.e2e.test.ts b/packages/commands/tests/e2e/managed-agent.e2e.test.ts index 4449179..827a8cc 100644 --- a/packages/commands/tests/e2e/managed-agent.e2e.test.ts +++ b/packages/commands/tests/e2e/managed-agent.e2e.test.ts @@ -5,8 +5,10 @@ import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts"; /** * managed-agent:help / 缺参不依赖密钥;所有 mutation 命令的 --dry-run * 必须在构建 SDK runtime(凭证注入 / 联网 / 写盘)之前短路,因此同样不需要密钥。 + * 鉴权分层:离线命令(init/validate/state list|show|rm)auth: "none";联网命令 + * provider-aware,只校验本次涉及的 provider(见 managed-agent-auth-chain e2e)。 * 真实集成(apply/destroy/session 流程)依赖工作区内的 agents.yaml 与远端资源, - * 属于批量场景,暂仅覆盖 dry-run 契约。 + * 属批量场景,暂仅覆盖 dry-run 契约。 */ describe("e2e: managed-agent", () => { @@ -67,21 +69,17 @@ describe("e2e: managed-agent", () => { }); test("managed-agent skill-list --source all 通过参数校验(缺配置文件时才失败)", async () => { - // auth: "apiKey" 的凭证解析先于 run() 执行;注入假 key 让用例不依赖环境凭证, - // 命令仍会在配置加载阶段因文件缺失短路,不产生任何网络请求。 - const { stderr, exitCode } = await runCommandE2e( - MANAGED_AGENT_ROUTES, - [ - "managed-agent", - "skill-list", - "--source", - "all", - "--file", - "agents.e2e-missing.yaml", - "--quiet", - ], - { DASHSCOPE_API_KEY: "sk-e2e-skill-list" }, - ); + // provider-aware 鉴权不再前置硬门禁:无需注入假 key,命令在配置加载阶段 + // 因文件缺失短路,不产生任何网络请求。 + const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ + "managed-agent", + "skill-list", + "--source", + "all", + "--file", + "agents.e2e-missing.yaml", + "--quiet", + ]); // all 是合法值:不应报 --source 用法错误,而是走到配置加载后因文件缺失退出 expect(exitCode).toBe(2); expect(stderr).not.toMatch(/--source must be one of/i); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 266565e..f010ec9 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -159,8 +159,10 @@ export const TOKEN_PLAN_ROUTES: E2eRouteExports = { export const MANAGED_AGENT_ROUTES: E2eRouteExports = { "managed-agent init": "managedAgentInit", "managed-agent validate": "managedAgentValidate", + "managed-agent plan": "managedAgentPlan", "managed-agent apply": "managedAgentApply", "managed-agent destroy": "managedAgentDestroy", + "managed-agent state list": "managedAgentStateList", "managed-agent state rm": "managedAgentStateRm", "managed-agent state import": "managedAgentStateImport", "managed-agent session create": "managedAgentSessionCreate", diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 4ed9e0d..59f5e33 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -67,10 +67,21 @@ export type AuthRequirement = "apiKey" | "console" | "openapi" | "none"; // ── Flag 分组:全局(所有命令) + 凭证域(按命令的 auth 可见) ──────────────────── /** 所有命令都可用的全局 flag。 */ export const GLOBAL_FLAGS = { - output: { type: "string", valueHint: "<format>", description: "Output format: text, json" }, - timeout: { type: "number", valueHint: "<seconds>", description: "Request timeout" }, + output: { + type: "string", + valueHint: "<format>", + description: "Output format: text, json", + }, + timeout: { + type: "number", + valueHint: "<seconds>", + description: "Request timeout", + }, quiet: { type: "switch", description: "Suppress non-essential output" }, - verbose: { type: "switch", description: "Print HTTP request/response details" }, + verbose: { + type: "switch", + description: "Print HTTP request/response details", + }, dryRun: { type: "switch", description: "Dry run mode" }, config: { type: "string", @@ -92,7 +103,10 @@ export const CONCURRENT_FLAG = { /** Command-scoped flag for task-based commands that can return without polling. */ export const ASYNC_FLAG = { - async: { type: "switch", description: "Return async task id without waiting" }, + async: { + type: "switch", + description: "Return async task id without waiting", + }, } satisfies FlagsDef; /** Model 域凭证/连接 flag,`auth: "apiKey"` 命令可见。 */ @@ -193,6 +207,14 @@ export interface Command<F extends FlagsDef = FlagsDef> { description: string; /** Credential this command requires. See {@link AuthRequirement}. */ auth: AuthRequirement; + /** + * Soften the auth gate: authStage still resolves the `auth` domain's + * credential into `ctx.client` when available, but a missing credential no + * longer fails before `run`. For commands that enforce their own scoped + * credential requirements (e.g. managed-agent commands, where a run may only + * involve third-party providers and must not be blocked on a Bailian key). + */ + authOptional?: boolean; /** Usage line arg portion, e.g. "--prompt <text> [flags]". Manually written. */ usageArgs?: string; /** Example arg strings (without the `<bin> <path>` prefix). */ diff --git a/packages/runtime/src/middleware.ts b/packages/runtime/src/middleware.ts index 0bd9bff..44afff4 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -75,17 +75,24 @@ export function compose(stack: Middleware[]): (ctx: RunContext) => Promise<void> * Bake the credential for the command's declared `auth` into `ctx.client`, and * gate: no credential → throw before the command runs. dry-run 例外:凭证解析失败 * 不抛(dry-run 只打印请求,无需凭证;console 的 dry-run 展示读 settings.console*)。 + * `authOptional` 例外:凭证可用则注入,缺失不在此处抛 —— 命令自行按实际涉及范围 + * 校验(如 managed-agent 只校验本次运行涉及的 provider)。 * `auth: "none"` commands keep a credential-less client. */ export const authStage: Middleware = async (ctx, next) => { const { command, settings, sources } = ctx; - const base = { identity: ctx.identity, settings, baseUrl: resolveModelBaseUrl(sources) }; + const base = { + identity: ctx.identity, + settings, + baseUrl: resolveModelBaseUrl(sources), + }; + const tolerateMissing = settings.dryRun || command.authOptional === true; if (command.auth === "apiKey") { let cred: ApiKeyCredential | undefined; try { cred = resolveApiKey(sources); } catch (err) { - if (!settings.dryRun) throw err; + if (!tolerateMissing) throw err; } ctx.client = new Client({ ...base, apiCred: cred }); if (cred) maybeShowStatusBar(settings, cred.token, cred); @@ -94,7 +101,7 @@ export const authStage: Middleware = async (ctx, next) => { try { cred = resolveConsole(sources); } catch (err) { - if (!settings.dryRun) throw err; + if (!tolerateMissing) throw err; } if (cred) ctx.client = new Client({ ...base, consoleCred: cred }); } else if (command.auth === "openapi") { @@ -102,7 +109,7 @@ export const authStage: Middleware = async (ctx, next) => { try { cred = resolveOpenApi(sources); } catch (err) { - if (!settings.dryRun) throw err; + if (!tolerateMissing) throw err; } ctx.client = new Client({ ...base, openApiCred: cred }); } @@ -112,7 +119,11 @@ export const authStage: Middleware = async (ctx, next) => { /** Record command execution (start / success / failure) around the command. */ export const telemetryStage: Middleware = (ctx, next) => { return trackCommandExecution( - { identity: ctx.identity, settings: ctx.settings, authMethod: ctx.command.auth }, + { + identity: ctx.identity, + settings: ctx.settings, + authMethod: ctx.command.auth, + }, ctx.path, ctx.flags, next, diff --git a/skills/bailian-cli/reference/managed-agent.md b/skills/bailian-cli/reference/managed-agent.md index 7c180c7..6b145f9 100644 --- a/skills/bailian-cli/reference/managed-agent.md +++ b/skills/bailian-cli/reference/managed-agent.md @@ -53,6 +53,7 @@ Index: [index.md](index.md) - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -87,6 +88,7 @@ bl managed-agent apply --provider bailian --yes - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -115,8 +117,6 @@ bl managed-agent destroy --yes --cascade | `--agent-name <name>` | string | no | Name of the first agent (default: assistant) | | `--file <path>` | string | no | Output config path (default: agents.yaml) | | `--force` | switch | no | Overwrite an existing config file | -| `--api-key <key>` | string | no | API key | -| `--base-url <url>` | string | no | API base URL | #### Examples @@ -155,6 +155,7 @@ bl managed-agent init --provider all - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -197,6 +198,7 @@ bl managed-agent plan --no-refresh - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -235,6 +237,7 @@ bl managed-agent session create --agent assistant --title 'debug run' - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -267,6 +270,7 @@ bl managed-agent session delete --session-id sess_abc123 - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -301,6 +305,7 @@ bl managed-agent session events --session-id sess_abc123 --all - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -332,6 +337,7 @@ bl managed-agent session get --session-id sess_abc123 - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -376,6 +382,7 @@ bl managed-agent session list --all - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -412,6 +419,7 @@ bl managed-agent session run --agent assistant --prompt "summarize this repo" - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -442,6 +450,7 @@ bl managed-agent session send --session-id sess_abc123 --message "continue" - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. - Providers without a skill listing API (e.g. ark) return an empty list. - For agent-driven skill selection, use `--source all --output json`: one call returns both catalogs with per-skill `source` and `description` fields to pick from. @@ -488,6 +497,7 @@ bl managed-agent skill-list --source custom --provider bailian - Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). - Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. +- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked. - Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. #### Examples @@ -506,17 +516,13 @@ bl managed-agent state import --address bailian.agent.assistant --remote-id agen #### Flags -| Flag | Type | Required | Description | -| ------------------ | ------ | -------- | --------------------------------------- | -| `--file <path>` | string | no | Config file path (default: agents.yaml) | -| `--api-key <key>` | string | no | API key | -| `--base-url <url>` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------- | +| `--file <path>` | string | no | Config file path (default: agents.yaml) | #### Notes -- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). -- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. -- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. +- Runs fully offline against local files: no login or provider credentials required. #### Examples @@ -542,14 +548,10 @@ bl managed-agent state list --file agents.yaml | -------------------------------- | ------ | -------- | --------------------------------------- | | `--address <provider.type.name>` | string | yes | Resource state address (required) | | `--file <path>` | string | no | Config file path (default: agents.yaml) | -| `--api-key <key>` | string | no | API key | -| `--base-url <url>` | string | no | API base URL | #### Notes -- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). -- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. -- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. +- Runs fully offline against local files: no login or provider credentials required. #### Examples @@ -571,14 +573,10 @@ bl managed-agent state rm --address bailian.agent.assistant | -------------------------------- | ------ | -------- | --------------------------------------- | | `--address <provider.type.name>` | string | yes | Resource state address (required) | | `--file <path>` | string | no | Config file path (default: agents.yaml) | -| `--api-key <key>` | string | no | API key | -| `--base-url <url>` | string | no | API base URL | #### Notes -- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). -- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. -- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. +- Runs fully offline against local files: no login or provider credentials required. #### Examples @@ -596,17 +594,13 @@ bl managed-agent state show --address bailian.agent.assistant #### Flags -| Flag | Type | Required | Description | -| ------------------ | ------ | -------- | --------------------------------------- | -| `--file <path>` | string | no | Config file path (default: agents.yaml) | -| `--api-key <key>` | string | no | API key | -| `--base-url <url>` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------- | +| `--file <path>` | string | no | Config file path (default: agents.yaml) | #### Notes -- Bailian credentials come from bl's auth chain: --api-key > DASHSCOPE_API_KEY > `bl auth login` (active config profile). -- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json. -- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env. +- Runs fully offline against local files: no login or provider credentials required. #### Examples