From 5fa61f1eba37e7344c28992a1c8c32f998b77c71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 18:15:55 +0000 Subject: [PATCH 1/3] Initial plan From bec787a75571beaf9892abd0930ef68cbc9c0f9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 18:20:13 +0000 Subject: [PATCH 2/3] feat: implement BrowserAutomationReceipt and visible automation session controls - Add schemas/browser-automation-receipt.schema.json with full receipt contract - Add examples/browser-automation-receipt.example.json fixture - Add automation/automation-session-ui.yaml for visible session surface - Add policy/automation-receipt-policy.yaml for runtime governance rules - Update runtime/playwright-smoke.mjs to emit automation receipts on start/end/deny - Add scripts/bearbrowser-verify-automation-receipt.py with 6 acceptance-criteria tests - Update docs/runtime-automation.md to document receipt lifecycle and revocation" Agent-Logs-Url: https://github.com/SourceOS-Linux/BearBrowser/sessions/15431771-da06-4027-a77d-45212fbaa98c Co-authored-by: mdheller <21163552+mdheller@users.noreply.github.com> --- automation/automation-session-ui.yaml | 66 +++++ docs/runtime-automation.md | 63 +++++ .../browser-automation-receipt.example.json | 20 ++ policy/automation-receipt-policy.yaml | 62 +++++ runtime/playwright-smoke.mjs | 71 +++++- .../browser-automation-receipt.schema.json | 124 ++++++++++ .../bearbrowser-verify-automation-receipt.py | 227 ++++++++++++++++++ 7 files changed, 625 insertions(+), 8 deletions(-) create mode 100644 automation/automation-session-ui.yaml create mode 100644 examples/browser-automation-receipt.example.json create mode 100644 policy/automation-receipt-policy.yaml create mode 100644 schemas/browser-automation-receipt.schema.json create mode 100755 scripts/bearbrowser-verify-automation-receipt.py diff --git a/automation/automation-session-ui.yaml b/automation/automation-session-ui.yaml new file mode 100644 index 0000000..e090744 --- /dev/null +++ b/automation/automation-session-ui.yaml @@ -0,0 +1,66 @@ +apiVersion: sourceos.dev/v1alpha1 +kind: AutomationSessionUI +metadata: + name: bearbrowser-automation-session-surface + labels: + sourceos.dev/product: BearBrowser + sourceos.dev/surface: automation-session-ui +spec: + description: >- + Visible, user-controlled surface that displays every active browser automation + session backed by a BrowserAutomationReceipt. The surface must be rendered + whenever any automation transport is active and must never be suppressed. + + visibility: + always: true + suppressible: false + location: browser-toolbar-badge-and-panel + + sessionCard: + requiredFields: + - receiptId + - ownerRef + - displayName + - transport + - tabScope + - permissionScope + - origin + - status + - capturedAt + + displayMapping: + ownerRef: "Owner / Agent" + displayName: "Session label" + transport: "Active transport" + tabScope: "Controlled tab(s) / window(s)" + permissionScope: "Granted permissions" + origin: "Origin (local / remote / workspace)" + receiptId: "Evidence receipt ID" + status: "Session status" + capturedAt: "Session started" + + actions: + revoke: + label: "Revoke / Kill session" + icon: stop-circle + requiresConfirmation: true + confirmationPrompt: "Revoking this session will immediately terminate the automation transport and invalidate all session tokens. Proceed?" + effect: + - terminateTransport: true + - invalidateSessionToken: true + - updateReceiptStatus: "revoked" + - emitProvenanceEvent: "automation.action_denied" + - setRevokedAt: now + + sessionList: + emptyState: "No active automation sessions." + sortOrder: capturedAt-desc + maxVisible: 10 + overflowBehavior: paginate + + enforcement: + noOwnerPolicy: reject + noPolicyDecisionPolicy: reject + orphanPolicy: quarantine + logMode: compactReceiptRef + debugLogMode: fullTopologyOnlyWhenDebugEnabled diff --git a/docs/runtime-automation.md b/docs/runtime-automation.md index 1b521a3..d90a2b2 100644 --- a/docs/runtime-automation.md +++ b/docs/runtime-automation.md @@ -63,6 +63,64 @@ Backend preference order: 5. Links 6. Lynx +## Automation receipts + +Every automation transport start produces a `BrowserAutomationReceipt` before the transport is permitted to operate. + +### Receipt structure + +| Field | Description | +|---|---| +| `receiptId` | Stable URN: `urn:srcos:receipt:browser-automation:` | +| `sessionRef` | Active BearBrowser session reference | +| `ownerRef` | Agent, plugin, or workspace that owns the session | +| `transport` | `native_pipe`, `cdp`, `webdriver`, `extension`, or `accessibility` | +| `permissionScope` | Explicit permissions: `read_dom`, `click`, `type`, `download`, `upload`, `inspect_network`, `inspect_cookies`, `use_credentials` | +| `origin` | `local`, `remote`, or `workspace` | +| `userVisible` | Always `true` | +| `revocable` | Always `true` | +| `policyDecisionRef` | PolicyFabric decision that admitted the session | +| `evidenceRefs` | Provenance artifact references | +| `capturedAt` | Session start timestamp | +| `status` | `active`, `revoked`, `ended`, `denied`, or `orphaned` | + +Schema: `schemas/browser-automation-receipt.schema.json` +Example: `examples/browser-automation-receipt.example.json` + +### Receipt lifecycle + +1. **Transport starts** → receipt created with `status: active` and persisted to the provenance directory. +2. **Policy denied** → receipt created with `status: denied`; transport is not started. +3. **User revokes** → receipt updated to `status: revoked`, `revokedAt` set; transport terminated and session token invalidated. +4. **Session ends normally** → receipt updated to `status: ended`. +5. **Orphaned event** (no matching receipt) → event quarantined, never silently accepted. + +### Verifying receipts + +```bash +python3 scripts/bearbrowser-verify-automation-receipt.py examples/browser-automation-receipt.example.json +``` + +Run the built-in acceptance test suite: + +```bash +python3 scripts/bearbrowser-verify-automation-receipt.py --self-test +``` + +## Visible session surface + +The automation session UI (`automation/automation-session-ui.yaml`) shows: + +1. Which agent/plugin/workspace owns the session +2. Active transport +3. Controlled tab/window/page scope +4. Granted permissions +5. Local/remote/workspace origin +6. Evidence receipt ID +7. One-click revoke/kill control + +The surface is always visible when any automation transport is active and cannot be suppressed. + ## Governance rules - Automation frameworks provide control mechanisms, not authority. @@ -70,6 +128,11 @@ Backend preference order: - BearBrowser emits provenance events. - Remote debugging remains denied unless explicitly granted. - Agent-runtime live browser operations require policy decision IDs. +- No automation session may run without an owner. +- No automation session may run without a policy decision. +- Orphaned automation events are quarantined, never silently accepted. + +See `policy/automation-receipt-policy.yaml` for the full runtime policy. ## Current caveat diff --git a/examples/browser-automation-receipt.example.json b/examples/browser-automation-receipt.example.json new file mode 100644 index 0000000..ea948b7 --- /dev/null +++ b/examples/browser-automation-receipt.example.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": "bearbrowser.browser_automation_receipt.v1", + "receiptId": "urn:srcos:receipt:browser-automation:local-smoke-001", + "sessionRef": "bb-session-example", + "ownerRef": "agent-example", + "transport": "cdp", + "permissionScope": ["read_dom", "click", "type"], + "origin": "local", + "userVisible": true, + "revocable": true, + "policyDecisionRef": "policy-decision-example", + "evidenceRefs": [ + "artifact://bearbrowser/capture/example", + "evt-provenance-example-001" + ], + "capturedAt": "2026-05-06T18:00:00Z", + "status": "active", + "tabScope": ["tab-1"], + "displayName": "Example Agent (local smoke)" +} diff --git a/policy/automation-receipt-policy.yaml b/policy/automation-receipt-policy.yaml new file mode 100644 index 0000000..acb8d14 --- /dev/null +++ b/policy/automation-receipt-policy.yaml @@ -0,0 +1,62 @@ +apiVersion: sourceos.dev/v1alpha1 +kind: AutomationReceiptPolicy +metadata: + name: bearbrowser-automation-receipt-policy + labels: + sourceos.dev/product: BearBrowser + sourceos.dev/surface: automation-receipt +spec: + description: >- + Runtime policy rules governing BrowserAutomationReceipt creation, validation, + revocation, and orphan handling. Every automation transport start must produce + a receipt before the transport is permitted to operate. + + sessionStart: + requireReceipt: true + requireOwnerRef: true + requirePolicyDecisionRef: true + defaultDecision: deny + receiptMustBeUserVisible: true + receiptMustBeRevocable: true + + receiptLifecycle: + onTransportStart: + action: createReceipt + emitEvent: automation.action_requested + statusOnCreation: active + onPolicyDenied: + action: createReceiptWithTerminalState + status: denied + emitEvent: automation.action_denied + onRevoke: + action: updateReceiptStatus + status: revoked + setRevokedAt: now + terminateTransport: true + invalidateSessionToken: true + emitEvent: automation.action_denied + onSessionEnd: + action: updateReceiptStatus + status: ended + emitEvent: automation.observed + + orphanHandling: + definition: "An automation event that cannot be matched to an active, owner-bearing receipt." + defaultAction: quarantine + allowedActions: + - quarantine + - reject + logMode: compactReceiptRef + neverSilentlyAccept: true + + missingOwner: + action: reject + reason: "No automation session may run without an owner." + + logging: + normalMode: compactReceiptRef + debugMode: fullTopologyOnlyWhenExplicitlyEnabled + neverLogHighLeakageTopologyInNormalMode: true + + receiptSchema: schemas/browser-automation-receipt.schema.json + uiContract: automation/automation-session-ui.yaml diff --git a/runtime/playwright-smoke.mjs b/runtime/playwright-smoke.mjs index 003cc93..d1d91b5 100644 --- a/runtime/playwright-smoke.mjs +++ b/runtime/playwright-smoke.mjs @@ -1,6 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import crypto from 'node:crypto'; const url = process.env.BEARBROWSER_URL || 'about:blank'; const mode = process.env.BEARBROWSER_MODE || 'agent-runtime'; @@ -8,15 +9,45 @@ const live = process.env.BEARBROWSER_ENABLE_LIVE_PLAYWRIGHT === '1'; const policyDecisionId = process.env.BEARBROWSER_POLICY_DECISION_ID || ''; const provenanceDir = process.env.BEARBROWSER_PROVENANCE_PATH || path.join(os.tmpdir(), 'bearbrowser-provenance'); +const sessionId = process.env.BEARBROWSER_SESSION_ID || `bb-${Date.now()}`; +const agentId = process.env.BEARBROWSER_AGENT_ID || 'local-smoke'; +const workspaceId = process.env.BEARBROWSER_WORKSPACE_ID || 'local'; + +// Generate a stable local receipt ID for this session. +const localReceiptId = crypto.randomBytes(8).toString('hex'); +const receiptId = `urn:srcos:receipt:browser-automation:${localReceiptId}`; + +function buildReceipt(status, extra = {}) { + const receipt = { + schemaVersion: 'bearbrowser.browser_automation_receipt.v1', + receiptId, + sessionRef: sessionId, + ownerRef: agentId, + transport: 'cdp', + permissionScope: ['read_dom', 'click', 'type'], + origin: 'local', + userVisible: true, + revocable: true, + policyDecisionRef: policyDecisionId || 'not-yet-assigned', + evidenceRefs: [], + capturedAt: new Date().toISOString(), + status, + displayName: `${agentId} (playwright smoke)`, + ...extra, + }; + return receipt; +} + const event = { eventType: 'browser.session.started', eventVersion: 'v1alpha1', timestamp: new Date().toISOString(), - sessionId: process.env.BEARBROWSER_SESSION_ID || `bb-${Date.now()}`, - agentId: process.env.BEARBROWSER_AGENT_ID || 'local-smoke', - workspaceId: process.env.BEARBROWSER_WORKSPACE_ID || 'local', + sessionId, + agentId, + workspaceId, profileMode: mode, policyDecisionId: policyDecisionId || null, + automationReceiptId: receiptId, url, liveExecution: live, controlPlane: 'playwright' @@ -29,19 +60,35 @@ if (!policyDecisionId && mode === 'agent-runtime') { console.error('ERROR: BEARBROWSER_POLICY_DECISION_ID is required for live agent-runtime Playwright execution.'); if (!live) { console.log('Dry-run mode accepted without live execution.'); + // Emit a denied receipt so the session is not silently orphaned. + const deniedReceipt = buildReceipt('denied'); + console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt: deniedReceipt }, null, 2)); process.exit(0); } + // Emit a denied receipt before exiting. + const deniedReceipt = buildReceipt('denied'); + console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt: deniedReceipt }, null, 2)); process.exit(64); } if (!live) { console.log('Dry run complete. Set BEARBROWSER_ENABLE_LIVE_PLAYWRIGHT=1 to run a guarded live smoke test.'); + // Emit an active receipt for observability, then mark ended for the dry-run. + const dryReceipt = buildReceipt('ended'); + console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt: dryReceipt }, null, 2)); process.exit(0); } const { chromium } = await import('playwright'); fs.mkdirSync(provenanceDir, { recursive: true }); -fs.writeFileSync(path.join(provenanceDir, `${event.sessionId}.started.json`), JSON.stringify(event, null, 2)); + +// Emit and persist the active automation receipt before the transport starts. +const activeReceipt = buildReceipt('active'); +const receiptPath = path.join(provenanceDir, `${sessionId}.receipt.json`); +fs.writeFileSync(receiptPath, JSON.stringify(activeReceipt, null, 2)); +console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt: activeReceipt }, null, 2)); + +fs.writeFileSync(path.join(provenanceDir, `${sessionId}.started.json`), JSON.stringify(event, null, 2)); const browser = await chromium.launch({ headless: true }); try { @@ -50,7 +97,8 @@ try { console.log(JSON.stringify({ eventType: 'browser.navigation.requested', timestamp: new Date().toISOString(), - sessionId: event.sessionId, + sessionId, + automationReceiptId: receiptId, url, policyDecisionId }, null, 2)); @@ -58,16 +106,23 @@ try { console.log(JSON.stringify({ eventType: 'browser.navigation.completed', timestamp: new Date().toISOString(), - sessionId: event.sessionId, + sessionId, + automationReceiptId: receiptId, url: page.url(), title: await page.title() }, null, 2)); } finally { await browser.close(); + // Update the receipt to ended state. + const endedReceipt = buildReceipt('ended'); + fs.writeFileSync(receiptPath, JSON.stringify(endedReceipt, null, 2)); console.log(JSON.stringify({ eventType: 'browser.session.ended', timestamp: new Date().toISOString(), - sessionId: event.sessionId, - cleanupStatus: 'browserClosed' + sessionId, + automationReceiptId: receiptId, + cleanupStatus: 'browserClosed', + receiptStatus: 'ended' }, null, 2)); + console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt: endedReceipt }, null, 2)); } diff --git a/schemas/browser-automation-receipt.schema.json b/schemas/browser-automation-receipt.schema.json new file mode 100644 index 0000000..0d6cc93 --- /dev/null +++ b/schemas/browser-automation-receipt.schema.json @@ -0,0 +1,124 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sourceos.dev/schemas/bearbrowser/browser-automation-receipt.schema.json", + "title": "BrowserAutomationReceipt v1", + "description": "Governs every automation transport started in BearBrowser. A receipt must exist before any automation session runs and must be updated on revocation or termination.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "receiptId", + "sessionRef", + "ownerRef", + "transport", + "permissionScope", + "origin", + "userVisible", + "revocable", + "policyDecisionRef", + "evidenceRefs", + "capturedAt", + "status" + ], + "properties": { + "schemaVersion": { + "type": "string", + "const": "bearbrowser.browser_automation_receipt.v1" + }, + "receiptId": { + "type": "string", + "description": "Stable receipt URN: urn:srcos:receipt:browser-automation:", + "pattern": "^urn:srcos:receipt:browser-automation:[a-zA-Z0-9_-]+$" + }, + "sessionRef": { + "type": "string", + "description": "Reference to the active BearBrowser session.", + "minLength": 1 + }, + "ownerRef": { + "type": "string", + "description": "Reference to the agent, plugin, or workspace that owns this session.", + "minLength": 1 + }, + "transport": { + "type": "string", + "description": "Active automation transport.", + "enum": ["native_pipe", "cdp", "webdriver", "extension", "accessibility"] + }, + "permissionScope": { + "type": "array", + "description": "Explicit set of permissions granted for this session.", + "items": { + "type": "string", + "enum": [ + "read_dom", + "click", + "type", + "download", + "upload", + "inspect_network", + "inspect_cookies", + "use_credentials" + ] + }, + "minItems": 0, + "uniqueItems": true + }, + "origin": { + "type": "string", + "description": "Where the automation session originates from.", + "enum": ["local", "remote", "workspace"] + }, + "userVisible": { + "type": "boolean", + "description": "The session must be visible to the user at all times.", + "const": true + }, + "revocable": { + "type": "boolean", + "description": "The session must be revocable by the user.", + "const": true + }, + "policyDecisionRef": { + "type": "string", + "description": "Reference to the PolicyFabric decision that admitted this session.", + "minLength": 1 + }, + "evidenceRefs": { + "type": "array", + "description": "References to provenance evidence artifacts supporting this receipt.", + "items": { "type": "string", "minLength": 1 } + }, + "capturedAt": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 timestamp when the receipt was first created." + }, + "status": { + "type": "string", + "description": "Terminal or non-terminal state of the automation session.", + "enum": ["active", "revoked", "ended", "denied", "orphaned"] + }, + "revokedAt": { + "type": "string", + "format": "date-time", + "description": "ISO-8601 timestamp when the session was revoked. Present only when status is revoked." + }, + "tabScope": { + "type": "array", + "description": "Tab/window/page identifiers controlled by this session.", + "items": { "type": "string" } + }, + "displayName": { + "type": "string", + "description": "Human-readable label for the owning agent/plugin/workspace shown in the session UI." + } + }, + "if": { + "properties": { "status": { "const": "revoked" } }, + "required": ["status"] + }, + "then": { + "required": ["revokedAt"] + } +} diff --git a/scripts/bearbrowser-verify-automation-receipt.py b/scripts/bearbrowser-verify-automation-receipt.py new file mode 100755 index 0000000..cf2e97e --- /dev/null +++ b/scripts/bearbrowser-verify-automation-receipt.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Verify BearBrowser BrowserAutomationReceipt files against the schema contract. + +Covers acceptance-criteria test cases: + - successful local automation (active receipt, valid fields) + - denied policy decision (status=denied) + - missing owner (ownerRef absent or empty) + - revoked session (status=revoked, revokedAt present) + - orphan event (no matching receipt) +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +SCHEMA_VERSION = "bearbrowser.browser_automation_receipt.v1" +RECEIPT_ID_PATTERN = re.compile(r"^urn:srcos:receipt:browser-automation:[a-zA-Z0-9_-]+$") +VALID_TRANSPORTS = {"native_pipe", "cdp", "webdriver", "extension", "accessibility"} +VALID_PERMISSIONS = { + "read_dom", "click", "type", "download", "upload", + "inspect_network", "inspect_cookies", "use_credentials", +} +VALID_ORIGINS = {"local", "remote", "workspace"} +VALID_STATUSES = {"active", "revoked", "ended", "denied", "orphaned"} + +REQUIRED_FIELDS = { + "schemaVersion", + "receiptId", + "sessionRef", + "ownerRef", + "transport", + "permissionScope", + "origin", + "userVisible", + "revocable", + "policyDecisionRef", + "evidenceRefs", + "capturedAt", + "status", +} + + +def verify_receipt(receipt: dict[str, Any], source: str) -> list[str]: + errors: list[str] = [] + + # Required fields + missing = sorted(REQUIRED_FIELDS - set(receipt)) + if missing: + errors.append(f"{source}: missing required fields: {', '.join(missing)}") + + if receipt.get("schemaVersion") != SCHEMA_VERSION: + errors.append(f"{source}: schemaVersion must be '{SCHEMA_VERSION}', got {receipt.get('schemaVersion')!r}") + + receipt_id = receipt.get("receiptId", "") + if not RECEIPT_ID_PATTERN.match(receipt_id): + errors.append( + f"{source}: receiptId must match 'urn:srcos:receipt:browser-automation:', got {receipt_id!r}" + ) + + if not receipt.get("sessionRef"): + errors.append(f"{source}: sessionRef must be a non-empty string") + + if not receipt.get("ownerRef"): + errors.append(f"{source}: ownerRef must be a non-empty string (no automation session may run without an owner)") + + transport = receipt.get("transport") + if transport not in VALID_TRANSPORTS: + errors.append(f"{source}: transport must be one of {sorted(VALID_TRANSPORTS)}, got {transport!r}") + + scope = receipt.get("permissionScope") + if not isinstance(scope, list): + errors.append(f"{source}: permissionScope must be an array") + else: + invalid = sorted(set(scope) - VALID_PERMISSIONS) + if invalid: + errors.append(f"{source}: unknown permissions: {invalid}") + if len(scope) != len(set(scope)): + errors.append(f"{source}: permissionScope must have unique items") + + origin = receipt.get("origin") + if origin not in VALID_ORIGINS: + errors.append(f"{source}: origin must be one of {sorted(VALID_ORIGINS)}, got {origin!r}") + + if receipt.get("userVisible") is not True: + errors.append(f"{source}: userVisible must be true") + + if receipt.get("revocable") is not True: + errors.append(f"{source}: revocable must be true") + + if not receipt.get("policyDecisionRef"): + errors.append(f"{source}: policyDecisionRef must be a non-empty string (no automation without a policy decision)") + + if not isinstance(receipt.get("evidenceRefs"), list): + errors.append(f"{source}: evidenceRefs must be an array") + + if not receipt.get("capturedAt"): + errors.append(f"{source}: capturedAt must be present") + + status = receipt.get("status") + if status not in VALID_STATUSES: + errors.append(f"{source}: status must be one of {sorted(VALID_STATUSES)}, got {status!r}") + elif status == "revoked" and not receipt.get("revokedAt"): + errors.append(f"{source}: status=revoked requires revokedAt to be set") + + return errors + + +def run_builtin_tests() -> int: + """Run in-process acceptance-criteria test cases and print results.""" + cases: list[tuple[str, dict[str, Any], bool]] = [] + + base_good = { + "schemaVersion": SCHEMA_VERSION, + "receiptId": "urn:srcos:receipt:browser-automation:test-001", + "sessionRef": "bb-session-test", + "ownerRef": "agent-test", + "transport": "cdp", + "permissionScope": ["read_dom", "click"], + "origin": "local", + "userVisible": True, + "revocable": True, + "policyDecisionRef": "policy-decision-test", + "evidenceRefs": [], + "capturedAt": "2026-05-06T18:00:00Z", + "status": "active", + } + + # 1. Successful local automation + cases.append(("successful local automation", base_good, True)) + + # 2. Denied policy decision + denied = {**base_good, "status": "denied", "policyDecisionRef": "policy-denied-test"} + cases.append(("denied policy decision", denied, True)) + + # 3. Missing owner + no_owner = {**base_good, "ownerRef": ""} + cases.append(("missing owner", no_owner, False)) + + # 4. Revoked session (with revokedAt) + revoked = {**base_good, "status": "revoked", "revokedAt": "2026-05-06T18:30:00Z"} + cases.append(("revoked session with revokedAt", revoked, True)) + + # 5. Revoked session missing revokedAt + revoked_no_ts = {**base_good, "status": "revoked"} + cases.append(("revoked session missing revokedAt", revoked_no_ts, False)) + + # 6. Orphan event — receipt with status=orphaned, no policyDecisionRef + orphan = {**base_good, "status": "orphaned", "policyDecisionRef": ""} + cases.append(("orphan event (no policy decision)", orphan, False)) + + passed = 0 + failed = 0 + for name, receipt, expect_valid in cases: + errs = verify_receipt(receipt, f"test:{name}") + is_valid = len(errs) == 0 + outcome = "PASS" if is_valid == expect_valid else "FAIL" + if outcome == "FAIL": + failed += 1 + print(f" FAIL {name}") + if expect_valid: + for e in errs: + print(f" {e}") + else: + print(" Expected validation errors but got none.") + else: + passed += 1 + print(f" PASS {name}") + + print(f"\nResults: {passed} passed, {failed} failed") + return 0 if failed == 0 else 1 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Verify BrowserAutomationReceipt files or run built-in acceptance tests." + ) + parser.add_argument( + "files", + nargs="*", + help="Receipt JSON files to validate. Omit to run built-in acceptance tests.", + ) + parser.add_argument( + "--self-test", + action="store_true", + help="Run the built-in acceptance-criteria test suite.", + ) + args = parser.parse_args() + + if args.self_test or not args.files: + print("BearBrowser BrowserAutomationReceipt acceptance tests") + print("=" * 55) + return run_builtin_tests() + + all_errors: list[str] = [] + for file_path in args.files: + path = Path(file_path).expanduser() + if not path.exists(): + all_errors.append(f"{path}: file not found") + continue + try: + receipt = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + all_errors.append(f"{path}: invalid JSON: {exc}") + continue + if not isinstance(receipt, dict): + all_errors.append(f"{path}: top-level value must be a JSON object") + continue + errors = verify_receipt(receipt, str(path)) + all_errors.extend(errors) + if not errors: + print(f"OK: {path}") + + if all_errors: + for error in all_errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print("All receipts valid.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 32bcda019d2f4c5d0d36bed0882d6fb837bff02e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 18:22:14 +0000 Subject: [PATCH 3/3] refactor: address code review - rename receiptHexId, extract emitReceipt helper Agent-Logs-Url: https://github.com/SourceOS-Linux/BearBrowser/sessions/15431771-da06-4027-a77d-45212fbaa98c Co-authored-by: mdheller <21163552+mdheller@users.noreply.github.com> --- runtime/playwright-smoke.mjs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/runtime/playwright-smoke.mjs b/runtime/playwright-smoke.mjs index d1d91b5..bbb3862 100644 --- a/runtime/playwright-smoke.mjs +++ b/runtime/playwright-smoke.mjs @@ -13,9 +13,9 @@ const sessionId = process.env.BEARBROWSER_SESSION_ID || `bb-${Date.now()}`; const agentId = process.env.BEARBROWSER_AGENT_ID || 'local-smoke'; const workspaceId = process.env.BEARBROWSER_WORKSPACE_ID || 'local'; -// Generate a stable local receipt ID for this session. -const localReceiptId = crypto.randomBytes(8).toString('hex'); -const receiptId = `urn:srcos:receipt:browser-automation:${localReceiptId}`; +// Generate the hex suffix for the receipt URN. +const receiptHexId = crypto.randomBytes(8).toString('hex'); +const receiptId = `urn:srcos:receipt:browser-automation:${receiptHexId}`; function buildReceipt(status, extra = {}) { const receipt = { @@ -38,6 +38,12 @@ function buildReceipt(status, extra = {}) { return receipt; } +function emitReceipt(status, extra = {}) { + const receipt = buildReceipt(status, extra); + console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt }, null, 2)); + return receipt; +} + const event = { eventType: 'browser.session.started', eventVersion: 'v1alpha1', @@ -58,24 +64,19 @@ console.log(JSON.stringify(event, null, 2)); if (!policyDecisionId && mode === 'agent-runtime') { console.error('ERROR: BEARBROWSER_POLICY_DECISION_ID is required for live agent-runtime Playwright execution.'); + // Emit a denied receipt so the session is not silently orphaned. + emitReceipt('denied'); if (!live) { console.log('Dry-run mode accepted without live execution.'); - // Emit a denied receipt so the session is not silently orphaned. - const deniedReceipt = buildReceipt('denied'); - console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt: deniedReceipt }, null, 2)); process.exit(0); } - // Emit a denied receipt before exiting. - const deniedReceipt = buildReceipt('denied'); - console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt: deniedReceipt }, null, 2)); process.exit(64); } if (!live) { console.log('Dry run complete. Set BEARBROWSER_ENABLE_LIVE_PLAYWRIGHT=1 to run a guarded live smoke test.'); - // Emit an active receipt for observability, then mark ended for the dry-run. - const dryReceipt = buildReceipt('ended'); - console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt: dryReceipt }, null, 2)); + // Emit an ended receipt for observability in dry-run mode. + emitReceipt('ended'); process.exit(0); } @@ -83,10 +84,9 @@ const { chromium } = await import('playwright'); fs.mkdirSync(provenanceDir, { recursive: true }); // Emit and persist the active automation receipt before the transport starts. -const activeReceipt = buildReceipt('active'); +const activeReceipt = emitReceipt('active'); const receiptPath = path.join(provenanceDir, `${sessionId}.receipt.json`); fs.writeFileSync(receiptPath, JSON.stringify(activeReceipt, null, 2)); -console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt: activeReceipt }, null, 2)); fs.writeFileSync(path.join(provenanceDir, `${sessionId}.started.json`), JSON.stringify(event, null, 2)); @@ -114,7 +114,7 @@ try { } finally { await browser.close(); // Update the receipt to ended state. - const endedReceipt = buildReceipt('ended'); + const endedReceipt = emitReceipt('ended'); fs.writeFileSync(receiptPath, JSON.stringify(endedReceipt, null, 2)); console.log(JSON.stringify({ eventType: 'browser.session.ended', @@ -124,5 +124,4 @@ try { cleanupStatus: 'browserClosed', receiptStatus: 'ended' }, null, 2)); - console.log(JSON.stringify({ eventType: 'browser.automation.receipt', receipt: endedReceipt }, null, 2)); }