Skip to content
Open
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
3 changes: 2 additions & 1 deletion registry/curated/system/browser-automation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,6 @@
"homepage": "https://github.com/framerslab/agentos-extensions/tree/master/registry/curated/system/browser-automation#readme",
"bugs": {
"url": "https://github.com/framerslab/agentos-extensions/issues"
}
},
"type": "module"
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ export interface AttachBackend {
* `UNSUPPORTED_OP` when missing. Never exposed on the agent tool surface.
*/
evalInTab?(tab: string, expression: string, timeoutMs?: number): Promise<unknown>;
/**
* OPTIONAL: capture a PNG screenshot of the agent tab, returned as base64.
* CDP-only (`Page.captureScreenshot`); the JXA transport has no pixel access,
* so callers surface `UNSUPPORTED_OP` when this is absent. Read-only — capture
* never navigates, focuses, or mutates the page.
*/
screenshotTab?(tab: string, fullPage?: boolean): Promise<string>;
}

/**
Expand All @@ -67,6 +74,8 @@ export interface AttachSurface {
pause(): void;
resume(): void;
setDryRun(on: boolean): void;
/** OPTIONAL (CDP transport only): write a PNG of the agent tab to disk. */
screenshot?(filePath: string, fullPage?: boolean): Promise<{ path: string; bytes: number }>;
}

