diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index cac9ec1..ef6c32a 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -57,7 +57,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx `bl managed-agent *` 按调用链分两层,不再全命令硬门禁: -- **离线命令** — `init`、`validate`、`state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言(`plan --no-refresh` 同样传 `"none"`) +- **离线命令** — `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 不阻塞。 凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/` 的**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道五步: diff --git a/packages/commands/src/commands/managed-agent/plan.ts b/packages/commands/src/commands/managed-agent/plan.ts index 516fc2e..237b4c3 100644 --- a/packages/commands/src/commands/managed-agent/plan.ts +++ b/packages/commands/src/commands/managed-agent/plan.ts @@ -41,28 +41,34 @@ 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). + // 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, + notes: [ + ...CREDENTIALS_NOTE, + "--no-refresh and --dry-run plan offline from local config and state: no credentials, no remote requests, no state writes.", + ], async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); 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). + const offline = Boolean(flags.noRefresh) || settings.dryRun; const planned = await withAgentErrors(() => withStdoutProtected(async () => { - // --no-refresh never talks to a provider → no credentials required. const runtime = await buildAgentRuntime(ctx, file, { - credentials: flags.noRefresh ? "none" : (flags.provider ?? "targets"), + credentials: offline ? "none" : (flags.provider ?? "targets"), }); assertProviderConfigured(runtime, flags.provider); return planProjectContext(runtime, { provider: flags.provider, - refresh: !flags.noRefresh, + refresh: !offline, quiet: format === "json", onFeedback: format === "json" ? undefined : renderAgentFeedback, }); 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 aace7a0..c9d3b39 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 @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -57,6 +57,35 @@ function isolatedAgentsConfigEnv(): NodeJS.ProcessEnv { return { AGENTS_CONFIG_PATH: join(tmpdir(), "bl-e2e-no-agents-config.json") }; } +/** + * 在临时目录里搭一套非空 state 的项目:agents.yaml 复用单 provider fixture, + * agents.state.json 预置一条已追踪资源 —— 非空 state 是触发 plan 默认 refresh + * 路径的前提,用于验证 --dry-run 强制离线。目录纳入 tempDirs 自动清理。 + */ +function makeStatefulProject(): { configPath: string; statePath: string } { + const dir = mkdtempSync(join(tmpdir(), "bl-managed-agent-dry-run-")); + tempDirs.push(dir); + const configPath = join(dir, "agents.yaml"); + const statePath = join(dir, "agents.state.json"); + writeFileSync(configPath, readFileSync(AGENTS_YAML, "utf8")); + writeFileSync( + statePath, + `${JSON.stringify( + { + resources: [ + { + address: { provider: "bailian", type: "agent", name: "assistant" }, + remote_id: "agent-e2e-dry-run", + }, + ], + }, + null, + 2, + )}\n`, + ); + return { configPath, statePath }; +} + /** 分配一个刚释放的本地端口,连接必然 ECONNREFUSED,用于网络错误场景。 */ async function closedPort(): Promise { const server = createServer(); @@ -215,3 +244,38 @@ describe("e2e: managed-agent 鉴权分层(离线命令免登录 / provider-awa expect(stderr).toMatch(/ANTHROPIC_API_KEY/); }); }); + +describe("e2e: plan --dry-run 离线契约(不联网 / 不写 state / 免凭证)", () => { + test("无任何凭证时 plan --dry-run 不报 AUTH,离线出 plan (0)", async () => { + const env = makeConfigEnv({}); + const { stderr, exitCode } = await runCommandE2e( + ROUTES, + [...planArgs(AGENTS_YAML), "--dry-run"], + env, + ); + expect(exitCode, stderr).toBe(0); + }); + + test("有凭证且 state 非空时,--dry-run 跳过 refresh:不发请求、state 文件不变;同环境不加 --dry-run 则证明会联网", async () => { + const { configPath, statePath } = makeStatefulProject(); + // base_url 指向必然 ECONNREFUSED 的本地端口:一旦 refresh 真发请求必现形。 + // refresh 对 API 错误优雅降级(不影响退出码),因此用 stderr 的 + // "Failed to refresh" 告警作为「发过请求」的观测信号。 + const port = await closedPort(); + const env = makeConfigEnv({ + api_key: "sk-e2e-dry-run", + base_url: `http://127.0.0.1:${port}`, + }); + const stateBefore = readFileSync(statePath, "utf8"); + + // 对照组:不加 --dry-run,默认 refresh 路径真实访问远端 → 出现 refresh 失败告警。 + const withoutDryRun = await runCommandE2e(ROUTES, planArgs(configPath), env); + expect(withoutDryRun.stderr).toMatch(/Failed to refresh/i); + + // --dry-run:同环境必须完全离线成功,无任何 refresh 痕迹,且不回写 state 文件。 + const withDryRun = await runCommandE2e(ROUTES, [...planArgs(configPath), "--dry-run"], env); + expect(withDryRun.exitCode, withDryRun.stderr).toBe(0); + expect(withDryRun.stderr).not.toMatch(/Failed to refresh/i); + expect(readFileSync(statePath, "utf8")).toBe(stateBefore); + }); +}); diff --git a/skills/bailian-cli/reference/managed-agent.md b/skills/bailian-cli/reference/managed-agent.md index 6b145f9..9014686 100644 --- a/skills/bailian-cli/reference/managed-agent.md +++ b/skills/bailian-cli/reference/managed-agent.md @@ -157,6 +157,7 @@ bl managed-agent init --provider all - 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. #### Examples