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
58 changes: 56 additions & 2 deletions skills/webcmd-browser/references/browser-run-playwright.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<artifactId>/<filename>` (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

Expand Down
36 changes: 35 additions & 1 deletion src/browser/run/runner.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';

Expand All @@ -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);
}

Expand Down Expand Up @@ -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 });
Expand Down
18 changes: 12 additions & 6 deletions src/browser/run/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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')
);
Expand Down
3 changes: 2 additions & 1 deletion src/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down