diff --git a/harnesses/openclaw/src/graph-lifecycle.ts b/harnesses/openclaw/src/graph-lifecycle.ts index c2836791..16e171dd 100644 --- a/harnesses/openclaw/src/graph-lifecycle.ts +++ b/harnesses/openclaw/src/graph-lifecycle.ts @@ -57,6 +57,8 @@ export function spawnOpenclawGraphOnStop( const child = sp(process.execPath, [workerPath], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, cwd, }); child.on("error", () => { /* best-effort */ }); @@ -77,9 +79,15 @@ export function spawnOpenclawGraphPullWorker( if (!existsFn(workerPath)) return; try { const sp = deps.spawn ?? realSpawn; - const child = sp("nohup", ["node", workerPath, "--cwd", cwd], { + // `nohup` is POSIX-only — on Windows this spawn ENOENT'd, so the pull + // worker never ran there at all and windowsHide could not help. detached + // + unref already gives the survival nohup was there for, and it matches + // what graph-on-stop does. + const child = sp(process.execPath, [workerPath, "--cwd", cwd], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, }); child.on("error", () => { graphPullSpawned = false; }); child.unref(); diff --git a/harnesses/openclaw/src/index.ts b/harnesses/openclaw/src/index.ts index fca649ab..78cb7976 100644 --- a/harnesses/openclaw/src/index.ts +++ b/harnesses/openclaw/src/index.ts @@ -591,7 +591,10 @@ function detectOpenclawGateAgent(): GateAgent | null { ]; for (const [agent, bin] of candidates) { try { - realExecFileSync("which", [bin], { stdio: ["ignore", "pipe", "ignore"] }); + // `which` is Unix-only; Windows needs `where`. Without this the gate + // detection throws on every candidate and reports "no agent found". + const lookup = process.platform === "win32" ? "where" : "which"; + realExecFileSync(lookup, [bin], { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); return agent; } catch { /* not on PATH, try next */ } } @@ -674,6 +677,8 @@ function spawnOpenclawSkillifyWorker(a: OpenclawSpawnArgs): boolean { realSpawn(process.execPath, [OPENCLAW_SKILLIFY_WORKER_PATH, configPath], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...inheritedEnv.env, HIVEMIND_SKILLIFY_WORKER: "1", HIVEMIND_CAPTURE: "false" }, }).unref(); return true; diff --git a/harnesses/pi/extension-source/hivemind.ts b/harnesses/pi/extension-source/hivemind.ts index e1e6dd31..610e26af 100644 --- a/harnesses/pi/extension-source/hivemind.ts +++ b/harnesses/pi/extension-source/hivemind.ts @@ -398,6 +398,8 @@ function trySpawnDaemonInline(): boolean { const child = spawn(process.execPath, [EMBED_DAEMON_ENTRY], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, }); child.unref(); logHm(`embed: spawned daemon pid=${child.pid}`); @@ -633,6 +635,8 @@ function skilloptReact(sessionId: string, reaction: string): void { const child = spawn(process.execPath, [PI_SKILLOPT_WORKER_PATH], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...process.env, HIVEMIND_SKILLOPT_WORKER: "1", // recursion guard (worker won't re-fire the trigger) @@ -838,6 +842,8 @@ function spawnWikiWorker( spawn(process.execPath, [PI_WIKI_WORKER_PATH, configPath], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...process.env, HIVEMIND_WIKI_WORKER: "1", HIVEMIND_CAPTURE: "false" }, }).unref(); } catch (e: any) { @@ -928,6 +934,8 @@ function spawnPiSkillifyWorker(creds: Creds, sessionId: string, cwd: string): vo spawn(process.execPath, [PI_SKILLIFY_WORKER_PATH, configPath], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...process.env, HIVEMIND_SKILLIFY_WORKER: "1", HIVEMIND_CAPTURE: "false" }, }).unref(); } catch (e: any) { @@ -1126,8 +1134,18 @@ function piMaybeAutoMineLocal(): boolean { } catch { /* fall through to which */ } if (!launcher) { try { - const out = execFileSync("which", ["hivemind"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }); - const bin = String(out).trim(); + // `which` is Unix-only; Windows needs `where`, which prints one match + // per line. Mirror src/utils/resolve-cli-bin.ts: prefer a real .exe, + // then a .cmd/.bat shim, else the first match — an extensionless shim + // is not directly runnable on Windows. + const isWin = process.platform === "win32"; + const out = execFileSync(isWin ? "where" : "which", ["hivemind"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); + const matches = String(out).split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const bin = !isWin + ? (matches[0] ?? "") + : (matches.find((m) => m.toLowerCase().endsWith(".exe")) + ?? matches.find((m) => /\.(cmd|bat)$/i.test(m)) + ?? matches[0] ?? ""); if (bin) launcher = { kind: "bin", path: bin }; } catch { return false; } } @@ -1147,9 +1165,23 @@ function piMaybeAutoMineLocal(): boolean { const [cmd, args]: [string, string[]] = launcher.kind === "node-script" ? [process.execPath, [launcher.path, "skillify", "mine-local"]] : [launcher.path, ["skillify", "mine-local"]]; - const child = spawn(cmd, args, { + // A Windows .cmd/.bat shim is not directly executable — it needs a + // shell. Mirror of binNeedsShell in src/utils/resolve-cli-bin.ts, + // including the win32 gate: on POSIX a file merely named *.cmd must + // still spawn directly. + const needsShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(cmd); + // Under `shell: true` Node concatenates file + args into one command + // string with no escaping, so an unquoted install path containing a + // space (C:\\Users\\Jane Doe\\...) is parsed as two tokens. Quote the + // executable; only the fixed subcommand rides the command line, never + // user text. + const shellCmd = needsShell ? `"${cmd}"` : cmd; + const child = spawn(shellCmd, args, { detached: true, stdio: ["ignore", out, out], + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, + ...(needsShell ? { shell: true } : {}), env: process.env, }); closeSync(out); diff --git a/src/embeddings/client.ts b/src/embeddings/client.ts index 1e690c91..3764af6d 100644 --- a/src/embeddings/client.ts +++ b/src/embeddings/client.ts @@ -371,6 +371,10 @@ export class EmbedClient { const child = spawn(process.execPath, [this.daemonEntry], { detached: true, stdio: "ignore", + // SW_HIDE: the daemon is auto-spawned from the backfill path, which + // itself runs detached — without this it flashes a console. No-op on + // POSIX. + windowsHide: true, env: process.env, }); child.unref(); diff --git a/src/embeddings/standalone-embed-client.ts b/src/embeddings/standalone-embed-client.ts index 5b328dd7..f0979040 100644 --- a/src/embeddings/standalone-embed-client.ts +++ b/src/embeddings/standalone-embed-client.ts @@ -219,6 +219,8 @@ function trySpawnDaemon(daemonEntry: string, pidPath: string): boolean { const child = _spawn(process.execPath, [daemonEntry], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, }); child.unref(); return true; diff --git a/src/hooks/codex/session-start.ts b/src/hooks/codex/session-start.ts index 5cbdbe1e..0d0f3d27 100644 --- a/src/hooks/codex/session-start.ts +++ b/src/hooks/codex/session-start.ts @@ -72,6 +72,8 @@ async function main(): Promise { const child = spawn("node", [setupScript], { detached: true, stdio: ["pipe", "ignore", "ignore"], + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...process.env }, }); // Feed the same stdin input to the setup process diff --git a/src/hooks/shared/autoupdate.ts b/src/hooks/shared/autoupdate.ts index 4ba5ff5d..292a03e2 100644 --- a/src/hooks/shared/autoupdate.ts +++ b/src/hooks/shared/autoupdate.ts @@ -128,6 +128,8 @@ const defaultSpawn = (cmd: string, args: string[]): { pid?: number } => { const child = spawn(cmd, args, { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, }); child.unref(); // Swallow the unhandled 'error' event that fires synchronously when diff --git a/src/hooks/wiki-worker-spawn.ts b/src/hooks/wiki-worker-spawn.ts index 7d5a6f20..79f374ce 100644 --- a/src/hooks/wiki-worker-spawn.ts +++ b/src/hooks/wiki-worker-spawn.ts @@ -1,5 +1,5 @@ import type { ExecFileSyncOptions } from "node:child_process"; -import { binNeedsShell } from "../utils/resolve-cli-bin.js"; +import { binNeedsShell, shellFile } from "../utils/resolve-cli-bin.js"; /** Fixed flags for the summary-generation `claude -p` call (no user input). */ const CLAUDE_FLAGS = [ @@ -33,7 +33,7 @@ export interface ClaudeInvocation { export function buildClaudeInvocation(claudeBin: string, prompt: string): ClaudeInvocation { if (binNeedsShell(claudeBin)) { return { - file: claudeBin, + file: shellFile(claudeBin), args: ["-p", ...CLAUDE_FLAGS], // windowsHide: the wiki worker is a detached, console-less process, so // without CREATE_NO_WINDOW Windows allocates a visible console window @@ -64,7 +64,7 @@ export function buildClaudeInvocation(claudeBin: string, prompt: string): Claude export function buildTrailingPromptInvocation(bin: string, flags: string[], prompt: string): ClaudeInvocation { if (binNeedsShell(bin)) { return { - file: bin, + file: shellFile(bin), args: [...flags], // windowsHide: see buildClaudeInvocation — suppress the visible console // window Windows would pop for a child of the console-less worker. @@ -87,7 +87,7 @@ export function buildTrailingPromptInvocation(bin: string, flags: string[], prom */ export function buildStdinPromptInvocation(bin: string, flags: string[], prompt: string): ClaudeInvocation { return { - file: bin, + file: shellFile(bin), args: [...flags], options: { input: prompt, diff --git a/src/skillify/skillopt-trigger.ts b/src/skillify/skillopt-trigger.ts index 16525cc9..e1e16022 100644 --- a/src/skillify/skillopt-trigger.ts +++ b/src/skillify/skillopt-trigger.ts @@ -155,6 +155,8 @@ function spawnWorker(sessionId: string, skill: string, reaction: string, toolUse const child = spawn(process.execPath, [entry], { detached: true, stdio: "ignore", + // SW_HIDE: libuv applies it alongside detached. No-op on POSIX. + windowsHide: true, env: { ...process.env, [SKILLOPT_ENV.WORKER]: "1", diff --git a/src/skillify/spawn-backfill-memory-worker.ts b/src/skillify/spawn-backfill-memory-worker.ts index 0c3c6582..a709f799 100644 --- a/src/skillify/spawn-backfill-memory-worker.ts +++ b/src/skillify/spawn-backfill-memory-worker.ts @@ -50,6 +50,9 @@ function realSpawn(): boolean { const child = spawn(cmd, cmdArgs as string[], { detached: true, stdio: ["ignore", out, out], + // SW_HIDE: libuv still applies it alongside detached, so the backfill + // worker never flashes a console. No-op on POSIX. + windowsHide: true, // Mark the spawned process as the lock owner so it (and only it) releases // the lock on exit — a manual `hivemind memory backfill` won't clear it. env: { ...process.env, HIVEMIND_BACKFILL_LOCK_OWNED: "1" }, diff --git a/src/skillify/spawn-mine-local-worker.ts b/src/skillify/spawn-mine-local-worker.ts index 7ecf796e..a9a9419f 100644 --- a/src/skillify/spawn-mine-local-worker.ts +++ b/src/skillify/spawn-mine-local-worker.ts @@ -85,6 +85,9 @@ export function findHivemindLauncher(): HivemindLauncher | null { const out = execFileSync(lookup, ["hivemind"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], + // CREATE_NO_WINDOW: same reason as resolveCliBin — this runs from a + // detached worker with no console to inherit. No-op on POSIX. + windowsHide: true, }); const bin = out.trim(); return bin ? { kind: "bin", path: bin } : null; @@ -192,6 +195,9 @@ export function maybeAutoMineLocal(opts: AutoMineOptions = {}): AutoMineGuardRep const child = spawn(cmd, args, { detached: true, stdio: ["ignore", out, out], + // SW_HIDE: libuv still applies it alongside detached, so the mining + // worker never flashes a console. No-op on POSIX. + windowsHide: true, env: process.env, }); closeSync(out); diff --git a/src/utils/resolve-cli-bin.ts b/src/utils/resolve-cli-bin.ts index 16c6fea0..29d73ac6 100644 --- a/src/utils/resolve-cli-bin.ts +++ b/src/utils/resolve-cli-bin.ts @@ -29,7 +29,13 @@ import { join } from "node:path"; export function resolveCliBin(cli: string, fallback?: string): string { const isWin = process.platform === "win32"; try { - const out = execFileSync(isWin ? "where" : "which", [cli], { encoding: "utf-8" }); + const out = execFileSync(isWin ? "where" : "which", [cli], { + encoding: "utf-8", + // CREATE_NO_WINDOW. Reached from detached background workers, which + // have no console to inherit, so where.exe would otherwise allocate a + // visible one. No-op on POSIX. + windowsHide: true, + }); const matches = out .split(/\r?\n/) .map((line) => line.trim()) @@ -59,3 +65,17 @@ export function resolveCliBin(cli: string, fallback?: string): string { export function binNeedsShell(bin: string): boolean { return process.platform === "win32" && /\.(cmd|bat)$/i.test(bin); } + +/** + * The `file` to hand a shell-mode spawn. + * + * Under `shell: true` Node concatenates file + args into a single command + * string with no escaping, so an unquoted path containing a space — + * `C:\Users\Jane Doe\AppData\Roaming\npm\claude.cmd`, the default npm + * global bin for any Windows account with a space in its name — is parsed as + * two tokens and the spawn fails. Quote it. Non-shell spawns pass argv + * directly and must NOT be quoted. + */ +export function shellFile(bin: string): string { + return binNeedsShell(bin) ? `"${bin}"` : bin; +} diff --git a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts index 3159c3c9..b226238b 100644 --- a/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts +++ b/tests/claude-code/inner-cli-spawn-windowshide-source.test.ts @@ -37,6 +37,23 @@ describe("inner CLI spawn windowsHide — source guards", () => { expect(src("src/hooks/commit-kpi-extract.ts")).toMatch(/spawn\(\s*cli\.bin[^)]*windowsHide:\s*true/); }); + // The helper LOOKUPS, not the CLI spawns. These run `where.exe` on Windows + // on the way to launching a detached worker, so without CREATE_NO_WINDOW + // each one allocates its own visible window — the same flash the CLI spawns + // produced, one layer earlier. (resolveCliBin is also called from inside + // already-detached workers, which have no console to inherit at all.) + it("resolveCliBin's where/which lookup passes windowsHide", () => { + expect(src("src/utils/resolve-cli-bin.ts")).toMatch( + /execFileSync\(isWin \? "where" : "which"[^)]*windowsHide:\s*true/, + ); + }); + + it("the mine-local worker's hivemind lookup passes windowsHide", () => { + expect(src("src/skillify/spawn-mine-local-worker.ts")).toMatch( + /execFileSync\(lookup[^)]*windowsHide:\s*true/, + ); + }); + it("stage-memory threads windowsHide from the invocation into the spawn plan and spawn call", () => { const s = src("src/skillify/stage-memory.ts"); // plan carries it through from the builder's options... @@ -45,3 +62,107 @@ describe("inner CLI spawn windowsHide — source guards", () => { expect(s).toMatch(/spawn\(\s*plan\.file[^)]*windowsHide:\s*plan\.windowsHide/); }); }); + +/** + * The detached WORKER launches themselves. `detached: true` maps to + * DETACHED_PROCESS, which makes Windows ignore CREATE_NO_WINDOW — but libuv + * sets SW_HIDE from `windowsHide` as well, and that still applies, so the + * option is not a no-op on these. `spawn-detached.ts` has always paired the + * two; these are the launches that were missing it. + */ +describe("detached worker spawn windowsHide — source guards", () => { + it("mine-local worker launch passes windowsHide", () => { + expect(src("src/skillify/spawn-mine-local-worker.ts")).toMatch(/spawn\(cmd,\s*args[^)]*windowsHide:\s*true/); + }); + + it("backfill-memory worker launch passes windowsHide", () => { + expect(src("src/skillify/spawn-backfill-memory-worker.ts")).toMatch(/spawn\(cmd,\s*cmdArgs[^)]*windowsHide:\s*true/); + }); + + it("the auto-spawned embedding daemon passes windowsHide", () => { + expect(src("src/embeddings/client.ts")).toMatch(/spawn\(process\.execPath,\s*\[this\.daemonEntry\][^)]*windowsHide:\s*true/); + }); + + it("pi's auto-mine launcher lookup and worker launch both pass windowsHide", () => { + const pi = src("harnesses/pi/extension-source/hivemind.ts"); + expect(pi).toMatch(/execFileSync\(isWin \? "where" : "which",\s*\["hivemind"\][^)]*windowsHide:\s*true/); + expect(pi).toMatch(/spawn\(shellCmd,\s*args[^)]*windowsHide:\s*true/); + }); + + it("openclaw's agent lookup and skillify worker launch both pass windowsHide", () => { + const oc = src("harnesses/openclaw/src/index.ts"); + expect(oc).toMatch(/realExecFileSync\(lookup,\s*\[bin\][^)]*windowsHide:\s*true/); + expect(oc).toMatch(/realSpawn\(process\.execPath,\s*\[OPENCLAW_SKILLIFY_WORKER_PATH[^)]*windowsHide:\s*true/); + }); +}); + +/** + * Hook-triggered detached launches. These fire during an ordinary session + * (session start, stop, skill reactions, embedding warm-up), so a missing + * SW_HIDE here is the same user-visible flash #331 reported, from a + * different worker. + */ +describe("hook-triggered detached launch windowsHide — source guards", () => { + const CASES: Array<[string, string, RegExp]> = [ + ["shared skillopt worker", "src/skillify/skillopt-trigger.ts", /spawn\(process\.execPath,\s*\[entry\][^)]*windowsHide:\s*true/], + ["standalone embedding daemon", "src/embeddings/standalone-embed-client.ts", /_spawn\(process\.execPath,\s*\[daemonEntry\][^)]*windowsHide:\s*true/], + ["codex session-start setup", "src/hooks/codex/session-start.ts", /spawn\("node",\s*\[setupScript\][^)]*windowsHide:\s*true/], + ["shared autoupdate", "src/hooks/shared/autoupdate.ts", /spawn\(cmd,\s*args[^)]*windowsHide:\s*true/], + ]; + for (const [name, rel, re] of CASES) { + it(`${name} passes windowsHide`, () => { + expect(src(rel)).toMatch(re); + }); + } + + it("openclaw's graph build and pull workers both pass windowsHide", () => { + const oc = src("harnesses/openclaw/src/graph-lifecycle.ts"); + expect(oc).toMatch(/sp\(process\.execPath,\s*\[workerPath\][^)]*windowsHide:\s*true/); + // nohup is POSIX-only: on Windows it ENOENT'd, so the pull worker never + // ran there and windowsHide was moot. Must spawn node directly. + expect(oc).not.toContain('"nohup"'); + expect(oc).toMatch(/sp\(process\.execPath,\s*\[workerPath,\s*"--cwd",\s*cwd\][^)]*windowsHide:\s*true/); + }); + + it("pi's four detached launches all pass windowsHide", () => { + const pi = src("harnesses/pi/extension-source/hivemind.ts"); + // embedding daemon, skillopt worker, wiki worker, skillify worker + expect(pi).toMatch(/spawn\(process\.execPath,\s*\[EMBED_DAEMON_ENTRY\][^)]*windowsHide:\s*true/); + expect(pi).toMatch(/spawn\(process\.execPath,\s*\[PI_SKILLOPT_WORKER_PATH\][^)]*windowsHide:\s*true/); + expect(pi).toMatch(/spawn\(process\.execPath,\s*\[PI_WIKI_WORKER_PATH,\s*configPath\][^)]*windowsHide:\s*true/); + expect(pi).toMatch(/spawn\(process\.execPath,\s*\[PI_SKILLIFY_WORKER_PATH,\s*configPath\][^)]*windowsHide:\s*true/); + }); +}); + +/** + * The launcher lookups hardcoded Unix `which`, so on Windows they threw on + * every call — pi's auto-mine fallback and openclaw's agent detection were + * not merely noisy there, they never resolved a binary at all. + */ +describe("platform-correct binary lookups — source guards", () => { + it("pi mirrors resolve-cli-bin's .exe -> .cmd/.bat selection on Windows", () => { + const pi = src("harnesses/pi/extension-source/hivemind.ts"); + expect(pi).toContain('const isWin = process.platform === "win32";'); + expect(pi).toMatch(/execFileSync\(isWin \? "where" : "which",\s*\["hivemind"\]/); + // an extensionless shim is not runnable on Windows, so .exe wins, then .cmd/.bat + expect(pi).toMatch(/find\(\(m\) => m\.toLowerCase\(\)\.endsWith\("\.exe"\)\)/); + expect(pi).toMatch(/find\(\(m\) => \/\\\.\(cmd\|bat\)\$\/i\.test\(m\)\)/); + }); + + it("pi shells a .cmd/.bat launcher, win32-gated and quoted", () => { + const pi = src("harnesses/pi/extension-source/hivemind.ts"); + // win32-gated, mirroring binNeedsShell: a POSIX file merely named *.cmd + // must still spawn directly + expect(pi).toMatch(/const needsShell = process\.platform === "win32" && \/\\\.\(cmd\|bat\)\$\/i\.test\(cmd\)/); + expect(pi).toMatch(/\.\.\.\(needsShell \? \{ shell: true \} : \{\}\)/); + // quoted: shell:true concatenates without escaping, so a path containing + // a space would otherwise be parsed as two tokens + expect(pi).toMatch(/const shellCmd = needsShell \? `"\$\{cmd\}"` : cmd;/); + }); + + it("openclaw selects where/which by platform", () => { + const oc = src("harnesses/openclaw/src/index.ts"); + expect(oc).toContain('const lookup = process.platform === "win32" ? "where" : "which";'); + expect(oc).toMatch(/realExecFileSync\(lookup,\s*\[bin\]/); + }); +}); diff --git a/tests/claude-code/spawn-wiki-worker.test.ts b/tests/claude-code/spawn-wiki-worker.test.ts index 63e2282b..7857c0d5 100644 --- a/tests/claude-code/spawn-wiki-worker.test.ts +++ b/tests/claude-code/spawn-wiki-worker.test.ts @@ -325,13 +325,13 @@ describe("per-agent bin resolvers", () => { it.each(RESOLVERS)("find%sBin returns the resolved path when the lookup succeeds", (_n, fn, _fallback, cli) => { vi.mocked(execFileSync).mockReturnValueOnce("/usr/local/bin/the-cli\n"); expect(fn()).toBe("/usr/local/bin/the-cli"); - expect(execFileSync).toHaveBeenCalledWith(lookupCmd, [cli], { encoding: "utf-8" }); + expect(execFileSync).toHaveBeenCalledWith(lookupCmd, [cli], { encoding: "utf-8", windowsHide: true }); }); it.each(RESOLVERS)("find%sBin falls back to the literal name when the lookup fails", (_n, fn, fallback, cli) => { vi.mocked(execFileSync).mockImplementationOnce(() => { throw new Error("not found"); }); expect(fn()).toBe(fallback); - expect(execFileSync).toHaveBeenCalledWith(lookupCmd, [cli], { encoding: "utf-8" }); + expect(execFileSync).toHaveBeenCalledWith(lookupCmd, [cli], { encoding: "utf-8", windowsHide: true }); }); it.each(RESOLVERS)( diff --git a/tests/claude-code/wiki-worker-windows.test.ts b/tests/claude-code/wiki-worker-windows.test.ts index 90219e53..a6d297e9 100644 --- a/tests/claude-code/wiki-worker-windows.test.ts +++ b/tests/claude-code/wiki-worker-windows.test.ts @@ -28,7 +28,7 @@ vi.mock("node:os", async () => { return { ...actual, homedir: () => "/home/tester" }; }); -import { resolveCliBin, binNeedsShell } from "../../src/utils/resolve-cli-bin.js"; +import { resolveCliBin, binNeedsShell, shellFile } from "../../src/utils/resolve-cli-bin.js"; import { buildClaudeInvocation, buildTrailingPromptInvocation, buildStdinPromptInvocation, buildClaudeStdinInvocation } from "../../src/hooks/wiki-worker-spawn.js"; const realPlatform = process.platform; @@ -54,7 +54,7 @@ describe("resolveCliBin — Windows", () => { setPlatform("win32"); execFileSyncMock.mockReturnValue("C:\\npm\\claude.cmd\r\n"); resolveCliBin("claude"); - expect(execFileSyncMock).toHaveBeenCalledWith("where", ["claude"], { encoding: "utf-8" }); + expect(execFileSyncMock).toHaveBeenCalledWith("where", ["claude"], { encoding: "utf-8", windowsHide: true }); }); it("prefers a .exe over a .cmd shim when both are on PATH", () => { @@ -97,7 +97,8 @@ describe("resolveCliBin — Unix (unchanged behavior)", () => { setPlatform("linux"); execFileSyncMock.mockReturnValue("/usr/local/bin/claude\n"); expect(resolveCliBin("claude")).toBe("/usr/local/bin/claude"); - expect(execFileSyncMock).toHaveBeenCalledWith("which", ["claude"], { encoding: "utf-8" }); + // windowsHide is a no-op on POSIX but the option object is platform-agnostic. + expect(execFileSyncMock).toHaveBeenCalledWith("which", ["claude"], { encoding: "utf-8", windowsHide: true }); }); it("falls back to an extensionless ~/.claude/local/ when not found", () => { @@ -139,7 +140,9 @@ describe("buildClaudeInvocation", () => { it("Windows .cmd: spawns through a shell with the prompt over stdin, never on the command line", () => { setPlatform("win32"); const inv = buildClaudeInvocation("C:\\npm\\claude.cmd", "PROMPT-TEXT"); - expect(inv.file).toBe("C:\\npm\\claude.cmd"); + // quoted: under shell:true Node concatenates without escaping, so the + // path must carry its own quotes (see the spaced-path describe below) + expect(inv.file).toBe('"C:\\npm\\claude.cmd"'); expect(inv.options.shell).toBe(true); expect(inv.options.input).toBe("PROMPT-TEXT"); expect(inv.args).toEqual(["-p", ...CLAUDE_FLAGS]); @@ -169,7 +172,7 @@ describe("buildTrailingPromptInvocation (codex / cursor / pi)", () => { it("Windows .cmd: shell + prompt over stdin; flags only on the command line", () => { setPlatform("win32"); const inv = buildTrailingPromptInvocation("C:\\npm\\codex.cmd", FLAGS, "PROMPT-TEXT"); - expect(inv.file).toBe("C:\\npm\\codex.cmd"); + expect(inv.file).toBe('"C:\\npm\\codex.cmd"'); expect(inv.options.shell).toBe(true); expect(inv.options.input).toBe("PROMPT-TEXT"); expect(inv.args).toEqual(FLAGS); @@ -230,3 +233,59 @@ describe("windowsHide — no visible console window for the summarizer CLI", () expect(buildClaudeStdinInvocation("/usr/local/bin/claude", "P").options.windowsHide).toBe(true); }); }); + +/** + * Behavioral cover for the spaced-path bug. `shell: true` makes Node + * concatenate file + args into one command string with NO escaping, so an + * unquoted path with a space is parsed as two tokens and the spawn fails. + * + * This is the default npm layout for any Windows account whose name contains + * a space — `C:\Users\Jane Doe\AppData\Roaming\npm\claude.cmd` — and npm + * ships no .exe, so those users always take the shell branch. A failing + * summary run is what drives the #331 respawn loop, so this path matters. + */ +describe("shell-mode spawns quote a shim path containing spaces", () => { + const SPACED = "C:\\Users\\Jane Doe\\AppData\\Roaming\\npm\\claude.cmd"; + + it("shellFile quotes a Windows shim and leaves everything else alone", () => { + setPlatform("win32"); + expect(shellFile(SPACED)).toBe(`"${SPACED}"`); + expect(shellFile("C:\\x\\claude.exe")).toBe("C:\\x\\claude.exe"); + setPlatform("linux"); + // a POSIX file merely named *.cmd is spawned directly — quoting it would + // make the path itself wrong + expect(shellFile("/usr/bin/weird.cmd")).toBe("/usr/bin/weird.cmd"); + }); + + it("buildClaudeInvocation quotes the spaced shim and keeps the prompt off argv", () => { + setPlatform("win32"); + const inv = buildClaudeInvocation(SPACED, "PROMPT"); + expect(inv.file).toBe(`"${SPACED}"`); + expect(inv.options.shell).toBe(true); + expect(inv.options.input).toBe("PROMPT"); + expect(inv.args).not.toContain("PROMPT"); + }); + + it("buildTrailingPromptInvocation quotes the spaced shim", () => { + setPlatform("win32"); + const inv = buildTrailingPromptInvocation(SPACED, ["exec"], "PROMPT"); + expect(inv.file).toBe(`"${SPACED}"`); + expect(inv.options.shell).toBe(true); + }); + + it("buildStdinPromptInvocation quotes the spaced shim", () => { + setPlatform("win32"); + const inv = buildStdinPromptInvocation(SPACED, ["-p"], "PROMPT"); + expect(inv.file).toBe(`"${SPACED}"`); + expect(inv.options.shell).toBe(true); + }); + + it("does NOT quote on the non-shell path, where argv is passed directly", () => { + setPlatform("win32"); + const exe = "C:\\Program Files\\claude\\claude.exe"; + const inv = buildClaudeInvocation(exe, "PROMPT"); + // no shell -> argv, so a quoted path would be a literally wrong filename + expect(inv.file).toBe(exe); + expect(inv.options.shell).toBeUndefined(); + }); +}); diff --git a/tests/openclaw/graph-lifecycle.test.ts b/tests/openclaw/graph-lifecycle.test.ts index 926747f2..db6b3c57 100644 --- a/tests/openclaw/graph-lifecycle.test.ts +++ b/tests/openclaw/graph-lifecycle.test.ts @@ -55,10 +55,13 @@ describe("openclaw graph-lifecycle", () => { spawnOpenclawGraphPullWorker("/dist/graph-pull-worker.js", "/my/repo", { spawn, exists }); spawnOpenclawGraphPullWorker("/dist/graph-pull-worker.js", "/my/repo", { spawn, exists }); expect(spawn).toHaveBeenCalledTimes(1); + // node directly, not `nohup` — nohup is POSIX-only and ENOENT'd on + // Windows, so the pull worker never ran there. detached + unref is what + // provides survival, matching graph-on-stop. expect(spawn).toHaveBeenCalledWith( - "nohup", - ["node", "/dist/graph-pull-worker.js", "--cwd", "/my/repo"], - expect.objectContaining({ detached: true }), + process.execPath, + ["/dist/graph-pull-worker.js", "--cwd", "/my/repo"], + expect.objectContaining({ detached: true, windowsHide: true }), ); });