// Drive the committed Preact workbench, including the real Artifact Viewer. // Header-only tests cannot prove that the shell permits the sandbox frame, or // that the renderer actually requests a grant. Both loopback directions run in // fresh browser contexts so an app cookie never leaks from an earlier case. // This harness owns a disposable daemon, port, data directory and artifacts. // Stdout carries exactly one machine-readable record: SUMMARY { ... }. import { spawn } from "node:child_process"; import fs from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { authenticate, boundedLogCollector, minimalChildEnvironment, redactSecrets, waitUntil, } from "./browser_auth.mjs"; const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const pythonPath = process.env.OPENAI4S_PYTHON ? path.resolve(process.env.OPENAI4S_PYTHON) : path.join(workspaceRoot, ".venv", "bin", "python"); const executablePath = process.env.OPENAI4S_BROWSER_EXECUTABLE || undefined; const summary = { schema_version: 2, name: "artifact_sandbox_preview_browser_acceptance", cases: [], ok: false, }; function assertion(condition, message) { if (!condition) throw new Error(message); } async function allocateLoopbackPort() { return await new Promise((resolve, reject) => { const probe = net.createServer(); probe.once("error", reject); probe.listen(0, "127.0.0.1", () => { const { port } = probe.address(); probe.close(() => resolve(port)); }); }); } function reportDocument(otherArtifactId) { return `

static report

