From dcba17d0193963893f6a50862112455291a2f374 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 7 Jun 2026 11:28:21 +0200 Subject: [PATCH] fix(e2e): stream installer session scans --- scripts/docker/install-sh-e2e/run.sh | 137 ++++++++++++++++++++--- test/scripts/docker-build-helper.test.ts | 20 ++++ 2 files changed, 143 insertions(+), 14 deletions(-) diff --git a/scripts/docker/install-sh-e2e/run.sh b/scripts/docker/install-sh-e2e/run.sh index 828ac17c0b7b..f8a7b72bcfe8 100755 --- a/scripts/docker/install-sh-e2e/run.sh +++ b/scripts/docker/install-sh-e2e/run.sh @@ -496,9 +496,11 @@ const fs = require("node:fs"); const jsonl = process.argv[2]; const required = new Set(process.argv.slice(3)); -const raw = fs.readFileSync(jsonl, "utf8"); -const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean); const seen = new Set(); +const head = []; +let scannedBytes = 0; +let truncated = false; +let skippedOversizedLines = 0; const toolTypes = new Set([ "tool_use", @@ -511,10 +513,30 @@ const toolTypes = new Set([ "toolresult", "tool-result", ]); -function walk(node, parent) { +function readPositiveIntEnv(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + if (!/^[1-9][0-9]*$/.test(raw)) { + console.error(`${name} must be a positive integer`); + process.exit(2); + } + return Number(raw); +} +const maxBytes = readPositiveIntEnv("OPENCLAW_INSTALL_E2E_SESSION_SCAN_BYTES", 16 * 1024 * 1024); +const maxLineBytes = readPositiveIntEnv("OPENCLAW_INSTALL_E2E_SESSION_LINE_BYTES", 1024 * 1024); +const maxDepth = readPositiveIntEnv("OPENCLAW_INSTALL_E2E_SESSION_SCAN_DEPTH", 64); +const maxNodes = readPositiveIntEnv("OPENCLAW_INSTALL_E2E_SESSION_SCAN_NODES", 100000); + +function missingTools() { + return [...required].filter((t) => !seen.has(t)); +} + +function walk(node, depth, state) { if (!node) return; + if (depth > maxDepth || state.nodes >= maxNodes) return; + state.nodes += 1; if (Array.isArray(node)) { - for (const item of node) walk(item, node); + for (const item of node) walk(item, depth + 1, state); return; } if (typeof node !== "object") return; @@ -542,26 +564,113 @@ function walk(node, parent) { } } if (obj.function && typeof obj.function.name === "string") seen.add(obj.function.name); - for (const v of Object.values(obj)) walk(v, obj); + for (const v of Object.values(obj)) walk(v, depth + 1, state); } -for (const line of lines) { +function processLine(lineBuffer) { + const line = lineBuffer.toString("utf8").trim(); + if (!line) return; + if (head.length < 5) head.push(line.slice(0, maxLineBytes)); try { const entry = JSON.parse(line); - walk(entry, null); + walk(entry, 0, { nodes: 0 }); } catch { // ignore unparsable lines } } -const missing = [...required].filter((t) => !seen.has(t)); -if (missing.length > 0) { - console.error(`Missing tools in transcript: ${missing.join(", ")}`); - console.error(`Seen tools: ${[...seen].sort().join(", ")}`); - console.error("Transcript head:"); - console.error(lines.slice(0, 5).join("\n")); - process.exit(1); +async function scan() { + const stream = fs.createReadStream(jsonl, { highWaterMark: 64 * 1024 }); + let lineParts = []; + let lineBytes = 0; + let skippingLine = false; + + function resetLine() { + lineParts = []; + lineBytes = 0; + skippingLine = false; + } + + function appendLineChunk(chunk) { + if (skippingLine) return; + if (lineBytes + chunk.length > maxLineBytes) { + skippedOversizedLines += 1; + lineParts = []; + lineBytes = 0; + skippingLine = true; + return; + } + lineParts.push(chunk); + lineBytes += chunk.length; + } + + function finishLine() { + if (!skippingLine && lineBytes > 0) { + processLine(Buffer.concat(lineParts, lineBytes)); + } + resetLine(); + } + + for await (const rawChunk of stream) { + let chunk = rawChunk; + let stopAfterChunk = false; + if (scannedBytes + rawChunk.length > maxBytes) { + const remaining = Math.max(0, maxBytes - scannedBytes); + chunk = rawChunk.subarray(0, remaining); + truncated = true; + stopAfterChunk = true; + } + scannedBytes += chunk.length; + let offset = 0; + while (offset < chunk.length) { + const newline = chunk.indexOf(10, offset); + const end = newline === -1 ? chunk.length : newline; + if (end > offset) { + appendLineChunk(chunk.subarray(offset, end)); + } + if (newline === -1) { + break; + } + finishLine(); + if (missingTools().length === 0) { + stream.destroy(); + return; + } + offset = newline + 1; + } + if (stopAfterChunk) { + stream.destroy(); + break; + } + } + if (!truncated && lineBytes > 0) { + finishLine(); + } } + +scan() + .then(() => { + const missing = missingTools(); + if (missing.length > 0) { + console.error(`Missing tools in transcript: ${missing.join(", ")}`); + console.error(`Seen tools: ${[...seen].sort().join(", ")}`); + if (truncated) { + console.error(`Transcript scan stopped after ${maxBytes} bytes`); + } + if (skippedOversizedLines > 0) { + console.error(`Skipped ${skippedOversizedLines} oversized transcript line(s)`); + } + console.error("Transcript head:"); + console.error(head.join("\n")); + process.exit(1); + } + }) + .catch((error) => { + console.error( + `Could not scan transcript ${jsonl}: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + }); NODE } diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index e5718a5da6ef..dfc818cf0624 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -2820,6 +2820,26 @@ output="$(cat "$sampler_log")" expect(runner).toContain('TURN4_SESSION_ID="${SESSION_ID_PREFIX}-image-write"'); }); + it("bounds installer E2E session transcript tool scans", () => { + const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8"); + const start = runner.indexOf("assert_session_used_tools() {"); + const end = runner.indexOf("\nsession_jsonl_path()", start); + const helper = runner.slice(start, end); + + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + expect(helper).toContain("OPENCLAW_INSTALL_E2E_SESSION_SCAN_BYTES"); + expect(helper).toContain("OPENCLAW_INSTALL_E2E_SESSION_LINE_BYTES"); + expect(helper).toContain("OPENCLAW_INSTALL_E2E_SESSION_SCAN_DEPTH"); + expect(helper).toContain("OPENCLAW_INSTALL_E2E_SESSION_SCAN_NODES"); + expect(helper).toContain("fs.createReadStream"); + expect(helper).toContain("Buffer.concat"); + expect(helper).toContain("skippedOversizedLines"); + expect(helper).not.toContain('require("node:readline")'); + expect(helper).not.toContain("fs.readFileSync"); + expect(helper).not.toContain('.split("\\n")'); + }); + it("keeps OpenAI web search smoke on one gateway agent connection", () => { const runner = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_E2E_PATH, "utf8"); const scenario = readFileSync(OPENAI_WEB_SEARCH_MINIMAL_SCENARIO_PATH, "utf8");