diff --git a/registry/curated/system/browser-automation/src/attach/AttachController.ts b/registry/curated/system/browser-automation/src/attach/AttachController.ts index 0fde1c8..8c1d2d1 100644 --- a/registry/curated/system/browser-automation/src/attach/AttachController.ts +++ b/registry/curated/system/browser-automation/src/attach/AttachController.ts @@ -47,6 +47,16 @@ export interface AttachBackend { readTab(tab: string, selector?: string, maxChars?: number): Promise; } +/** + * Human-in-the-loop navigation decision. Called before every `goto` (after the + * URL clears the deny-by-default policy). Return `false`/`{ approved: false }` + * to block the navigation with a `POLICY_BLOCKED` result. Async so a caller can + * prompt a real operator. + */ +export type NavigationApprover = ( + url: string, +) => boolean | { approved: boolean; reason?: string } | Promise; + /** Controller configuration. */ export interface AttachControllerOptions { backend: AttachBackend; @@ -62,6 +72,17 @@ export interface AttachControllerOptions { urlPolicy?: UrlPolicyOptions; /** Per-operation deadline in ms (default 45s). */ deadlineMs?: number; + /** + * Dry-run: navigation and reads are validated and reported but NOT performed + * against the browser. Lets a mission preview its plan (or run in CI) without + * touching the live session. + */ + dryRun?: boolean; + /** + * HITL gate on navigation. Runs after the URL policy passes; a denial blocks + * the goto. Use to require operator approval for each page an assistant opens. + */ + onNavigate?: NavigationApprover; } /** Status snapshot returned by {@link AttachController.status}. */ @@ -71,6 +92,14 @@ export interface AttachStatus { leaseHeld: boolean; agentTab?: string; lastError?: StructuredAttachError; + /** Session paused — operations are refused until resume(). */ + paused: boolean; + /** Dry-run mode — navigation/reads are simulated, not performed. */ + dryRun: boolean; + /** Count of navigations performed this session. */ + navigations: number; + /** Last URL the agent tab navigated to. */ + lastUrl?: string; } /** @@ -84,10 +113,15 @@ export class AttachController { private lease?: AttachLease; private tab?: string; private lastError?: StructuredAttachError; + private paused = false; + private dryRun: boolean; + private navigations = 0; + private lastUrl?: string; constructor(opts: AttachControllerOptions) { this.backend = opts.backend; this.opts = opts; + this.dryRun = !!opts.dryRun; } private get deadline(): number { @@ -102,9 +136,28 @@ export class AttachController { leaseHeld: !!this.lease, agentTab: this.tab, lastError: this.lastError, + paused: this.paused, + dryRun: this.dryRun, + navigations: this.navigations, + lastUrl: this.lastUrl, }; } + /** Pause the session — navigation/read refuse until {@link resume}. */ + pause(): void { + this.paused = true; + } + + /** Resume a paused session. */ + resume(): void { + this.paused = false; + } + + /** Toggle dry-run at runtime (simulate navigation/reads, don't perform them). */ + setDryRun(on: boolean): void { + this.dryRun = on; + } + /** * Acquire the lease, open the transport, verify profile identity, and claim * the marker agent tab. Aborts (never launches) on any verification failure. @@ -132,17 +185,34 @@ export class AttachController { } } - /** Navigate the agent tab (policy-checked, redirect-revalidated). */ + /** Navigate the agent tab (policy-checked, HITL-gated, redirect-revalidated). */ async goto(url: string): Promise { this.requireReady(); const verdict = isNavigationAllowed(url, this.opts.urlPolicy); if (!verdict.allowed) { throw new AttachError('POLICY_BLOCKED', `navigation to "${url}" rejected: ${verdict.reason}`); } + // HITL gate: after the policy passes, an approver may still block the page. + if (this.opts.onNavigate) { + const decision = await this.opts.onNavigate(url); + const approved = typeof decision === 'boolean' ? decision : decision.approved; + if (!approved) { + const reason = typeof decision === 'object' && decision.reason ? decision.reason : 'operator denied'; + throw new AttachError('POLICY_BLOCKED', `navigation to "${url}" denied by approver: ${reason}`); + } + } requireLease(this.opts.leaseFile, this.lease!.nonce); + // Dry-run: report the URL as the landed target without touching the browser. + if (this.dryRun) { + this.navigations += 1; + this.lastUrl = url; + return url; + } this.machine.to('navigating'); try { const landed = await withDeadline(this.backend.gotoTab(this.tab!, url), this.deadline, 'goto'); + this.navigations += 1; + this.lastUrl = landed; const landedVerdict = revalidateRedirect(landed, this.opts.urlPolicy); if (!landedVerdict.allowed) { this.machine.to('ready'); @@ -161,6 +231,7 @@ export class AttachController { async read(selector?: string, maxChars?: number): Promise { this.requireReady(); requireLease(this.opts.leaseFile, this.lease!.nonce); + if (this.dryRun) return '[dry-run] read skipped'; this.machine.to('reading'); try { const text = await withDeadline(this.backend.readTab(this.tab!, selector, maxChars), this.deadline, 'read'); @@ -193,6 +264,9 @@ export class AttachController { } private requireReady(): void { + if (this.paused) { + throw new AttachError('UNKNOWN', 'attach session is paused — call resume() before further operations'); + } if (this.machine.state !== 'ready' || !this.tab || !this.lease) { throw new AttachError('UNKNOWN', `attach session not ready (state=${this.machine.state}) — call claim() first`); } diff --git a/registry/curated/system/browser-automation/src/attach/__tests__/AttachController.test.ts b/registry/curated/system/browser-automation/src/attach/__tests__/AttachController.test.ts index 53da661..557879e 100644 --- a/registry/curated/system/browser-automation/src/attach/__tests__/AttachController.test.ts +++ b/registry/curated/system/browser-automation/src/attach/__tests__/AttachController.test.ts @@ -144,6 +144,52 @@ describe('AttachController', () => { expect(c.status().state).toBe('disconnected'); }); + it('HITL approver can deny a policy-clean navigation', async () => { + const { backend, calls } = makeBackend(); + const c = controller(backend, { onNavigate: (url) => !url.includes('secret') }); + await c.claim(); + await expect(c.goto('https://ok.example.com/secret')).rejects.toMatchObject({ code: 'POLICY_BLOCKED' }); + expect(calls.filter((x) => x.startsWith('goto:'))).toHaveLength(0); // never reached the backend + await expect(c.goto('https://ok.example.com/public')).resolves.toContain('public'); + await c.detach(); + }); + + it('dry-run reports the target without touching the backend, and counts navigations', async () => { + const { backend, calls } = makeBackend(); + const c = controller(backend, { dryRun: true }); + await c.claim(); + expect(await c.goto('https://ok.example.com/')).toBe('https://ok.example.com/'); + expect(await c.read()).toContain('dry-run'); + expect(calls.filter((x) => x.startsWith('goto:'))).toHaveLength(0); + expect(c.status().dryRun).toBe(true); + expect(c.status().navigations).toBe(1); + expect(c.status().lastUrl).toBe('https://ok.example.com/'); + await c.detach(); + }); + + it('pause refuses operations until resume', async () => { + const { backend } = makeBackend(); + const c = controller(backend); + await c.claim(); + c.pause(); + expect(c.status().paused).toBe(true); + await expect(c.goto('https://ok.example.com/')).rejects.toThrow(/paused/i); + c.resume(); + await expect(c.goto('https://ok.example.com/')).resolves.toContain('ok.example.com'); + await c.detach(); + }); + + it('status tracks navigation count and last url on real ops', async () => { + const { backend } = makeBackend(); + const c = controller(backend); + await c.claim(); + await c.goto('https://a.example.com/'); + await c.goto('https://b.example.com/'); + expect(c.status().navigations).toBe(2); + expect(c.status().lastUrl).toBe('https://b.example.com/'); + await c.detach(); + }); + it('ops enforce the lease nonce at call time (foreign takeover is detected)', async () => { let t = 1_000_000; const { backend } = makeBackend(); diff --git a/registry/curated/system/browser-automation/src/attach/__tests__/pack-registration.test.ts b/registry/curated/system/browser-automation/src/attach/__tests__/pack-registration.test.ts index 5ce0174..7a4479f 100644 --- a/registry/curated/system/browser-automation/src/attach/__tests__/pack-registration.test.ts +++ b/registry/curated/system/browser-automation/src/attach/__tests__/pack-registration.test.ts @@ -14,6 +14,7 @@ describe('browser-automation attach registration', () => { const attach = pack.descriptors.filter((d) => d.id.startsWith('browser_attach_')); expect(attach.map((d) => d.id).sort()).toEqual([ 'browser_attach_claim', + 'browser_attach_control', 'browser_attach_goto', 'browser_attach_read', 'browser_attach_release', diff --git a/registry/curated/system/browser-automation/src/attach/__tests__/tools.test.ts b/registry/curated/system/browser-automation/src/attach/__tests__/tools.test.ts index 687bddb..582d61b 100644 --- a/registry/curated/system/browser-automation/src/attach/__tests__/tools.test.ts +++ b/registry/curated/system/browser-automation/src/attach/__tests__/tools.test.ts @@ -6,6 +6,7 @@ import { AttachController, type AttachBackend } from '../AttachController.js'; import { ATTACH_TOOL_IDS, AttachClaimTool, + AttachControlTool, AttachGotoTool, AttachReadTool, AttachReleaseTool, @@ -40,16 +41,27 @@ beforeEach(() => { }); describe('attach tools', () => { - it('exposes exactly the five ids and no eval/js tool', () => { + it('exposes exactly the six 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_release', + 'browser_attach_control', ]); expect(ATTACH_TOOL_IDS).not.toContain('browser_attach_eval'); - expect(createAttachTools(controller)).toHaveLength(5); + expect(createAttachTools(controller)).toHaveLength(6); + }); + + it('control tool pauses, resumes, and toggles dry-run at runtime', async () => { + const ctl = new AttachControlTool(controller); + await new AttachClaimTool(controller).execute(); + expect((await ctl.execute({ action: 'pause' })).data.paused).toBe(true); + expect((await ctl.execute({ action: 'resume' })).data.paused).toBe(false); + expect((await ctl.execute({ action: 'dry_run_on' })).data.dryRun).toBe(true); + expect((await ctl.execute({ action: 'dry_run_off' })).data.dryRun).toBe(false); + expect((await ctl.execute({ action: 'status' })).data.leaseHeld).toBe(true); }); it('status and read are non-side-effecting; claim/goto/release are side-effecting', () => { diff --git a/registry/curated/system/browser-automation/src/attach/tools.ts b/registry/curated/system/browser-automation/src/attach/tools.ts index 601dc55..4ac0456 100644 --- a/registry/curated/system/browser-automation/src/attach/tools.ts +++ b/registry/curated/system/browser-automation/src/attach/tools.ts @@ -134,6 +134,51 @@ export class AttachReleaseTool { } } +/** `browser_attach_control` — runtime session controls (pause/resume/dry-run). */ +export class AttachControlTool { + readonly id = 'browser_attach_control'; + readonly name = 'browser_attach_control'; + readonly displayName = 'Attach: session control'; + readonly description = + 'Control the attach session at runtime: pause (refuse further ops), resume, or toggle dry-run (simulate navigation/reads without touching the browser). Returns the updated status.'; + readonly category = 'browser'; + readonly version = '0.1.0'; + readonly hasSideEffects = true; + readonly inputSchema = { + type: 'object' as const, + properties: { + action: { + type: 'string', + enum: ['pause', 'resume', 'dry_run_on', 'dry_run_off', 'status'], + description: 'The control action to apply', + }, + }, + required: ['action'], + }; + constructor(private controller: AttachController) {} + async execute(args: { action: 'pause' | 'resume' | 'dry_run_on' | 'dry_run_off' | 'status' }) { + return envelope(async () => { + switch (args.action) { + case 'pause': + this.controller.pause(); + break; + case 'resume': + this.controller.resume(); + break; + case 'dry_run_on': + this.controller.setDryRun(true); + break; + case 'dry_run_off': + this.controller.setDryRun(false); + break; + case 'status': + break; + } + return this.controller.status(); + }); + } +} + /** Instantiate the full attach tool set sharing one controller. */ export function createAttachTools(controller: AttachController) { return [ @@ -142,6 +187,7 @@ export function createAttachTools(controller: AttachController) { new AttachGotoTool(controller), new AttachReadTool(controller), new AttachReleaseTool(controller), + new AttachControlTool(controller), ]; } @@ -152,4 +198,5 @@ export const ATTACH_TOOL_IDS = [ 'browser_attach_goto', 'browser_attach_read', 'browser_attach_release', + 'browser_attach_control', ] as const; diff --git a/registry/curated/system/browser-automation/src/index.ts b/registry/curated/system/browser-automation/src/index.ts index 3e443e5..eecc0a7 100644 --- a/registry/curated/system/browser-automation/src/index.ts +++ b/registry/curated/system/browser-automation/src/index.ts @@ -58,6 +58,10 @@ export interface BrowserAutomationOptions { transport?: 'jxa' | 'cdp'; /** Optional https host allowlist for navigation. */ allowHosts?: string[]; + /** Start in dry-run (simulate navigation/reads without touching the browser). */ + dryRun?: boolean; + /** Per-operation deadline in ms (default 45s). */ + deadlineMs?: number; }; } @@ -154,6 +158,8 @@ export function createExtensionPack(context: ExtensionContext): ExtensionPack { leaseFile: a.leaseFile ?? join(homedir(), '.wunderland', 'attach.lease'), expectedIdentity: a.expectedIdentity, urlPolicy: a.allowHosts ? { allowHosts: a.allowHosts } : undefined, + dryRun: a.dryRun, + deadlineMs: a.deadlineMs, }); for (const tool of createAttachTools(controller)) { attachDescriptors.push({ id: tool.id, kind: 'tool', priority: 50, enableByDefault: false, payload: tool });