From 9819eb6ddc401d994e30c1a95f802696e4b766ff Mon Sep 17 00:00:00 2001 From: chenanran555 Date: Mon, 27 Jul 2026 21:33:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E9=9D=9Ebailian=20provider?= =?UTF-8?q?=E4=B9=9F=E8=B5=B0=E9=89=B4=E6=9D=83=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/auth-change.md | 10 ++-- packages/cli/agents.yaml | 27 ---------- .../managed-agent/_engine/config-loader.ts | 28 +++------- .../managed-agent/_engine/credentials.ts | 52 ++++++------------- .../src/commands/managed-agent/apply.ts | 6 +-- .../src/commands/managed-agent/destroy.ts | 6 +-- .../src/commands/managed-agent/plan.ts | 10 ++-- .../commands/managed-agent/session-create.ts | 6 +-- .../commands/managed-agent/session-delete.ts | 6 +-- .../commands/managed-agent/session-events.ts | 6 +-- .../src/commands/managed-agent/session-get.ts | 6 +-- .../commands/managed-agent/session-list.ts | 6 +-- .../src/commands/managed-agent/session-run.ts | 6 +-- .../commands/managed-agent/session-send.ts | 6 +-- .../src/commands/managed-agent/skill-list.ts | 6 +-- .../commands/managed-agent/state-import.ts | 8 +-- .../commands/tests/credentials-bridge.test.ts | 32 +----------- .../e2e/managed-agent-auth-chain.e2e.test.ts | 33 +++++++++--- .../tests/e2e/managed-agent.e2e.test.ts | 28 +++++----- packages/core/src/types/command.ts | 8 --- packages/runtime/src/middleware.ts | 9 ++-- skills/bailian-cli/reference/managed-agent.md | 14 +---- 22 files changed, 91 insertions(+), 228 deletions(-) delete mode 100644 packages/cli/agents.yaml diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index ef6c32a..9099b8d 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -55,18 +55,18 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx ### 例外:agent 命令的分层鉴权与 SDK 凭证内存注入 -`bl managed-agent *` 按调用链分两层,不再全命令硬门禁: +`bl managed-agent *` 按调用链分两层: -- **离线命令** — `init`、`validate`、`state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言(`plan --no-refresh` 与 `plan --dry-run` 同样传 `"none"` 并强制 `refresh: false`:不联网、不回写 state) -- **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 不阻塞。 +- **离线命令** — `init`、`validate`、`state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言 +- **联网命令** — `plan`、`apply`、`destroy`、`state import`、`skill-list`、全部 `session *`:统一声明 `auth: "apiKey"` 硬门禁 —— 无论目标 provider 是谁,authStage 都经 `resolveApiKey(sources)` 解析 bailian 凭证(flag > env > active profile config),缺失报统一 AUTH;引擎层 `assertProviderCredentials` 再对 agents.yaml 里**全部已声明 provider** 的空 key 拦截并给 provider 专属 hint。例外:`plan --no-refresh` / `plan --dry-run` 传 `credentials: "none"` 并强制 `refresh: false`(不联网、不回写 state,不查 provider key),其中 `--dry-run` 连登录也不要求(authStage 的 dry-run 豁免),`--no-refresh` 仍需登录。 凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/` 的**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道五步: 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") +2. `resolveProjectConfig` — 插值发生:bailian 插值拿到占位空串,claude/ark 拿到真实 env 值;随后 `normalizeInterpolatedProviderBlocks()` 把插值为空导致的 YAML `null` 归一为 `""`(避免离线命令下空 key 在 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 +5. `assertProviderCredentials(providers)` — 任一已声明 provider 的 `api_key` 为空 → CLI 权威 `AUTH` 错误 + provider 专属 hint(取代 SDK 原始插值/zod 报错);离线命令传 `credentials: "none"` 整体跳过 `bl auth login` 仅管理 bailian(DashScope)凭证;claude/ark/qoder 的 key 从 env(shell / `.env` / `~/.agents/config.json`)经插值进入 config 对象,同样被清扫。禁止命令层直接 `readConfigFile` 裸读凭证;bailian 字段以 CLI 鉴权链为唯一信源。 diff --git a/packages/cli/agents.yaml b/packages/cli/agents.yaml deleted file mode 100644 index 715470a..0000000 --- a/packages/cli/agents.yaml +++ /dev/null @@ -1,27 +0,0 @@ -version: "1" - -providers: - bailian: - # bl auth login --api-key sets DASHSCOPE_API_KEY; --agentstudio-base-url sets BAILIAN_BASE_URL - api_key: ${DASHSCOPE_API_KEY} - base_url: ${BAILIAN_BASE_URL} - -defaults: - provider: bailian - -environments: - dev: - config: - type: cloud - networking: - type: unrestricted - -agents: - assistant: - description: "General-purpose assistant" - model: qwen3.7-max - instructions: | - You are a helpful assistant. - environment: dev - tools: - builtin: [bash, read, glob, grep] 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 0043c69..0457fda 100644 --- a/packages/commands/src/commands/managed-agent/_engine/config-loader.ts +++ b/packages/commands/src/commands/managed-agent/_engine/config-loader.ts @@ -11,7 +11,6 @@ import { injectProviderCredentials, normalizeInterpolatedProviderBlocks, prepareProviderEnv, - resolveTargetProviderNames, scrubCredentialEnv, } from "./credentials.ts"; import { loadFileState } from "./file-state-manager.ts"; @@ -20,15 +19,12 @@ import { type HostContext, installSdkTransport } from "./transport.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) + * Whether this run requires provider keys: + * - "all" (default) — online command: every provider declared in agents.yaml + * must have a non-empty key after injection * - "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 & {}); +export type CredentialScope = "all" | "none"; interface AgentConfigOptions { resolveEnv?: boolean; @@ -46,8 +42,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 a provider within this run's - * {@link CredentialScope} has an empty key (offline commands pass "none") + * 5. fail with a CLI-authoritative AUTH error if any provider's key is empty + * (offline commands pass `credentials: "none"` to skip the check) */ export async function resolveAgentProjectConfig( host: CredentialHost, @@ -59,16 +55,8 @@ export async function resolveAgentProjectConfig( normalizeInterpolatedProviderBlocks(resolved.config.providers); injectProviderCredentials(resolved.config.providers, host); scrubCredentialEnv(); - 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], - ); + if ((options.credentials ?? "all") !== "none") { + assertProviderCredentials(resolved.config.providers); } 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 2dc096a..cf0212c 100644 --- a/packages/commands/src/commands/managed-agent/_engine/credentials.ts +++ b/packages/commands/src/commands/managed-agent/_engine/credentials.ts @@ -51,7 +51,6 @@ 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.", ]; @@ -89,10 +88,10 @@ export function prepareProviderEnv(): void { * `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. * 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. + * credential-less (defaults to the CLI's model-domain base URL) — so offline + * commands (which skip the credential assert) still satisfy the SDK's + * "workspace_id or base_url" schema. With no credential the `api_key` is left + * untouched: online commands reject it via {@link assertProviderCredentials}. */ export function injectProviderCredentials( providers: Record, @@ -133,10 +132,11 @@ export function scrubCredentialEnv(): void { /** * 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. + * every null provider field back to "" so the pipeline stays uniform: for + * online commands an empty api_key is caught by {@link + * assertProviderCredentials}; for offline commands (which skip the assert) the + * 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)) { @@ -149,37 +149,15 @@ export function normalizeInterpolatedProviderBlocks(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 + * After injection, fail with a CLI-authoritative AUTH error if any configured * 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. - * `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. + * 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; offline commands skip the check entirely. */ -export function assertProviderCredentials( - providers: Record, - required?: readonly string[], -): void { - for (const name of required ?? Object.keys(providers)) { - const raw = providers[name]; +export function assertProviderCredentials(providers: Record): void { + for (const [name, raw] of Object.entries(providers)) { 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 985a201..a00a166 100644 --- a/packages/commands/src/commands/managed-agent/apply.ts +++ b/packages/commands/src/commands/managed-agent/apply.ts @@ -46,8 +46,6 @@ 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"], @@ -75,9 +73,7 @@ export default defineCommand({ const planned = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file, { - credentials: flags.provider ?? "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); 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 c784af0..1732067 100644 --- a/packages/commands/src/commands/managed-agent/destroy.ts +++ b/packages/commands/src/commands/managed-agent/destroy.ts @@ -31,8 +31,6 @@ 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"], @@ -56,9 +54,7 @@ export default defineCommand({ const planned = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file, { - credentials: "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); return planDestroyProjectContext(runtime); }), ); diff --git a/packages/commands/src/commands/managed-agent/plan.ts b/packages/commands/src/commands/managed-agent/plan.ts index 237b4c3..0dcd0e1 100644 --- a/packages/commands/src/commands/managed-agent/plan.ts +++ b/packages/commands/src/commands/managed-agent/plan.ts @@ -41,16 +41,12 @@ const PLAN_FLAGS = { export default defineCommand({ description: "Show what changes would be applied to agent infrastructure", auth: "apiKey", - // Provider-aware gate: --no-refresh / --dry-run plan 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"], notes: [ ...CREDENTIALS_NOTE, - "--no-refresh and --dry-run plan offline from local config and state: no credentials, no remote requests, no state writes.", + "--no-refresh and --dry-run plan offline from local config and state: no remote requests, no state writes, provider keys are not checked.", ], async run(ctx) { const { settings, flags } = ctx; @@ -58,12 +54,14 @@ export default defineCommand({ const file = flags.file ?? "agents.yaml"; // Offline mode never talks to a provider and never saves refreshed state: // --no-refresh by explicit request, --dry-run by contract (read-only run). + // Provider keys are skipped then; the bl login gate (auth: "apiKey") still + // applies except under --dry-run (authStage's dry-run exemption). const offline = Boolean(flags.noRefresh) || settings.dryRun; const planned = await withAgentErrors(() => withStdoutProtected(async () => { const runtime = await buildAgentRuntime(ctx, file, { - credentials: offline ? "none" : (flags.provider ?? "targets"), + credentials: offline ? "none" : "all", }); assertProviderConfigured(runtime, flags.provider); return planProjectContext(runtime, { diff --git a/packages/commands/src/commands/managed-agent/session-create.ts b/packages/commands/src/commands/managed-agent/session-create.ts index 90a5c06..1485ab5 100644 --- a/packages/commands/src/commands/managed-agent/session-create.ts +++ b/packages/commands/src/commands/managed-agent/session-create.ts @@ -43,8 +43,6 @@ 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'"], @@ -74,9 +72,7 @@ export default defineCommand({ const run = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file, { - credentials: flags.provider ?? "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); 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 9785bc5..5d463db 100644 --- a/packages/commands/src/commands/managed-agent/session-delete.ts +++ b/packages/commands/src/commands/managed-agent/session-delete.ts @@ -27,8 +27,6 @@ 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"], @@ -52,9 +50,7 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file, { - credentials: flags.provider ?? "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); 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 2de0d39..0cdcf33 100644 --- a/packages/commands/src/commands/managed-agent/session-events.ts +++ b/packages/commands/src/commands/managed-agent/session-events.ts @@ -38,8 +38,6 @@ 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"], @@ -51,9 +49,7 @@ export default defineCommand({ const { items: events, hasMore } = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file, { - credentials: flags.provider ?? "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); 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 628dfe0..522daad 100644 --- a/packages/commands/src/commands/managed-agent/session-get.ts +++ b/packages/commands/src/commands/managed-agent/session-get.ts @@ -27,8 +27,6 @@ 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"], @@ -40,9 +38,7 @@ export default defineCommand({ const session = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file, { - credentials: flags.provider ?? "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); 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 b5a23bb..2694e20 100644 --- a/packages/commands/src/commands/managed-agent/session-list.ts +++ b/packages/commands/src/commands/managed-agent/session-list.ts @@ -31,8 +31,6 @@ 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"], @@ -44,9 +42,7 @@ export default defineCommand({ const { items: summaries, hasMore } = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file, { - credentials: flags.provider ?? "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); 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 2a6637c..ebae004 100644 --- a/packages/commands/src/commands/managed-agent/session-run.ts +++ b/packages/commands/src/commands/managed-agent/session-run.ts @@ -57,8 +57,6 @@ 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"'], @@ -103,9 +101,7 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file, { - credentials: flags.provider ?? "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); 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 96df4b6..22f9dc0 100644 --- a/packages/commands/src/commands/managed-agent/session-send.ts +++ b/packages/commands/src/commands/managed-agent/session-send.ts @@ -38,8 +38,6 @@ 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"'], @@ -68,9 +66,7 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file, { - credentials: flags.provider ?? "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); 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 3d35f13..f5cf185 100644 --- a/packages/commands/src/commands/managed-agent/skill-list.ts +++ b/packages/commands/src/commands/managed-agent/skill-list.ts @@ -30,8 +30,6 @@ 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: [ @@ -58,9 +56,7 @@ export default defineCommand({ const skills = await withAgentErrors(() => withStdoutProtected(async () => { - const runtime = await buildAgentRuntime(ctx, file, { - credentials: flags.provider ?? "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); 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 e171482..7d4565e 100644 --- a/packages/commands/src/commands/managed-agent/state-import.ts +++ b/packages/commands/src/commands/managed-agent/state-import.ts @@ -33,8 +33,6 @@ 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, @@ -64,13 +62,11 @@ export default defineCommand({ await withAgentErrors(() => withStdoutProtected(async () => { - // Parse first: the address names the one provider this import touches. + // Parse first so a malformed address fails fast, before any config I/O. const parsed = parseStateAddress(flags.address, { requireProvider: true, }); - const runtime = await buildAgentRuntime(ctx, file, { - credentials: parsed.provider ?? "targets", - }); + const runtime = await buildAgentRuntime(ctx, file); await importResource(runtime, parsed, flags.remoteId, { resourceVersion: flags.resourceVersion, }); diff --git a/packages/commands/tests/credentials-bridge.test.ts b/packages/commands/tests/credentials-bridge.test.ts index 60c9dda..3a1fb98 100644 --- a/packages/commands/tests/credentials-bridge.test.ts +++ b/packages/commands/tests/credentials-bridge.test.ts @@ -13,15 +13,14 @@ import { type CredentialHost, injectProviderCredentials, prepareProviderEnv, - resolveTargetProviderNames, scrubCredentialEnv, } from "../src/commands/managed-agent/_engine/credentials.ts"; /** * 凭证内存注入管道:injectProviderCredentials 把 authStage 解析进 Client 的凭证 * 权威覆写 bailian 配置块(不落 env),scrubCredentialEnv 清空所有凭证 env, - * assertProviderCredentials 按本次运行涉及的 provider 范围对空 key 给 CLI 权威 - * AUTH 错误。用快照隔离凭证 env。 + * assertProviderCredentials 对任一已声明 provider 的空 key 给 CLI 权威 AUTH + * 错误(离线命令跳过断言)。用快照隔离凭证 env。 */ const TRACKED_ENV = [ "DASHSCOPE_API_KEY", @@ -192,33 +191,6 @@ 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/managed-agent-auth-chain.e2e.test.ts b/packages/commands/tests/e2e/managed-agent-auth-chain.e2e.test.ts index c9d3b39..fce243c 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 @@ -9,8 +9,8 @@ import { MANAGED_AGENT_ROUTES } from "./topic-routes.ts"; /** * managed-agent 凭证链 e2e:验证 bl 自有配置体系(config 写入 / 命名 Profile / * logout)与错误映射如何流入 SDK 引擎。全部离线:凭证门禁用 `managed-agent plan` - * 验证(provider-aware:空 state 不发网络请求,但仍按目标 provider 校验凭证); - * `validate` / `state list` / `plan --no-refresh` 属离线命令,无凭证也必须可用。 + * 验证(空 state 不发网络请求,但 auth: "apiKey" 硬门禁 + 引擎全量 provider key + * 断言照常生效);`validate` / `state list` 属离线命令,无凭证也必须可用。 * 配置一律通过 BAILIAN_CONFIG_DIR 指向临时目录,绝不触碰真实用户配置。 */ @@ -47,7 +47,7 @@ function validateArgs(file: string): string[] { return ["managed-agent", "validate", "--file", file, "--quiet"]; } -/** plan 是凭证门禁命令:空 state 下不发网络,但仍按目标 provider 校验凭证。 */ +/** plan 是凭证门禁命令:空 state 下不发网络,但 authStage + 引擎断言照常生效。 */ function planArgs(file: string): string[] { return ["managed-agent", "plan", "--file", file, "--quiet"]; } @@ -184,7 +184,7 @@ describe("e2e: managed-agent 凭证链(config 写入 / Profile / logout / 错 }); }); -describe("e2e: managed-agent 鉴权分层(离线命令免登录 / provider-aware 按需校验)", () => { +describe("e2e: managed-agent 鉴权分层(离线命令免登录 / 联网命令统一 apiKey 门禁)", () => { test("validate 无任何凭证也离线通过 (0)", async () => { const env = makeConfigEnv({}); const { stderr, exitCode } = await runCommandE2e(ROUTES, validateArgs(AGENTS_YAML), env); @@ -203,17 +203,33 @@ describe("e2e: managed-agent 鉴权分层(离线命令免登录 / provider-awa expect(Array.isArray(data.resources)).toBe(true); }); - test("plan --no-refresh 无任何凭证也离线通过 (0)", async () => { + test("plan --no-refresh 无登录时仍被 apiKey 硬门禁拦住 (3)", async () => { const env = makeConfigEnv({}); const { stderr, exitCode } = await runCommandE2e( ROUTES, [...planArgs(AGENTS_YAML), "--no-refresh"], env, ); + expect(exitCode).toBe(3); + expect(stderr).toMatch(/auth login|API key/i); + }); + + test("已登录 bailian 时,多 provider 配置下 plan --no-refresh 离线通过,不查其他 provider key (0)", async () => { + const env = { + ...makeConfigEnv({ api_key: "sk-e2e-no-refresh" }), + ...isolatedAgentsConfigEnv(), + ANTHROPIC_API_KEY: "", + CLAUDE_API_KEY: "", + }; + const { stderr, exitCode } = await runCommandE2e( + ROUTES, + [...planArgs(AGENTS_YAML_MULTI), "--no-refresh"], + env, + ); expect(exitCode, stderr).toBe(0); }); - test("多 provider 下 plan --provider claude 只需 claude 凭证,bailian 未登录不阻塞 (0)", async () => { + test("统一登录门禁:只配 claude key 未登录 bailian 时,plan --provider claude 仍报 AUTH (3)", async () => { const env = { ...makeConfigEnv({}), ...isolatedAgentsConfigEnv(), @@ -225,10 +241,11 @@ describe("e2e: managed-agent 鉴权分层(离线命令免登录 / provider-awa [...planArgs(AGENTS_YAML_MULTI), "--provider", "claude"], env, ); - expect(exitCode, stderr).toBe(0); + expect(exitCode).toBe(3); + expect(stderr).toMatch(/auth login|API key/i); }); - test("plan --provider claude 缺 claude key 时报 AUTH (3),hint 指向 ANTHROPIC_API_KEY", async () => { + test("已登录但缺 claude key 时,全量断言拦住并给 ANTHROPIC_API_KEY hint (3)", async () => { const env = { ...makeConfigEnv({ api_key: "sk-e2e-bailian-present" }), ...isolatedAgentsConfigEnv(), diff --git a/packages/commands/tests/e2e/managed-agent.e2e.test.ts b/packages/commands/tests/e2e/managed-agent.e2e.test.ts index 827a8cc..ede46b7 100644 --- a/packages/commands/tests/e2e/managed-agent.e2e.test.ts +++ b/packages/commands/tests/e2e/managed-agent.e2e.test.ts @@ -6,7 +6,7 @@ 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)。 + * 统一 auth: "apiKey" 硬门禁(见 managed-agent-auth-chain e2e)。 * 真实集成(apply/destroy/session 流程)依赖工作区内的 agents.yaml 与远端资源, * 属批量场景,暂仅覆盖 dry-run 契约。 */ @@ -69,17 +69,21 @@ describe("e2e: managed-agent", () => { }); test("managed-agent skill-list --source all 通过参数校验(缺配置文件时才失败)", async () => { - // provider-aware 鉴权不再前置硬门禁:无需注入假 key,命令在配置加载阶段 - // 因文件缺失短路,不产生任何网络请求。 - const { stderr, exitCode } = await runCommandE2e(MANAGED_AGENT_ROUTES, [ - "managed-agent", - "skill-list", - "--source", - "all", - "--file", - "agents.e2e-missing.yaml", - "--quiet", - ]); + // 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" }, + ); // all 是合法值:不应报 --source 用法错误,而是走到配置加载后因文件缺失退出 expect(exitCode).toBe(2); expect(stderr).not.toMatch(/--source must be one of/i); diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 59f5e33..4bd14be 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -207,14 +207,6 @@ 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 44afff4..b9daf71 100644 --- a/packages/runtime/src/middleware.ts +++ b/packages/runtime/src/middleware.ts @@ -75,8 +75,6 @@ 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) => { @@ -86,13 +84,12 @@ export const authStage: Middleware = async (ctx, next) => { 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 (!tolerateMissing) throw err; + if (!settings.dryRun) throw err; } ctx.client = new Client({ ...base, apiCred: cred }); if (cred) maybeShowStatusBar(settings, cred.token, cred); @@ -101,7 +98,7 @@ export const authStage: Middleware = async (ctx, next) => { try { cred = resolveConsole(sources); } catch (err) { - if (!tolerateMissing) throw err; + if (!settings.dryRun) throw err; } if (cred) ctx.client = new Client({ ...base, consoleCred: cred }); } else if (command.auth === "openapi") { @@ -109,7 +106,7 @@ export const authStage: Middleware = async (ctx, next) => { try { cred = resolveOpenApi(sources); } catch (err) { - if (!tolerateMissing) throw err; + if (!settings.dryRun) throw err; } ctx.client = new Client({ ...base, openApiCred: cred }); } diff --git a/skills/bailian-cli/reference/managed-agent.md b/skills/bailian-cli/reference/managed-agent.md index 3724477..db5e29e 100644 --- a/skills/bailian-cli/reference/managed-agent.md +++ b/skills/bailian-cli/reference/managed-agent.md @@ -53,7 +53,6 @@ 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 @@ -88,7 +87,6 @@ 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 @@ -155,9 +153,8 @@ 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. -- --no-refresh and --dry-run plan offline from local config and state: no credentials, no remote requests, no state writes. +- --no-refresh and --dry-run plan offline from local config and state: no remote requests, no state writes, provider keys are not checked. #### Examples @@ -199,7 +196,6 @@ 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 @@ -238,7 +234,6 @@ 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 @@ -271,7 +266,6 @@ 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 @@ -306,7 +300,6 @@ 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 @@ -338,7 +331,6 @@ 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 @@ -383,7 +375,6 @@ 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. - --output json emits one envelope: { session_id, provider, agent, events } — read session_id to chain `session send/get/events/delete`. @@ -421,7 +412,6 @@ 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 @@ -452,7 +442,6 @@ 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. @@ -499,7 +488,6 @@ 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