/**
Expand Down Expand Up @@ -266,6 +275,40 @@ export class AttachController {
}
}

/**
* Capture a PNG screenshot of the agent tab and write it to `filePath`.
*
* CDP-only: the JXA transport has no pixel access, so a jxa-backed session
* refuses with `UNSUPPORTED_OP` rather than pretending. Read-only — capture
* never navigates, focuses, or mutates the page.
*
* @returns The written path plus byte size, for evidence logging.
*/
async screenshot(filePath: string, fullPage?: boolean): Promise<{ path: string; bytes: number }> {
this.requireReady();
requireLease(this.opts.leaseFile, this.lease!.nonce);
const fn = this.backend.screenshotTab?.bind(this.backend);
if (!fn) {
throw new AttachError('UNSUPPORTED_OP', `${this.backend.kind} backend has no screenshot capability (CDP transport required)`);
}
if (this.dryRun) return { path: filePath, bytes: 0 };
this.machine.to('reading');
try {
const b64 = await withDeadline(fn(this.tab!, fullPage), this.deadline, 'screenshot');
const { writeFileSync, mkdirSync } = await import('node:fs');
const { dirname } = await import('node:path');
mkdirSync(dirname(filePath), { recursive: true });
const buf = Buffer.from(b64, 'base64');
writeFileSync(filePath, buf);
Comment on lines +287 to +302

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Constrain screenshot destinations before writing.

Line 300 writes a claimant-supplied path as the daemon user. Since daemon validation only checks that path is non-empty, a caller can overwrite arbitrary files or target special files. Restrict output to a configured evidence directory and reject traversal, symlinks, and non-regular targets; use exclusive/atomic creation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@registry/curated/system/browser-automation/src/attach/AttachController.ts`
around lines 287 - 302, Constrain the file-writing logic in screenshot to a
configured evidence directory before creating the output: reject traversal,
symlinked paths, special files, and existing non-regular targets, and create the
result exclusively and atomically. Preserve dry-run behavior and return the
resolved safe destination and byte count after writing.

this.machine.to('ready');
return { path: filePath, bytes: buf.length };
} catch (err) {
if (this.machine.state === 'reading') this.machine.to('failed');
this.lastError = toStructuredError(err);
throw err;
}
}

/** Fixed, parameterized extraction expression: the selector map is data, never code. */
private static extractExpression(fields?: Record<string, string>): string {
const spec = JSON.stringify(fields ?? {});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('browser-automation attach registration', () => {
'browser_attach_goto',
'browser_attach_read',
'browser_attach_release',
'browser_attach_screenshot',
'browser_attach_status',
]);
for (const d of attach) expect(d.enableByDefault).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,18 @@ beforeEach(() => {
});

describe('attach tools', () => {
it('exposes exactly the six ids and no eval/js tool', () => {
it('exposes exactly the seven ids and no eval/js tool', () => {
expect(ATTACH_TOOL_IDS).toEqual([
'browser_attach_status',
'browser_attach_claim',
'browser_attach_goto',
'browser_attach_read',
'browser_attach_screenshot',
'browser_attach_release',
'browser_attach_control',
]);
expect(ATTACH_TOOL_IDS).not.toContain('browser_attach_eval');
expect(createAttachTools(controller)).toHaveLength(6);
expect(createAttachTools(controller)).toHaveLength(7);
});

it('control tool pauses, resumes, and toggles dry-run at runtime', async () => {
Expand Down Expand Up @@ -90,7 +91,7 @@ describe('attach tools', () => {
});

it('status never throws even before a claim', async () => {
const res = await new AttachStatusTool(controller).execute();
const res = (await new AttachStatusTool(controller).execute()) as { success: boolean; data: any };
expect(res.success).toBe(true);
expect(res.data.leaseHeld).toBe(false);
});
Expand All @@ -108,7 +109,12 @@ describe('tools over the daemon surface', () => {
try {
const surface = new DaemonAttachSurface({ ipcDir: join(dir, 'ipc') });
const tools = createAttachTools(surface);
const byId = Object.fromEntries(tools.map((t) => [t.id, t]));
// Tool args differ per tool; index them loosely so the union of input
// schemas doesn't collapse into an impossible intersection.
const byId = Object.fromEntries(tools.map((t) => [t.id, t])) as Record<
string,
{ id: string; hasSideEffects: boolean; execute: (args?: any) => Promise<any> }
>;
expect(Object.keys(byId).sort()).toEqual([...ATTACH_TOOL_IDS].sort()); // ids unchanged
expect(byId['browser_attach_read'].hasSideEffects).toBe(false);
expect(byId['browser_attach_status'].hasSideEffects).toBe(false);
Expand All @@ -120,7 +126,7 @@ describe('tools over the daemon surface', () => {
const read = await byId['browser_attach_read'].execute({});
expect(read.success).toBe(true);
expect(read.data.untrusted).toBe(true);
expect((await byId['browser_attach_release'].execute()).success).toBe(true);
expect((await byId['browser_attach_release'].execute({})).success).toBe(true);

await new AttachDaemonClient({ ipcDir: join(dir, 'ipc'), client: 'test-quit', pollMs: 25 }).quit();
await run;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ function run(argv) {
delay(1);
return t.url();
}
if (cmd === 'raise') {
// Bring the agent window forward AND return its bounds, so a display
// capture can be cropped to this window only — never the whole screen
// (which would leak the user's unrelated windows into evidence shots).
w.index = 1;
try { w.activeTabIndex = w.tabs().findIndex((x) => String(x.id()) === String(tid)) + 1; } catch (e) {}
delay(0.6);
const b = w.bounds();
return JSON.stringify({ x: b.x, y: b.y, width: b.width, height: b.height });
}
if (cmd === 'url') return t.url() + '\\n' + t.title();
if (cmd === 'read') {
const sel = argv[4] || 'body';
Expand Down Expand Up @@ -149,6 +159,11 @@ export class JxaBackend implements AttachBackend {
}

async claimAgentTab(): Promise<string> {
// IDEMPOTENT: probeIdentity() claims a tab before the controller's own
// claimAgentTab() call, so a second `claim` would look for another
// about:blank tab that no longer exists and fail NO_BLANK_TAB. Reuse the
// tab this backend already holds instead of consuming a second one.
if (this.claimed) return `${this.claimed.wid} ${this.claimed.tid}`;
const out = await this.exec('claim', []);
const [wid, tid] = out.split(/\s+/);
if (!wid || !tid) throw new AttachError('TAB_CLOSED', `claim returned malformed ids: "${out}"`);
Expand All @@ -173,4 +188,63 @@ export class JxaBackend implements AttachBackend {
const [wid, tid] = tab.split(/\s+/);
return this.exec('read', [wid, tid, selector ?? 'body', String(maxChars ?? 6000)]);
}

/**
* Screenshot via macOS `screencapture` (base64 PNG).
*
* The JXA transport has no CDP pixel pipe, so this raises the agent tab's
* window to the front and captures the display. Two consequences the caller
* must accept: it FOCUSES the agent window (the one exception to the
* no-focus rule — a screenshot is meaningless if the window is behind
* others), and the capture is the whole screen, so `fullPage` is ignored.
* Requires the host to hold macOS Screen Recording permission; without it
* `screencapture` writes nothing and this surfaces a structured failure.
*/
async screenshotTab(tab: string, _fullPage?: boolean): Promise<string> {
// OFF BY DEFAULT. `screencapture -R` works in display POINTS while Chrome's
// window bounds and Retina backing scale disagree, so the cropped region
// drifted onto NEIGHBOURING windows in live testing (2026-07-24) — which
// would silently put the user's unrelated, private windows into a research
// evidence file. Correct page-pixel capture is CDP `Page.captureScreenshot`
// (see RawCdpBackend); this lane stays refused unless explicitly opted in
// via WUNDERLAND_ATTACH_JXA_SHOTS=1 for a single-display debug session.
if (process.env.WUNDERLAND_ATTACH_JXA_SHOTS !== '1') {
throw new AttachError(
'UNSUPPORTED_OP',
'screenshots require the CDP transport (WUNDERLAND_ATTACH_TRANSPORT=cdp); the JXA display-capture lane is disabled because its crop can include other windows',
);
}
const [wid, tid] = tab.split(/\s+/);
const raised = await this.exec('raise', [wid, tid]);
const { mkdtempSync, readFileSync, existsSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const file = join(mkdtempSync(join(tmpdir(), 'attach-shot-')), 'shot.png');
// Crop to the agent WINDOW's bounds. A full-display capture would include
// every other window the user has open — private content must never land
// in a research evidence shot.
let region: string | undefined;
try {
const b = JSON.parse(raised) as { x: number; y: number; width: number; height: number };
if ([b.x, b.y, b.width, b.height].every((n) => typeof n === 'number' && Number.isFinite(n)) && b.width > 0 && b.height > 0) {
region = `${Math.max(0, Math.round(b.x))},${Math.max(0, Math.round(b.y))},${Math.round(b.width)},${Math.round(b.height)}`;
}
Comment on lines +221 to +230

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): Temporary directories created for screenshots are never removed, which can accumulate over time.

Each screenshot call creates a new temp directory via mkdtempSync, but only shot.png is deleted, leaving the directory in /tmp. Over many runs this can lead to unnecessary disk usage, especially for long-lived processes. Please also remove the temp directory returned by mkdtempSync (e.g. rmSync(dir, { recursive: true, force: true })) in the finally block.

Suggested implementation:

    const { mkdtempSync, readFileSync, existsSync, rmSync } = await import('node:fs');
    const { tmpdir } = await import('node:os');
    const dir = mkdtempSync(join(tmpdir(), 'attach-shot-'));
    const file = join(dir, 'shot.png');

You’ll also need to update the surrounding error-handling/cleanup logic:

  1. In the finally block that currently deletes shot.png (if present), add rmSync(dir, { recursive: true, force: true }); to remove the temporary directory.
  2. If there is no finally block yet, wrap the screenshot capture and file reading logic in a try/finally and in finally:
    • Check existsSync(file) and delete it as currently done.
    • Call rmSync(dir, { recursive: true, force: true }); to ensure the temp directory is removed even if the file was never written.
  3. Ensure dir is in scope for that finally block (i.e. declared in the same function scope rather than inside a narrower block).

} catch {
/* older driver without bounds → refuse rather than capture the screen */
}
if (!region) {
throw new AttachError('UNSUPPORTED_OP', 'could not resolve agent-window bounds; refusing a full-display capture');
}
try {
await pExecFile('screencapture', ['-x', '-o', '-t', 'png', '-R', region, file], { timeout: 20_000 });
if (!existsSync(file)) {
throw new AttachError('UNSUPPORTED_OP', 'screencapture produced no file (Screen Recording permission?)');
}
return readFileSync(file).toString('base64');
} catch (err) {
if (err instanceof AttachError) throw err;
throw new AttachError('UNSUPPORTED_OP', `screencapture failed: ${err instanceof Error ? err.message : String(err)}`);
Comment on lines +203 to +245

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Do not expose the known-unsafe JXA capture fallback.

The implementation acknowledges that its point/pixel mismatch can capture neighboring private windows, then enables that same path via an environment variable. Keep screenshotTab unavailable for JXA until bounds can be converted and validated reliably; an opt-in flag is not sufficient containment for an exposed evidence-writing tool.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@registry/curated/system/browser-automation/src/attach/backends/jxa.ts` around
lines 203 - 245, Remove the WUNDERLAND_ATTACH_JXA_SHOTS opt-in escape hatch from
screenshotTab so the JXA screenshot implementation always rejects the operation.
Keep the existing AttachError response and do not execute raise, bounds
resolution, or screencapture while this unsafe fallback remains unavailable.

} finally {
rmSync(file, { force: true });
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,26 @@ export class RawCdpBackend implements AttachBackend {
return value;
}

/**
* `Page.captureScreenshot` on the agent tab → base64 PNG (optional backend
* capability). Read-only: no navigation, no focus change, no page mutation.
* `fullPage` uses `captureBeyondViewport` so a long results page comes back
* whole instead of clipped to the viewport.
*/
async screenshotTab(tab: string, fullPage?: boolean): Promise<string> {
this.requireTab(tab);
const res = (await this.send(
'Page.captureScreenshot',
{ format: 'png', captureBeyondViewport: !!fullPage },
this.sessionId!,
Comment on lines +218 to +220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compute a content-sized clip for full-page captures

When fullPage is true on a document taller than the viewport, this still captures only the viewport: captureBeyondViewport permits a requested clip to extend outside the viewport, but it does not expand the default screenshot area by itself. Query the layout/content dimensions and pass an appropriate clip (or temporarily resize the viewport) so the advertised full-page option includes the entire scrollable page.

Useful? React with 👍 / 👎.

45_000,
Comment on lines +215 to +221

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== raw-cdp.ts around screenshotTab ==\n'
sed -n '180,280p' registry/curated/system/browser-automation/src/attach/backends/raw-cdp.ts

printf '\n== search for screenshot-related guards and metrics ==\n'
rg -n "captureBeyondViewport|Page\.getLayoutMetrics|layoutMetrics|screenshotTab|captureScreenshot|clip|pixel budget|fullPage" registry/curated/system/browser-automation/src -S

Repository: framerslab/agentos-extensions

Length of output: 8311


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,260p' registry/curated/system/browser-automation/src/attach/backends/raw-cdp.ts

Repository: framerslab/agentos-extensions

Length of output: 10155


🏁 Script executed:

#!/bin/bash
set -euo pipefail
printf '\n== raw-cdp.ts around screenshotTab ==\n'
sed -n '180,280p' registry/curated/system/browser-automation/src/attach/backends/raw-cdp.ts

printf '\n== search for screenshot-related guards and metrics ==\n'
rg -n "captureBeyondViewport|Page\.getLayoutMetrics|layoutMetrics|screenshotTab|captureScreenshot|clip|pixel budget|fullPage" registry/curated/system/browser-automation/src -S

Repository: framerslab/agentos-extensions

Length of output: 8311


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== AttachController screenshot flow ==\n'
sed -n '260,340p' registry/curated/system/browser-automation/src/attach/AttachController.ts

printf '\n== daemon/client screenshot path ==\n'
sed -n '100,150p' registry/curated/system/browser-automation/src/attach/daemon/client.ts
sed -n '240,290p' registry/curated/system/browser-automation/src/attach/daemon/daemon.ts

printf '\n== search for screenshot limits / byte caps ==\n'
rg -n "screenshot.*(limit|max|cap|budget|bytes|size)|base64|Buffer\\.from\\(.*base64|captureBeyondViewport|fullPage" registry/curated/system/browser-automation/src -S

Repository: framerslab/agentos-extensions

Length of output: 13686


Cap full-page screenshot output.

captureBeyondViewport can turn a tall page into a huge PNG, and this path immediately holds the result as base64 before writing it as a Buffer. Add a layout/pixel cap before allowing full-page capture so a single request can’t exhaust Chrome or the daemon.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@registry/curated/system/browser-automation/src/attach/backends/raw-cdp.ts`
around lines 215 - 221, Update screenshotTab to cap full-page capture dimensions
before calling Page.captureScreenshot, using the page layout or pixel bounds to
prevent oversized PNG responses. Preserve normal viewport screenshots, and
ensure the cap is applied before the base64 result is held or converted to a
Buffer.

)) as { data?: string };
if (!res.data) {
throw new AttachError('UNKNOWN', 'Page.captureScreenshot returned no data');
}
return res.data;
}

private requireTab(tab: string): void {
if (!this.sessionId || tab !== this.createdTargetId) {
throw new AttachError('TAB_CLOSED', 'agent tab is not claimed on this transport');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ export class AttachDaemonClient {
}

/** Give up driving rights (daemon parks the tab; its session persists). */
/** Capture a PNG of the agent tab to `path` (CDP transport only). */
async screenshot(path: string, fullPage?: boolean): Promise<{ path: string; bytes: number }> {
return (await this.request('screenshot', { path, fullPage: !!fullPage }, 60_000)) as {
path: string;
bytes: number;
};
}

async release(): Promise<void> {
await this.request('release');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,14 @@ export class AttachDaemon {
const data = await this.controller.extract(a.fields as Record<string, string> | undefined);
return { ...base, ok: true, data: { untrusted: true, data } };
}
case 'screenshot': {
this.requireClaimant(cmd);
if (typeof a.path !== 'string' || !a.path) {
return { ...base, ok: false, error: { code: 'UNKNOWN', message: 'screenshot requires a path' } };
}
const shot = await this.controller.screenshot(a.path, a.fullPage === true);
return { ...base, ok: true, data: shot };
}
case 'eval': {
this.requireClaimant(cmd);
const value = await this.controller.evaluate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type AttachOp =
| 'read'
| 'extract'
| 'eval'
| 'screenshot'
| 'release'
| 'control'
| 'quit';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ export class DaemonAttachSurface implements AttachSurface {
return (await this.client.read({ selector, maxChars })).text;
}

/** Capture a PNG of the agent tab to `path` (CDP transport behind the daemon). */
async screenshot(path: string, fullPage?: boolean): Promise<{ path: string; bytes: number }> {
return this.client.screenshot(path, fullPage);
}

/** Give up driving rights; the daemon keeps its held session. */
async detach(): Promise<void> {
await this.client.release();
Expand Down
34 changes: 33 additions & 1 deletion registry/curated/system/browser-automation/src/attach/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* @module browser-automation/attach/tools
*/
import type { AttachSurface } from './AttachController.js';
import { toStructuredError } from './errors.js';
import { AttachError, toStructuredError } from './errors.js';

/** Wrap an operation into the standard tool result envelope. */
async function envelope(fn) {
Expand Down Expand Up @@ -110,6 +110,36 @@ export class AttachReadTool {
}
}

/** `browser_attach_screenshot` — PNG evidence capture of the agent tab (CDP only). */
export class AttachScreenshotTool {
readonly id = 'browser_attach_screenshot';
readonly name = 'browser_attach_screenshot';
readonly displayName = 'Attach: screenshot';
readonly description =
'Capture a PNG screenshot of the agent tab to a local file path — visual evidence for a research report. Requires the CDP transport (the AppleScript/JXA transport has no pixel access and refuses with UNSUPPORTED_OP). Read-only: never navigates or mutates the page.';
readonly category = 'browser';
readonly version = '0.1.0';
readonly hasSideEffects = false;
Comment on lines +118 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): The screenshot tool writes a file to disk, which is a side effect despite being read-only from the page’s perspective.

Although the operation is read-only for page state, browser_attach_screenshot still writes a PNG to disk. If hasSideEffects is meant to capture any external state changes (not just page mutations), this should be true or the semantics of hasSideEffects should be clarified to avoid upstream tools misclassifying this call as purely observational.

Suggested change
readonly description =
'Capture a PNG screenshot of the agent tab to a local file path — visual evidence for a research report. Requires the CDP transport (the AppleScript/JXA transport has no pixel access and refuses with UNSUPPORTED_OP). Read-only: never navigates or mutates the page.';
readonly category = 'browser';
readonly version = '0.1.0';
readonly hasSideEffects = false;
readonly description =
'Capture a PNG screenshot of the agent tab to a local file path — visual evidence for a research report. Requires the CDP transport (the AppleScript/JXA transport has no pixel access and refuses with UNSUPPORTED_OP). Read-only with respect to page state (never navigates or mutates the page), but writes a PNG file to disk.';
readonly category = 'browser';
readonly version = '0.1.0';
readonly hasSideEffects = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate screenshot file writes as side effects

When a mission uses the documented deny-side-effects policy, this tool remains callable even though AttachController.screenshot() creates directories and overwrites the caller-selected path with writeFileSync. Because the path can name any file writable by the agent process, an unapproved invocation can truncate user files while bypassing the policy intended to gate writes; mark this tool as side-effecting and consider constraining or refusing existing destinations.

Useful? React with 👍 / 👎.

Comment on lines +119 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Mark the screenshot tool as side-effecting.

This tool creates directories and writes a PNG, but Line 122 declares hasSideEffects = false. That can bypass tool confirmation or policy controls intended for local writes.

Proposed fix
-  readonly hasSideEffects = false;
+  readonly hasSideEffects = true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'Capture a PNG screenshot of the agent tab to a local file path — visual evidence for a research report. Requires the CDP transport (the AppleScript/JXA transport has no pixel access and refuses with UNSUPPORTED_OP). Read-only: never navigates or mutates the page.';
readonly category = 'browser';
readonly version = '0.1.0';
readonly hasSideEffects = false;
'Capture a PNG screenshot of the agent tab to a local file path — visual evidence for a research report. Requires the CDP transport (the AppleScript/JXA transport has no pixel access and refuses with UNSUPPORTED_OP). Read-only: never navigates or mutates the page.';
readonly category = 'browser';
readonly version = '0.1.0';
readonly hasSideEffects = true;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@registry/curated/system/browser-automation/src/attach/tools.ts` around lines
119 - 122, Update the screenshot tool’s hasSideEffects property to indicate that
capturing a screenshot is side-effecting because it creates directories and
writes a PNG file. Keep the existing category and version unchanged so
confirmation and policy controls apply to this tool’s local filesystem writes.

readonly inputSchema = {
type: 'object' as const,
properties: {
path: { type: 'string', description: 'Absolute file path to write the PNG to' },
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of just the viewport' },
},
required: ['path'],
};
constructor(private controller: AttachSurface) {}
async execute(args: { path: string; fullPage?: boolean }) {
return envelope(async () => {
const fn = this.controller.screenshot?.bind(this.controller);
if (!fn) {
throw new AttachError('UNSUPPORTED_OP', 'this attach transport has no screenshot capability (CDP required)');
}
return fn(args.path, args.fullPage);
});
}
}

/** `browser_attach_release` — park + release the lease (no browser teardown). */
export class AttachReleaseTool {
readonly id = 'browser_attach_release';
Expand Down Expand Up @@ -188,6 +218,7 @@ export function createAttachTools(controller: AttachSurface) {
new AttachClaimTool(controller),
new AttachGotoTool(controller),
new AttachReadTool(controller),
new AttachScreenshotTool(controller),
new AttachReleaseTool(controller),
new AttachControlTool(controller),
];
Expand All @@ -199,6 +230,7 @@ export const ATTACH_TOOL_IDS = [
'browser_attach_claim',
'browser_attach_goto',
'browser_attach_read',
'browser_attach_screenshot',
'browser_attach_release',
'browser_attach_control',
] as const;
22 changes: 16 additions & 6 deletions registry/curated/system/browser-automation/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"target": "ES2022",
"module": "Node16",
"lib": [
"ES2022"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
"sourceMap": true,
"moduleResolution": "Node16"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"test",
"**/*.spec.ts"
]
}