From 97b29ce01cf1eb3d7852bfd9fdd648fda89be2d5 Mon Sep 17 00:00:00 2001 From: Lxr-max Date: Sat, 3 Oct 2026 19:29:47 +0800 Subject: [PATCH] fix(desktop): serve done/preview harness documents to Chrome from memory The done() verifier and the preview tool wrote their harness HTML under os.tmpdir() and navigated system Chrome to that file:// URL. Chrome builds with a private /tmp (Snap Chromium, Flatpak) cannot see that file, so every verification failed with net::ERR_FILE_NOT_FOUND and generation ended in GENERATION_INCOMPLETE. Fulfil the harness document request through the existing request interception instead, keeping the same file:// URL so relative workspace assets and the file-URL allowlist behave exactly as before. Nothing is written to the temp directory any more. Refs #455 --- .changeset/done-verify-sandboxed-chrome.md | 5 + apps/desktop/src/main/done-verify.ts | 31 +++-- .../desktop/src/main/harness-document.test.ts | 122 ++++++++++++++++++ apps/desktop/src/main/preview-runtime.ts | 28 +++- 4 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 .changeset/done-verify-sandboxed-chrome.md create mode 100644 apps/desktop/src/main/harness-document.test.ts diff --git a/.changeset/done-verify-sandboxed-chrome.md b/.changeset/done-verify-sandboxed-chrome.md new file mode 100644 index 00000000..5ffad0e7 --- /dev/null +++ b/.changeset/done-verify-sandboxed-chrome.md @@ -0,0 +1,5 @@ +--- +"@open-codesign/desktop": patch +--- + +Serve the `done` verifier and `preview` harness documents to system Chrome from memory instead of temporary files, so verification no longer fails with `ERR_FILE_NOT_FOUND` when Chrome/Chromium runs with a private `/tmp` (Snap, Flatpak). diff --git a/apps/desktop/src/main/done-verify.ts b/apps/desktop/src/main/done-verify.ts index 3ecea41f..fa316ce4 100644 --- a/apps/desktop/src/main/done-verify.ts +++ b/apps/desktop/src/main/done-verify.ts @@ -3,7 +3,7 @@ * * The agent emits a JSX module (TWEAK_DEFAULTS + App + ReactDOM.createRoot). * We wrap it via `@open-codesign/runtime`'s `buildSrcdoc` (same path the - * preview iframe uses), write the srcdoc to a temporary HTML file, load it with + * preview iframe uses), serve it from memory at a file:// URL, load it with * the same system Chrome/Puppeteer engine used by `preview`, and capture * console/page errors for a short settle window. The collected errors flow * back through the `done` tool so the agent can self-heal. @@ -14,7 +14,8 @@ * confirm the next `done` tool result lists the error. */ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL, URL } from 'node:url'; @@ -23,7 +24,12 @@ import { findSystemChrome } from '@open-codesign/exporters'; import { buildSrcdoc } from '@open-codesign/runtime'; import type { Browser, ConsoleMessage, HTTPRequest, Page } from 'puppeteer-core'; import { boundedPreview } from './preview-interactions'; -import { buildWorkspacePreviewDocument, isPreviewFileUrlAllowed } from './preview-runtime'; +import { + buildWorkspacePreviewDocument, + isHarnessDocumentRequest, + isPreviewFileUrlAllowed, + respondWithHarnessDocument, +} from './preview-runtime'; const VERIFY_LOAD_TIMEOUT_MS = 15_000; const SETTLE_AFTER_LOAD_MS = 1200; @@ -95,9 +101,14 @@ function mapConsoleSource(raw: string): string | null { async function handleVerifierRequest( req: HTTPRequest, verifyFilePath: string, + html: string, workspaceRoot?: string, ): Promise { try { + if (isHarnessDocumentRequest(req.url(), verifyFilePath)) { + await respondWithHarnessDocument(req, html); + return; + } const allowed = workspaceRoot !== undefined && req.url().startsWith('file:') ? await isPreviewFileUrlAllowed(req.url(), workspaceRoot, verifyFilePath) @@ -167,6 +178,7 @@ function pushUniqueError( async function verifyWithSystemChrome( verifyUrl: string, verifyPath: string, + html: string, workspaceRoot?: string, signal?: AbortSignal, ): Promise { @@ -199,7 +211,7 @@ async function verifyWithSystemChrome( await page.setViewport({ width: 1280, height: 800 }); await page.setRequestInterception(true); page.on('request', (req: HTTPRequest) => { - void handleVerifierRequest(req, verifyPath, workspaceRoot); + void handleVerifierRequest(req, verifyPath, html, workspaceRoot); }); page.on('console', (msg: ConsoleMessage) => { const source = mapConsoleSource(msg.type()); @@ -261,15 +273,8 @@ export function makeRuntimeVerifier(options?: { workspaceRoot: string }): DoneRu context?.path ?? 'App.jsx', ) : buildSrcdoc(artifactSource); - const tempDir = await mkdtemp(join(tmpdir(), 'codesign-done-verify-')); - const verifyPath = join(tempDir, 'verify.html'); - await writeFile(verifyPath, srcdoc, 'utf8'); + const verifyPath = join(tmpdir(), `codesign-done-verify-${randomUUID()}`, 'verify.html'); const verifyUrl = pathToFileURL(verifyPath).href; - - try { - return await verifyWithSystemChrome(verifyUrl, verifyPath, workspaceRoot, context?.signal); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } + return verifyWithSystemChrome(verifyUrl, verifyPath, srcdoc, workspaceRoot, context?.signal); }; } diff --git a/apps/desktop/src/main/harness-document.test.ts b/apps/desktop/src/main/harness-document.test.ts new file mode 100644 index 00000000..612719a4 --- /dev/null +++ b/apps/desktop/src/main/harness-document.test.ts @@ -0,0 +1,122 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeRuntimeVerifier } from './done-verify'; +import { isHarnessDocumentRequest, runPreview } from './preview-runtime'; + +interface FakeRequest { + url: () => string; + isNavigationRequest: () => boolean; + resourceType: () => string; + respond: ReturnType; + continue: ReturnType; + abort: ReturnType; +} + +const browser = vi.hoisted(() => ({ + navigations: [] as Array<{ url: string; existedOnDisk: boolean; request: FakeRequest }>, +})); + +vi.mock('@open-codesign/exporters', () => ({ findSystemChrome: async () => 'synthetic-chrome' })); +vi.mock('./logger', () => ({ getLogger: () => ({ warn: vi.fn(), error: vi.fn() }) })); +vi.mock('puppeteer-core', () => ({ + default: { + launch: async () => { + const handlers = new Map void>(); + const page = { + setViewport: async () => {}, + setRequestInterception: async () => {}, + evaluateOnNewDocument: async () => {}, + on: (event: string, handler: (arg: unknown) => void) => handlers.set(event, handler), + goto: async (url: string) => { + const request: FakeRequest = { + url: () => url, + isNavigationRequest: () => true, + resourceType: () => 'document', + respond: vi.fn(async () => {}), + continue: vi.fn(async () => {}), + abort: vi.fn(async () => {}), + }; + browser.navigations.push({ + url, + existedOnDisk: existsSync(fileURLToPath(url)), + request, + }); + handlers.get('request')?.(request); + await vi.waitFor(() => expect(request.respond).toHaveBeenCalled()); + }, + evaluate: async () => ({ nodes: 1, width: 1280, height: 800 }), + close: async () => {}, + }; + return { newPage: async () => page, close: async () => {} }; + }, + }, +})); + +const workspace = mkdtempSync(join(tmpdir(), 'codesign-harness-doc-')); + +afterAll(() => { + rmSync(workspace, { recursive: true, force: true }); +}); + +beforeEach(() => { + browser.navigations.length = 0; +}); + +function onlyNavigation() { + expect(browser.navigations).toHaveLength(1); + const [navigation] = browser.navigations; + if (navigation === undefined) throw new Error('no navigation recorded'); + return navigation; +} + +describe('harness documents for sandboxed system Chrome (#455)', () => { + it('serves the done verifier document from memory instead of a shared temp file', async () => { + const errors = await makeRuntimeVerifier()('
ok
', { + path: 'index.html', + }); + + const navigation = onlyNavigation(); + expect(errors).toEqual([]); + expect(navigation.url).toMatch(/^file:.*verify\.html$/); + expect(navigation.existedOnDisk).toBe(false); + expect(navigation.request.continue).not.toHaveBeenCalled(); + expect(navigation.request.respond).toHaveBeenCalledWith( + expect.objectContaining({ + status: 200, + contentType: 'text/html; charset=utf-8', + body: expect.stringContaining('verify-marker'), + }), + ); + }); + + it('serves the preview document from memory instead of a shared temp file', async () => { + writeFileSync(join(workspace, 'index.html'), '
ok
', 'utf8'); + + const result = await runPreview({ + workspaceRoot: workspace, + path: 'index.html', + vision: false, + }); + + const navigation = onlyNavigation(); + expect(result.ok).toBe(true); + expect(navigation.url).toMatch(/^file:.*preview\.html$/); + expect(navigation.existedOnDisk).toBe(false); + expect(navigation.request.continue).not.toHaveBeenCalled(); + expect(navigation.request.respond).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.stringContaining('preview-marker') }), + ); + }); + + it('matches only the exact harness document URL', () => { + const documentPath = join(tmpdir(), 'codesign-done-verify-x', 'verify.html'); + const documentUrl = pathToFileURL(documentPath).href; + expect(isHarnessDocumentRequest(documentUrl, documentPath)).toBe(true); + expect(isHarnessDocumentRequest(`${documentUrl}.bak`, documentPath)).toBe(false); + expect(isHarnessDocumentRequest('https://example.com/verify.html', documentPath)).toBe(false); + expect(isHarnessDocumentRequest('not a url', documentPath)).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/preview-runtime.ts b/apps/desktop/src/main/preview-runtime.ts index 4c0cc584..caaef532 100644 --- a/apps/desktop/src/main/preview-runtime.ts +++ b/apps/desktop/src/main/preview-runtime.ts @@ -11,7 +11,7 @@ * PDF exporter's discovery rules (no bundled Chromium — PRINCIPLES §1). */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL, URL } from 'node:url'; @@ -180,7 +180,6 @@ export async function runPreview(opts: RunPreviewOptions): Promise { + await req.respond({ status: 200, contentType: 'text/html; charset=utf-8', body: html }); +} + async function handlePreviewRequest( req: HTTPRequest, absWorkspace: string, previewFilePath: string, + html: string, ): Promise { try { + if (isHarnessDocumentRequest(req.url(), previewFilePath)) { + await respondWithHarnessDocument(req, html); + return; + } if (!(await isPreviewFileUrlAllowed(req.url(), absWorkspace, previewFilePath))) { await req.abort('blockedbyclient'); return;