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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/done-verify-sandboxed-chrome.md
Original file line number Diff line number Diff line change
@@ -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).
31 changes: 18 additions & 13 deletions apps/desktop/src/main/done-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -95,9 +101,14 @@ function mapConsoleSource(raw: string): string | null {
async function handleVerifierRequest(
req: HTTPRequest,
verifyFilePath: string,
html: string,
workspaceRoot?: string,
): Promise<void> {
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)
Expand Down Expand Up @@ -167,6 +178,7 @@ function pushUniqueError(
async function verifyWithSystemChrome(
verifyUrl: string,
verifyPath: string,
html: string,
workspaceRoot?: string,
signal?: AbortSignal,
): Promise<DoneError[]> {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
};
}
122 changes: 122 additions & 0 deletions apps/desktop/src/main/harness-document.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
continue: ReturnType<typeof vi.fn>;
abort: ReturnType<typeof vi.fn>;
}

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<string, (arg: unknown) => 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()('<main id="verify-marker">ok</main>', {
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'), '<main id="preview-marker">ok</main>', '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);
});
});
28 changes: 25 additions & 3 deletions apps/desktop/src/main/preview-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -180,7 +180,6 @@ export async function runPreview(opts: RunPreviewOptions): Promise<PreviewResult
});

const previewFilePath = join(userDataDir, 'preview.html');
await writeFile(previewFilePath, html, 'utf8');
const previewUrl = pathToFileURL(previewFilePath).href;
let initialNavigation = true;
if (hasSteps) {
Expand All @@ -201,7 +200,7 @@ export async function runPreview(opts: RunPreviewOptions): Promise<PreviewResult
}
initialNavigation = false;
}
void handlePreviewRequest(req, absWorkspace, previewFilePath);
void handlePreviewRequest(req, absWorkspace, previewFilePath, html);
});
await boundedPreview(
page.goto(previewUrl, {
Expand Down Expand Up @@ -418,12 +417,35 @@ export async function isPreviewFileUrlAllowed(
}
}

/**
* Sandboxed Chrome builds (Snap, Flatpak) get a private /tmp, so a harness file
* written under os.tmpdir() is invisible to them (ERR_FILE_NOT_FOUND). The
* harness document is therefore served from memory at its file:// URL, which
* keeps relative workspace assets and the file-URL allowlist working.
*/
export function isHarnessDocumentRequest(rawUrl: string, documentPath: string): boolean {
try {
return fileURLToPath(new URL(rawUrl)) === documentPath;
} catch {
return false;
}
}

export async function respondWithHarnessDocument(req: HTTPRequest, html: string): Promise<void> {
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<void> {
try {
if (isHarnessDocumentRequest(req.url(), previewFilePath)) {
await respondWithHarnessDocument(req, html);
return;
}
if (!(await isPreviewFileUrlAllowed(req.url(), absWorkspace, previewFilePath))) {
await req.abort('blockedbyclient');
return;
Expand Down
Loading