diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index e58c64502..9667e9cf5 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -637,6 +637,12 @@ function summarizeProbeError(body, status) { return `HTTP ${status}: ${compact.slice(0, 200)}`; } +/** True when spawnSync killed the child due to its timeout option. */ +function isSpawnTimeout(result) { + return result.error?.code === "ETIMEDOUT" || + (result.status == null && result.signal === "SIGTERM"); +} + function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { const probes = [ { @@ -678,11 +684,16 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey) { const result = spawnSync("bash", ["-c", cmd], { cwd: ROOT, encoding: "utf8", + timeout: 30_000, env: { ...process.env, NEMOCLAW_PROBE_API_KEY: apiKey, }, }); + if (isSpawnTimeout(result)) { + failures.push({ name: probe.name, httpStatus: 0, curlStatus: 1, message: "timed out" }); + continue; + } const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; const status = Number(String(result.stdout || "").trim()); if (result.status === 0 && status >= 200 && status < 300) { @@ -727,11 +738,15 @@ function probeAnthropicEndpoint(endpointUrl, model, apiKey) { const result = spawnSync("bash", ["-c", cmd], { cwd: ROOT, encoding: "utf8", + timeout: 30_000, env: { ...process.env, NEMOCLAW_PROBE_API_KEY: apiKey, }, }); + if (isSpawnTimeout(result)) { + return { ok: false, message: "timed out", failures: [{ name: "Anthropic Messages API", httpStatus: 0, curlStatus: 1, message: "timed out" }] }; + } const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; const status = Number(String(result.stdout || "").trim()); if (result.status === 0 && status >= 200 && status < 300) { @@ -867,11 +882,15 @@ function fetchNvidiaEndpointModels(apiKey) { const result = spawnSync("bash", ["-c", cmd], { cwd: ROOT, encoding: "utf8", + timeout: 30_000, env: { ...process.env, NEMOCLAW_PROBE_API_KEY: apiKey, }, }); + if (isSpawnTimeout(result)) { + return { ok: false, message: "timed out" }; + } const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; const status = Number(String(result.stdout || "").trim()); if (result.status !== 0 || !(status >= 200 && status < 300)) { @@ -920,11 +939,15 @@ function fetchOpenAiLikeModels(endpointUrl, apiKey) { const result = spawnSync("bash", ["-c", cmd], { cwd: ROOT, encoding: "utf8", + timeout: 30_000, env: { ...process.env, NEMOCLAW_PROBE_API_KEY: apiKey, }, }); + if (isSpawnTimeout(result)) { + return { ok: false, status: 0, message: "timed out" }; + } const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; const status = Number(String(result.stdout || "").trim()); if (result.status !== 0 || !(status >= 200 && status < 300)) { @@ -957,11 +980,15 @@ function fetchAnthropicModels(endpointUrl, apiKey) { const result = spawnSync("bash", ["-c", cmd], { cwd: ROOT, encoding: "utf8", + timeout: 30_000, env: { ...process.env, NEMOCLAW_PROBE_API_KEY: apiKey, }, }); + if (isSpawnTimeout(result)) { + return { ok: false, status: 0, message: "timed out" }; + } const body = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : ""; const status = Number(String(result.stdout || "").trim()); if (result.status !== 0 || !(status >= 200 && status < 300)) { @@ -1223,6 +1250,7 @@ function pullOllamaModel(model) { cwd: ROOT, encoding: "utf8", stdio: "inherit", + timeout: 300_000, env: { ...process.env }, }); return result.status === 0; @@ -1358,6 +1386,7 @@ function installOpenshell() { env: process.env, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", + timeout: 120_000, }); if (result.status !== 0) { const output = `${result.stdout || ""}${result.stderr || ""}`.trim(); diff --git a/test/onboard.test.js b/test/onboard.test.js index 16b7e5453..79f970ab5 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1331,3 +1331,48 @@ const { setupInference } = require(${onboardPath}); }); }); + +describe("spawnSync timeout safety", () => { + const onboardSrc = fs.readFileSync( + path.join(import.meta.dirname, "..", "bin", "lib", "onboard.js"), + "utf8" + ); + const lines = onboardSrc.split("\n"); + + // Find every line that starts a spawnSync call, then scan ahead for the + // closing options object. We collect the text from the spawnSync line + // through the next line containing only "});", which is the end of the + // options object in the codebase's formatting. + function collectSpawnBlocks() { + const blocks = []; + for (let i = 0; i < lines.length; i++) { + if (/spawnSync\(/.test(lines[i])) { + let block = lines[i]; + for (let j = i + 1; j < lines.length; j++) { + block += "\n" + lines[j]; + if (/^\s*\);/.test(lines[j]) || /^\s*\}\);/.test(lines[j])) break; + } + blocks.push({ line: i + 1, text: block }); + } + } + return blocks; + } + + const spawnBlocks = collectSpawnBlocks(); + + it("has non-sleep spawnSync calls to test", () => { + const actionable = spawnBlocks.filter(({ text }) => !/spawnSync\("sleep"/.test(text)); + expect(actionable.length).toBeGreaterThan(0); + }); + + it("every spawnSync call that runs an external command includes a timeout", () => { + const missing = []; + for (const { line, text } of spawnBlocks) { + if (/spawnSync\("sleep"/.test(text)) continue; + if (!text.includes("timeout")) { + missing.push(`line ${line}: ${text.split("\n")[0].trim()}`); + } + } + expect(missing, `spawnSync calls missing timeout:\n${missing.join("\n")}`).toHaveLength(0); + }); +});