diff --git a/.github/qa/opencode-windows-docker-e2e/Dockerfile.windows b/.github/qa/opencode-windows-docker-e2e/Dockerfile.windows new file mode 100644 index 000000000..96930b049 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/Dockerfile.windows @@ -0,0 +1,36 @@ +# escape=` +FROM mcr.microsoft.com/windows/servercore:ltsc2022@sha256:b841bb042e13a079f68fc82461f15abcefe8063fb9a3072120348252f13a6ce3 + +SHELL ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue';"] + +RUN Invoke-WebRequest ` + -UseBasicParsing ` + -Uri 'https://nodejs.org/dist/v24.18.0/node-v24.18.0-win-x64.zip' ` + -OutFile 'C:\node.zip'; ` + if ((Get-FileHash -Algorithm SHA256 'C:\node.zip').Hash.ToLowerInvariant() -ne '0ae68406b42d7725661da979b1403ec9926da205c6770827f33aac9d8f26e821') { ` + throw 'Node archive checksum mismatch' ` + }; ` + Expand-Archive -LiteralPath 'C:\node.zip' -DestinationPath 'C:\node-dist'; ` + Move-Item -LiteralPath 'C:\node-dist\node-v24.18.0-win-x64' -Destination 'C:\node'; ` + Remove-Item -LiteralPath 'C:\node.zip', 'C:\node-dist' -Recurse -Force + +ENV PATH="C:\node;C:\Windows\System32\WindowsPowerShell\v1.0;C:\Windows\System32;C:\Windows" + +WORKDIR C:/ftqa + +COPY package.json package-lock.json ./ +RUN npm.cmd ci --omit=dev --no-audit --no-fund; ` + $version = (& 'C:\ftqa\node_modules\opencode-ai\bin\opencode.exe' --version).Trim(); ` + if ($version -ne '1.18.7') { throw "Unexpected OpenCode version: $version" } + +COPY qa-provider.mjs ./ +COPY project ./project +COPY windows ./windows +COPY Dockerfile.windows compose.windows.yaml ./ + +RUN node.exe --check C:\ftqa\qa-provider.mjs; ` + node.exe --check C:\ftqa\windows\job-launch-wrapper.mjs; ` + node.exe --check C:\ftqa\windows\windows-harness.mjs; ` + node.exe C:\ftqa\windows\static-check.mjs + +ENTRYPOINT ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", "C:\\ftqa\\windows\\job-supervisor.ps1"] diff --git a/.github/qa/opencode-windows-docker-e2e/README.md b/.github/qa/opencode-windows-docker-e2e/README.md new file mode 100644 index 000000000..4cf550d1d --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/README.md @@ -0,0 +1,38 @@ +# One-time OpenCode Windows Docker E2E + +This branch-scoped harness exercises OpenCode 1.18.7 on a real Windows +container kernel. It is test infrastructure only and does not modify First +Tree product code. + +The Windows Server Core LTSC 2022 manifest, Node 24.18.0 archive, npm package, +and Windows native OpenCode packages are pinned and verified. The runtime +performs a serial database readiness gate, deterministic concurrent new and +resume turns in separate workdirs, stdin-only prompts, explicit agent/model +selection, JSONL terminal validation, and real shell-tool execution against a +local OpenAI-compatible QA provider. + +All runtime OpenCode invocations, including the runtime version probe, are +admitted through a wrapper assigned to a supervisor-owned nested Job Object +before execution. The Docker build-time version gate only validates the pinned +artifact and version; it is not a runtime invocation or Job-admission proof. +The final tool creates a detached child. The harness stops the OpenCode root, +confirms the same child remains a Job member, invokes `TerminateJobObject`, and +requires two empty process-list snapshots 500 ms apart. + +Run on a Windows Docker engine: + +```powershell +.\windows\run-windows.ps1 +``` + +The entrypoint writes machine-readable runtime, runner/engine identity, and +cleanup receipts. It removes only project containers, networks, and the local +service image; it does not prune the cached LTSC base image. The one-time +workflow uploads only an allowlisted evidence set, with provider request +bodies removed. + +Static inspection on any host with Node: + +```sh +node windows/static-check.mjs +``` diff --git a/.github/qa/opencode-windows-docker-e2e/compose.windows.yaml b/.github/qa/opencode-windows-docker-e2e/compose.windows.yaml new file mode 100644 index 000000000..b5d06f507 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/compose.windows.yaml @@ -0,0 +1,12 @@ +services: + windows-job: + build: + context: . + dockerfile: Dockerfile.windows + isolation: ${FTQA_WINDOWS_ISOLATION:-process} + environment: + FTQA_EVIDENCE_DIR: 'C:\artifacts' + volumes: + - type: bind + source: ${FTQA_EVIDENCE_DIR:?set FTQA_EVIDENCE_DIR to an absolute run-local Windows path} + target: 'C:\artifacts' diff --git a/.github/qa/opencode-windows-docker-e2e/package-lock.json b/.github/qa/opencode-windows-docker-e2e/package-lock.json new file mode 100644 index 000000000..c63b588a0 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/package-lock.json @@ -0,0 +1,190 @@ +{ + "name": "first-tree-opencode-cross-platform-qa", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "first-tree-opencode-cross-platform-qa", + "dependencies": { + "opencode-ai": "1.18.7" + } + }, + "node_modules/opencode-ai": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.7.tgz", + "integrity": "sha512-/C4Bc+mHbTK+GvhiZ83rguwVSNBs1sCzooQ3CCOz7G8SBYqbsXajlm0OtZ7RaMEUWxHWeMvOnGi4RC4OpAnC/g==", + "cpu": [ + "arm64", + "x64" + ], + "hasInstallScript": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "bin": { + "opencode": "bin/opencode.exe" + }, + "optionalDependencies": { + "opencode-darwin-arm64": "1.18.7", + "opencode-darwin-x64": "1.18.7", + "opencode-darwin-x64-baseline": "1.18.7", + "opencode-linux-arm64": "1.18.7", + "opencode-linux-arm64-musl": "1.18.7", + "opencode-linux-x64": "1.18.7", + "opencode-linux-x64-baseline": "1.18.7", + "opencode-linux-x64-baseline-musl": "1.18.7", + "opencode-linux-x64-musl": "1.18.7", + "opencode-windows-arm64": "1.18.7", + "opencode-windows-x64": "1.18.7", + "opencode-windows-x64-baseline": "1.18.7" + } + }, + "node_modules/opencode-darwin-arm64": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.7.tgz", + "integrity": "sha512-Gt5+V3mRcRRupFrrmtFjjdu/MmMy4ZOjJwAj1q3KwWL5ZFm7KmGCdnCxi7UWmZDxhb+YWcxAFAz3eTqBfmkPaA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-darwin-x64": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.7.tgz", + "integrity": "sha512-yhu8sAL9oBymJyZtD6nA2j3PO4Cr0syp9bt4GjDneGLvyrk5pqVlCij88ozQVw55eqvxAvyNV3zy7VCkPlap6g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-darwin-x64-baseline": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.7.tgz", + "integrity": "sha512-U4tcIU0hWqe3iv7q/dGNhbGOFu/MVdf59G08rKPAkhB0jH2cfxKLnxD/eyin/T/aUYLZYQMGX8mon1jHe+ccUQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/opencode-linux-arm64": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.7.tgz", + "integrity": "sha512-2aBUMGXPmi7Rqx2IAjxopH3u+GPntyjmtte7RjvT1dbLlgnrOJbbsqEYx2lGvhnqjjcNRhX7WrX9Lzv5PzhtRg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-arm64-musl": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.7.tgz", + "integrity": "sha512-3Cxms2SNjODOu/NdLbPt0RDjz9yv2VsJVXRo6S6/3QJtCahpYbXAKJWMOCOnzk4G7wnlxALp8sFyNzqHNX+pfw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.7.tgz", + "integrity": "sha512-RL834fB905Lco9AJKs7gfhoYRQhackbTXGuwOgWTHiQrNbju5lCsjApAf2WmyYhtZowZVpPjwztFgDJvc8q5Bg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-baseline": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.7.tgz", + "integrity": "sha512-bcEUvb83MqaVz+9w4Zv6pISYCwHiC80zcOag6xv6ZY1fk9TTd2USPw1iKv68/JLwAl3l1HitwkXoBGOSvCmKdw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-baseline-musl": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.7.tgz", + "integrity": "sha512-1ljAlEnmgq5spKkjkj47z9mkW8PTNElxN5OXgn4ktmxDbHUufMbIG/POuX9XvGbdlFu+JQ2a7JGtdG0waPES2Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-linux-x64-musl": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.7.tgz", + "integrity": "sha512-Izy92K/d2Ua2VDlFVEElUImwl1YiXbrEK0cNGouq25CR6cLpDdSq4n76kRj9sbXChQKq0yTCZSgXyD39LKvcQA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/opencode-windows-arm64": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.7.tgz", + "integrity": "sha512-WOPSPOzRU3bYH5vPdnl4KQkI9xNzvkAiNZ41+ZcBZ8daSzbcboGUzpL+QE8KX4qWSxv7xh688crL4/cLKs4hcw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/opencode-windows-x64": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.7.tgz", + "integrity": "sha512-Jd6jOXLiKFaZO7aQ4+3CJvqkXca/jNFXuKIQQg9IIe8dMRQOaIib3qFVlNuuLQQ6stK/dkgrclgo/GIxX+UnxQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/opencode-windows-x64-baseline": { + "version": "1.18.7", + "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.7.tgz", + "integrity": "sha512-NBX3LBA5d0YHMNdibN3Xhu/zHgHyIHZcfzQdY9cBUW7HY10CaVISDdXa5QsIU/pEM1olXwltF5zSU9Wk4B6aGQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + } + } +} diff --git a/.github/qa/opencode-windows-docker-e2e/package.json b/.github/qa/opencode-windows-docker-e2e/package.json new file mode 100644 index 000000000..3eefdafc2 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "first-tree-opencode-cross-platform-qa", + "private": true, + "type": "module", + "scripts": { + "check:windows-static": "node --check qa-provider.mjs && node --check windows/job-launch-wrapper.mjs && node --check windows/windows-harness.mjs && node windows/static-check.mjs" + }, + "dependencies": { + "opencode-ai": "1.18.7" + } +} diff --git a/.github/qa/opencode-windows-docker-e2e/project/AGENTS.md b/.github/qa/opencode-windows-docker-e2e/project/AGENTS.md new file mode 100644 index 000000000..d443d5b32 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/project/AGENTS.md @@ -0,0 +1,5 @@ +# OpenCode Windows per-turn CLI QA + +PROJECT_AGENTS_MARKER_218A + +This is isolated synthetic QA state. Do not access files outside this run root. diff --git a/.github/qa/opencode-windows-docker-e2e/qa-provider.mjs b/.github/qa/opencode-windows-docker-e2e/qa-provider.mjs new file mode 100644 index 000000000..96ceeece2 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/qa-provider.mjs @@ -0,0 +1,668 @@ +import fs from "node:fs"; +import http from "node:http"; + +const port = Number(process.env.QA_PROVIDER_PORT || "43127"); +const logPath = process.env.QA_PROVIDER_LOG; + +if (!logPath) { + throw new Error("QA_PROVIDER_LOG is required"); +} + +let requestSequence = 0; +const barrierDefinitions = { + new: ["QA_CONCURRENCY_NEW_A_218A", "QA_CONCURRENCY_NEW_B_218A"], + resume: ["QA_CONCURRENCY_RESUME_A_218A", "QA_CONCURRENCY_RESUME_B_218A"], +}; +const barrierState = new Map( + Object.entries(barrierDefinitions).map(([name, markers]) => [name, { markers, pending: [], released: false }]), +); + +function writeLog(event) { + fs.appendFileSync(logPath, `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`, "utf8"); +} + +function contentText(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object") { + if (typeof part.text === "string") return part.text; + if (typeof part.content === "string") return part.content; + } + return ""; + }) + .join("\n"); +} + +function messagesText(messages, role) { + return (Array.isArray(messages) ? messages : []) + .filter((message) => !role || message?.role === role) + .map((message) => contentText(message?.content)) + .join("\n"); +} + +function lastUserMessageText(messages) { + const list = Array.isArray(messages) ? messages : []; + for (let index = list.length - 1; index >= 0; index--) { + if (list[index]?.role === "user") { + return contentText(list[index]?.content); + } + } + return ""; +} + +function sendSse(response, chunks) { + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + for (const chunk of chunks) { + response.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + response.end("data: [DONE]\n\n"); +} + +function textChunks({ id, model, text }) { + const created = Math.floor(Date.now() / 1000); + return [ + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { role: "assistant", content: "" }, + finish_reason: null, + }, + ], + }, + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { content: text }, + finish_reason: null, + }, + ], + }, + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + }, + }, + ]; +} + +function toolCallChunks({ id, model, toolName, argumentsJson }) { + const created = Math.floor(Date.now() / 1000); + return [ + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_ftqa_218a", + type: "function", + function: { + name: toolName, + arguments: argumentsJson, + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: "tool_calls", + }, + ], + usage: { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + }, + }, + ]; +} + +function sendJsonCompletion(response, { id, model, text }) { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + id, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: text }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + }, + }), + ); +} + +function barrierFor(text) { + for (const [name, markers] of Object.entries(barrierDefinitions)) { + const marker = markers.find((value) => text.includes(value)); + if (marker) return { name, marker }; + } +} + +const server = http.createServer((request, response) => { + if (request.method === "GET" && request.url === "/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ status: "ok" })); + return; + } + + if (request.method === "GET" && request.url === "/v1/models") { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + object: "list", + data: [ + { + id: "qa-model", + object: "model", + created: 0, + owned_by: "ftqa", + }, + ], + }), + ); + return; + } + + const releaseMatch = request.url?.match(/^\/ftqa\/barriers\/(new|resume)\/release$/); + if (request.method === "POST" && releaseMatch) { + const name = releaseMatch[1]; + const barrier = barrierState.get(name); + if (!barrier || barrier.released || barrier.pending.length !== barrier.markers.length) { + response.writeHead(409, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + error: "barrier is not ready for one-time release", + name, + pending: barrier?.pending.length ?? 0, + }), + ); + return; + } + + barrier.released = true; + const releasedAt = new Date().toISOString(); + for (const pending of barrier.pending) { + const text = [ + "QA_PROVIDER_BARRIER_RELEASE", + `barrier=${name}`, + `marker=${pending.marker}`, + `firstTurn=${pending.firstTurn}`, + ].join(" "); + writeLog({ + kind: "barrier_release", + barrier: name, + marker: pending.marker, + requestID: pending.requestID, + releasedAt, + }); + writeLog({ + kind: "response", + requestID: pending.requestID, + responseMode: `barrier_${name}_release`, + text, + }); + if (pending.body.stream) { + sendSse( + pending.response, + textChunks({ + id: pending.completionID, + model: pending.body.model || "qa-model", + text, + }), + ); + } else { + sendJsonCompletion(pending.response, { + id: pending.completionID, + model: pending.body.model || "qa-model", + text, + }); + } + } + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + name, + releasedAt, + requestIDs: barrier.pending.map((pending) => pending.requestID), + }), + ); + return; + } + + if (request.method !== "POST" || request.url !== "/v1/chat/completions") { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "not found" } })); + return; + } + + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + const requestID = ++requestSequence; + const completionID = `chatcmpl-ftqa-${requestID}`; + let body; + try { + body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch (error) { + writeLog({ + kind: "invalid_json", + requestID, + error: String(error), + }); + response.writeHead(400, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: "invalid JSON" } })); + return; + } + + const userText = lastUserMessageText(body.messages); + const systemText = messagesText(body.messages, "system"); + const allText = messagesText(body.messages); + const toolMessageCount = (Array.isArray(body.messages) ? body.messages : []).filter( + (message) => message?.role === "tool", + ).length; + const toolNames = (Array.isArray(body.tools) ? body.tools : []).map((tool) => tool?.function?.name).filter(Boolean); + + writeLog({ + kind: "request", + requestID, + method: request.method, + url: request.url, + authorizationPresent: Boolean(request.headers.authorization), + userAgent: request.headers["user-agent"] || null, + model: body.model, + stream: body.stream, + messageCount: Array.isArray(body.messages) ? body.messages.length : 0, + toolMessageCount, + toolNames, + markers: { + slock: systemText.includes("SLOCK_STANDING_PROMPT_MARKER_218A"), + v1System: systemText.includes("V1_TURN_SYSTEM_MARKER_218A"), + projectAgents: systemText.includes("PROJECT_AGENTS_MARKER_218A"), + instructions: systemText.includes("INSTRUCTIONS_MARKER_218A"), + plugin: systemText.includes("PLUGIN_SYSTEM_MARKER_218A"), + firstTurn: allText.includes("FIRST_TURN_MARKER_218A"), + toolResult: allText.includes("QA_TOOL_RESULT_218A"), + mcpResult: allText.includes("MCP_OUTPUT_218A"), + bridgeEnv: + allText.includes("FT_AGENT=agent-v1-218a") && + allText.includes("FT_CHAT=chat-v1-218a") && + allText.includes("OC_PASS=") && + !allText.includes("OC_PASS=qa-server-secret-218a"), + }, + body, + }); + + const concurrencyBarrier = barrierFor(userText); + if (concurrencyBarrier) { + const barrier = barrierState.get(concurrencyBarrier.name); + if ( + !barrier || + barrier.released || + barrier.pending.some((pending) => pending.marker === concurrencyBarrier.marker) + ) { + writeLog({ + kind: "barrier_error", + barrier: concurrencyBarrier.name, + marker: concurrencyBarrier.marker, + requestID, + }); + response.writeHead(409, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + error: "duplicate or already-released concurrency barrier marker", + }), + ); + return; + } + const arrivedAt = new Date().toISOString(); + barrier.pending.push({ + body, + completionID, + firstTurn: allText.includes("FIRST_TURN_MARKER_218A"), + marker: concurrencyBarrier.marker, + requestID, + response, + }); + writeLog({ + kind: "barrier_arrival", + barrier: concurrencyBarrier.name, + marker: concurrencyBarrier.marker, + requestID, + arrivedAt, + pending: barrier.pending.length, + }); + return; + } + + if (userText.includes("QA_FORCE_500")) { + writeLog({ kind: "response", requestID, responseMode: "forced_500" }); + response.writeHead(500, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + error: { + message: "FTQA forced provider failure", + type: "server_error", + }, + }), + ); + return; + } + + if (userText.includes("QA_DELAY_SHORT")) { + writeLog({ kind: "response", requestID, responseMode: "delayed_short" }); + let ended = false; + const timer = setTimeout(() => { + if (ended) return; + const text = `QA_PROVIDER_DELAY_SHORT_COMPLETE request=${requestID}`; + if (body.stream) { + sendSse( + response, + textChunks({ + id: completionID, + model: body.model || "qa-model", + text, + }), + ); + } else { + sendJsonCompletion(response, { + id: completionID, + model: body.model || "qa-model", + text, + }); + } + }, 2_000); + const markAbort = () => { + if (ended) return; + ended = true; + clearTimeout(timer); + writeLog({ kind: "client_abort", requestID }); + }; + request.once("aborted", markAbort); + response.once("close", () => { + if (!response.writableEnded) markAbort(); + }); + return; + } + + if (userText.includes("QA_DELAY")) { + writeLog({ kind: "response", requestID, responseMode: "delayed" }); + let ended = false; + const timer = setTimeout(() => { + if (ended) return; + const text = `QA_PROVIDER_DELAY_COMPLETE request=${requestID}`; + if (body.stream) { + sendSse( + response, + textChunks({ + id: completionID, + model: body.model || "qa-model", + text, + }), + ); + } else { + sendJsonCompletion(response, { + id: completionID, + model: body.model || "qa-model", + text, + }); + } + }, 90_000); + const markAbort = () => { + if (ended) return; + ended = true; + clearTimeout(timer); + writeLog({ kind: "client_abort", requestID }); + }; + request.once("aborted", markAbort); + response.once("close", () => { + if (!response.writableEnded) markAbort(); + }); + return; + } + + const toolName = toolNames.find((name) => name === "bash"); + if (userText.includes("QA_CALL_BRIDGE") && toolMessageCount === 0 && toolName) { + writeLog({ + kind: "response", + requestID, + responseMode: "bridge_tool_call", + toolName, + }); + sendSse( + response, + toolCallChunks({ + id: completionID, + model: body.model || "qa-model", + toolName, + argumentsJson: JSON.stringify({ + command: + 'printf \'FT_AGENT=%s FT_CHAT=%s OC_PASS=%s FT_MARKER=%s\' "$FIRST_TREE_AGENT_ID" "$FIRST_TREE_CHAT_ID" "$OPENCODE_SERVER_PASSWORD" "$FIRST_TREE_PROVIDER_MARKER"', + description: "Read deterministic First Tree bridge environment", + }), + }), + ); + return; + } + + if (userText.includes("QA_CALL_BASH") && toolMessageCount === 0 && toolName) { + writeLog({ + kind: "response", + requestID, + responseMode: "tool_call", + toolName, + }); + sendSse( + response, + toolCallChunks({ + id: completionID, + model: body.model || "qa-model", + toolName, + argumentsJson: JSON.stringify({ + command: process.env.FTQA_SHELL_COMMAND ?? "printf QA_TOOL_RESULT_218A", + description: "Emit deterministic QA marker", + }), + }), + ); + return; + } + + if (userText.includes("QA_CALL_BACKGROUND") && toolMessageCount === 0 && toolName) { + writeLog({ + kind: "response", + requestID, + responseMode: "background_tool_call", + toolName, + }); + sendSse( + response, + toolCallChunks({ + id: completionID, + model: body.model || "qa-model", + toolName, + argumentsJson: JSON.stringify({ + command: process.env.FTQA_BACKGROUND_COMMAND ?? '/usr/bin/python3 "$FTQA_DAEMONIZE_SCRIPT"', + description: "Start one double-forked synthetic QA daemon", + }), + }), + ); + return; + } + + if (userText.includes("QA_CALL_BACKGROUND_HOLD") && toolMessageCount > 0) { + writeLog({ + kind: "response", + requestID, + responseMode: "background_hold", + }); + let ended = false; + const timer = setTimeout(() => { + if (ended) return; + const text = `QA_BACKGROUND_HOLD_COMPLETE request=${requestID}`; + if (body.stream) { + sendSse( + response, + textChunks({ + id: completionID, + model: body.model || "qa-model", + text, + }), + ); + } else { + sendJsonCompletion(response, { + id: completionID, + model: body.model || "qa-model", + text, + }); + } + }, 90_000); + const markAbort = () => { + if (ended) return; + ended = true; + clearTimeout(timer); + writeLog({ kind: "client_abort", requestID }); + }; + request.once("aborted", markAbort); + response.once("close", () => { + if (!response.writableEnded) markAbort(); + }); + return; + } + + const mcpToolName = toolNames.find((name) => name.includes("qa_echo")); + if (userText.includes("QA_CALL_MCP") && toolMessageCount === 0 && mcpToolName) { + writeLog({ + kind: "response", + requestID, + responseMode: "tool_call", + toolName: mcpToolName, + }); + sendSse( + response, + toolCallChunks({ + id: completionID, + model: body.model || "qa-model", + toolName: mcpToolName, + argumentsJson: '{"text":"MCP_INPUT_218A"}', + }), + ); + return; + } + + const markerSummary = { + slock: systemText.includes("SLOCK_STANDING_PROMPT_MARKER_218A"), + v1System: systemText.includes("V1_TURN_SYSTEM_MARKER_218A"), + projectAgents: systemText.includes("PROJECT_AGENTS_MARKER_218A"), + instructions: systemText.includes("INSTRUCTIONS_MARKER_218A"), + plugin: systemText.includes("PLUGIN_SYSTEM_MARKER_218A"), + firstTurn: allText.includes("FIRST_TURN_MARKER_218A"), + toolResult: allText.includes("QA_TOOL_RESULT_218A"), + mcpResult: allText.includes("MCP_OUTPUT_218A"), + bridgeEnv: + allText.includes("FT_AGENT=agent-v1-218a") && + allText.includes("FT_CHAT=chat-v1-218a") && + allText.includes("OC_PASS=") && + !allText.includes("OC_PASS=qa-server-secret-218a"), + }; + const text = [ + "QA_PROVIDER_OK", + `request=${requestID}`, + ...Object.entries(markerSummary).map(([key, value]) => `${key}=${value}`), + `messages=${Array.isArray(body.messages) ? body.messages.length : 0}`, + ].join(" "); + + writeLog({ + kind: "response", + requestID, + responseMode: body.stream ? "stream_text" : "json_text", + text, + }); + + if (body.stream) { + sendSse( + response, + textChunks({ + id: completionID, + model: body.model || "qa-model", + text, + }), + ); + } else { + sendJsonCompletion(response, { + id: completionID, + model: body.model || "qa-model", + text, + }); + } + }); +}); + +server.listen(port, "127.0.0.1", () => { + writeLog({ kind: "ready", port }); + process.stdout.write(`QA provider ready on http://127.0.0.1:${port}\n`); +}); + +function shutdown(signal) { + writeLog({ kind: "shutdown", signal }); + server.close(() => process.exit(0)); +} + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); diff --git a/.github/qa/opencode-windows-docker-e2e/windows/background-child.ps1 b/.github/qa/opencode-windows-docker-e2e/windows/background-child.ps1 new file mode 100644 index 000000000..46ce58ae6 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/windows/background-child.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference = "Stop" + +$process = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $PID" +$record = [ordered]@{ + pid = $PID + parentPid = [int]$process.ParentProcessId + startedAt = (Get-Process -Id $PID).StartTime.ToUniversalTime().ToString("o") + clientId = [string]$env:FIRST_TREE_CLIENT_ID + agentId = [string]$env:FIRST_TREE_AGENT_ID + chatId = [string]$env:FIRST_TREE_CHAT_ID + providerMarker = [string]$env:FIRST_TREE_PROVIDER_MARKER + attributionSource = "child-self-report" +} + +$json = $record | ConvertTo-Json -Depth 5 +[System.IO.File]::WriteAllText( + $env:FTQA_BACKGROUND_RECORD_FILE, + $json + [Environment]::NewLine, + (New-Object System.Text.UTF8Encoding($false)) +) + +[System.Threading.Thread]::Sleep(600000) diff --git a/.github/qa/opencode-windows-docker-e2e/windows/capture-runner-identity.ps1 b/.github/qa/opencode-windows-docker-e2e/windows/capture-runner-identity.ps1 new file mode 100644 index 000000000..0a9c44bfb --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/windows/capture-runner-identity.ps1 @@ -0,0 +1,71 @@ +param( + [Parameter(Mandatory = $true)][string]$EvidenceDir +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$windowsBase = "mcr.microsoft.com/windows/servercore:ltsc2022@sha256:b841bb042e13a079f68fc82461f15abcefe8063fb9a3072120348252f13a6ce3" +$resolvedEvidence = (New-Item -ItemType Directory -Force $EvidenceDir).FullName +$hostOperatingSystem = Get-CimInstance Win32_OperatingSystem + +$dockerVersionText = (& docker version --format "{{json .}}" | Out-String).Trim() +if ($LASTEXITCODE -ne 0) { + throw "docker version failed" +} +$dockerVersion = $dockerVersionText | ConvertFrom-Json +$dockerInfoText = (& docker info --format "{{json .}}" | Out-String).Trim() +if ($LASTEXITCODE -ne 0) { + throw "docker info failed" +} +$dockerInfo = $dockerInfoText | ConvertFrom-Json +$manifestText = (& docker manifest inspect $windowsBase | Out-String).Trim() +if ($LASTEXITCODE -ne 0) { + throw "pinned Windows base manifest inspection failed" +} +$manifest = $manifestText | ConvertFrom-Json + +$identity = [ordered]@{ + schema = "first-tree.opencode.windows-actions-runner-identity.v1" + capturedAt = [DateTime]::UtcNow.ToString("o") + githubRunnerImage = [ordered]@{ + imageOS = $env:ImageOS + imageVersion = $env:ImageVersion + } + host = [ordered]@{ + frameworkOSVersion = [Environment]::OSVersion.VersionString + caption = [string]$hostOperatingSystem.Caption + version = [string]$hostOperatingSystem.Version + buildNumber = [string]$hostOperatingSystem.BuildNumber + } + docker = [ordered]@{ + client = $dockerVersion.Client + server = $dockerVersion.Server + osType = [string]$dockerInfo.OSType + isolation = [string]$dockerInfo.Isolation + serverVersion = [string]$dockerInfo.ServerVersion + operatingSystem = [string]$dockerInfo.OperatingSystem + kernelVersion = [string]$dockerInfo.KernelVersion + } + pinnedBase = [ordered]@{ + reference = $windowsBase + manifestDigest = "sha256:b841bb042e13a079f68fc82461f15abcefe8063fb9a3072120348252f13a6ce3" + mediaType = [string]$manifest.mediaType + platforms = @( + $manifest.manifests | ForEach-Object { + [ordered]@{ + digest = [string]$_.digest + architecture = [string]$_.platform.architecture + os = [string]$_.platform.os + osVersion = [string]$_.platform.'os.version' + } + } + ) + } +} +$path = Join-Path $resolvedEvidence "windows-actions-runner-identity.json" +[System.IO.File]::WriteAllText( + $path, + (($identity | ConvertTo-Json -Depth 20) + [Environment]::NewLine), + (New-Object System.Text.UTF8Encoding($false)) +) diff --git a/.github/qa/opencode-windows-docker-e2e/windows/job-launch-wrapper.mjs b/.github/qa/opencode-windows-docker-e2e/windows/job-launch-wrapper.mjs new file mode 100644 index 000000000..10cd28ab4 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/windows/job-launch-wrapper.mjs @@ -0,0 +1,57 @@ +import { spawn } from "node:child_process"; +import { closeSync, existsSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; + +const specPath = process.argv[2]; +if (!specPath) throw new Error("launch spec path is required"); + +const spec = JSON.parse(readFileSync(specPath, "utf8").replace(/^\uFEFF/, "")); +const deadline = Date.now() + 30_000; +while (!existsSync(spec.goFile)) { + if (Date.now() >= deadline) { + throw new Error("timed out waiting for Job Object admission"); + } + await delay(10); +} + +const stdoutFd = openSync(spec.stdoutPath, "w"); +const stderrFd = openSync(spec.stderrPath, "w"); +const startedAt = new Date().toISOString(); +const child = spawn(spec.binary, spec.args, { + cwd: spec.cwd, + env: spec.env, + stdio: ["pipe", stdoutFd, stderrFd], + windowsHide: true, +}); + +writeFileSync(spec.pidFile, `${child.pid}\n`, { mode: 0o600 }); +child.stdin.end(spec.stdinText ?? ""); + +let forcedByWrapper = false; +for (const signal of ["SIGTERM", "SIGINT"]) { + process.on(signal, () => { + if (child.exitCode === null && child.signalCode === null) { + forcedByWrapper = true; + child.kill(); + } + }); +} + +const result = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + closeSync(stdoutFd); + closeSync(stderrFd); + resolve({ + startedAt, + exitedAt: new Date().toISOString(), + childPid: child.pid, + exitCode: code, + signal, + forcedByWrapper, + }); + }); +}); + +writeFileSync(spec.resultPath, `${JSON.stringify(result)}\n`, { mode: 0o600 }); +process.exitCode = Number.isInteger(result.exitCode) && result.exitCode >= 0 ? result.exitCode : 1; diff --git a/.github/qa/opencode-windows-docker-e2e/windows/job-supervisor.ps1 b/.github/qa/opencode-windows-docker-e2e/windows/job-supervisor.ps1 new file mode 100644 index 000000000..72eca0f34 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/windows/job-supervisor.ps1 @@ -0,0 +1,423 @@ +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$root = "C:\ftqa" +$state = Join-Path $root "state" +$readyPath = Join-Path $state "windows-job-ready.json" +$controlRequestPath = Join-Path $state "windows-job-control-request.json" +$evidenceDir = ` + $(if ($env:FTQA_EVIDENCE_DIR) { $env:FTQA_EVIDENCE_DIR } else { "C:\ftqa\evidence" }) +$supervisorResultPath = Join-Path $evidenceDir "windows-supervisor-result.json" + +trap { + $supervisorError = $_.Exception.ToString() + $failureEvidence = $evidenceDir + $failurePath = Join-Path $failureEvidence "windows-result.json" + $supervisorFailurePath = $supervisorResultPath + try { + New-Item -ItemType Directory -Force $failureEvidence | Out-Null + $priorStatus = $null + if (Test-Path -LiteralPath $failurePath) { + try { + $prior = Get-Content -LiteralPath $failurePath -Raw | ConvertFrom-Json + $priorStatus = [string]$prior.status + } catch { + $priorStatus = "UNREADABLE" + } + } + if ($priorStatus -ne "FAIL") { + $failure = [ordered]@{ + schema = "first-tree.opencode.windows-cli-e2e.v1" + status = "FAIL" + failClosed = $true + phase = "job-supervisor" + error = $supervisorError + priorReceiptStatus = $priorStatus + finalResidue = [ordered]@{ + runtimeScope = [ordered]@{ + jobPids = $null + backgroundAlive = $null + rootAlive = $null + } + harnessSupport = $null + } + } + $failureJson = $failure | ConvertTo-Json -Depth 12 + [System.IO.File]::WriteAllText( + $failurePath, + $failureJson + [Environment]::NewLine, + (New-Object System.Text.UTF8Encoding($false)) + ) + } + $supervisorFailure = [ordered]@{ + schema = "first-tree.opencode.windows-job-supervisor.v1" + status = "FAIL" + failClosed = $true + error = $supervisorError + priorReceiptStatus = $priorStatus + } + $supervisorFailureJson = $supervisorFailure | ConvertTo-Json -Depth 12 + [System.IO.File]::WriteAllText( + $supervisorFailurePath, + $supervisorFailureJson + [Environment]::NewLine, + (New-Object System.Text.UTF8Encoding($false)) + ) + } catch { + [Console]::Error.WriteLine( + "Could not persist supervisor failure receipt: $($_.Exception.Message)" + ) + } + [Console]::Error.WriteLine($supervisorError) + exit 1 +} + +New-Item -ItemType Directory -Force $state | Out-Null +Remove-Item -Force -ErrorAction SilentlyContinue $readyPath, $controlRequestPath +Get-ChildItem -LiteralPath $state -Filter "windows-job-*.json" ` + -ErrorAction SilentlyContinue | Remove-Item -Force + +Add-Type -TypeDefinition @" +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class FirstTreeJob +{ + private const int JobObjectBasicProcessIdList = 3; + private const int JobObjectExtendedLimitInformation = 9; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private const int ERROR_MORE_DATA = 234; + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObject(IntPtr securityAttributes, string name); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + IntPtr job, + int informationClass, + IntPtr information, + uint informationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool QueryInformationJobObject( + IntPtr job, + int informationClass, + IntPtr information, + uint informationLength, + IntPtr returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateJobObject(IntPtr job, uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + private static void Check(bool ok, string operation) + { + if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error(), operation); + } + + public static IntPtr CreateKillOnClose() + { + IntPtr job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) + throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject"); + + var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(limits, buffer, false); + Check( + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + buffer, + (uint)size), + "SetInformationJobObject(KILL_ON_JOB_CLOSE)"); + return job; + } + catch + { + CloseHandle(job); + throw; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public static void Assign(IntPtr job, IntPtr process) + { + Check(AssignProcessToJobObject(job, process), "AssignProcessToJobObject"); + } + + public static long[] ProcessIds(IntPtr job) + { + int capacity = 64; + while (capacity <= 65536) + { + int size = 8 + capacity * IntPtr.Size; + IntPtr buffer = Marshal.AllocHGlobal(size); + try + { + for (int offset = 0; offset < size; offset += 4) + Marshal.WriteInt32(buffer, offset, 0); + + bool ok = QueryInformationJobObject( + job, + JobObjectBasicProcessIdList, + buffer, + (uint)size, + IntPtr.Zero); + int error = ok ? 0 : Marshal.GetLastWin32Error(); + int assigned = Marshal.ReadInt32(buffer, 0); + int listed = Marshal.ReadInt32(buffer, 4); + + if (!ok && error != ERROR_MORE_DATA) + throw new Win32Exception(error, "QueryInformationJobObject(ProcessIdList)"); + if (!ok || assigned > capacity) + { + capacity = Math.Max(capacity * 2, assigned + 16); + continue; + } + + long[] result = new long[listed]; + for (int index = 0; index < listed; index++) + { + int offset = 8 + index * IntPtr.Size; + result[index] = IntPtr.Size == 8 + ? Marshal.ReadInt64(buffer, offset) + : Marshal.ReadInt32(buffer, offset); + } + return result; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + throw new InvalidOperationException("Job process list exceeded the safety bound"); + } + + public static void Terminate(IntPtr job) + { + Check(TerminateJobObject(job, 137), "TerminateJobObject"); + } + + public static void Close(IntPtr job) + { + if (job != IntPtr.Zero) Check(CloseHandle(job), "CloseHandle(job)"); + } +} +"@ + +function Write-Utf8Json { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)]$Value + ) + $json = $Value | ConvertTo-Json -Depth 12 -Compress + [System.IO.File]::WriteAllText( + $Path, + $json + [Environment]::NewLine, + (New-Object System.Text.UTF8Encoding($false)) + ) +} + +function Wait-ForPath { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][int]$TimeoutMilliseconds, + [System.Diagnostics.Process]$GuardProcess + ) + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + while (-not (Test-Path -LiteralPath $Path)) { + if ($GuardProcess -and $GuardProcess.HasExited) { + throw "Guard process exited before $Path appeared (exit $($GuardProcess.ExitCode))" + } + if ([DateTime]::UtcNow -ge $deadline) { + throw "Timed out waiting for $Path" + } + Start-Sleep -Milliseconds 20 + } +} + +$job = [IntPtr]::Zero +$driver = $null + +try { + $job = [FirstTreeJob]::CreateKillOnClose() + Write-Utf8Json -Path $readyPath -Value ([ordered]@{ + killOnJobClose = $true + authority = "QueryInformationJobObject" + readyAt = [DateTime]::UtcNow.ToString("o") + }) + + $driverInfo = New-Object System.Diagnostics.ProcessStartInfo + $driverInfo.FileName = "node.exe" + $driverInfo.Arguments = "C:\ftqa\windows\windows-harness.mjs" + $driverInfo.WorkingDirectory = $root + $driverInfo.UseShellExecute = $false + $driverInfo.EnvironmentVariables["FTQA_JOB_STATE_DIR"] = $state + $driverInfo.EnvironmentVariables["FTQA_EVIDENCE_DIR"] = ` + $(if ($env:FTQA_EVIDENCE_DIR) { $env:FTQA_EVIDENCE_DIR } else { "C:\ftqa\evidence" }) + $driver = [System.Diagnostics.Process]::Start($driverInfo) + + $overallDeadline = [DateTime]::UtcNow.AddMinutes(5) + while (-not $driver.HasExited) { + if ([DateTime]::UtcNow -ge $overallDeadline) { + throw "Windows Job Object harness exceeded five minutes" + } + + $launchRequests = @(Get-ChildItem ` + -LiteralPath $state ` + -Filter "windows-job-launch-request-*.json" ` + -ErrorAction SilentlyContinue) + foreach ($launchRequestFile in $launchRequests) { + $launchRequest = $null + $request = $null + try { + $launchRequest = Get-Content -LiteralPath $launchRequestFile.FullName -Raw | + ConvertFrom-Json + Remove-Item -LiteralPath $launchRequestFile.FullName -Force + + $wrapperInfo = New-Object System.Diagnostics.ProcessStartInfo + $wrapperInfo.FileName = "node.exe" + $wrapperInfo.Arguments = ` + "C:\ftqa\windows\job-launch-wrapper.mjs $($launchRequest.specPath)" + $wrapperInfo.WorkingDirectory = $root + $wrapperInfo.UseShellExecute = $false + $wrapper = [System.Diagnostics.Process]::Start($wrapperInfo) + [FirstTreeJob]::Assign($job, $wrapper.Handle) + + $launchResponse = [ordered]@{ + seq = [string]$launchRequest.seq + wrapperPid = $wrapper.Id + killOnJobClose = $true + assignedAt = [DateTime]::UtcNow.ToString("o") + } + } catch { + $launchResponse = [ordered]@{ + seq = $(if ($launchRequest) { [string]$launchRequest.seq } else { "unparsed" }) + error = $_.Exception.ToString() + observedAt = [DateTime]::UtcNow.ToString("o") + } + } + $launchResponsePath = Join-Path ` + $state ` + "windows-job-launch-response-$($launchResponse.seq).json" + Write-Utf8Json -Path $launchResponsePath -Value $launchResponse + } + + if (Test-Path -LiteralPath $controlRequestPath) { + $request = $null + try { + $request = Get-Content -LiteralPath $controlRequestPath -Raw | ConvertFrom-Json + Remove-Item -LiteralPath $controlRequestPath -Force + if ($request.action -eq "terminate") { + [FirstTreeJob]::Terminate($job) + Start-Sleep -Milliseconds 20 + } elseif ($request.action -ne "snapshot") { + throw "Unknown job request action: $($request.action)" + } + $response = [ordered]@{ + seq = [string]$request.seq + action = [string]$request.action + pids = @([FirstTreeJob]::ProcessIds($job)) + observedAt = [DateTime]::UtcNow.ToString("o") + } + } catch { + $response = [ordered]@{ + seq = $(if ($request) { [string]$request.seq } else { "unparsed" }) + error = $_.Exception.ToString() + observedAt = [DateTime]::UtcNow.ToString("o") + } + } + $responsePath = Join-Path $state "windows-job-response-$($response.seq).json" + Write-Utf8Json -Path $responsePath -Value $response + } + Start-Sleep -Milliseconds 20 + } + + $driver.WaitForExit() + if ($driver.ExitCode -ne 0) { + throw "Windows driver exited with code $($driver.ExitCode)" + } +} finally { + if ($job -ne [IntPtr]::Zero) { + try { + if ([FirstTreeJob]::ProcessIds($job).Count -gt 0) { + [FirstTreeJob]::Terminate($job) + } + } finally { + [FirstTreeJob]::Close($job) + } + } + if ($driver -and -not $driver.HasExited) { + $driver.Kill() + $driver.WaitForExit() + } + Remove-Item -Force -ErrorAction SilentlyContinue $readyPath +} + +New-Item -ItemType Directory -Force $evidenceDir | Out-Null +$mainResultPath = Join-Path $evidenceDir "windows-result.json" +if (-not (Test-Path -LiteralPath $mainResultPath)) { + throw "Driver exited successfully without windows-result.json" +} +$mainResult = Get-Content -LiteralPath $mainResultPath -Raw | ConvertFrom-Json +if ([string]$mainResult.status -ne "PASS") { + throw "Driver exited successfully but main receipt was not PASS" +} +Write-Utf8Json -Path $supervisorResultPath -Value ([ordered]@{ + schema = "first-tree.opencode.windows-job-supervisor.v1" + status = "PASS" + failClosed = $true + driverExitCode = $driver.ExitCode + mainReceiptStatus = [string]$mainResult.status + jobClosed = $true + observedAt = [DateTime]::UtcNow.ToString("o") + }) diff --git a/.github/qa/opencode-windows-docker-e2e/windows/redact-evidence.ps1 b/.github/qa/opencode-windows-docker-e2e/windows/redact-evidence.ps1 new file mode 100644 index 000000000..58630cf58 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/windows/redact-evidence.ps1 @@ -0,0 +1,73 @@ +param( + [Parameter(Mandatory = $true)][string]$EvidenceDir, + [Parameter(Mandatory = $true)][string]$OutputDir +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Write-Utf8 { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Value + ) + [System.IO.File]::WriteAllText( + $Path, + $Value, + (New-Object System.Text.UTF8Encoding($false)) + ) +} + +New-Item -ItemType Directory -Force $OutputDir | Out-Null + +$receiptNames = @( + "windows-result.json", + "windows-supervisor-result.json", + "windows-runner-identity.json", + "windows-actions-runner-identity.json", + "windows-cleanup-result.json", + "windows-actions-cleanup-result.json" +) +foreach ($name in $receiptNames) { + $source = Join-Path $EvidenceDir $name + if (Test-Path -LiteralPath $source) { + $parsed = Get-Content -LiteralPath $source -Raw | ConvertFrom-Json + $json = $parsed | ConvertTo-Json -Depth 30 + Write-Utf8 ` + -Path (Join-Path $OutputDir $name) ` + -Value ($json + [Environment]::NewLine) + } +} + +$providerLog = Join-Path $EvidenceDir "windows-provider.jsonl" +if (Test-Path -LiteralPath $providerLog) { + $redactedLines = @() + foreach ($line in Get-Content -LiteralPath $providerLog) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + $record = $line | ConvertFrom-Json + if ($record.PSObject.Properties.Name -contains "body") { + $record.PSObject.Properties.Remove("body") + } + $redactedLines += ($record | ConvertTo-Json -Depth 20 -Compress) + } + Write-Utf8 ` + -Path (Join-Path $OutputDir "windows-provider.redacted.jsonl") ` + -Value (($redactedLines -join [Environment]::NewLine) + [Environment]::NewLine) +} + +$manifest = [ordered]@{ + schema = "first-tree.opencode.windows-evidence-manifest.v1" + generatedAt = [DateTime]::UtcNow.ToString("o") + files = @( + Get-ChildItem -LiteralPath $OutputDir -File | + Select-Object -ExpandProperty Name | + Sort-Object + ) + providerRequestBodiesRemoved = $true + rawEvidenceUploaded = $false +} +Write-Utf8 ` + -Path (Join-Path $OutputDir "evidence-manifest.json") ` + -Value (($manifest | ConvertTo-Json -Depth 10) + [Environment]::NewLine) diff --git a/.github/qa/opencode-windows-docker-e2e/windows/run-windows.ps1 b/.github/qa/opencode-windows-docker-e2e/windows/run-windows.ps1 new file mode 100644 index 000000000..dfcf05634 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/windows/run-windows.ps1 @@ -0,0 +1,219 @@ +param( + [string]$EvidenceDir = "C:\ftqa-evidence", + [string]$ProjectName = "ftqa-opencode-windows-218a", + [ValidateSet("process", "hyperv")] + [string]$Isolation = "process" +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$harnessRoot = Split-Path -Parent $PSScriptRoot +$composeFile = Join-Path $harnessRoot "compose.windows.yaml" +$resolvedEvidence = (New-Item -ItemType Directory -Force $EvidenceDir).FullName +$identityPath = Join-Path $resolvedEvidence "windows-runner-identity.json" +$cleanupPath = Join-Path $resolvedEvidence "windows-cleanup-result.json" +$windowsBase = "mcr.microsoft.com/windows/servercore:ltsc2022@sha256:b841bb042e13a079f68fc82461f15abcefe8063fb9a3072120348252f13a6ce3" +$env:FTQA_EVIDENCE_DIR = $resolvedEvidence +$env:FTQA_WINDOWS_ISOLATION = $Isolation + +function Write-Utf8Json { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)]$Value + ) + $json = $Value | ConvertTo-Json -Depth 20 + [System.IO.File]::WriteAllText( + $Path, + $json + [Environment]::NewLine, + (New-Object System.Text.UTF8Encoding($false)) + ) +} + +function Invoke-DockerJson { + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Description + ) + $output = & docker @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$Description failed" + } + return ($output | Out-String).Trim() | ConvertFrom-Json +} + +$osType = (& docker info --format "{{.OSType}}").Trim() +if ($LASTEXITCODE -ne 0) { + throw "docker info failed" +} +if ($osType -ne "windows") { + throw "Windows Docker engine required; docker reported '$osType'" +} + +$serviceImageReferences = @( + @( + & docker compose ` + -p $ProjectName ` + -f $composeFile ` + config ` + --images + ) | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object -Unique +) +if ($LASTEXITCODE -ne 0 -or $serviceImageReferences.Count -ne 1) { + throw "Expected exactly one Compose service image reference" +} +$serviceImageReference = [string]$serviceImageReferences[0] + +$hostOperatingSystem = Get-CimInstance Win32_OperatingSystem +$dockerVersion = Invoke-DockerJson ` + -Arguments @("version", "--format", "{{json .}}") ` + -Description "docker version" +$dockerInfo = Invoke-DockerJson ` + -Arguments @("info", "--format", "{{json .}}") ` + -Description "docker info" +$baseImageIDBefore = $null +$baseInspect = & docker image inspect $windowsBase --format "{{.Id}}" 2>$null +if ($LASTEXITCODE -eq 0) { + $baseImageIDBefore = ($baseInspect | Out-String).Trim() +} +$identity = [ordered]@{ + schema = "first-tree.opencode.windows-runner-identity.v1" + capturedAt = [DateTime]::UtcNow.ToString("o") + githubRunnerImage = [ordered]@{ + imageOS = $env:ImageOS + imageVersion = $env:ImageVersion + } + host = [ordered]@{ + frameworkOSVersion = [Environment]::OSVersion.VersionString + caption = [string]$hostOperatingSystem.Caption + version = [string]$hostOperatingSystem.Version + buildNumber = [string]$hostOperatingSystem.BuildNumber + } + docker = [ordered]@{ + client = $dockerVersion.Client + server = $dockerVersion.Server + osType = [string]$dockerInfo.OSType + isolation = [string]$dockerInfo.Isolation + serverVersion = [string]$dockerInfo.ServerVersion + operatingSystem = [string]$dockerInfo.OperatingSystem + kernelVersion = [string]$dockerInfo.KernelVersion + } + build = [ordered]@{ + baseReference = $windowsBase + baseManifestDigest = "sha256:b841bb042e13a079f68fc82461f15abcefe8063fb9a3072120348252f13a6ce3" + baseImageIDBefore = $baseImageIDBefore + serviceImageReference = $serviceImageReference + serviceImageID = $null + } +} +Write-Utf8Json -Path $identityPath -Value $identity + +$serviceImageID = $null +$runFailure = $null +$cleanupFailure = $null +try { + try { + & docker compose ` + -p $ProjectName ` + -f $composeFile ` + build ` + windows-job + if ($LASTEXITCODE -ne 0) { + throw "Windows harness image build failed" + } + + $serviceImageID = ( + & docker image inspect ` + $serviceImageReference ` + --format "{{.Id}}" | + Out-String + ).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($serviceImageID)) { + throw "Could not resolve the built Windows harness service image" + } + $identity.build.serviceImageID = $serviceImageID + Write-Utf8Json -Path $identityPath -Value $identity + + & docker compose ` + -p $ProjectName ` + -f $composeFile ` + run ` + --rm ` + windows-job + if ($LASTEXITCODE -ne 0) { + throw "Windows harness execution failed; inspect $resolvedEvidence" + } + } catch { + $runFailure = $_.Exception.ToString() + } +} finally { + & docker compose ` + -p $ProjectName ` + -f $composeFile ` + down ` + --remove-orphans ` + --rmi local + $downExitCode = $LASTEXITCODE + + $containerResidue = @(& docker ps ` + -aq ` + --filter "label=com.docker.compose.project=$ProjectName") + $containerReadExit = $LASTEXITCODE + $networkResidue = @(& docker network ls ` + -q ` + --filter "label=com.docker.compose.project=$ProjectName") + $networkReadExit = $LASTEXITCODE + + $serviceImageIDAlive = $false + if (-not [string]::IsNullOrWhiteSpace($serviceImageID)) { + & docker image inspect $serviceImageID *> $null + $serviceImageIDAlive = $LASTEXITCODE -eq 0 + } + & docker image inspect $serviceImageReference *> $null + $serviceImageReferenceAlive = $LASTEXITCODE -eq 0 + + $baseImageIDAfter = $null + $baseInspectAfter = & docker image inspect $windowsBase --format "{{.Id}}" 2>$null + if ($LASTEXITCODE -eq 0) { + $baseImageIDAfter = ($baseInspectAfter | Out-String).Trim() + } + $baseCachePreserved = ` + [string]::IsNullOrWhiteSpace($baseImageIDBefore) -or ` + $baseImageIDAfter -eq $baseImageIDBefore + $cleanupPassed = ` + $downExitCode -eq 0 -and ` + $containerReadExit -eq 0 -and ` + $networkReadExit -eq 0 -and ` + @($containerResidue | Where-Object { $_ }).Count -eq 0 -and ` + @($networkResidue | Where-Object { $_ }).Count -eq 0 -and ` + -not $serviceImageIDAlive -and ` + -not $serviceImageReferenceAlive -and ` + $baseCachePreserved + $cleanup = [ordered]@{ + schema = "first-tree.opencode.windows-docker-cleanup.v1" + status = $(if ($cleanupPassed) { "PASS" } else { "FAIL" }) + completedAt = [DateTime]::UtcNow.ToString("o") + projectName = $ProjectName + downExitCode = $downExitCode + containers = @($containerResidue | Where-Object { $_ }) + networks = @($networkResidue | Where-Object { $_ }) + serviceImageReference = $serviceImageReference + serviceImageID = $serviceImageID + serviceImageIDAlive = $serviceImageIDAlive + serviceImageReferenceAlive = $serviceImageReferenceAlive + baseImageIDBefore = $baseImageIDBefore + baseImageIDAfter = $baseImageIDAfter + baseCachePreserved = $baseCachePreserved + } + Write-Utf8Json -Path $cleanupPath -Value $cleanup + if (-not $cleanupPassed) { + $cleanupFailure = "Scoped Docker cleanup or readback failed" + } +} + +if ($runFailure) { + throw $runFailure +} +if ($cleanupFailure) { + throw $cleanupFailure +} diff --git a/.github/qa/opencode-windows-docker-e2e/windows/static-check.mjs b/.github/qa/opencode-windows-docker-e2e/windows/static-check.mjs new file mode 100644 index 000000000..210c1d5e2 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/windows/static-check.mjs @@ -0,0 +1,350 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const windowsDir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.dirname(windowsDir); + +function read(relative) { + return readFileSync(path.join(root, relative), "utf8"); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function includesEvery(text, values, label) { + for (const value of values) { + assert(text.includes(value), `${label} missing ${JSON.stringify(value)}`); + } +} + +const packageJson = JSON.parse(read("package.json")); +const packageLock = JSON.parse(read("package-lock.json")); +assert(packageJson.dependencies?.["opencode-ai"] === "1.18.7", "opencode-ai must be exactly 1.18.7"); +assert( + !Object.hasOwn(packageJson.dependencies ?? {}, "@opencode-ai/sdk"), + "Windows CLI candidate must not depend on the SDK", +); +assert(packageLock.lockfileVersion === 3, "package-lock must use lockfile v3"); +assert( + packageLock.packages?.["node_modules/opencode-ai"]?.version === "1.18.7", + "package-lock must pin opencode-ai 1.18.7", +); +assert( + packageLock.packages?.["node_modules/opencode-ai"]?.integrity === + "sha512-/C4Bc+mHbTK+GvhiZ83rguwVSNBs1sCzooQ3CCOz7G8SBYqbsXajlm0OtZ7RaMEUWxHWeMvOnGi4RC4OpAnC/g==", + "package-lock has unexpected opencode-ai integrity", +); +for (const [packageName, integrity] of Object.entries({ + "opencode-windows-x64": + "sha512-Jd6jOXLiKFaZO7aQ4+3CJvqkXca/jNFXuKIQQg9IIe8dMRQOaIib3qFVlNuuLQQ6stK/dkgrclgo/GIxX+UnxQ==", + "opencode-windows-x64-baseline": + "sha512-NBX3LBA5d0YHMNdibN3Xhu/zHgHyIHZcfzQdY9cBUW7HY10CaVISDdXa5QsIU/pEM1olXwltF5zSU9Wk4B6aGQ==", +})) { + assert( + packageLock.packages?.[`node_modules/${packageName}`]?.version === "1.18.7", + `package-lock must pin ${packageName} 1.18.7`, + ); + assert( + packageLock.packages?.[`node_modules/${packageName}`]?.integrity === integrity, + `package-lock has unexpected ${packageName} integrity`, + ); +} + +const dockerfile = read("Dockerfile.windows"); +const windowsBaseDigest = "sha256:b841bb042e13a079f68fc82461f15abcefe8063fb9a3072120348252f13a6ce3"; +const expectedWindowsBase = `mcr.microsoft.com/windows/servercore:ltsc2022@${windowsBaseDigest}`; +includesEvery( + dockerfile, + [ + expectedWindowsBase, + "node-v24.18.0-win-x64.zip", + "0ae68406b42d7725661da979b1403ec9926da205c6770827f33aac9d8f26e821", + "COPY package.json package-lock.json", + "COPY Dockerfile.windows compose.windows.yaml ./", + "npm.cmd ci", + "Unexpected OpenCode version", + "windows\\static-check.mjs", + ], + "Dockerfile.windows", +); + +const harness = read("windows/windows-harness.mjs"); +includesEvery( + harness, + [ + '"runtime-version-gate"', + '"db"', + '"SELECT 1 AS ready"', + '"run"', + '"--format"', + '"json"', + '"--auto"', + '"--agent"', + '"--model"', + '"--session"', + "stdinText", + "distinctSessionIDs", + "terminal step_finish", + "supervisor-owned Windows Job Object membership", + "await delay(500)", + "childRegistryIsAuthority: false", + "failClosed: true", + "expectedNodeArchiveSha256", + "expectedOpenCodeNpmIntegrity", + "backgroundRootProcessBeforeStop", + "deterministicBarrier", + "processOverlap", + "barrier_arrival", + "barrier_release", + "writeJsonAtomic(resultPath, value, 2)", + "background root command line missing", + "background prompt leaked into the OpenCode process command line", + "promptMarkerAbsent: true", + "requiredArgumentsPresent: true", + "background pid identity changed across OpenCode root exit", + "background pid no longer identifies the child that wrote the evidence record", + "$record = [ordered]@{", + '.join("\\r\\n")', + "windows-actions-runner-identity.json", + "windows-runner-identity.json", + "!currentRunIdentity", + "providerCleanup", + "harnessSupport", + ], + "windows/windows-harness.mjs", +); +assert(!harness.includes('"serve"'), "Windows candidate still launches opencode serve"); +assert(!harness.includes("@opencode-ai/sdk"), "Windows candidate still imports the SDK"); +assert(!harness.includes('execFileSync(binary, ["--version"]'), "OpenCode version probe bypasses Job pre-admission"); +const processRecordSource = harness.slice( + harness.indexOf("function processRecord(pid)"), + harness.indexOf("async function jobRequest(action)"), +); +includesEvery( + processRecordSource, + [ + '";`,', + '"if (-not $item) { exit 3 };",', + "-ErrorAction Stop;`", + '"$record = [ordered]@{",', + '"};",', + '"$record | ConvertTo-Json -Compress",', + '.join("\\r\\n")', + ], + "processRecord PowerShell statement boundaries", +); +assert( + !processRecordSource.includes('.join(" ")') && !processRecordSource.includes("[ordered]@{;"), + "processRecord still emits ambiguous PowerShell statement boundaries", +); +const evidenceCleanupSource = harness.slice( + harness.indexOf("for (const name of readdirSync(evidence))"), + harness.indexOf("rmSync(workRoot"), +); +includesEvery( + evidenceCleanupSource, + [ + 'name === "windows-actions-runner-identity.json"', + 'name === "windows-runner-identity.json"', + 'name.startsWith("windows-") && !currentRunIdentity', + ], + "current-run identity evidence preserve allowlist", +); +assert( + harness.match(/const windowsBase\s*=\s*\n?\s*"([^"]+)"/u)?.[1] === expectedWindowsBase, + "runtime receipt base digest differs from Dockerfile", +); +assert( + dockerfile.match(/^FROM\s+(\S+)$/mu)?.[1] === expectedWindowsBase, + "Dockerfile FROM does not equal the pinned Windows base", +); +const newObserveIndex = harness.indexOf("const newConcurrencyProof = await observeConcurrentBatch("); +const newCollectIndex = harness.indexOf("const [newARun, newBRun] = await Promise.all("); +const resumeObserveIndex = harness.indexOf("const resumeConcurrencyProof = await observeConcurrentBatch("); +const resumeCollectIndex = harness.indexOf("const [resumeARun, resumeBRun] = await Promise.all("); +assert( + newObserveIndex >= 0 && + newCollectIndex > newObserveIndex && + resumeObserveIndex > newCollectIndex && + resumeCollectIndex > resumeObserveIndex, + "new/resume concurrency barrier must precede collection", +); +assert( + harness.indexOf("providerCleanup = await stopProvider();") < harness.indexOf('status: "PASS"'), + "main PASS is written before local provider cleanup", +); + +const qaProvider = read("qa-provider.mjs"); +includesEvery( + qaProvider, + [ + "QA_CONCURRENCY_NEW_A_218A", + "QA_CONCURRENCY_NEW_B_218A", + "QA_CONCURRENCY_RESUME_A_218A", + "QA_CONCURRENCY_RESUME_B_218A", + "barriers", + "barrier_arrival", + "barrier_release", + "lastUserMessageText", + ], + "qa-provider.mjs", +); +assert( + qaProvider.includes("const userText = lastUserMessageText(body.messages);"), + "provider does not select current-turn control markers from the last user message", +); +assert( + !qaProvider.includes('messagesText(body.messages, "user")'), + "provider still selects current-turn markers from concatenated user history", +); + +const wrapper = read("windows/job-launch-wrapper.mjs"); +includesEvery( + wrapper, + ["while (!existsSync(spec.goFile))", "spawn(spec.binary, spec.args", "child.stdin.end(spec.stdinText"], + "windows/job-launch-wrapper.mjs", +); + +const supervisor = read("windows/job-supervisor.ps1"); +includesEvery( + supervisor, + [ + "JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE", + "AssignProcessToJobObject", + "QueryInformationJobObject", + "TerminateJobObject", + "windows-job-launch-request-*.json", + "[FirstTreeJob]::Assign($job, $wrapper.Handle)", + 'phase = "job-supervisor"', + "failClosed = $true", + "priorReceiptStatus", + "windows-supervisor-result.json", + 'status = "PASS"', + "mainReceiptStatus", + "jobClosed = $true", + ], + "windows/job-supervisor.ps1", +); + +const compose = read("compose.windows.yaml"); +includesEvery(compose, ["Dockerfile.windows", "FTQA_EVIDENCE_DIR"], "compose.windows.yaml"); + +const runner = read("windows/run-windows.ps1"); +includesEvery( + runner, + [ + 'if ($osType -ne "windows")', + "build", + "run", + "--rm", + "finally", + "down", + "--remove-orphans", + "--rmi local", + "config", + "--images", + "docker image inspect", + "windows-runner-identity.json", + "windows-cleanup-result.json", + "serviceImageReference", + "serviceImageIDAlive", + "serviceImageReferenceAlive", + "baseCachePreserved", + "com.docker.compose.project=", + ], + "windows/run-windows.ps1", +); +assert( + runner.match(/^\$windowsBase\s*=\s*"([^"]+)"$/mu)?.[1] === expectedWindowsBase, + "runner identity base digest differs from Dockerfile", +); +assert( + !/^\s*images\s*`\s*$[\s\S]{0,80}^\s*-q\s*`/mu.test(runner), + "runner still uses compose images -q before a service container exists", +); + +const workflowPath = path.join(root, "..", "..", "workflows", "opencode-windows-docker-e2e.yml"); +if (existsSync(workflowPath)) { + const workflow = readFileSync(workflowPath, "utf8"); + includesEvery( + workflow, + [ + "config `", + "--images", + "docker image inspect", + "serviceImageReference", + "serviceImageIDAlive", + "serviceImageReferenceAlive", + "--rmi local", + "exit 0", + ], + ".github/workflows/opencode-windows-docker-e2e.yml", + ); + assert(!/^\s*images\s*`\s*$[\s\S]{0,80}^\s*-q\s*`/mu.test(workflow), "workflow cleanup still uses compose images -q"); + assert( + workflow.indexOf('if ($receipt.status -ne "PASS")') < workflow.indexOf("exit 0"), + "workflow cleanup PASS does not explicitly exit zero after the fail-closed guard", + ); +} + +const redactor = read("windows/redact-evidence.ps1"); +includesEvery( + redactor, + [ + "windows-result.json", + "windows-supervisor-result.json", + "windows-runner-identity.json", + "windows-actions-runner-identity.json", + "windows-cleanup-result.json", + "windows-actions-cleanup-result.json", + 'Properties.Remove("body")', + "rawEvidenceUploaded = $false", + ], + "windows/redact-evidence.ps1", +); + +const actionsIdentity = read("windows/capture-runner-identity.ps1"); +includesEvery( + actionsIdentity, + [ + "ImageOS", + "ImageVersion", + "Win32_OperatingSystem", + 'docker version --format "{{json .}}"', + 'docker info --format "{{json .}}"', + "docker manifest inspect", + windowsBaseDigest, + "windows-actions-runner-identity.json", + ], + "windows/capture-runner-identity.ps1", +); + +const result = { + schema: "first-tree.opencode.windows-cli-static.v1", + status: "PASS", + checked: [ + "exact artifact pins", + "per-turn CLI flags", + "stdin prompt", + "DB readiness gate", + "deterministically concurrent new and resume", + "JSONL terminal parser", + "Job-admitted runtime version gate", + "pre-admission Job assignment", + "Job membership and termination", + "double-zero interval", + "local provider cleanup receipt", + "valid PowerShell process observer statement boundaries", + "current-run runner identity evidence preserve allowlist", + "runner and Docker identity receipt", + "Compose image-reference resolution and ID/reference cleanup readback", + "redacted evidence allowlist", + "fail-closed result", + "one-command scoped Windows cleanup and readback", + "explicit zero exit after Actions cleanup PASS", + ], + runtimeExecuted: false, +}; +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); diff --git a/.github/qa/opencode-windows-docker-e2e/windows/windows-harness.mjs b/.github/qa/opencode-windows-docker-e2e/windows/windows-harness.mjs new file mode 100644 index 000000000..5011e9d38 --- /dev/null +++ b/.github/qa/opencode-windows-docker-e2e/windows/windows-harness.mjs @@ -0,0 +1,1100 @@ +import { execFileSync, spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:net"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const root = "C:\\ftqa"; +const projectTemplate = path.join(root, "project"); +const workRoot = path.join(root, "work"); +const workdirA = path.join(workRoot, "chat-a"); +const workdirB = path.join(workRoot, "chat-b"); +const home = path.join(root, "home"); +const state = process.env.FTQA_JOB_STATE_DIR ?? path.join(root, "state"); +const evidence = process.env.FTQA_EVIDENCE_DIR ?? path.join(root, "evidence"); +const binary = path.join(root, "node_modules", "opencode-ai", "bin", "opencode.exe"); +const providerScript = path.join(root, "qa-provider.mjs"); +const backgroundScript = path.join(root, "windows", "background-child.ps1"); +const providerLog = path.join(evidence, "windows-provider.jsonl"); +const resultPath = path.join(evidence, "windows-result.json"); +const readyPath = path.join(state, "windows-job-ready.json"); +const controlRequestPath = path.join(state, "windows-job-control-request.json"); +const backgroundRecordFile = path.join(state, "windows-background.json"); +const database = path.join(home, "data", "opencode", "opencode.db"); +const managedAgent = "slock"; +const explicitModel = "ftqa/qa-model"; +const clientID = "client-windows-cli-218a"; +const agentID = "agent-windows-cli-218a"; +const chatIDA = "chat-windows-cli-a-218a"; +const chatIDB = "chat-windows-cli-b-218a"; +const marker = "ft-opencode-windows-cli-218a"; +const expectedNodeVersion = "v24.18.0"; +const expectedOpenCodeVersion = "1.18.7"; +const expectedNodeArchiveSha256 = "0ae68406b42d7725661da979b1403ec9926da205c6770827f33aac9d8f26e821"; +const expectedOpenCodeNpmIntegrity = + "sha512-/C4Bc+mHbTK+GvhiZ83rguwVSNBs1sCzooQ3CCOz7G8SBYqbsXajlm0OtZ7RaMEUWxHWeMvOnGi4RC4OpAnC/g=="; +const windowsBase = + "mcr.microsoft.com/windows/servercore:ltsc2022@sha256:b841bb042e13a079f68fc82461f15abcefe8063fb9a3072120348252f13a6ce3"; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function errorText(error) { + return error instanceof Error ? (error.stack ?? error.message) : String(error); +} + +function readJson(file) { + return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, "")); +} + +function writeJsonAtomic(file, value, space = 0) { + const temporary = `${file}.${randomBytes(5).toString("hex")}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value, null, space)}\n`, { + mode: 0o600, + }); + renameSync(temporary, file); +} + +function persistResult(value) { + mkdirSync(evidence, { recursive: true }); + writeJsonAtomic(resultPath, value, 2); +} + +async function waitUntil(check, timeoutMs, label, intervalMs = 25) { + const started = Date.now(); + let lastError; + while (Date.now() - started < timeoutMs) { + try { + const value = await check(); + if (value) return value; + } catch (error) { + lastError = error; + } + await delay(intervalMs); + } + throw new Error(`Timed out waiting for ${label}${lastError ? `: ${errorText(lastError)}` : ""}`); +} + +async function freePort() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + await new Promise((resolve) => server.close(resolve)); + assert(port > 0, "failed to allocate loopback provider port"); + return port; +} + +function environmentValue(name) { + const key = Object.keys(process.env).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); + return key ? process.env[key] : undefined; +} + +function minimalEnvironment(extra = {}) { + const env = {}; + for (const key of ["PATH", "SystemRoot", "ComSpec", "TEMP", "TMP", "PATHEXT", "USERNAME"]) { + const value = environmentValue(key); + if (value) env[key] = value; + } + return { ...env, ...extra }; +} + +function opencodeConfig(providerPort) { + return { + $schema: "https://opencode.ai/config.json", + model: explicitModel, + small_model: explicitModel, + autoupdate: false, + share: "disabled", + snapshot: false, + enabled_providers: ["ftqa"], + provider: { + ftqa: { + npm: "@ai-sdk/openai-compatible", + name: "First Tree Windows per-turn CLI QA provider", + options: { + baseURL: `http://127.0.0.1:${providerPort}/v1`, + apiKey: "qa-local-only", + }, + models: { + "qa-model": { + name: "First Tree Windows per-turn CLI QA model", + limit: { context: 131072, output: 4096 }, + }, + }, + }, + }, + agent: { + [managedAgent]: { + description: "First Tree managed Windows CLI QA agent", + mode: "primary", + prompt: "SLOCK_STANDING_PROMPT_MARKER_218A", + model: explicitModel, + permission: { + edit: "deny", + bash: "allow", + webfetch: "deny", + websearch: "deny", + }, + }, + }, + permission: { + edit: "deny", + bash: "allow", + webfetch: "deny", + websearch: "deny", + }, + }; +} + +function opencodeEnvironment(providerPort, chatID = chatIDA) { + const roaming = path.join(home, "AppData", "Roaming"); + const local = path.join(home, "AppData", "Local"); + return minimalEnvironment({ + HOME: home, + USERPROFILE: home, + APPDATA: roaming, + LOCALAPPDATA: local, + XDG_CONFIG_HOME: path.join(home, "config"), + XDG_DATA_HOME: path.join(home, "data"), + XDG_CACHE_HOME: path.join(home, "cache"), + XDG_STATE_HOME: path.join(home, "state"), + OPENCODE_CONFIG_CONTENT: JSON.stringify(opencodeConfig(providerPort)), + FIRST_TREE_CLIENT_ID: clientID, + FIRST_TREE_AGENT_ID: agentID, + FIRST_TREE_CHAT_ID: chatID, + FIRST_TREE_PROVIDER_MARKER: marker, + FTQA_BACKGROUND_RECORD_FILE: backgroundRecordFile, + npm_config_cache: path.join(home, "npm-cache"), + }); +} + +function processAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function processRecord(pid) { + const script = [ + `$item = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = ${pid}";`, + "if (-not $item) { exit 3 };", + `$process = Get-Process -Id ${pid} -ErrorAction Stop;`, + "$record = [ordered]@{", + `pid = ${pid}`, + "parentPid = [int]$item.ParentProcessId", + "startedAt = $process.StartTime.ToUniversalTime().ToString('o')", + "executablePath = [string]$item.ExecutablePath", + "commandLine = [string]$item.CommandLine", + "};", + "$record | ConvertTo-Json -Compress", + ].join("\r\n"); + return JSON.parse( + execFileSync( + "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], + { encoding: "utf8", windowsHide: true }, + ).replace(/^\uFEFF/, ""), + ); +} + +async function jobRequest(action) { + const seq = `${Date.now()}-${randomBytes(5).toString("hex")}`; + const responsePath = path.join(state, `windows-job-response-${seq}.json`); + writeJsonAtomic(controlRequestPath, { seq, action }); + const response = await waitUntil( + () => (existsSync(responsePath) ? readJson(responsePath) : undefined), + 8_000, + `Job Object ${action}`, + ); + rmSync(responsePath, { force: true }); + assert(!response.error, `Job Object ${action} failed: ${response.error}`); + return response; +} + +function launchPaths(name, seq) { + const prefix = path.join(state, `windows-${name}-${seq}`); + return { + specPath: `${prefix}-spec.json`, + goFile: `${prefix}-go`, + pidFile: `${prefix}-child.pid`, + resultPath: `${prefix}-result.json`, + stdoutPath: path.join(evidence, `windows-${name}-${seq}.stdout`), + stderrPath: path.join(evidence, `windows-${name}-${seq}.stderr`), + }; +} + +async function admitLaunch({ name, args, cwd, stdinText, env }) { + const seq = `${Date.now()}-${randomBytes(5).toString("hex")}`; + const files = launchPaths(name, seq); + const requestPath = path.join(state, `windows-job-launch-request-${seq}.json`); + const responsePath = path.join(state, `windows-job-launch-response-${seq}.json`); + const spec = { + binary, + args, + cwd, + stdinText, + env, + ...files, + }; + writeJsonAtomic(files.specPath, spec); + writeJsonAtomic(requestPath, { seq, specPath: files.specPath }); + const admission = await waitUntil( + () => (existsSync(responsePath) ? readJson(responsePath) : undefined), + 10_000, + `${name} wrapper Job admission`, + ); + rmSync(responsePath, { force: true }); + assert(!admission.error, `${name} admission failed: ${admission.error}`); + assert(admission.killOnJobClose === true, `${name} lacks KILL_ON_JOB_CLOSE`); + assert( + Number.isInteger(Number(admission.wrapperPid)) && Number(admission.wrapperPid) > 1, + `${name} returned invalid wrapper pid`, + ); + return { + name, + seq, + args, + cwd, + stdinText, + ...files, + wrapperPid: Number(admission.wrapperPid), + admission, + }; +} + +async function admitBatch(specs) { + const handles = await Promise.all(specs.map((spec) => admitLaunch(spec))); + const snapshot = await jobRequest("snapshot"); + for (const handle of handles) { + assert(snapshot.pids.includes(handle.wrapperPid), `${handle.name} wrapper was released outside the Job`); + } + for (const handle of handles) { + writeFileSync(handle.goFile, "go\n", { mode: 0o600 }); + } + return { handles, preReleaseJob: snapshot }; +} + +async function collectLaunch(handle, timeoutMs = 90_000) { + const wrapperResult = await waitUntil( + () => (existsSync(handle.resultPath) ? readJson(handle.resultPath) : undefined), + timeoutMs, + `${handle.name} completion`, + ); + const stdout = readFileSync(handle.stdoutPath, "utf8"); + const stderr = readFileSync(handle.stderrPath, "utf8"); + return { + ...handle, + childPid: Number(wrapperResult.childPid), + wrapperResult, + stdout, + stderr, + }; +} + +async function launchPid(handle, timeoutMs = 10_000) { + return waitUntil( + () => { + if (!existsSync(handle.pidFile)) return undefined; + const value = Number(readFileSync(handle.pidFile, "utf8").trim()); + return Number.isInteger(value) && value > 1 ? value : undefined; + }, + timeoutMs, + `${handle.name} OpenCode root pid`, + ); +} + +function parseJsonl(text, label) { + const events = []; + for (const [index, raw] of text.split(/\r?\n/u).entries()) { + if (!raw.trim()) continue; + try { + events.push(JSON.parse(raw)); + } catch (error) { + throw new Error(`${label} invalid JSONL line ${index + 1}: ${errorText(error)}`); + } + } + assert(events.length > 0, `${label} produced no JSONL events`); + return events; +} + +function analyzeTurn(run, { expectedSessionID } = {}) { + assert(run.wrapperResult.exitCode === 0, `${run.name} exit ${run.wrapperResult.exitCode}`); + const events = parseJsonl(run.stdout, run.name); + const sessionIDs = new Set(events.flatMap((event) => [event.sessionID, event.part?.sessionID]).filter(Boolean)); + assert(sessionIDs.size === 1, `${run.name} emitted ambiguous session IDs`); + const [sessionID] = sessionIDs; + if (expectedSessionID) { + assert(sessionID === expectedSessionID, `${run.name} resumed the wrong session`); + } + const terminalEvents = events.filter( + (event) => event.type === "step_finish" && event.part?.reason && event.part.reason !== "tool-calls", + ); + assert(terminalEvents.length > 0, `${run.name} emitted no terminal step_finish`); + assert(!events.some((event) => event.type === "error"), `${run.name} emitted error`); + return { + name: run.name, + wrapperPid: run.wrapperPid, + childPid: run.childPid, + exitCode: run.wrapperResult.exitCode, + startedAt: run.wrapperResult.startedAt, + exitedAt: run.wrapperResult.exitedAt, + sessionID, + eventTypes: events.map((event) => event.type), + terminalReasons: terminalEvents.map((event) => event.part.reason), + text: events + .filter((event) => event.type === "text") + .map((event) => event.part?.text ?? "") + .join("\n"), + toolEvents: events + .filter((event) => event.type === "tool_use") + .map((event) => ({ + tool: event.part?.tool, + status: event.part?.state?.status, + output: event.part?.state?.output, + exit: event.part?.state?.metadata?.exit, + })), + }; +} + +function processIntervalsOverlap(turns, label) { + assert(turns.length === 2, `${label} requires exactly two turns`); + const intervals = turns.map((turn) => ({ + name: turn.name, + startMs: Date.parse(turn.startedAt), + endMs: Date.parse(turn.exitedAt), + })); + assert( + intervals.every( + (interval) => + Number.isFinite(interval.startMs) && Number.isFinite(interval.endMs) && interval.endMs >= interval.startMs, + ), + `${label} has invalid wrapper lifecycle timestamps`, + ); + const overlapMs = + Math.min(...intervals.map((interval) => interval.endMs)) - + Math.max(...intervals.map((interval) => interval.startMs)); + assert(overlapMs > 0, `${label} wrapper lifecycles did not overlap`); + return { intervals, overlapMs }; +} + +function runArgs({ workdir, sessionID, title }) { + const args = [ + "run", + "--format", + "json", + "--auto", + "--agent", + managedAgent, + "--model", + explicitModel, + "--title", + title, + "--dir", + workdir, + "--print-logs", + "--log-level", + "ERROR", + ]; + if (sessionID) args.push("--session", sessionID); + return args; +} + +function runSpec({ name, workdir, prompt, providerPort, chatID, sessionID, title = "First Tree Windows CLI QA" }) { + return { + name, + args: runArgs({ workdir, sessionID, title }), + cwd: workdir, + stdinText: `${prompt}\n`, + env: opencodeEnvironment(providerPort, chatID), + }; +} + +function providerRecords() { + if (!existsSync(providerLog)) return []; + return readFileSync(providerLog, "utf8") + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +async function observeConcurrentBatch(batch, barrier, providerPort) { + const rootPids = await Promise.all(batch.handles.map((handle) => launchPid(handle))); + const arrivals = await waitUntil( + () => { + const rows = providerRecords().filter( + (record) => record.kind === "barrier_arrival" && record.barrier === barrier, + ); + return rows.length === 2 ? rows : undefined; + }, + 20_000, + `${barrier} provider concurrency barrier`, + ); + const membership = await jobRequest("snapshot"); + for (const rootPid of rootPids) { + assert(membership.pids.includes(rootPid), `${barrier} Job snapshot missed concurrent OpenCode root ${rootPid}`); + } + const releaseResponse = await fetch(`http://127.0.0.1:${providerPort}/ftqa/barriers/${barrier}/release`, { + method: "POST", + signal: AbortSignal.timeout(5_000), + }); + const release = await releaseResponse.json(); + assert(releaseResponse.ok, `${barrier} provider barrier release failed: ${JSON.stringify(release)}`); + return { + barrier, + arrivals: arrivals.map((record) => ({ + marker: record.marker, + requestID: record.requestID, + arrivedAt: record.arrivedAt, + })), + rootPids, + membership, + release, + }; +} + +function barrierProof(records, name) { + const arrivals = records.filter((record) => record.kind === "barrier_arrival" && record.barrier === name); + const releases = records.filter((record) => record.kind === "barrier_release" && record.barrier === name); + assert(arrivals.length === 2, `${name} barrier lacks two arrivals`); + assert(releases.length === 2, `${name} barrier lacks two releases`); + const arrivalTimes = arrivals.map((record) => Date.parse(record.arrivedAt)); + const releaseTimes = releases.map((record) => Date.parse(record.releasedAt)); + assert([...arrivalTimes, ...releaseTimes].every(Number.isFinite), `${name} barrier has invalid timestamps`); + assert( + Math.max(...arrivalTimes) <= Math.min(...releaseTimes), + `${name} barrier released before both requests arrived`, + ); + return { + arrivals: arrivals.map((record) => ({ + marker: record.marker, + requestID: record.requestID, + arrivedAt: record.arrivedAt, + })), + releases: releases.map((record) => ({ + marker: record.marker, + requestID: record.requestID, + releasedAt: record.releasedAt, + })), + arrivalSpreadMs: Math.max(...arrivalTimes) - Math.min(...arrivalTimes), + }; +} + +function providerAssertions() { + const records = providerRecords(); + const requests = records.filter((record) => record.kind === "request"); + const responseModes = records.filter((record) => record.kind === "response").map((record) => record.responseMode); + assert(requests.length >= 7, "provider observed too few real OpenCode requests"); + assert( + requests.every((record) => record.model === "qa-model"), + "provider observed a model other than explicit qa-model", + ); + assert( + requests.every((record) => record.markers?.slock === true), + "managed agent prompt marker was missing", + ); + for (const expected of ["tool_call", "background_tool_call", "background_hold"]) { + assert(responseModes.includes(expected), `provider did not exercise ${expected}`); + } + return { + requestCount: requests.length, + models: [...new Set(requests.map((record) => record.model))], + managedAgentMarkerEveryRequest: true, + responseModes, + concurrencyBarriers: { + new: barrierProof(records, "new"), + resume: barrierProof(records, "resume"), + }, + }; +} + +let provider; +let providerError = ""; +let jobTerminated = false; +let backgroundPid; +let backgroundRootPid; +let providerCleanup; +const timings = {}; +const startedAt = Date.now(); + +async function failClosedCleanup() { + const cleanup = { attempted: false, error: null, finalJobPids: null }; + if (!existsSync(readyPath)) return cleanup; + cleanup.attempted = true; + try { + await jobRequest("terminate"); + jobTerminated = true; + const final = await jobRequest("snapshot"); + cleanup.finalJobPids = final.pids; + } catch (error) { + cleanup.error = errorText(error); + } + return cleanup; +} + +async function stopProvider() { + if (!provider) { + return { + pid: null, + requested: false, + exitCode: null, + signalCode: null, + alive: false, + exitObserved: true, + error: null, + }; + } + const pid = provider.pid; + let cleanupError = null; + const exitObservation = + provider.exitCode !== null || provider.signalCode !== null + ? Promise.resolve(true) + : new Promise((resolve) => provider.once("exit", () => resolve(true))); + let exitObserved = false; + try { + if (processAlive(pid)) { + provider.kill(); + try { + await waitUntil(() => (!processAlive(pid) ? true : undefined), 3_000, "local QA provider exit", 50); + } catch { + execFileSync("taskkill.exe", ["/pid", String(pid), "/f", "/t"], { stdio: "ignore", windowsHide: true }); + await waitUntil(() => (!processAlive(pid) ? true : undefined), 3_000, "forced local QA provider exit", 50); + } + } + exitObserved = (await Promise.race([exitObservation, delay(3_000).then(() => false)])) === true; + if (!exitObserved) { + cleanupError = "local QA provider exit event was not observed"; + } + } catch (error) { + cleanupError = errorText(error); + } + return { + pid, + requested: true, + exitCode: provider.exitCode, + signalCode: provider.signalCode, + alive: processAlive(pid), + exitObserved, + error: cleanupError, + }; +} + +try { + mkdirSync(state, { recursive: true }); + mkdirSync(evidence, { recursive: true }); + mkdirSync(home, { recursive: true }); + for (const name of readdirSync(state)) { + if (name === path.basename(readyPath)) continue; + if (name.startsWith("windows-")) { + rmSync(path.join(state, name), { recursive: true, force: true }); + } + } + for (const name of readdirSync(evidence)) { + const currentRunIdentity = + name === "windows-actions-runner-identity.json" || name === "windows-runner-identity.json"; + if (name.startsWith("windows-") && !currentRunIdentity) { + rmSync(path.join(evidence, name), { recursive: true, force: true }); + } + } + rmSync(workRoot, { recursive: true, force: true }); + cpSync(projectTemplate, workdirA, { recursive: true }); + cpSync(projectTemplate, workdirB, { recursive: true }); + rmSync(home, { recursive: true, force: true }); + mkdirSync(home, { recursive: true }); + + assert(process.platform === "win32", "Windows harness requires a Windows kernel"); + assert(process.arch === "x64", "pinned Windows image requires x64"); + assert(process.version === expectedNodeVersion, `unexpected Node ${process.version}`); + const jobReady = await waitUntil( + () => (existsSync(readyPath) ? readJson(readyPath) : undefined), + 10_000, + "supervisor Job readiness", + ); + assert(jobReady.killOnJobClose === true, "supervisor Job lacks KILL_ON_JOB_CLOSE"); + + const runtimeVersionStarted = Date.now(); + const versionBatch = await admitBatch([ + { + name: "runtime-version-gate", + args: ["--version"], + cwd: workdirA, + stdinText: "", + env: minimalEnvironment(), + }, + ]); + const versionRun = await collectLaunch(versionBatch.handles[0], 30_000); + timings.runtimeVersionGateMs = Date.now() - runtimeVersionStarted; + assert(versionRun.wrapperResult.exitCode === 0, `runtime version gate exit ${versionRun.wrapperResult.exitCode}`); + const openCodeVersion = versionRun.stdout.trim(); + assert(openCodeVersion === expectedOpenCodeVersion, `unexpected OpenCode ${openCodeVersion}`); + + const providerPort = await freePort(); + const backgroundCommand = [ + "Start-Process", + "-FilePath 'powershell.exe'", + "-ArgumentList @('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass',", + `'-File','${backgroundScript.replaceAll("'", "''")}')`, + "-WindowStyle Hidden", + ].join(" "); + provider = spawn(process.execPath, [providerScript], { + env: minimalEnvironment({ + QA_PROVIDER_PORT: String(providerPort), + QA_PROVIDER_LOG: providerLog, + FTQA_BACKGROUND_COMMAND: backgroundCommand, + FTQA_SHELL_COMMAND: "echo QA_TOOL_RESULT_218A", + }), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + let providerOutput = ""; + provider.stdout.on("data", (chunk) => { + providerOutput += chunk.toString(); + }); + provider.stderr.on("data", (chunk) => { + providerError += chunk.toString(); + }); + await waitUntil(() => providerOutput.includes("QA provider ready"), 5_000, "local OpenAI-compatible provider"); + + const dbGateStarted = Date.now(); + const dbBatch = await admitBatch([ + { + name: "db-gate", + args: ["db", "SELECT 1 AS ready", "--format", "json", "--print-logs", "--log-level", "ERROR"], + cwd: workdirA, + stdinText: "", + env: opencodeEnvironment(providerPort), + }, + ]); + const dbRun = await collectLaunch(dbBatch.handles[0], 60_000); + timings.dbGateMs = Date.now() - dbGateStarted; + assert(dbRun.wrapperResult.exitCode === 0, `DB gate exit ${dbRun.wrapperResult.exitCode}`); + const dbGateOutput = JSON.parse(dbRun.stdout.replace(/^\uFEFF/, "")); + assert(JSON.stringify(dbGateOutput).includes('"ready":1'), "DB gate did not return ready=1"); + assert(existsSync(database), "default isolated OpenCode database was not created"); + + const concurrentStarted = Date.now(); + const newBatch = await admitBatch([ + runSpec({ + name: "new-a", + workdir: workdirA, + prompt: "QA_CONCURRENCY_NEW_A_218A FIRST_TURN_MARKER_218A CHAT_A", + providerPort, + chatID: chatIDA, + title: "First Tree Windows CLI A", + }), + runSpec({ + name: "new-b", + workdir: workdirB, + prompt: "QA_CONCURRENCY_NEW_B_218A FIRST_TURN_MARKER_218A CHAT_B", + providerPort, + chatID: chatIDB, + title: "First Tree Windows CLI B", + }), + ]); + const newConcurrencyProof = await observeConcurrentBatch(newBatch, "new", providerPort); + const [newARun, newBRun] = await Promise.all(newBatch.handles.map((handle) => collectLaunch(handle))); + timings.concurrentNewMs = Date.now() - concurrentStarted; + const newA = analyzeTurn(newARun); + const newB = analyzeTurn(newBRun); + const newProcessOverlap = processIntervalsOverlap([newA, newB], "concurrent new"); + assert(newA.sessionID !== newB.sessionID, "concurrent workdirs shared a session ID"); + assert(newA.text.includes("firstTurn=true"), "chat A provider history marker missing"); + assert(newB.text.includes("firstTurn=true"), "chat B provider history marker missing"); + + const resumeStarted = Date.now(); + const resumeBatch = await admitBatch([ + runSpec({ + name: "resume-a", + workdir: workdirA, + prompt: "QA_CONCURRENCY_RESUME_A_218A RESUME_MARKER_A_218A", + providerPort, + chatID: chatIDA, + sessionID: newA.sessionID, + }), + runSpec({ + name: "resume-b", + workdir: workdirB, + prompt: "QA_CONCURRENCY_RESUME_B_218A RESUME_MARKER_B_218A", + providerPort, + chatID: chatIDB, + sessionID: newB.sessionID, + }), + ]); + const resumeConcurrencyProof = await observeConcurrentBatch(resumeBatch, "resume", providerPort); + const [resumeARun, resumeBRun] = await Promise.all(resumeBatch.handles.map((handle) => collectLaunch(handle))); + timings.concurrentResumeMs = Date.now() - resumeStarted; + const resumeA = analyzeTurn(resumeARun, { expectedSessionID: newA.sessionID }); + const resumeB = analyzeTurn(resumeBRun, { expectedSessionID: newB.sessionID }); + const resumeProcessOverlap = processIntervalsOverlap([resumeA, resumeB], "concurrent resume"); + assert(resumeA.text.includes("firstTurn=true"), "chat A resume lost history"); + assert(resumeB.text.includes("firstTurn=true"), "chat B resume lost history"); + + const toolStarted = Date.now(); + const toolBatch = await admitBatch([ + runSpec({ + name: "shell-tool", + workdir: workdirA, + prompt: "QA_CALL_BASH", + providerPort, + chatID: chatIDA, + sessionID: newA.sessionID, + }), + ]); + const toolRun = await collectLaunch(toolBatch.handles[0]); + timings.shellToolMs = Date.now() - toolStarted; + const tool = analyzeTurn(toolRun, { expectedSessionID: newA.sessionID }); + const shellEvent = tool.toolEvents.find((event) => event.tool === "bash"); + assert(shellEvent, "OpenCode emitted no shell tool event"); + assert(shellEvent.status === "completed", "shell tool did not complete"); + assert(shellEvent.exit === 0, "shell tool returned non-zero"); + assert(String(shellEvent.output).includes("QA_TOOL_RESULT_218A"), "shell tool output marker missing"); + + rmSync(backgroundRecordFile, { force: true }); + const backgroundStarted = Date.now(); + const backgroundBatch = await admitBatch([ + runSpec({ + name: "background-hold", + workdir: workdirB, + prompt: "QA_CALL_BACKGROUND_HOLD", + providerPort, + chatID: chatIDB, + sessionID: newB.sessionID, + }), + ]); + const backgroundHandle = backgroundBatch.handles[0]; + backgroundRootPid = await waitUntil( + () => { + if (!existsSync(backgroundHandle.pidFile)) return undefined; + const value = Number(readFileSync(backgroundHandle.pidFile, "utf8").trim()); + return Number.isInteger(value) && value > 1 ? value : undefined; + }, + 10_000, + "background OpenCode root pid", + ); + const backgroundRecord = await waitUntil( + () => (existsSync(backgroundRecordFile) ? readJson(backgroundRecordFile) : undefined), + 20_000, + "detached Windows background child", + ); + timings.backgroundAdmissionMs = Date.now() - backgroundStarted; + backgroundPid = Number(backgroundRecord.pid); + assert(Number.isInteger(backgroundPid) && backgroundPid > 1, "invalid background pid"); + assert(backgroundRecord.clientId === clientID, "background lost client attribution"); + assert(backgroundRecord.agentId === agentID, "background lost agent attribution"); + assert(backgroundRecord.chatId === chatIDB, "background lost chat attribution"); + assert(backgroundRecord.providerMarker === marker, "background marker mismatch"); + assert(backgroundRecord.parentPid !== backgroundRootPid, "background child remained a direct child of OpenCode root"); + assert(processAlive(backgroundRootPid), "OpenCode root exited before explicit stop"); + assert(processAlive(backgroundPid), "background child exited before root stop"); + + const backgroundEventsBeforeStop = await waitUntil( + () => { + if (!existsSync(backgroundHandle.stdoutPath)) return undefined; + const text = readFileSync(backgroundHandle.stdoutPath, "utf8"); + if (!text.includes('"type":"tool_use"')) return undefined; + return parseJsonl(text, "background partial JSONL"); + }, + 10_000, + "background shell tool event", + ); + const backgroundTool = backgroundEventsBeforeStop.find( + (event) => event.type === "tool_use" && event.part?.tool === "bash", + ); + assert(backgroundTool, "background run emitted no shell tool event"); + assert(backgroundTool.part?.state?.status === "completed", "background shell tool did not complete"); + + const membershipBeforeRootStop = await jobRequest("snapshot"); + assert( + membershipBeforeRootStop.pids.includes(backgroundHandle.wrapperPid), + "Job missed background wrapper before root stop", + ); + assert(membershipBeforeRootStop.pids.includes(backgroundRootPid), "Job missed OpenCode root before root stop"); + assert( + membershipBeforeRootStop.pids.includes(backgroundPid), + "Job missed detached background child before root stop", + ); + const backgroundRootProcessBeforeStop = processRecord(backgroundRootPid); + assert( + path.normalize(backgroundRootProcessBeforeStop.executablePath).toLowerCase() === + path.normalize(binary).toLowerCase(), + "root pid no longer identifies the admitted OpenCode binary", + ); + const backgroundRootCommandLine = backgroundRootProcessBeforeStop.commandLine.toLowerCase(); + for (const expectedArguments of [ + " run ", + "--format json", + "--auto", + `--agent ${managedAgent}`, + `--model ${explicitModel}`, + `--session ${newB.sessionID.toLowerCase()}`, + ]) { + assert( + backgroundRootCommandLine.includes(expectedArguments), + `background root command line missing ${expectedArguments.trim()}`, + ); + } + assert( + !backgroundRootProcessBeforeStop.commandLine.includes("QA_CALL_BACKGROUND_HOLD"), + "background prompt leaked into the OpenCode process command line", + ); + const backgroundProcessBeforeRootStop = processRecord(backgroundPid); + assert( + backgroundProcessBeforeRootStop.startedAt === backgroundRecord.startedAt, + "background pid no longer identifies the child that wrote the evidence record", + ); + + const rootStopStarted = Date.now(); + process.kill(backgroundRootPid, "SIGTERM"); + await waitUntil( + () => !processAlive(backgroundRootPid) && !processAlive(backgroundHandle.wrapperPid), + 10_000, + "OpenCode root and wrapper exit", + ); + timings.rootStopMs = Date.now() - rootStopStarted; + assert(processAlive(backgroundPid), "background did not survive OpenCode root exit"); + const membershipAfterRootStop = await waitUntil( + async () => { + const snapshot = await jobRequest("snapshot"); + return snapshot.pids.includes(backgroundPid) && + !snapshot.pids.includes(backgroundRootPid) && + !snapshot.pids.includes(backgroundHandle.wrapperPid) + ? snapshot + : undefined; + }, + 8_000, + "detached child Job membership after root exit", + 50, + ); + const backgroundProcessAfterRootStop = processRecord(backgroundPid); + assert( + backgroundProcessAfterRootStop.startedAt === backgroundProcessBeforeRootStop.startedAt, + "background pid identity changed across OpenCode root exit", + ); + + const terminateStarted = Date.now(); + const terminateResponse = await jobRequest("terminate"); + jobTerminated = true; + const zeroScan1 = await waitUntil( + async () => { + const snapshot = await jobRequest("snapshot"); + return snapshot.pids.length === 0 ? snapshot : undefined; + }, + 8_000, + "first empty Job process list", + 50, + ); + await delay(500); + const zeroScan2 = await jobRequest("snapshot"); + assert(zeroScan2.pids.length === 0, "second Job process list was not empty"); + assert(!processAlive(backgroundPid), "background survived TerminateJobObject"); + timings.terminateAndDoubleZeroMs = Date.now() - terminateStarted; + + const providerProof = providerAssertions(); + providerCleanup = await stopProvider(); + assert(!providerCleanup.alive, "local QA provider remained alive after cleanup"); + assert(!providerCleanup.error, `local QA provider cleanup failed: ${providerCleanup.error}`); + const binaryBytes = readFileSync(binary); + const result = { + schema: "first-tree.opencode.windows-cli-e2e.v1", + status: "PASS", + artifact: { + windowsBase, + windowsVersion: execFileSync("cmd.exe", ["/d", "/s", "/c", "ver"], { + encoding: "utf8", + windowsHide: true, + }).trim(), + nodeVersion: process.version, + nodeArchiveSha256: expectedNodeArchiveSha256, + opencodePackage: `opencode-ai@${openCodeVersion}`, + opencodeNpmIntegrity: expectedOpenCodeNpmIntegrity, + opencodeVersion: openCodeVersion, + opencodeBinary: binary, + opencodeBinarySha256: createHash("sha256").update(binaryBytes).digest("hex"), + platform: process.platform, + arch: process.arch, + }, + configuration: { + transport: "per-turn opencode run JSONL", + promptTransport: "stdin+EOF", + managedAgent, + explicitModel, + sessionBindings: [ + { chatID: chatIDA, workdir: workdirA }, + { chatID: chatIDB, workdir: workdirB }, + ], + isolatedHome: home, + opencodeDbOverridePresent: Object.hasOwn(opencodeEnvironment(providerPort), "OPENCODE_DB"), + defaultDatabase: { + path: database, + bytes: statSync(database).size, + }, + }, + dbGate: { + query: "SELECT 1 AS ready", + exitCode: dbRun.wrapperResult.exitCode, + output: dbGateOutput, + preReleaseJob: dbBatch.preReleaseJob, + elapsedMs: timings.dbGateMs, + }, + runtimeVersionGate: { + version: openCodeVersion, + exitCode: versionRun.wrapperResult.exitCode, + rootPid: versionRun.childPid, + preReleaseJob: versionBatch.preReleaseJob, + elapsedMs: timings.runtimeVersionGateMs, + }, + concurrency: { + workdirs: [workdirA, workdirB], + new: [newA, newB], + distinctSessionIDs: newA.sessionID !== newB.sessionID, + preReleaseJob: newBatch.preReleaseJob, + deterministicBarrier: newConcurrencyProof, + processOverlap: newProcessOverlap, + elapsedMs: timings.concurrentNewMs, + }, + resume: { + sameDirectory: true, + explicitSessionFlag: true, + turns: [resumeA, resumeB], + preReleaseJob: resumeBatch.preReleaseJob, + deterministicBarrier: resumeConcurrencyProof, + processOverlap: resumeProcessOverlap, + elapsedMs: timings.concurrentResumeMs, + }, + tool: { + sessionID: tool.sessionID, + event: shellEvent, + preReleaseJob: toolBatch.preReleaseJob, + elapsedMs: timings.shellToolMs, + }, + provider: providerProof, + job: { + authority: "supervisor-owned Windows Job Object membership", + killOnJobClose: jobReady.killOnJobClose, + preAdmissionRaceClosed: true, + childRegistryIsAuthority: false, + background: { + rootPid: backgroundRootPid, + wrapperPid: backgroundHandle.wrapperPid, + record: backgroundRecord, + backgroundRootProcessBeforeStop, + argvEvidence: { + requiredArgumentsObserved: [ + "run", + "--format json", + "--auto", + `--agent ${managedAgent}`, + `--model ${explicitModel}`, + `--session ${newB.sessionID}`, + ], + requiredArgumentsPresent: true, + promptMarkerAbsent: true, + }, + processBeforeRootStop: backgroundProcessBeforeRootStop, + processAfterRootStop: backgroundProcessAfterRootStop, + toolEvent: { + tool: backgroundTool.part?.tool, + status: backgroundTool.part?.state?.status, + output: backgroundTool.part?.state?.output, + }, + preReleaseJob: backgroundBatch.preReleaseJob, + membershipBeforeRootStop, + rootExited: true, + childSurvivedRootExit: true, + membershipAfterRootStop, + }, + terminate: { + response: terminateResponse, + zeroScan1, + zeroScan2, + intervalMs: 500, + }, + }, + timings: { + ...timings, + totalMs: Date.now() - startedAt, + }, + finalResidue: { + runtimeScope: { + jobPids: zeroScan2.pids, + backgroundAlive: processAlive(backgroundPid), + rootAlive: processAlive(backgroundRootPid), + }, + harnessSupport: { + localProvider: providerCleanup, + }, + }, + providerStderr: providerError, + }; + persistResult(result); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} catch (error) { + const cleanup = await failClosedCleanup(); + providerCleanup = await stopProvider(); + const failure = { + schema: "first-tree.opencode.windows-cli-e2e.v1", + status: "FAIL", + failClosed: true, + error: errorText(error), + artifact: { + expectedNodeVersion, + expectedNodeArchiveSha256, + expectedOpenCodeVersion, + expectedOpenCodeNpmIntegrity, + windowsBase, + platform: process.platform, + arch: process.arch, + }, + partial: { + backgroundPid, + backgroundRootPid, + jobTerminated, + timings, + }, + cleanup, + finalResidue: { + runtimeScope: { + jobPids: cleanup.finalJobPids, + backgroundAlive: Number.isInteger(backgroundPid) && backgroundPid > 1 ? processAlive(backgroundPid) : null, + rootAlive: + Number.isInteger(backgroundRootPid) && backgroundRootPid > 1 ? processAlive(backgroundRootPid) : null, + }, + harnessSupport: { + localProvider: providerCleanup, + }, + }, + providerStderr: providerError, + }; + try { + persistResult(failure); + } catch (receiptError) { + failure.receiptWriteError = errorText(receiptError); + } + process.stderr.write(`${JSON.stringify(failure, null, 2)}\n`); + process.exitCode = 1; +} finally { + if (!jobTerminated && existsSync(readyPath)) { + await failClosedCleanup(); + } + if (provider && Number.isInteger(provider.pid) && processAlive(provider.pid)) { + await stopProvider(); + } +} diff --git a/.github/workflows/opencode-windows-docker-e2e.yml b/.github/workflows/opencode-windows-docker-e2e.yml new file mode 100644 index 000000000..f68e850ba --- /dev/null +++ b/.github/workflows/opencode-windows-docker-e2e.yml @@ -0,0 +1,169 @@ +name: One-time OpenCode Windows Docker E2E + +"on": + push: + branches: + - test/opencode-windows-docker-e2e + +permissions: + contents: read + +jobs: + windows-docker-e2e: + runs-on: windows-2022 + timeout-minutes: 30 + env: + FTQA_ROOT: .github/qa/opencode-windows-docker-e2e + + steps: + - name: Check out exact branch revision + uses: actions/checkout@v4 + + - name: Capture runner and Docker engine identity + shell: pwsh + run: | + $evidenceDir = Join-Path $env:RUNNER_TEMP "opencode-windows-evidence" + $artifactDir = Join-Path $env:RUNNER_TEMP "opencode-windows-artifact" + "FTQA_EVIDENCE_DIR=$evidenceDir" >> $env:GITHUB_ENV + "FTQA_ARTIFACT_DIR=$artifactDir" >> $env:GITHUB_ENV + & "$env:FTQA_ROOT/windows/capture-runner-identity.ps1" ` + -EvidenceDir $evidenceDir + + - name: Run static contract checks + shell: pwsh + run: | + Push-Location $env:FTQA_ROOT + try { + npm run check:windows-static + } finally { + Pop-Location + } + + - name: Run Windows Docker E2E + shell: pwsh + run: | + $projectName = "ftqa-opencode-windows-${{ github.run_id }}-${{ github.run_attempt }}" + $evidenceDir = $env:FTQA_EVIDENCE_DIR + "FTQA_PROJECT_NAME=$projectName" >> $env:GITHUB_ENV + & "$env:FTQA_ROOT/windows/run-windows.ps1" ` + -EvidenceDir $evidenceDir ` + -ProjectName $projectName + + - name: Enforce scoped Docker cleanup + if: always() + shell: pwsh + run: | + $ErrorActionPreference = "Continue" + if (-not $env:FTQA_PROJECT_NAME) { + $env:FTQA_PROJECT_NAME = "ftqa-opencode-windows-${{ github.run_id }}-${{ github.run_attempt }}" + } + if (-not $env:FTQA_EVIDENCE_DIR) { + $env:FTQA_EVIDENCE_DIR = Join-Path $env:RUNNER_TEMP "opencode-windows-evidence" + } + New-Item -ItemType Directory -Force $env:FTQA_EVIDENCE_DIR | Out-Null + $serviceImageReferences = @( + @( + docker compose ` + -p $env:FTQA_PROJECT_NAME ` + -f "$env:FTQA_ROOT/compose.windows.yaml" ` + config ` + --images + ) | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object -Unique + ) + $imageConfigExit = $LASTEXITCODE + $serviceImageReference = $null + $serviceImageID = $null + if ($serviceImageReferences.Count -eq 1) { + $serviceImageReference = [string]$serviceImageReferences[0] + $serviceImageID = ( + docker image inspect ` + $serviceImageReference ` + --format "{{.Id}}" 2>$null | + Out-String + ).Trim() + } + docker compose ` + -p $env:FTQA_PROJECT_NAME ` + -f "$env:FTQA_ROOT/compose.windows.yaml" ` + down ` + --remove-orphans ` + --rmi local + $downExit = $LASTEXITCODE + $containers = @( + docker ps -aq ` + --filter "label=com.docker.compose.project=$env:FTQA_PROJECT_NAME" + ) | Where-Object { $_ } + $containerReadExit = $LASTEXITCODE + $networks = @( + docker network ls -q ` + --filter "label=com.docker.compose.project=$env:FTQA_PROJECT_NAME" + ) | Where-Object { $_ } + $networkReadExit = $LASTEXITCODE + $serviceImageIDAlive = $false + if ($serviceImageID) { + docker image inspect $serviceImageID *> $null + $serviceImageIDAlive = $LASTEXITCODE -eq 0 + } + $serviceImageReferenceAlive = $false + if ($serviceImageReference) { + docker image inspect $serviceImageReference *> $null + $serviceImageReferenceAlive = $LASTEXITCODE -eq 0 + } + $receipt = [ordered]@{ + schema = "first-tree.opencode.windows-actions-cleanup.v1" + status = $( + if ( + $downExit -eq 0 -and + $containerReadExit -eq 0 -and + $networkReadExit -eq 0 -and + $imageConfigExit -eq 0 -and + $serviceImageReferences.Count -eq 1 -and + @($containers).Count -eq 0 -and + @($networks).Count -eq 0 -and + -not $serviceImageIDAlive -and + -not $serviceImageReferenceAlive + ) { "PASS" } else { "FAIL" } + ) + projectName = $env:FTQA_PROJECT_NAME + downExitCode = $downExit + containers = @($containers) + networks = @($networks) + serviceImageReference = $serviceImageReference + serviceImageID = $serviceImageID + serviceImageIDAlive = $serviceImageIDAlive + serviceImageReferenceAlive = $serviceImageReferenceAlive + completedAt = [DateTime]::UtcNow.ToString("o") + } + $receiptPath = Join-Path $env:FTQA_EVIDENCE_DIR "windows-actions-cleanup-result.json" + [System.IO.File]::WriteAllText( + $receiptPath, + (($receipt | ConvertTo-Json -Depth 10) + [Environment]::NewLine), + (New-Object System.Text.UTF8Encoding($false)) + ) + if ($receipt.status -ne "PASS") { + throw "Scoped Windows Docker cleanup failed" + } + exit 0 + + - name: Prepare redacted evidence + if: always() + shell: pwsh + run: | + if (-not $env:FTQA_EVIDENCE_DIR) { + $env:FTQA_EVIDENCE_DIR = Join-Path $env:RUNNER_TEMP "opencode-windows-evidence" + } + if (-not $env:FTQA_ARTIFACT_DIR) { + $env:FTQA_ARTIFACT_DIR = Join-Path $env:RUNNER_TEMP "opencode-windows-artifact" + } + & "$env:FTQA_ROOT/windows/redact-evidence.ps1" ` + -EvidenceDir $env:FTQA_EVIDENCE_DIR ` + -OutputDir $env:FTQA_ARTIFACT_DIR + + - name: Upload redacted evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: opencode-windows-docker-evidence-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/opencode-windows-artifact + if-no-files-found: error + retention-days: 7