From 00530019e5f767d32e9c882f8e3600c7b50463e8 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Tue, 18 Aug 2026 16:41:40 +0530 Subject: [PATCH] feat(browser): expose writeArtifact under a public name and document it __webcmdWriteArtifact was the only write path out of the QuickJS sandbox and its dunder prefix reads as private, so programs would not call it. Expose the same function as globalThis.writeArtifact, keep the dunder as an alias, and return the receipt instead of swallowing it. Also accept a null contentType, which is what JSON.stringify produces for the two-argument call, and document artifacts in the browser-run reference including the download path and how a receipt is redeemed. Co-Authored-By: Claude Opus 5 --- .../references/browser-run-playwright.md | 58 ++++++++++++++++++- src/browser/run/runner.test.ts | 36 +++++++++++- src/browser/run/runner.ts | 18 ++++-- src/skills.test.ts | 3 +- 4 files changed, 105 insertions(+), 10 deletions(-) diff --git a/skills/webcmd-browser/references/browser-run-playwright.md b/skills/webcmd-browser/references/browser-run-playwright.md index fdb2ac31..ec6a5b5b 100644 --- a/skills/webcmd-browser/references/browser-run-playwright.md +++ b/skills/webcmd-browser/references/browser-run-playwright.md @@ -6,9 +6,63 @@ `context.newPage()` is not available inside `run`; create or bind Session tabs through Webcmd commands so page ownership stays deterministic. -## Artifact paths +## Artifacts: getting bytes out of the sandbox -Artifacts written by Playwright must use a relative logical filename. Webcmd returns an artifact receipt with its locator; it does not grant host-path write access. +There is no host filesystem. The only way to get a file out of a run is to write it as an +artifact, using a **relative logical filename** — absolute paths and `..` are rejected with +`BROWSER_RUN_INVALID_INPUT`. + +### Writing one + +```js +const receipt = await writeArtifact('report.csv', new TextEncoder().encode(csv), 'text/csv'); +return receipt; +``` + +`writeArtifact(filename, bytes, contentType?)` takes a `Uint8Array` and resolves to the +receipt. `contentType` is optional and defaults to `application/octet-stream` for anything +that is not `.png`/`.jpg` — pass it explicitly when it matters. `__webcmdWriteArtifact` is a +legacy alias for the same function. + +Two other calls write artifacts for you: `page.screenshot({ path: 'shot.png' })` and +`download.saveAs('out.csv')`. Both take the same relative logical filename. + +### Capturing a download + +`download.createReadStream()` throws — Readable streams do not exist in the sandbox. Use +`saveAs` with a relative name instead; it routes through the artifact sink: + +```js +const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Convert' }).click(), +]); +await download.saveAs(download.suggestedFilename()); +return { saved: download.suggestedFilename() }; +``` + +Do not scrape an on-page preview as a substitute for the downloaded bytes — it will not match. + +### Redeeming a receipt + +Every artifact written during a run appears in the run result's `artifacts` array, whether it +came from `writeArtifact`, `saveAs`, or `screenshot`: + +```json +{ + "artifactId": "artifact_9d1f6368490aa37a22a18426", + "filename": "downloads/out.csv", + "contentType": "application/octet-stream", + "byteSize": 12, + "locator": "browser-run://artifact_9d1f6368490aa37a22a18426/downloads%2Fout.csv" +} +``` + +Locally the bytes land at `~/.webcmd/cache/browser-run//` (under +`$WEBCMD_CACHE_DIR/browser-run` when that is set), readable once `run` has returned. Hosted +runs use the same receipt shape with a `cloud-artifact://` locator backed by the execution's +trace artifact store. The receipt never carries the bytes themselves, so return the receipt — +or just read it off `artifacts` — rather than trying to return file contents through `result`. ## Errors diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index 7f6653eb..bdf91037 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -1,4 +1,6 @@ import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { createRequire } from 'node:module'; import { afterAll, @@ -16,6 +18,7 @@ import { type BrowserContext, type Page, } from 'playwright-core'; +import { LocalBrowserRunArtifactSink } from './artifacts.js'; import { QuickJSHost } from './quickjs-host.js'; import { runBrowserProgram } from './runner.js'; @@ -42,10 +45,11 @@ function sessionScope(pages: () => readonly Page[] = () => context.pages()) { }; } -function run(source: string, options = {}) { +function run(source: string, options = {}, input = {}) { return runBrowserProgram({ ...sessionScope(), pageId: 'page-1', + ...input, }, source, options); } @@ -643,6 +647,36 @@ afterAll(async () => { expect(output.result).toEqual({ length: 3, returned: [255, 0, 128] }); }); + it.each(['writeArtifact', '__webcmdWriteArtifact'])( + 'returns a redeemable receipt from %s', + async (fn) => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-artifact-')); + try { + const output = await run(` + const receipt = await ${fn}( + 'nested/report.csv', + new TextEncoder().encode('id,name\\n1,caf\\u00e9\\n'), + ); + return receipt; + `, {}, { artifactSink: new LocalBrowserRunArtifactSink({ baseDir }) }); + + const receipt = output.result as { artifactId: string; locator: string }; + expect(receipt).toMatchObject({ + filename: 'nested/report.csv', + contentType: 'application/octet-stream', + byteSize: 16, + locator: expect.stringContaining('browser-run://'), + }); + expect(output.artifacts).toEqual([receipt]); + expect( + fs.readFileSync(path.join(baseDir, receipt.artifactId, 'nested/report.csv'), 'utf8'), + ).toBe('id,name\n1,caf\u00e9\n'); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }, + ); + it('rejects absolute artifact paths instead of touching host paths', async () => { const target = '/tmp/webcmd-browser-run-owned.txt'; fs.rmSync(target, { force: true }); diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts index ba4395b9..e8e89203 100644 --- a/src/browser/run/runner.ts +++ b/src/browser/run/runner.ts @@ -264,7 +264,7 @@ export async function runBrowserProgram( name !== 'writeArtifact' || typeof args[0] !== 'string' || typeof args[1] !== 'string' - || (args[2] !== undefined && typeof args[2] !== 'string') + || (args[2] != null && typeof args[2] !== 'string') ) { throw new BrowserRunError( 'BROWSER_RUN_INVALID_INPUT', @@ -440,12 +440,18 @@ export async function runBrowserProgram( globalThis.__webcmdTransportReceive = message => { connection.dispatch(JSON.parse(message)); }; - globalThis.__webcmdWriteArtifact = async (filename, bytes, contentType) => { - await __webcmdHostCall( + globalThis.writeArtifact = async (filename, bytes, contentType) => ( + __webcmdHostCall( 'writeArtifact', - JSON.stringify([filename, __webcmdEncodeBase64(bytes), contentType]), - ); - }; + JSON.stringify([ + filename, + __webcmdEncodeBase64(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)), + contentType, + ]), + ) + ); + // Legacy alias; writeArtifact is the documented name. + globalThis.__webcmdWriteArtifact = globalThis.writeArtifact; __WebcmdPlaywrightClient.quickjsPlatform.fs().promises.readFile = () => ( unsupported('Host filesystem reads') ); diff --git a/src/skills.test.ts b/src/skills.test.ts index ea19e4a8..ce2c0cbf 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -216,7 +216,8 @@ describe('webcmd skills content', () => { expect(browser).not.toContain('page.snapshotForAI()'); expect(browser).not.toContain('--snapshot-diff'); expect(browserRunReference).toMatch(/sandbox boundaries/i); - expect(browserRunReference).toMatch(/artifact paths/i); + expect(browserRunReference).toMatch(/artifacts/i); + expect(browserRunReference).toContain("writeArtifact("); expect(browserRunReference).toMatch(/errors/i); expect(browserRunReference).toMatch(/snapshot behavior/i); expect(browserRunReference).toContain('--snapshot-mode act|tree');