Skip to content

Commit 0a3d6c1

Browse files
committed
fix(windows): isolate ACL modules and report cleanup failures
1 parent ef2fbb1 commit 0a3d6c1

4 files changed

Lines changed: 70 additions & 30 deletions

File tree

‎orchestrator/src/fsutil.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,12 @@ exit 0
3838
`;
3939

4040
function windowsAcl(script: string, file: string): { status: number | null; error?: Error } {
41+
const env: NodeJS.ProcessEnv = { ...process.env, VRA_PRIVATE_FILE: path.resolve(file) };
42+
// A parent PowerShell 7 process exports its module search path. Windows
43+
// PowerShell 5.1 cannot load those Security modules; use its own defaults.
44+
for (const key of Object.keys(env)) if (key.toLowerCase() === "psmodulepath") delete env[key];
4145
const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], {
42-
env: { ...process.env, VRA_PRIVATE_FILE: path.resolve(file) }, encoding: "utf8", windowsHide: true, timeout: 10_000,
46+
env, encoding: "utf8", windowsHide: true, timeout: 10_000,
4347
});
4448
return { status: result.status, error: result.error };
4549
}

‎orchestrator/src/local_agent_runtime.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -882,8 +882,10 @@ export async function runLocalAgent(agent: LocalAgentId, opts: RunLocalAgentOpti
882882
let killFallbackTimer: NodeJS.Timeout | null = null;
883883

884884
const cleanup = () => {
885-
fs.rmSync(tmpDir, { recursive: true, force: true });
886885
opts.signal?.removeEventListener("abort", onAbort);
886+
// Windows may briefly retain a handle after process close. Retry within a
887+
// fixed bound; a persistent cleanup failure must never become success.
888+
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
887889
};
888890
const finish = (fn: () => void) => {
889891
if (settled) return;
@@ -893,7 +895,11 @@ export async function runLocalAgent(agent: LocalAgentId, opts: RunLocalAgentOpti
893895
if (killFallbackTimer) clearTimeout(killFallbackTimer);
894896
activeLocalAgents = Math.max(0, activeLocalAgents - 1);
895897
untrackLocalAgentProcess(child);
896-
cleanup();
898+
try { cleanup(); }
899+
catch {
900+
reject(new LocalAgentError("agent_cleanup_failed", `${label} 临时工作区清理失败,请检查本机文件权限`));
901+
return;
902+
}
897903
fn();
898904
};
899905
let closed = false;

‎orchestrator/test/local_agent_runtime.test.ts‎

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import os from "node:os";
44
import path from "node:path";
55
import test from "node:test";
66

7+
// Minimal fixture environments still need Windows' OS/profile bootstrap values.
8+
// Do not inherit API keys, OAuth tokens or model-provider configuration.
9+
const PLATFORM_ENV: NodeJS.ProcessEnv = process.platform === "win32"
10+
? Object.fromEntries(Object.entries(process.env).filter(([key]) =>
11+
/^(SystemRoot|windir|ComSpec|PATHEXT|TEMP|TMP|USERPROFILE|APPDATA|LOCALAPPDATA|PROGRAMDATA|PROGRAMFILES|PROGRAMFILES\(X86\)|HOMEDRIVE|HOMEPATH)$/i.test(key)))
12+
: {};
13+
714
import {
815
LocalAgentError, claudeArgs, codeBuddyArgs, codexLoginProgress, findExecutable, parseClaudeOutput,
916
executableInvocation, normalizeLocalAgentTimeoutMs, parseCodeBuddyOutput, probeClaude, probeCodeBuddy, probeCodex, runLocalAgent,
@@ -28,7 +35,7 @@ function fakeNodeExecutable(dir: string, name: string, source: string): string {
2835
const bin = path.join(dir, `${name}.ps1`);
2936
fs.writeFileSync(script, source);
3037
const quote = (s: string) => s.replaceAll("'", "''");
31-
fs.writeFileSync(bin, `& '${quote(process.execPath)}' '${quote(script)}' @args\r\nexit $LASTEXITCODE\r\n`);
38+
fs.writeFileSync(bin, `$ErrorActionPreference = 'Stop'\r\n& '${quote(process.execPath)}' '${quote(script)}' @args\r\nexit $LASTEXITCODE\r\n`);
3239
return bin;
3340
}
3441

@@ -91,11 +98,11 @@ if(a[0]==='login'){
9198
}
9299
`);
93100
try {
94-
const before = await probeCodex(bin, codexHome, { TEST_LAUNCH_FILE: launchFile });
101+
const before = await probeCodex(bin, codexHome, { ...PLATFORM_ENV, TEST_LAUNCH_FILE: launchFile });
95102
assert.equal(before.status, "not_authenticated");
96103

97-
const first = startCodexLogin(bin, codexHome, { TEST_LAUNCH_FILE: launchFile });
98-
const duplicate = startCodexLogin(bin, codexHome, { TEST_LAUNCH_FILE: launchFile });
104+
const first = startCodexLogin(bin, codexHome, { ...PLATFORM_ENV, TEST_LAUNCH_FILE: launchFile });
105+
const duplicate = startCodexLogin(bin, codexHome, { ...PLATFORM_ENV, TEST_LAUNCH_FILE: launchFile });
99106
assert.equal(first.state, "started");
100107
assert.equal(duplicate.state, "pending", "同一个产品 home 不能同时弹出两个登录流程");
101108
assert.equal(codexLoginProgress(codexHome)?.state, "pending");
@@ -106,7 +113,7 @@ if(a[0]==='login'){
106113
const launched = JSON.parse(fs.readFileSync(launchFile, "utf8")) as { home: string; args: string[] };
107114
assert.equal(launched.home, codexHome);
108115
assert.deepEqual(launched.args, ["login"]);
109-
const after = await probeCodex(bin, codexHome, { TEST_LAUNCH_FILE: launchFile });
116+
const after = await probeCodex(bin, codexHome, { ...PLATFORM_ENV, TEST_LAUNCH_FILE: launchFile });
110117
assert.equal(after.status, "ready");
111118
} finally {
112119
fs.rmSync(dir, { recursive: true, force: true });
@@ -132,7 +139,7 @@ if(process.env.TEST_IS_CHILD==='1'){
132139
}
133140
`);
134141
try {
135-
const env = { TEST_PARENT_PID_FILE: parentPidFile, TEST_CHILD_PID_FILE: childPidFile };
142+
const env = { ...PLATFORM_ENV, TEST_PARENT_PID_FILE: parentPidFile, TEST_CHILD_PID_FILE: childPidFile };
136143
const started = Date.now();
137144
assert.equal(startCodexLogin(bin, codexHome, env, { timeoutMs: 6_000 }).state, "started");
138145
for (let i = 0; i < 500 && (!fs.existsSync(parentPidFile) || !fs.existsSync(childPidFile)); i += 1) {
@@ -184,7 +191,7 @@ test("本机 Claude 探针只返回版本与登录布尔,不泄露账号", asy
184191
const f = fakeClaude();
185192
try {
186193
assert.equal(findExecutable("claude", { CLAUDE_BIN: f.bin, PATH: "" }), f.bin);
187-
const status = await probeClaude({ CLAUDE_BIN: f.bin, PATH: "" });
194+
const status = await probeClaude({ ...PLATFORM_ENV, CLAUDE_BIN: f.bin, PATH: "" });
188195
assert.equal(status.status, "ready");
189196
assert.equal(status.version, "2.1.226 (Claude Code)");
190197
assert.ok(!JSON.stringify(status).includes("hidden@example.com"));
@@ -194,11 +201,11 @@ test("本机 Claude 探针只返回版本与登录布尔,不泄露账号", asy
194201
test("Claude 探针不把 API/云平台认证冒充订阅,旧版 CLI 也不点亮", async () => {
195202
const f = fakeClaude();
196203
try {
197-
const api = await probeClaude({ CLAUDE_BIN: f.bin, PATH: "", FAKE_AUTH_METHOD: "api_key" });
204+
const api = await probeClaude({ ...PLATFORM_ENV, CLAUDE_BIN: f.bin, PATH: "", FAKE_AUTH_METHOD: "api_key" });
198205
assert.equal(api.status, "not_authenticated");
199-
const bedrock = await probeClaude({ CLAUDE_BIN: f.bin, PATH: "", FAKE_API_PROVIDER: "bedrock" });
206+
const bedrock = await probeClaude({ ...PLATFORM_ENV, CLAUDE_BIN: f.bin, PATH: "", FAKE_API_PROVIDER: "bedrock" });
200207
assert.equal(bedrock.status, "not_authenticated");
201-
const old = await probeClaude({ CLAUDE_BIN: f.bin, PATH: "", FAKE_OLD_HELP: "1" });
208+
const old = await probeClaude({ ...PLATFORM_ENV, CLAUDE_BIN: f.bin, PATH: "", FAKE_OLD_HELP: "1" });
202209
assert.equal(old.status, "probe_failed");
203210
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
204211
});
@@ -208,7 +215,7 @@ test("Claude 订阅调用走 stdin,并移除会静默改计费方的 Anthropic
208215
try {
209216
const out = await runLocalAgent("claude", {
210217
systemPrompt: "规则", userPrompt: "USER_SECRET_PROMPT",
211-
env: {
218+
env: { ...PLATFORM_ENV,
212219
CLAUDE_BIN: f.bin, PATH: process.env.PATH, ANTHROPIC_API_KEY: "must-not-forward",
213220
CLAUDE_CODE_OAUTH_TOKEN: "official-subscription-token",
214221
},
@@ -271,15 +278,15 @@ test("CodeBuddy 官方控制探针只返回版本与登录布尔,不泄露账
271278
const f = fakeCodeBuddy();
272279
try {
273280
assert.equal(findExecutable("codebuddy", { CODEBUDDY_BIN: f.bin, PATH: "" }), f.bin);
274-
const status = await probeCodeBuddy({ CODEBUDDY_BIN: f.bin, PATH: "" });
281+
const status = await probeCodeBuddy({ ...PLATFORM_ENV, CODEBUDDY_BIN: f.bin, PATH: "" });
275282
assert.equal(status.status, "ready");
276283
assert.equal(status.version, "2.143.1 (CodeBuddy Code)");
277284
assert.ok(!JSON.stringify(status).includes("hidden@example.com"));
278285
assert.ok(!JSON.stringify(status).includes("secret-token"));
279286

280-
const loggedOut = await probeCodeBuddy({ CODEBUDDY_BIN: f.bin, PATH: "", FAKE_NOT_LOGGED: "1" });
287+
const loggedOut = await probeCodeBuddy({ ...PLATFORM_ENV, CODEBUDDY_BIN: f.bin, PATH: "", FAKE_NOT_LOGGED: "1" });
281288
assert.equal(loggedOut.status, "not_authenticated");
282-
const old = await probeCodeBuddy({ CODEBUDDY_BIN: f.bin, PATH: "", FAKE_OLD_HELP: "1" });
289+
const old = await probeCodeBuddy({ ...PLATFORM_ENV, CODEBUDDY_BIN: f.bin, PATH: "", FAKE_OLD_HELP: "1" });
283290
assert.equal(old.status, "probe_failed");
284291
} finally { fs.rmSync(f.dir, { recursive: true, force: true }); }
285292
});
@@ -288,7 +295,7 @@ test("CodeBuddy 冷启动超过五秒不应误报登录检测失败", async () =
288295
const f = fakeCodeBuddy();
289296
try {
290297
for (const flag of ["FAKE_COLD_HELP", "FAKE_COLD_AUTH"]) {
291-
const status = await probeCodeBuddy({ CODEBUDDY_BIN: f.bin, PATH: "", [flag]: "1" });
298+
const status = await probeCodeBuddy({ ...PLATFORM_ENV, CODEBUDDY_BIN: f.bin, PATH: "", [flag]: "1" });
292299
assert.equal(status.status, "ready", flag);
293300
assert.equal(status.authenticated, true);
294301
assert.doesNotMatch(JSON.stringify(status), /secret-user|secret-token|hidden@example/);
@@ -301,7 +308,7 @@ test("CodeBuddy 订阅调用走 stdin,移除 API / token / 自定义端点并
301308
try {
302309
const out = await runLocalAgent("codebuddy", {
303310
systemPrompt: "规则", userPrompt: "USER_SECRET_PROMPT",
304-
env: {
311+
env: { ...PLATFORM_ENV,
305312
CODEBUDDY_BIN: f.bin, PATH: process.env.PATH,
306313
CODEBUDDY_API_KEY: "must-not-forward", CODEBUDDY_AUTH_TOKEN: "must-not-forward",
307314
CODEBUDDY_BASE_URL: "https://custom.invalid", CODEBUDDY_MODEL: "other-model",
@@ -316,7 +323,7 @@ test("CodeBuddy Deep 首轮等待唯一 MCP,并维持订阅环境隔离", asyn
316323
try {
317324
const out = await runLocalAgent("codebuddy", {
318325
systemPrompt: "规则", userPrompt: "读取运行文件",
319-
env: { CODEBUDDY_BIN: f.bin, PATH: process.env.PATH, FAKE_ECHO_MCP_ENV: "1" },
326+
env: { ...PLATFORM_ENV, CODEBUDDY_BIN: f.bin, PATH: process.env.PATH, FAKE_ECHO_MCP_ENV: "1" },
320327
controlledMcp: {
321328
serverName: "vra", command: process.execPath, args: ["/app/run_tools_mcp.ts"],
322329
env: { VRA_RUN_DIR: "/data/runs/r1" }, allowedTools: ["mcp__vra__list_run_files"], maxTurns: 8,
@@ -337,14 +344,14 @@ test("WorkBuddy 桌面端旧 CLI 复用现有订阅登录,但回答只在一
337344
const baseAppData = path.join(baseProfile, "AppData", "Roaming");
338345
const baseLocalAppData = path.join(baseProfile, "AppData", "Local");
339346
fs.mkdirSync(baseHome);
340-
const status = await probeCodeBuddy({
347+
const status = await probeCodeBuddy({ ...PLATFORM_ENV,
341348
CODEBUDDY_BIN: f.bin, PATH: process.env.PATH, HOME: baseHome,
342349
FAKE_BASE_HOME: baseHome, FAKE_LEGACY_HELP: "1",
343350
});
344351
assert.equal(status.status, "ready");
345352
const out = await runLocalAgent("codebuddy", {
346353
systemPrompt: "规则", userPrompt: "USER_SECRET_PROMPT",
347-
env: {
354+
env: { ...PLATFORM_ENV,
348355
CODEBUDDY_BIN: f.bin, PATH: process.env.PATH, HOME: baseHome,
349356
USERPROFILE: baseProfile, APPDATA: baseAppData, LOCALAPPDATA: baseLocalAppData,
350357
FAKE_BASE_HOME: baseHome, FAKE_BASE_USERPROFILE: baseProfile,
@@ -373,7 +380,7 @@ test("WorkBuddy 原生图片仅进入 stdin 用户消息,不当文件路径或
373380
assert.equal(packet.type, "user");
374381
assert.deepEqual(packet.message.content[2], { type: "image", source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" } });
375382
const output = await runLocalAgent("codebuddy", { systemPrompt: "转写", userPrompt: "转写", userImages: images,
376-
env: { CODEBUDDY_BIN: f.bin, PATH: "", FAKE_IMAGE_INPUT: "1" } });
383+
env: { ...PLATFORM_ENV, CODEBUDDY_BIN: f.bin, PATH: "", FAKE_IMAGE_INPUT: "1" } });
377384
assert.match(output, /aW1hZ2U=/);
378385
assert.match(output, /\|input_format=stream-json\|builtin_tools=\|model_override=false/);
379386
assert.equal(localAgentInput("codebuddy", "plain"), "plain");
@@ -389,7 +396,7 @@ test("CodeBuddy 未登录即使 CLI 以 exit 0 返回纯文本,也要归类为
389396
await assert.rejects(
390397
() => runLocalAgent("codebuddy", {
391398
systemPrompt: "规则", userPrompt: "hello",
392-
env: { CODEBUDDY_BIN: f.bin, PATH: process.env.PATH, FAKE_EXEC_NOT_LOGGED: "1" },
399+
env: { ...PLATFORM_ENV, CODEBUDDY_BIN: f.bin, PATH: process.env.PATH, FAKE_EXEC_NOT_LOGGED: "1" },
393400
}),
394401
(e: unknown) => e instanceof LocalAgentError && e.code === "agent_not_authenticated",
395402
);
@@ -418,7 +425,7 @@ setInterval(()=>{},1000);
418425
// 全量测试并行启动大量 Node 子进程;1 秒可能在假进程真正获得调度前就到期,
419426
// 那只测到了机器负载,不是“已启动的顽固进程树能否被清理”。
420427
systemPrompt: "规则", userPrompt: "等待", timeoutMs: 5_000,
421-
env: {
428+
env: { ...PLATFORM_ENV,
422429
CLAUDE_BIN: bin, PATH: process.env.PATH,
423430
TEST_PARENT_PID_FILE: parentPidFile, TEST_CHILD_PID_FILE: childPidFile,
424431
},
@@ -442,7 +449,7 @@ setInterval(()=>{},1000);
442449
`);
443450
const running = runLocalAgent("claude", {
444451
systemPrompt: "规则", userPrompt: "等待", timeoutMs: 60_000,
445-
env: { CLAUDE_BIN: bin, PATH: process.env.PATH, TEST_PID_FILE: pidFile },
452+
env: { ...PLATFORM_ENV, CLAUDE_BIN: bin, PATH: process.env.PATH, TEST_PID_FILE: pidFile },
446453
});
447454
try {
448455
for (let i = 0; i < 100 && !fs.existsSync(pidFile); i += 1) await new Promise((resolve) => setTimeout(resolve, 20));
@@ -454,18 +461,40 @@ setInterval(()=>{},1000);
454461
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
455462
});
456463

