Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -1223,6 +1250,7 @@ function pullOllamaModel(model) {
cwd: ROOT,
encoding: "utf8",
stdio: "inherit",
timeout: 300_000,
env: { ...process.env },
});
return result.status === 0;
Expand Down Expand Up @@ -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();
Expand Down
45 changes: 45 additions & 0 deletions test/onboard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});