mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
fix(agent): plan command dry-run
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user