Skip to content

Commit 63ee5aa

Browse files
committed
fix(agent): plan command dry-run
1 parent 93c9149 commit 63ee5aa

4 files changed

Lines changed: 79 additions & 8 deletions

File tree

docs/agents/auth-change.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
5757

5858
`bl managed-agent *` 按调用链分两层,不再全命令硬门禁:
5959

60-
- **离线命令**`init``validate``state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言(`plan --no-refresh` 同样传 `"none"`
60+
- **离线命令**`init``validate``state list/show/rm`:`auth: "none"`,只读写本地文件,无需登录;引擎侧传 `credentials: "none"` 跳过凭证断言(`plan --no-refresh` `plan --dry-run` 同样传 `"none"` 并强制 `refresh: false`:不联网、不回写 state
6161
- **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 不阻塞。
6262

6363
凭证不以真实值写入 `process.env`,而是经 `packages/commands/src/commands/managed-agent/_engine/`**内存注入管道**(`resolveAgentProjectConfig`)注入 SDK,管道五步:

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,28 +41,34 @@ const PLAN_FLAGS = {
4141
export default defineCommand({
4242
description: "Show what changes would be applied to agent infrastructure",
4343
auth: "apiKey",
44-
// Provider-aware gate: --no-refresh plans fully offline; a refreshing run
45-
// only needs credentials for the providers it targets (see CredentialScope).
44+
// Provider-aware gate: --no-refresh / --dry-run plan fully offline; a
45+
// refreshing run only needs credentials for the providers it targets
46+
// (see CredentialScope).
4647
authOptional: true,
4748
usageArgs: "[--file <path>] [--provider <name>] [--no-refresh] [--refresh-only]",
4849
flags: PLAN_FLAGS,
4950
exampleArgs: ["", "--provider bailian", "--no-refresh"],
50-
notes: CREDENTIALS_NOTE,
51+
notes: [
52+
...CREDENTIALS_NOTE,
53+
"--no-refresh and --dry-run plan offline from local config and state: no credentials, no remote requests, no state writes.",
54+
],
5155
async run(ctx) {
5256
const { settings, flags } = ctx;
5357
const format = detectOutputFormat(settings.output);
5458
const file = flags.file ?? "agents.yaml";
59+
// Offline mode never talks to a provider and never saves refreshed state:
60+
// --no-refresh by explicit request, --dry-run by contract (read-only run).
61+
const offline = Boolean(flags.noRefresh) || settings.dryRun;
5562

5663
const planned = await withAgentErrors(() =>
5764
withStdoutProtected(async () => {
58-
// --no-refresh never talks to a provider → no credentials required.
5965
const runtime = await buildAgentRuntime(ctx, file, {
60-
credentials: flags.noRefresh ? "none" : (flags.provider ?? "targets"),
66+
credentials: offline ? "none" : (flags.provider ?? "targets"),
6167
});
6268
assertProviderConfigured(runtime, flags.provider);
6369
return planProjectContext(runtime, {
6470
provider: flags.provider,
65-
refresh: !flags.noRefresh,
71+
refresh: !offline,
6672
quiet: format === "json",
6773
onFeedback: format === "json" ? undefined : renderAgentFeedback,
6874
});

packages/commands/tests/e2e/managed-agent-auth-chain.e2e.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
import { createServer } from "node:net";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
@@ -57,6 +57,35 @@ function isolatedAgentsConfigEnv(): NodeJS.ProcessEnv {
5757
return { AGENTS_CONFIG_PATH: join(tmpdir(), "bl-e2e-no-agents-config.json") };
5858
}
5959

60+
/**
61+
* 在临时目录里搭一套非空 state 的项目:agents.yaml 复用单 provider fixture,
62+
* agents.state.json 预置一条已追踪资源 —— 非空 state 是触发 plan 默认 refresh
63+
* 路径的前提,用于验证 --dry-run 强制离线。目录纳入 tempDirs 自动清理。
64+
*/
65+
function makeStatefulProject(): { configPath: string; statePath: string } {
66+
const dir = mkdtempSync(join(tmpdir(), "bl-managed-agent-dry-run-"));
67+
tempDirs.push(dir);
68+
const configPath = join(dir, "agents.yaml");
69+
const statePath = join(dir, "agents.state.json");
70+
writeFileSync(configPath, readFileSync(AGENTS_YAML, "utf8"));
71+
writeFileSync(
72+
statePath,
73+
`${JSON.stringify(
74+
{
75+
resources: [
76+
{
77+
address: { provider: "bailian", type: "agent", name: "assistant" },
78+
remote_id: "agent-e2e-dry-run",
79+
},
80+
],
81+
},
82+
null,
83+
2,
84+
)}\n`,
85+
);
86+
return { configPath, statePath };
87+
}
88+
6089
/** 分配一个刚释放的本地端口,连接必然 ECONNREFUSED,用于网络错误场景。 */
6190
async function closedPort(): Promise<number> {
6291
const server = createServer();
@@ -215,3 +244,38 @@ describe("e2e: managed-agent 鉴权分层(离线命令免登录 / provider-awa
215244
expect(stderr).toMatch(/ANTHROPIC_API_KEY/);
216245
});
217246
});
247+
248+
describe("e2e: plan --dry-run 离线契约(不联网 / 不写 state / 免凭证)", () => {
249+
test("无任何凭证时 plan --dry-run 不报 AUTH,离线出 plan (0)", async () => {
250+
const env = makeConfigEnv({});
251+
const { stderr, exitCode } = await runCommandE2e(
252+
ROUTES,
253+
[...planArgs(AGENTS_YAML), "--dry-run"],
254+
env,
255+
);
256+
expect(exitCode, stderr).toBe(0);
257+
});
258+
259+
test("有凭证且 state 非空时,--dry-run 跳过 refresh:不发请求、state 文件不变;同环境不加 --dry-run 则证明会联网", async () => {
260+
const { configPath, statePath } = makeStatefulProject();
261+
// base_url 指向必然 ECONNREFUSED 的本地端口:一旦 refresh 真发请求必现形。
262+
// refresh 对 API 错误优雅降级(不影响退出码),因此用 stderr 的
263+
// "Failed to refresh" 告警作为「发过请求」的观测信号。
264+
const port = await closedPort();
265+
const env = makeConfigEnv({
266+
api_key: "sk-e2e-dry-run",
267+
base_url: `http://127.0.0.1:${port}`,
268+
});
269+
const stateBefore = readFileSync(statePath, "utf8");
270+
271+
// 对照组:不加 --dry-run,默认 refresh 路径真实访问远端 → 出现 refresh 失败告警。
272+
const withoutDryRun = await runCommandE2e(ROUTES, planArgs(configPath), env);
273+
expect(withoutDryRun.stderr).toMatch(/Failed to refresh/i);
274+
275+
// --dry-run:同环境必须完全离线成功,无任何 refresh 痕迹,且不回写 state 文件。
276+
const withDryRun = await runCommandE2e(ROUTES, [...planArgs(configPath), "--dry-run"], env);
277+
expect(withDryRun.exitCode, withDryRun.stderr).toBe(0);
278+
expect(withDryRun.stderr).not.toMatch(/Failed to refresh/i);
279+
expect(readFileSync(statePath, "utf8")).toBe(stateBefore);
280+
});
281+
});

skills/bailian-cli/reference/managed-agent.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ bl managed-agent init --provider all
157157
- Other providers read the env vars referenced in agents.yaml (e.g. ${ANTHROPIC_API_KEY}), including .env and ~/.agents/config.json.
158158
- Only the providers this run involves (--provider, or the config's default provider chain) need credentials; other configured providers are not checked.
159159
- Resolved credentials are injected into the SDK in-memory and cleared from the environment; they never persist in process env.
160+
- --no-refresh and --dry-run plan offline from local config and state: no credentials, no remote requests, no state writes.
160161

161162
#### Examples
162163

0 commit comments

Comments
 (0)