Skip to content
10 changes: 9 additions & 1 deletion harnesses/openclaw/src/graph-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ });
Expand All @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion harnesses/openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ }
}
Expand Down Expand Up @@ -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;
Expand Down
38 changes: 35 additions & 3 deletions harnesses/pi/extension-source/hivemind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,8 @@
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}`);
Expand Down Expand Up @@ -633,6 +635,8 @@
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)
Expand Down Expand Up @@ -838,6 +842,8 @@
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) {
Expand Down Expand Up @@ -928,6 +934,8 @@
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) {
Expand Down Expand Up @@ -1126,8 +1134,18 @@
} 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; }
}
Expand All @@ -1147,9 +1165,23 @@
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);
Expand Down Expand Up @@ -1179,7 +1211,7 @@
{ cmd: "hivemind skillify pull --dry-run", desc: "preview without touching disk" },
{ cmd: "hivemind skillify pull --force", desc: "overwrite local files even if up-to-date (creates .bak)" },
{ cmd: "hivemind skillify pull <skill-name>", desc: "pull only that one skill (combines with --user)" },
{ cmd: "hivemind skillify push <skill-name>", desc: "upload a local skill to the org table (inverse of pull)" },

Check failure

Code scanning / CodeQL

Potential file system race condition High

The file may have changed since it
was checked
.
{ cmd: "hivemind skillify push --from <project|global>", desc: "which local skills dir to read (default: project)" },
{ cmd: "hivemind skillify push --dry-run", desc: "preview without writing to the org table" },
{ cmd: "hivemind skillify unpull", desc: "remove every skill previously installed by pull" },
Expand Down
4 changes: 4 additions & 0 deletions src/embeddings/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/embeddings/standalone-embed-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/codex/session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ async function main(): Promise<void> {
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
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/shared/autoupdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/hooks/wiki-worker-spawn.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/skillify/skillopt-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/skillify/spawn-backfill-memory-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
6 changes: 6 additions & 0 deletions src/skillify/spawn-mine-local-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 21 additions & 1 deletion src/utils/resolve-cli-bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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;
}
Loading