`; } async function runDirection(browser, port, token, fixtures, appHost) { const appOrigin = `http://${appHost}:${port}`; const sandboxHost = appHost === "127.0.0.1" ? "localhost" : "127.0.0.1"; const sandboxOrigin = `http://${sandboxHost}:${port}`; const result = { app_origin: appOrigin, sandbox_origin: sandboxOrigin, ok: false }; summary.cases.push(result); const phase = (name) => { result.phase = name; process.stderr.write(`sandbox-preview ${appHost}: ${name}\n`); }; const context = await browser.newContext({ serviceWorkers: "block", viewport: { width: 1440, height: 1000 }, }); const page = await context.newPage(); const browserErrors = []; page.on("pageerror", (error) => browserErrors.push(String(error))); page.on("console", (message) => { if (message.type() === "error" && message.location().url.includes("ketcher")) { browserErrors.push(message.text()); } }); await page.addInitScript(() => { window.__sandboxProbes = []; window.addEventListener("message", (event) => { if (event.data?.type === "sandbox-probe") { window.__sandboxProbes.push({ ...event.data, eventOrigin: event.origin }); } }); }); try { phase("authenticate"); await authenticate(page, `${appOrigin}/`, token); const shell = await page.request.get(`${appOrigin}/`); const shellHtml = await shell.text(); assertion(/\/static\/dist\/assets\//.test(shellHtml), "the acceptance must run the Preact shell"); result.frame_src = (shell.headers()["content-security-policy"] || "") .split(";") .map((directive) => directive.trim()) .find((directive) => directive.startsWith("frame-src ")); assertion(result.frame_src?.includes(sandboxOrigin), "the shell does not permit its sandbox origin"); const viewer = page.locator("#dock-viewer iframe"); const openReport = async (versionId = null) => { const url = new URL(`${appOrigin}/projects/${fixtures.projectId}/frames/${fixtures.frameId}`); url.searchParams.set("artifact", fixtures.reportId); if (versionId) url.searchParams.set("version_id", versionId); await page.goto(url.href, { waitUntil: "domcontentloaded" }); await viewer.waitFor({ state: "visible", timeout: 30000 }); return viewer; }; const waitForReport = async () => { await waitUntil("report script and sibling image", () => page.evaluate(() => { const probes = window.__sandboxProbes.map((entry) => entry.probe); return probes.includes("script-ran") && probes.includes("sibling-loaded"); })); const src = await viewer.getAttribute("src"); assertion(src?.startsWith(`${sandboxOrigin}/sandbox/`), `preview used the wrong origin: ${src}`); assertion(await viewer.getAttribute("sandbox") === "allow-scripts allow-same-origin", "the authorized sandbox preview never enabled scripts"); return src; }; // These are the production deep-link and dock-renderer paths; no legacy // renderHtmlPreview global and no hand-built iframe participate. phase("interactive preview"); await openReport(); const grantedSrc = await waitForReport(); const reported = await page.evaluate(() => window.__sandboxProbes); result.script_ran = reported.some((entry) => entry.probe === "script-ran"); result.sibling_loaded = reported.some((entry) => entry.probe === "sibling-loaded"); result.parent_refused = reported.some((entry) => entry.probe === "parent-refused") && !reported.some((entry) => entry.probe === "parent-readable"); result.reported_origin = reported[0]?.origin; assertion(result.parent_refused, "the artifact could read its parent document"); assertion(reported.every((entry) => entry.origin === sandboxOrigin && entry.eventOrigin === sandboxOrigin), "the preview script did not execute on the sandbox origin"); result.sandbox_has_no_app_cookie = (await context.cookies(sandboxOrigin)).length === 0; assertion(result.sandbox_has_no_app_cookie, "the app login installed a cookie on the sandbox origin"); const contentFrame = await (await viewer.elementHandle()).contentFrame(); assertion(await contentFrame.locator("#heading").innerText() === "drawn by script", "the script did not update the visible report"); assertion(await contentFrame.locator("#figure").evaluate((image) => image.complete && image.naturalWidth > 0), "the report's sibling image did not decode"); // Let the untrusted document spend its own grant on a different frame. // A browser navigation, observed at the response, proves actual rejection; // an opaque no-cors fetch would reveal neither the status nor the content. phase("cross-session navigation"); const crossUrl = new URL(grantedSrc); crossUrl.pathname = crossUrl.pathname.replace(/preview\/[^/]+$/, `preview/${fixtures.secretId}`); const crossResponse = page.waitForResponse((response) => response.url() === crossUrl.href); await contentFrame.locator("#cross-frame").click(); const refusedCross = await crossResponse; result.cross_frame_status = refusedCross.status(); result.cross_frame_refused = refusedCross.status() === 404; assertion(result.cross_frame_refused, `a grant read another session (${refusedCross.status()})`); // A sandboxed frame can navigate itself. Reusing the authorized path on // the app origin must fail before any model-authored bytes are served. phase("app-origin grant replay"); await openReport(); const newSrc = await waitForReport(); const sameGrantAppUrl = new URL(newSrc); sameGrantAppUrl.hostname = appHost; const appResponse = page.waitForResponse((response) => response.url() === sameGrantAppUrl.href); const navigatingFrame = await (await viewer.elementHandle()).contentFrame(); await navigatingFrame.locator("#navigate-app").click(); const refusedApp = await appResponse; result.app_origin_replay_status = refusedApp.status(); result.app_origin_replay_refused = refusedApp.status() === 404; assertion(result.app_origin_replay_refused, `the same grant served executable artifact bytes on the app origin (${refusedApp.status()})`); // A historical Artifact deep link must keep its bytes after a newer edit. phase("exact version"); await openReport(fixtures.reportVersionId); await waitForReport(); const exactFrame = await (await viewer.elementHandle()).contentFrame(); result.exact_version_preserved = await exactFrame.locator("#revision").innerText() === "original revision"; assertion(result.exact_version_preserved, "the granted preview substituted the latest version for an exact deep link"); // Only the grant response is fault-injected. The artifact, inert preview // response, Viewer, and renderer are real, so this proves a useful static // report survives failure without ever running the embedded script. phase("inert fallback"); await page.route("**/api/v1/artifacts/*/sandbox-grant*", (route) => route.fulfill({ status: 403, contentType: "application/json", body: JSON.stringify({ error: "grant unavailable in browser acceptance" }), })); await openReport(); await page.locator("#dock-viewer .renderer-noscript").waitFor({ state: "visible" }); const inertFrame = await (await viewer.elementHandle()).contentFrame(); await inertFrame.locator("#heading").waitFor({ state: "visible" }); result.inert_without_grant = { sandbox: await viewer.getAttribute("sandbox"), app_origin: new URL(await viewer.getAttribute("src"), appOrigin).origin === appOrigin, static_content: await inertFrame.locator("#heading").innerText() === "static report", script_ran: await page.evaluate(() => window.__sandboxProbes.some((entry) => entry.probe === "script-ran")), }; assertion(result.inert_without_grant.sandbox === "" && result.inert_without_grant.app_origin && result.inert_without_grant.static_content && !result.inert_without_grant.script_ran, `a refused grant did not leave a useful inert preview: ${JSON.stringify(result.inert_without_grant)}`); await page.unroute("**/api/v1/artifacts/*/sandbox-grant*"); // Both loopback names are valid app entry points, so the sandbox name can // already hold a session cookie. Framed under the app origin the frame is // cross-site and SameSite=Strict keeps that cookie off every request it // makes, so what `api-fetch-refused` proves is `connect-src 'none'`, not // the cookie. (The cookie-bearing case -- a grant URL opened top-level on // that name -- is refused by the server outright; see test_gateway.) phase("authenticated sandbox API refusal"); const alternateLogin = await context.newPage(); await alternateLogin.goto(`${sandboxOrigin}/?token=${encodeURIComponent(token)}`, { waitUntil: "domcontentloaded", }); assertion(!new URL(alternateLogin.url()).searchParams.has("token"), "alternate-host login did not consume the credential"); assertion((await context.cookies(sandboxOrigin)).length > 0, "alternate-host scenario did not establish an authenticated cookie"); await alternateLogin.close(); await openReport(); await waitForReport(); await waitUntil("authenticated sandbox API refusal", () => page.evaluate(() => { const probes = window.__sandboxProbes.map((entry) => entry.probe); return probes.includes("connect-src-refused") && probes.includes("api-fetch-refused"); })); result.authenticated_sandbox_api_refused = await page.evaluate(() => !window.__sandboxProbes.some((entry) => entry.probe === "api-fetch-allowed")); assertion(result.authenticated_sandbox_api_refused, "an artifact could call the API after both loopback origins were authenticated"); // Ketcher is first-party UI, so it must stay on the app origin even after // a successful preview has established the alternate sandbox origin. phase("Ketcher edit and save"); await openReport(); await waitForReport(); await page.evaluate((artifactId) => window.openKetcher({ id: artifactId }), fixtures.moleculeId); const ketcherFrameElement = page.locator("#modal-body > iframe"); await ketcherFrameElement.waitFor({ state: "visible" }); const ketcherSrc = new URL(await ketcherFrameElement.getAttribute("src"), appOrigin); assertion(ketcherSrc.origin === appOrigin && ketcherSrc.pathname === "/ketcher", "Ketcher was moved off the first-party app origin"); assertion(await ketcherFrameElement.getAttribute("sandbox") === null, "Ketcher inherited the artifact sandbox"); const ketcherFrame = await (await ketcherFrameElement.elementHandle()).contentFrame(); await waitUntil("Ketcher artifact load", async () => (await ketcherFrame.locator("#ketcher-status").innerText()).startsWith("loaded "), 45000); // The wrapper loads the artifact twice -- once on the editor's init // message and again 400 ms after the frame's load event -- and both // report the same "loaded" status. An edit made between the two is // silently overwritten by the second, which on a fast host turns into a // "save without persisting" red that blames the server. Let the deferred // reload land before touching the canvas. await new Promise((resolve) => setTimeout(resolve, 1200)); const editor = ketcherFrame.childFrames().find((frame) => frame.url().includes("/static/vendor/ketcher/")); assertion(editor, "Ketcher never loaded its vendored editor"); await waitUntil("real Ketcher editor", () => editor.evaluate(() => typeof window.ketcher?.setMolecule === "function" && typeof window.ketcher?.getSmiles === "function"), 45000); const atom = appHost === "127.0.0.1" ? "O" : "N"; const editedMolfile = "edited molecule\n OpenAI4S\n\n" + " 2 1 0 0 0 0 999 V2000\n" + " 0.0000 0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n" + ` 1.0000 0.0000 0.0000 ${atom} 0 0 0 0 0 0 0 0 0 0 0 0\n` + " 1 2 1 0 0 0 0\nM END\n"; // The pinned editor's setMolecule returns before its import action // completes. Wait for the actual canvas serialization to change before // clicking Save, just as a user waits for the editor to finish drawing. const currentMolfile = () => editor.evaluate(() => Promise.race([ window.ketcher.getMolfile(), new Promise((_, reject) => setTimeout(() => reject(new Error("Ketcher serialization timed out")), 10000)), ])); await editor.evaluate((molfile) => window.ketcher.setMolecule(molfile), editedMolfile); // The edit has to *stay* on the canvas, not merely appear once: require // several consecutive reads that still carry the atom, re-applying the // edit if a late reload wiped it. let stableReads = 0; await waitUntil("Ketcher molecule edit", async () => { if ((await currentMolfile()).includes(` ${atom} `)) { stableReads += 1; return stableReads >= 4; } stableReads = 0; await editor.evaluate((molfile) => window.ketcher.setMolecule(molfile), editedMolfile); return false; }, 30000, 250); result.ketcher_edited_atom = atom; await ketcherFrame.locator("#ketcher-save").click(); await waitUntil("Ketcher artifact save", async () => { const status = await ketcherFrame.locator("#ketcher-status").innerText(); // "saved (unchanged)" is the wrapper telling us the edit was // gone before it posted; that is the failure, not a success to accept. assertion(!status.endsWith("(unchanged)"), `Ketcher saved an unchanged molecule: ${status}`); return status.startsWith("saved "); }); const savedMolecule = await page.request.get(`${appOrigin}/api/v1/artifacts/${fixtures.moleculeId}`); assertion(savedMolecule.ok() && (await savedMolecule.text()).includes(` ${atom} `), "Ketcher reported a save without persisting the edited molecule"); result.ketcher_first_party = true; result.ketcher_saved = true; phase("complete"); result.ok = true; } catch (error) { if (browserErrors.length) result.browser_errors = browserErrors.slice(-10); // The alternate-host login above navigates to `?token=` directly rather // than through authenticate(), so a Playwright error from it quotes the // credential; result.error reaches SUMMARY and the CI log. result.error = redactSecrets(error?.stack || String(error), token, encodeURIComponent(token)); } finally { await context.close(); } } async function main() { const port = await allocateLoopbackPort(); assertion(port !== 8760, "the acceptance must not use the user's daemon port"); const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "openai4s-sandbox-preview-")); const appOrigin = `http://127.0.0.1:${port}`; const daemon = spawn(pythonPath, ["-m", "openai4s", "serve", "--no-browser", "--port", String(port)], { cwd: workspaceRoot, // Pinned, not inherited: this gate measures the origin boundary, and a // provider key, team mode or sandbox posture from the developer's shell // would change what it measures (or make it time out on a live turn). env: minimalChildEnvironment({ OPENAI4S_DATA_DIR: dataDir, OPENAI4S_HOST: "127.0.0.1", OPENAI4S_PORT: String(port), OPENAI4S_ALLOW_NETWORK: "0", OPENAI4S_WEBUI: "", OPENAI4S_STAGE9_ARTIFACT_WORKBENCH: "1", }), stdio: ["ignore", "pipe", "pipe"], }); // Kept, not discarded: a failing run must be able to quote the daemon. const daemonStdout = boundedLogCollector(daemon.stdout); const daemonStderr = boundedLogCollector(daemon.stderr); let startupError = null; daemon.once("error", (error) => { startupError = error; }); let browser = null; let token = null; try { await waitUntil("daemon startup", async () => { if (startupError) throw startupError; if (daemon.exitCode !== null) throw new Error(`daemon exited with ${daemon.exitCode}`); if (!fs.existsSync(path.join(dataDir, "access-token"))) return false; return (await fetch(`${appOrigin}/health`)).ok; }, 60000, 250); token = fs.readFileSync(path.join(dataDir, "access-token"), "utf8").trim(); const headers = { "content-type": "application/json", "X-OpenAI4S-Token": token }; const api = async (route, body) => { const response = await fetch(`${appOrigin}/api/v1${route}`, { method: body === undefined ? "GET" : "POST", headers, body: body === undefined ? undefined : JSON.stringify(body), }); assertion(response.ok, `${route} answered ${response.status}`); return await response.json(); }; const projects = await api("/projects"); const projectId = projects.projects?.[0]?.project_id; assertion(projectId, "no project to attach a session to"); const frame = await api("/frames", { title: "sandbox preview", project_id: projectId }); const other = await api("/frames", { title: "other session", project_id: projectId }); const upload = async (frameId, filename, contentType, bytes) => { const result = await api("/uploads", { frame_id: frameId, filename, content_type: contentType, content_base64: bytes.toString("base64"), }); return result.artifact_id || result.id; }; const secretId = await upload(other.id, "secret.html", "text/html", Buffer.from("OTHER SESSION")); const report = reportDocument(secretId); const reportId = await upload(frame.id, "report.html", "text/html", Buffer.from(report.replace("original revision

latest revision

result.ok); if (!summary.ok) process.exitCode = 1; } finally { // Attached whenever the run is not green, not only when main() throws: // runDirection swallows its own assertion failures into `result.error`, // which is the likeliest way this gate goes red, and the data directory // is deleted a few lines below. Redacted because the daemon prints its // `?token=` bootstrap URL on startup and SUMMARY reaches the CI log. if (!summary.ok) { summary.daemon_log = redactSecrets( `${daemonStdout()}\n${daemonStderr()}`, token, ).trim().slice(-16 * 1024); } if (browser) await browser.close().catch(() => {}); daemon.kill("SIGTERM"); await new Promise((resolve) => setTimeout(resolve, 500)); if (daemon.exitCode === null) daemon.kill("SIGKILL"); fs.rmSync(dataDir, { recursive: true, force: true }); } } main().catch((error) => { summary.error = error?.stack || String(error); process.exitCode = 1; }).finally(() => { process.stdout.write(`SUMMARY ${JSON.stringify(summary)}\n`); });