fix(agent): plan command dry-run

This commit is contained in:
chenanran555
2026-07-27 20:03:09 +08:00
parent 93c9149e45
commit 63ee5aaec3
4 changed files with 79 additions and 8 deletions
+1 -1
View File
@@ -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,管道五步:
@@ -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 <path>] [--provider <name>] [--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,
});
@@ -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<number> {
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);
});
});
@@ -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