464+
test("临时目录清理失败应拒绝请求,不抛出未捕获异常或把任务悬空", async () => {
465+
const f = fakeClaude();
466+
const original = fs.rmSync;
467+
let retained: fs.PathLike | undefined;
468+
fs.rmSync = ((target, options) => {
469+
if (String(target).includes(`${path.sep}vra-claude-`)) {
470+
retained = target;
471+
throw Object.assign(new Error("synthetic permission failure"), { code: "EPERM" });
472+
}
473+
return original(target, options);
474+
}) as typeof fs.rmSync;
475+
try {
476+
await assert.rejects(runLocalAgent("claude", {
477+
systemPrompt: "规则", userPrompt: "测试", env: { ...process.env, CLAUDE_BIN: f.bin },
478+
}), (e: unknown) => e instanceof LocalAgentError && e.code === "agent_cleanup_failed");
479+
} finally {
480+
fs.rmSync = original;
481+
if (retained) original(retained, { recursive: true, force: true, maxRetries: 5 });
482+
original(f.dir, { recursive: true, force: true, maxRetries: 5 });
483+
}
484+
});
485+
457486
test("一次性任务也受本机 Agent 全局并发上限约束,不能绕过会话表无限启动", async () => {
458487
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vra-busy-claude-"));
459488
const bin = fakeNodeExecutable(dir, "claude", `setInterval(()=>{},1000);\n`);
460489
const controllers = Array.from({ length: 4 }, () => new AbortController());
461490
const runs = controllers.map((ac) => runLocalAgent("claude", {
462491
systemPrompt: "规则", userPrompt: "等待", signal: ac.signal,
463-
env: { CLAUDE_BIN: bin, PATH: process.env.PATH },
492+
env: { ...PLATFORM_ENV, CLAUDE_BIN: bin, PATH: process.env.PATH },
464493
}));
465494
try {
466495
await assert.rejects(
467496
() => runLocalAgent("claude", {
468-
systemPrompt: "规则", userPrompt: "第五个", env: { CLAUDE_BIN: bin, PATH: process.env.PATH },
497+
systemPrompt: "规则", userPrompt: "第五个", env: { ...PLATFORM_ENV, CLAUDE_BIN: bin, PATH: process.env.PATH },
469498
}),
470499
(e: unknown) => e instanceof LocalAgentError && e.code === "agent_busy",
471500
);

‎scripts/windows-ci-diagnostics.mjs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ try {
1717
const clean = { ...process.env };
1818
delete clean.PSModulePath;
1919
const bootstrap = { PATH: '', SystemRoot: process.env.SystemRoot };
20-
for (const [label, env] of [['full', process.env], ['clean-modules', clean], ['minimal', { PATH: '' }], ['bootstrap', bootstrap]]) {
21-
const launch = executableInvocation(bin, ['--help', 'two words'], env);
20+
const profile = Object.fromEntries(Object.entries(process.env).filter(([key]) => /^(SystemRoot|windir|ComSpec|PATHEXT|TEMP|TMP|USERPROFILE|APPDATA|LOCALAPPDATA|PROGRAMDATA|PROGRAMFILES|PROGRAMFILES\(X86\)|HOMEDRIVE|HOMEPATH)$/i.test(key)));
21+
for (const [label, env] of [['full', process.env], ['clean-modules', clean], ['minimal', { PATH: '' }], ['bootstrap', bootstrap], ['profile', { ...profile, PATH: '' }]]) {
22+
const launch = executableInvocation(bin, ['--help', 'two words', '', '{"x":"测试"}'], env);
2223
emit(label, spawnSync(launch.file, launch.args, { env, encoding: 'utf8', timeout: 10000 }));
2324
}
2425
const source = fs.readFileSync(new URL('../orchestrator/src/fsutil.ts', import.meta.url), 'utf8');

0 commit comments

Comments
 (0)