Skip to content

browser-automation: attach-lane primitives (Phase 1)#35

Merged
jddunn merged 5 commits into
masterfrom
feat/browser-attach-primitives
Jul 21, 2026
Merged

browser-automation: attach-lane primitives (Phase 1)#35
jddunn merged 5 commits into
masterfrom
feat/browser-attach-primitives

Conversation

@jddunn

@jddunn jddunn commented Jul 20, 2026

Copy link
Copy Markdown
Member

Foundation for attach-mode browsing (driving an already-running, logged-in Chrome — never launching or closing it). Adds six pure, unit-tested modules under src/attach/: DevToolsActivePort parser, SingletonLock PID resolver, deny-by-default https-only URL policy (private-net/credential blocks + redirect revalidation), structured errors with credential redaction, a no-replay state machine with op deadlines, and a PID+nonce-fenced cross-process lease. No behavior change to the existing launch-mode tools; nothing registers these yet (controller + typed tools follow in Phase 2/3).

Summary by Sourcery

Introduce foundational attach-mode primitives for driving an existing Chrome session without launch/shutdown responsibilities.

New Features:

  • Add parser for Chrome DevToolsActivePort files to resolve the local CDP endpoint.
  • Add helper to derive Chrome’s PID from its SingletonLock symlink target for precise process addressing.
  • Introduce a deny-by-default navigation URL policy for attach sessions, including HTTPS-only enforcement, credential stripping, private-network blocking, and redirect revalidation.
  • Add structured attach errors with code classification and credential redaction for outbound diagnostics.
  • Add an explicit attach-session state machine with no auto-replay semantics and a deadline wrapper for transport operations.
  • Introduce a cross-process attach lease mechanism to ensure only one active automation session can control the browser at a time.

Tests:

  • Add unit test coverage for DevToolsActivePort parsing, SingletonLock PID extraction, URL policy enforcement and redirect checks, structured error mapping and redaction, attach state machine transitions and timeouts, and lease acquisition/release semantics.

Summary by CodeRabbit

  • New Features
    • Added opt-in “attach mode” that conditionally registers browser_attach_* tools (status/claim/goto/read/release) for controlling an existing authenticated browser session.
    • Enforced HTTPS-only navigation with credential-in-URL blocking, private/loopback protection, optional host allowlisting, and redirect revalidation.
    • Introduced nonce-protected on-disk attach leases and two backends (macOS JXA by default, CDP optional) with identity probing and deadline-bound operations.
  • Bug Fixes
    • Improved structured, redacted error reporting with stable error codes and safer detach behavior that doesn’t close the user browser.
  • Tests
    • Added Vitest coverage for URL policy, parsing, lease semantics, state transitions, and end-to-end tool/backend flows.

DevToolsActivePort parser (re-read per connection, GUID rotates per boot),
SingletonLock PID resolver (disambiguates multi-instance Chrome), deny-by-
default https-only URL policy with private-net + credential blocks and
redirect revalidation, structured attach errors with credential redaction,
an explicit no-replay state machine with op deadlines, and a PID+nonce
fenced cross-process lease so exactly one session drives the browser.
@sourcery-ai

sourcery-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements foundational, pure attach-lane primitives for browser automation (DevTools port parsing, Chrome PID discovery, navigation policy, structured error handling, attach session state machine with deadlines, and a cross‑process lease), all covered by unit tests and not yet wired into existing launch-mode behavior.

Sequence diagram for withDeadline attach operation timeout handling

sequenceDiagram
  participant Caller
  participant withDeadline
  participant TransportOp as TransportPromise
  participant Timer

  Caller->>withDeadline: withDeadline(promise, ms, label)
  activate withDeadline
  withDeadline->>Timer: setTimeout(ms)
  withDeadline->>TransportOp: await p
  alt promise resolves first
    TransportOp-->>withDeadline: result
    withDeadline->>Timer: clearTimeout
    withDeadline-->>Caller: result
  else deadline fires first
    Timer-->>withDeadline: timeout
    withDeadline-->>Caller: throws AttachError(NAV_TIMEOUT)
  end
  deactivate withDeadline
Loading

File-Level Changes

Change Details Files
Add a parser for Chrome DevToolsActivePort discovery files to produce a structured endpoint used for CDP connections.
  • Parse two-line port/path format with trimming and basic validation.
  • Validate numeric port range and enforce /devtools/browser/ WebSocket path.
  • Return both raw path and synthesized ws://127.0.0.1: URL.
registry/curated/system/browser-automation/src/attach/devtools-port.ts
Add a helper for extracting Chrome’s PID from the SingletonLock symlink target to enable unambiguous attach addressing.
  • Use a trailing - regex to parse the PID from the target string.
  • Throw on malformed targets that do not end in -.
  • Return the parsed PID as a number for downstream process checks.
registry/curated/system/browser-automation/src/attach/singleton-lock.ts
Introduce a deny-by-default navigation URL policy for attach mode, including redirect revalidation and optional host allowlisting.
  • Define UrlPolicyOptions and UrlPolicyResult types for policy checks.
  • Allow only https URLs and about:blank while blocking dangerous schemes.
  • Detect and reject URLs with embedded credentials or private/loopback hosts.
  • Support optional allowed host list with subdomain matching.
  • Provide revalidateRedirect helper that re-applies the same policy to redirect destinations.
registry/curated/system/browser-automation/src/attach/url-policy.ts
Add structured attach-layer error handling with stable codes and credential redaction for diagnostics.
  • Define AttachErrorCode union and StructuredAttachError interface.
  • Implement AttachError Error subclass carrying an explicit code.
  • Redact credential-like substrings (tokens, keys, secrets, URL params) via regex patterns.
  • Map arbitrary thrown errors to structured codes using message classifiers, defaulting to UNKNOWN.
registry/curated/system/browser-automation/src/attach/errors.ts
Implement an explicit attach-session state machine plus an operation deadline helper to avoid silent replays and unbounded hangs.
  • Define AttachState union and legal transition table with explicit allowed successors.
  • Implement AttachStateMachine with guarded to() transitions and busy flag.
  • Raise AttachError on illegal transitions to encode failure structurally.
  • Provide withDeadline helper that races an operation promise against a timeout and surfaces NAV_TIMEOUT on expiry.
registry/curated/system/browser-automation/src/attach/state.ts
Add a PID- and nonce-fenced cross-process attach lease to guarantee a single active session driving the user’s browser.
  • Define AttachLease record and LeaseOptions for configuring TTL, clock, and liveness checks.
  • Implement default liveness probe using process.kill(pid, 0) with error handling.
  • Implement acquireLease with JSON on-disk file, mkdir fencing, TTL handling, and stale-lease reclamation based on PID liveness and expiry.
  • Implement requireLease that enforces nonce presence and equality for every operation.
  • Implement releaseLease that only deletes the lease file when the nonce matches, otherwise raising LEASE_DENIED.
registry/curated/system/browser-automation/src/attach/lease.ts
Add unit tests for all attach primitives to lock in behavior and error semantics without wiring them into existing tools yet.
  • Test DevToolsActivePort parsing success and various malformed inputs.
  • Test SingletonLock PID extraction including dash-containing hostnames and malformed targets.
  • Test URL policy allow/deny decisions, including private hosts, credential-bearing URLs, allowlist behavior, and redirect revalidation.
  • Test error redaction and structured error classification for known failure messages and default UNKNOWN.
  • Test AttachStateMachine legal and illegal transitions, failed-state semantics, and withDeadline NAV_TIMEOUT behavior.
  • Test lease acquisition, contention, TTL-based reclamation, and nonce-mismatch release failures using a temp directory-backed lease file.
registry/curated/system/browser-automation/src/attach/__tests__/attach-primitives.test.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 68451e8f-07b2-41b9-94ea-63c07feb5eeb

📥 Commits

Reviewing files that changed from the base of the PR and between 6a9fab4 and 4ed5774.

📒 Files selected for processing (1)
  • registry/curated/system/browser-automation/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • registry/curated/system/browser-automation/src/index.ts

📝 Walkthrough

Walkthrough

Changes

Adds an opt-in browser attach workflow with validated discovery, URL policies, structured errors, lifecycle and lease controls, a coordinating controller, CDP and JXA backends, five attach tools, extension-pack wiring, and Vitest coverage.

Browser attach workflow

Layer / File(s) Summary
Attach primitives
registry/curated/system/browser-automation/src/attach/{devtools-port.ts,singleton-lock.ts,errors.ts,lease.ts,state.ts,url-policy.ts}, registry/curated/system/browser-automation/src/attach/__tests__/attach-primitives.test.ts
Adds DevTools and PID discovery, deny-by-default navigation validation, redacted structured errors, legal state transitions with deadlines, nonce-protected leases, and tests for these behaviors.
Attach controller orchestration
registry/curated/system/browser-automation/src/attach/AttachController.ts, registry/curated/system/browser-automation/src/attach/__tests__/AttachController.test.ts
Coordinates lease acquisition, backend connection, identity verification, agent-tab claiming, guarded navigation and reads, failure recovery, and transport-only detachment.
CDP and JXA backends
registry/curated/system/browser-automation/src/attach/backends/{cdp.ts,jxa.ts}, registry/curated/system/browser-automation/src/attach/__tests__/jxa-args.test.ts
Implements DevTools/Playwright CDP attachment and PID-targeted JXA commands for tab claiming, identity probing, navigation, and reading.
Attach tools and extension wiring
registry/curated/system/browser-automation/src/attach/tools.ts, registry/curated/system/browser-automation/src/index.ts, registry/curated/system/browser-automation/src/attach/__tests__/{tools.test.ts,pack-registration.test.ts}
Adds five structured attach tools and conditionally registers them from attach configuration while retaining launch-mode descriptors.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: Phase 1 browser-automation attach-lane primitives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/browser-attach-primitives

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 4 issues, and left some high level feedback:

  • In acquireLease, you call now() twice when constructing the lease, which can yield slightly inconsistent acquiredAt/expiresAt values and complicates testing; consider capturing const ts = now() once and deriving both fields from that.
  • The readLease helper silently treats any JSON parse failure as “no lease”; if the lease file becomes corrupted this will look like a missing lease rather than a structural problem—consider surfacing an explicit AttachError or distinguishing parse errors from absence.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `acquireLease`, you call `now()` twice when constructing the lease, which can yield slightly inconsistent `acquiredAt`/`expiresAt` values and complicates testing; consider capturing `const ts = now()` once and deriving both fields from that.
- The `readLease` helper silently treats any JSON parse failure as “no lease”; if the lease file becomes corrupted this will look like a missing lease rather than a structural problem—consider surfacing an explicit `AttachError` or distinguishing parse errors from absence.

## Individual Comments

### Comment 1
<location path="registry/curated/system/browser-automation/src/attach/lease.ts" line_range="56-51" />
<code_context>
+  }
+}
+
+function readLease(file: string): AttachLease | undefined {
+  try {
+    return JSON.parse(readFileSync(file, 'utf8')) as AttachLease;
+  } catch {
+    return undefined;
+  }
</code_context>
<issue_to_address>
**issue (bug_risk):** Malformed or partially-written lease files are silently treated as "no lease".

If JSON parsing fails (e.g., corruption, partial or concurrent write), the code treats this as "no lease" and allows a new lease to be acquired. That can violate the "at most one session" guarantee when the existing holder is still alive but the file is temporarily unreadable. It would be safer to distinguish parse errors from a missing file and either surface a dedicated AttachError (e.g., a `STALE_PORT_FILE`-style code) or fail closed instead of re-acquiring when a lease file exists but cannot be parsed.
</issue_to_address>

### Comment 2
<location path="registry/curated/system/browser-automation/src/attach/__tests__/attach-primitives.test.ts" line_range="12-21" />
<code_context>
+import { AttachStateMachine, withDeadline } from '../state.js';
+import { acquireLease, releaseLease, requireLease } from '../lease.js';
+
+describe('parseDevToolsActivePort', () => {
+  it('parses a well-formed file', () => {
+    const r = parseDevToolsActivePort('9222\n/devtools/browser/07797f92-4fc3-432a-91dd-0e982e1a3bc0\n');
+    expect(r.port).toBe(9222);
+    expect(r.wsUrl).toBe('ws://127.0.0.1:9222/devtools/browser/07797f92-4fc3-432a-91dd-0e982e1a3bc0');
+  });
+  it('rejects a single-line file', () => {
+    expect(() => parseDevToolsActivePort('9222\n')).toThrow(/malformed/i);
+  });
+  it('rejects a non-numeric port', () => {
+    expect(() => parseDevToolsActivePort('nope\n/devtools/browser/x\n')).toThrow(/invalid port/i);
+  });
+  it('rejects a non-browser-target ws path', () => {
+    expect(() => parseDevToolsActivePort('9222\n/devtools/page/abc\n')).toThrow(/unexpected ws path/i);
+  });
</code_context>
<issue_to_address>
**suggestion (testing):** Add edge-case tests for whitespace, extra lines, and out-of-range ports in DevToolsActivePort parsing

Since the parser trims lines, skips empties, and enforces 1–65535, please add tests that: (1) confirm files with trailing blank lines/extra whitespace and additional lines still parse correctly; and (2) assert that ports like `0` and `65536` are rejected, covering the `<= 0 || > 65535` logic and clarifying behavior on slightly malformed inputs.
</issue_to_address>

### Comment 3
<location path="registry/curated/system/browser-automation/src/attach/__tests__/attach-primitives.test.ts" line_range="41-50" />
<code_context>
+describe('url-policy', () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Extend URL policy tests to cover IPv6 localhost and allowlist corner cases

Since the policy explicitly blocks `[::1]` and uses a host allowlist, it’d be good to cover those semantics in tests. Please add cases for: (1) an IPv6 loopback URL like `https://[::1]/` being rejected with `PRIVATE_HOST`; (2) an allowed host with mixed case (to confirm case-insensitive matching) and a similar-looking but disallowed domain (e.g. `evilfounderscard.com`); and (3) the difference between `allowHosts: []` and `allowHosts` being omitted, to show that an empty array does not behave as “deny all.”

Suggested implementation:

```typescript
describe('url-policy', () => {
  it('allows https and about:blank', () => {
    expect(isNavigationAllowed('https://founderscard.com/home').allowed).toBe(true);
    expect(isNavigationAllowed('about:blank').allowed).toBe(true);
  });

  it('rejects IPv6 localhost with PRIVATE_HOST', () => {
    const result = isNavigationAllowed('https://[::1]/');

    expect(result.allowed).toBe(false);
    expect(result.reason).toBe('PRIVATE_HOST');
  });

  it('matches allowHosts case-insensitively and does not allow similar-looking domains', () => {
    const allowed = isNavigationAllowed('https://FoundersCard.com/path', {
      allowHosts: ['founderscard.com'],
    });

    const blocked = isNavigationAllowed('https://evilfounderscard.com/', {
      allowHosts: ['founderscard.com'],
    });

    expect(allowed.allowed).toBe(true);
    expect(blocked.allowed).toBe(false);
  });

  it('treats an empty allowHosts array the same as the default allowlist', () => {
    const url = 'https://founderscard.com/home';

    const defaultResult = isNavigationAllowed(url);
    const explicitEmptyAllowlist = isNavigationAllowed(url, { allowHosts: [] });

    expect(defaultResult.allowed).toBe(true);
    expect(explicitEmptyAllowlist.allowed).toBe(true);
  });

  it.each([
    ['file:///etc/passwd', 'SCHEME_BLOCKED'],

```

If `isNavigationAllowed` does not currently accept an options object with an `allowHosts` property, or if the shape of the options object differs, you will need to:
1. Adjust the second and third new tests to pass configuration using the actual `isNavigationAllowed` signature (e.g., different parameter order or property names).
2. If the return type does not expose a `reason` field, adapt the IPv6 test to assert on whatever field or enum is used to expose `PRIVATE_HOST` (or update `isNavigationAllowed` to include `reason` in its result).
</issue_to_address>

### Comment 4
<location path="registry/curated/system/browser-automation/src/attach/__tests__/attach-primitives.test.ts" line_range="29-39" />
<code_context>
+  });
+});
+
+describe('pidFromSingletonLock', () => {
+  it('extracts the pid', () => {
+    expect(pidFromSingletonLock('MacBook-Air.local-666')).toBe(666);
+  });
+  it('handles hostnames containing dashes', () => {
+    expect(pidFromSingletonLock('my-host-name.local-12345')).toBe(12345);
+  });
+  it('rejects a target without a pid suffix', () => {
+    expect(() => pidFromSingletonLock('just-a-hostname.local')).toThrow(/malformed/i);
+  });
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for trimming behavior and large-PID parsing in singleton-lock PID extraction

Since `pidFromSingletonLock` trims the symlink target and parses a trailing `-<digits>`, consider adding tests for: (1) leading/trailing whitespace around the target (e.g. `'  Mac.local-123  '`); (2) a large PID near the typical macOS upper bound; and (3) a non-numeric suffix like `-abc` to assert that it throws. This would better cover both the parsing and error paths.

```suggestion
describe('pidFromSingletonLock', () => {
  it('extracts the pid', () => {
    expect(pidFromSingletonLock('MacBook-Air.local-666')).toBe(666);
  });
  it('handles hostnames containing dashes', () => {
    expect(pidFromSingletonLock('my-host-name.local-12345')).toBe(12345);
  });
  it('trims surrounding whitespace from the target', () => {
    expect(pidFromSingletonLock('  Mac.local-123  ')).toBe(123);
  });
  it('parses a large numeric pid suffix', () => {
    expect(pidFromSingletonLock('my-host.local-98765')).toBe(98765);
  });
  it('rejects a target without a pid suffix', () => {
    expect(() => pidFromSingletonLock('just-a-hostname.local')).toThrow(/malformed/i);
  });
  it('rejects a non-numeric pid suffix', () => {
    expect(() => pidFromSingletonLock('my-host.local-abc')).toThrow(/malformed/i);
  });
});
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

try {
process.kill(pid, 0);
return true;
} catch {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Malformed or partially-written lease files are silently treated as "no lease".

If JSON parsing fails (e.g., corruption, partial or concurrent write), the code treats this as "no lease" and allows a new lease to be acquired. That can violate the "at most one session" guarantee when the existing holder is still alive but the file is temporarily unreadable. It would be safer to distinguish parse errors from a missing file and either surface a dedicated AttachError (e.g., a STALE_PORT_FILE-style code) or fail closed instead of re-acquiring when a lease file exists but cannot be parsed.

Comment on lines +12 to +21
describe('parseDevToolsActivePort', () => {
it('parses a well-formed file', () => {
const r = parseDevToolsActivePort('9222\n/devtools/browser/07797f92-4fc3-432a-91dd-0e982e1a3bc0\n');
expect(r.port).toBe(9222);
expect(r.wsUrl).toBe('ws://127.0.0.1:9222/devtools/browser/07797f92-4fc3-432a-91dd-0e982e1a3bc0');
});
it('rejects a single-line file', () => {
expect(() => parseDevToolsActivePort('9222\n')).toThrow(/malformed/i);
});
it('rejects a non-numeric port', () => {

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 (testing): Add edge-case tests for whitespace, extra lines, and out-of-range ports in DevToolsActivePort parsing

Since the parser trims lines, skips empties, and enforces 1–65535, please add tests that: (1) confirm files with trailing blank lines/extra whitespace and additional lines still parse correctly; and (2) assert that ports like 0 and 65536 are rejected, covering the <= 0 || > 65535 logic and clarifying behavior on slightly malformed inputs.

Comment on lines +41 to +50
describe('url-policy', () => {
it('allows https and about:blank', () => {
expect(isNavigationAllowed('https://founderscard.com/home').allowed).toBe(true);
expect(isNavigationAllowed('about:blank').allowed).toBe(true);
});
it.each([
['file:///etc/passwd', 'SCHEME_BLOCKED'],
['javascript:alert(1)', 'SCHEME_BLOCKED'],
['data:text/html,<b>x</b>', 'SCHEME_BLOCKED'],
['devtools://devtools/bundled/inspector.html', 'SCHEME_BLOCKED'],

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 (testing): Extend URL policy tests to cover IPv6 localhost and allowlist corner cases

Since the policy explicitly blocks [::1] and uses a host allowlist, it’d be good to cover those semantics in tests. Please add cases for: (1) an IPv6 loopback URL like https://[::1]/ being rejected with PRIVATE_HOST; (2) an allowed host with mixed case (to confirm case-insensitive matching) and a similar-looking but disallowed domain (e.g. evilfounderscard.com); and (3) the difference between allowHosts: [] and allowHosts being omitted, to show that an empty array does not behave as “deny all.”

Suggested implementation:

describe('url-policy', () => {
  it('allows https and about:blank', () => {
    expect(isNavigationAllowed('https://founderscard.com/home').allowed).toBe(true);
    expect(isNavigationAllowed('about:blank').allowed).toBe(true);
  });

  it('rejects IPv6 localhost with PRIVATE_HOST', () => {
    const result = isNavigationAllowed('https://[::1]/');

    expect(result.allowed).toBe(false);
    expect(result.reason).toBe('PRIVATE_HOST');
  });

  it('matches allowHosts case-insensitively and does not allow similar-looking domains', () => {
    const allowed = isNavigationAllowed('https://FoundersCard.com/path', {
      allowHosts: ['founderscard.com'],
    });

    const blocked = isNavigationAllowed('https://evilfounderscard.com/', {
      allowHosts: ['founderscard.com'],
    });

    expect(allowed.allowed).toBe(true);
    expect(blocked.allowed).toBe(false);
  });

  it('treats an empty allowHosts array the same as the default allowlist', () => {
    const url = 'https://founderscard.com/home';

    const defaultResult = isNavigationAllowed(url);
    const explicitEmptyAllowlist = isNavigationAllowed(url, { allowHosts: [] });

    expect(defaultResult.allowed).toBe(true);
    expect(explicitEmptyAllowlist.allowed).toBe(true);
  });

  it.each([
    ['file:///etc/passwd', 'SCHEME_BLOCKED'],

If isNavigationAllowed does not currently accept an options object with an allowHosts property, or if the shape of the options object differs, you will need to:

  1. Adjust the second and third new tests to pass configuration using the actual isNavigationAllowed signature (e.g., different parameter order or property names).
  2. If the return type does not expose a reason field, adapt the IPv6 test to assert on whatever field or enum is used to expose PRIVATE_HOST (or update isNavigationAllowed to include reason in its result).

Comment on lines +29 to +39
describe('pidFromSingletonLock', () => {
it('extracts the pid', () => {
expect(pidFromSingletonLock('MacBook-Air.local-666')).toBe(666);
});
it('handles hostnames containing dashes', () => {
expect(pidFromSingletonLock('my-host-name.local-12345')).toBe(12345);
});
it('rejects a target without a pid suffix', () => {
expect(() => pidFromSingletonLock('just-a-hostname.local')).toThrow(/malformed/i);
});
});

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 (testing): Add tests for trimming behavior and large-PID parsing in singleton-lock PID extraction

Since pidFromSingletonLock trims the symlink target and parses a trailing -<digits>, consider adding tests for: (1) leading/trailing whitespace around the target (e.g. ' Mac.local-123 '); (2) a large PID near the typical macOS upper bound; and (3) a non-numeric suffix like -abc to assert that it throws. This would better cover both the parsing and error paths.

Suggested change
describe('pidFromSingletonLock', () => {
it('extracts the pid', () => {
expect(pidFromSingletonLock('MacBook-Air.local-666')).toBe(666);
});
it('handles hostnames containing dashes', () => {
expect(pidFromSingletonLock('my-host-name.local-12345')).toBe(12345);
});
it('rejects a target without a pid suffix', () => {
expect(() => pidFromSingletonLock('just-a-hostname.local')).toThrow(/malformed/i);
});
});
describe('pidFromSingletonLock', () => {
it('extracts the pid', () => {
expect(pidFromSingletonLock('MacBook-Air.local-666')).toBe(666);
});
it('handles hostnames containing dashes', () => {
expect(pidFromSingletonLock('my-host-name.local-12345')).toBe(12345);
});
it('trims surrounding whitespace from the target', () => {
expect(pidFromSingletonLock(' Mac.local-123 ')).toBe(123);
});
it('parses a large numeric pid suffix', () => {
expect(pidFromSingletonLock('my-host.local-98765')).toBe(98765);
});
it('rejects a target without a pid suffix', () => {
expect(() => pidFromSingletonLock('just-a-hostname.local')).toThrow(/malformed/i);
});
it('rejects a non-numeric pid suffix', () => {
expect(() => pidFromSingletonLock('my-host.local-abc')).toThrow(/malformed/i);
});
});

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c6f5fd498b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

expiresAt: now() + (opts.ttlMs ?? DEFAULT_TTL_MS),
};
mkdirSync(dirname(opts.file), { recursive: true });
writeFileSync(opts.file, JSON.stringify(lease));

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 Make lease acquisition atomic

When two attach processes start at the same time and the lease file does not exist yet (or has just become reclaimable), both can pass the existsSync/read check and then both return success after this unconditional write. That leaves two callers holding different nonces and able to drive the user's browser concurrently, which is the race this cross-process lease is supposed to prevent; acquisition needs an atomic create/replace protocol rather than check-then-write.

Useful? React with 👍 / 👎.

}

const PRIVATE_HOST_RE =
/^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|\[::1\]|.*\.local)$/i;

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 Block private IPv6 navigation targets

For IPv6 private-network targets such as https://[fd00::1]/ or link-local https://[fe80::1]/, URL.hostname is a bracketed IPv6 literal that does not match this regex unless it is exactly [::1], so isNavigationAllowed returns allowed for internal IPv6 services. In attach mode that bypasses the stated private/loopback host block for a user's logged-in browser.

Useful? React with 👍 / 👎.

return { allowed: false, reason: 'MALFORMED_URL' };
}
if (parsed.protocol !== 'https:') return { allowed: false, reason: 'SCHEME_BLOCKED' };
if (parsed.username || parsed.password) return { allowed: false, reason: 'CREDENTIALS_IN_URL' };

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 Reject token-bearing query URLs

When credentials are carried in the query string, for example https://example.com/callback?access_token=... or ?id_token=..., parsed.username and parsed.password are empty so the policy allows the navigation. Those parameters are already treated as credential-class data by the redactor, so allowing them here violates the attach lane's deny-by-default credential-bearing URL policy and can send sensitive tokens through navigation/tool diagnostics.

Useful? React with 👍 / 👎.

The controller is the only object allowed to touch an attached browser:
attach-only (aborts on identity mismatch, never launches), marker-tab
bound, lease-nonce checked per op, no-replay state machine, policy-checked
navigation with redirect revalidation. The backend contract exposes NO
browser/tab close surface, so a detach can only drop our transport — the
structural fix for the disconnect-closes-browser hazard. JXA backend
addresses Chrome by PID from SingletonLock (multi-instance safe) with a
claim/goto/url/read driver (no arbitrary JS); CDP backend re-reads
DevToolsActivePort per connect and reference-drops on disconnect.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with 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.

Inline comments:
In
`@registry/curated/system/browser-automation/src/attach/__tests__/attach-primitives.test.ts`:
- Around line 78-84: Update the redaction test in the “errors” describe block to
replace the ambiguous access_token fixture value and matching assertion with a
longer distinctive credential value that cannot occur in the unchanged URL
scheme or host. Preserve the existing checks for abc123 and xyz987 and continue
verifying that the access-token value is absent from redactDiagnostic output.

In `@registry/curated/system/browser-automation/src/attach/errors.ts`:
- Around line 63-74: Update the LEASE_DENIED matcher in CODE_MATCHERS to match
“lease” as a standalone word, or the specific lease-held message produced by
lease.ts, so unrelated text such as “please retry” is not classified as a lease
denial.
- Around line 44-49: Update CREDENTIAL_PATTERNS so the bearer credential form
also redacts tokens separated from “Bearer” by whitespace alone, while
preserving the existing colon/equal assignment handling for other credential
keywords. Ensure Authorization-style values such as “Bearer <token>” are fully
replaced before diagnostic text is emitted.

In `@registry/curated/system/browser-automation/src/attach/lease.ts`:
- Around line 70-90: Update acquireLease and releaseLease to use one atomic
CAS-style guard for lease ownership: create the lease file exclusively when
absent, atomically reclaim stale leases only if the expected nonce/content still
matches, and never overwrite a competing live lease. Apply the same
compare-and-delete protocol in releaseLease so it cannot remove a lease replaced
after validation, while preserving existing liveness, expiry, and AttachError
behavior.
- Around line 47-53: Update defaultIsPidAlive to inspect the error code from
process.kill(pid, 0): return false only for ESRCH, return true for EPERM, and
rethrow any other unexpected error instead of treating all failures as dead
processes.

In `@registry/curated/system/browser-automation/src/attach/state.ts`:
- Around line 73-76: Validate ms at the start of withDeadline before
constructing the gate or scheduling setTimeout, requiring it to be a finite
positive integer no greater than 2,147,483,647. Reject invalid values with the
existing error-handling convention, while preserving the current timeout
behavior for valid deadlines.

In `@registry/curated/system/browser-automation/src/attach/url-policy.ts`:
- Around line 36-37: Update PRIVATE_HOST_RE to deny the entire 169.254.0.0/16
link-local range, including 169.254.169.254, and add a regression test verifying
that metadata endpoint is rejected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 359f05a2-4412-43f4-9353-e7082a2cc36c

📥 Commits

Reviewing files that changed from the base of the PR and between 75671b9 and c6f5fd4.

📒 Files selected for processing (7)
  • registry/curated/system/browser-automation/src/attach/__tests__/attach-primitives.test.ts
  • registry/curated/system/browser-automation/src/attach/devtools-port.ts
  • registry/curated/system/browser-automation/src/attach/errors.ts
  • registry/curated/system/browser-automation/src/attach/lease.ts
  • registry/curated/system/browser-automation/src/attach/singleton-lock.ts
  • registry/curated/system/browser-automation/src/attach/state.ts
  • registry/curated/system/browser-automation/src/attach/url-policy.ts

Comment on lines +44 to +49
const CREDENTIAL_PATTERNS: Array<[RegExp, string]> = [
[/(token|secret|password|passwd|apikey|api_key|auth|bearer|session|cookie)(["']?\s*[:=]\s*["']?)[^\s"'&;]+/gi, '$1$2[redacted]'],
[/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, '[redacted-jwt]'],
[/\b(sk|pk|rk|ak)-[A-Za-z0-9_-]{12,}\b/g, '[redacted-key]'],
[/[?&](code|state|id_token|access_token|refresh_token)=[^\s&"']+/gi, '?[redacted-param]'],
];

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

Credential redaction misses space-separated Bearer <token> values.

The first pattern requires a literal :/= between the keyword and value: (["']?\s*[:=]\s*["']?)[^\s"'&;]+. A raw diagnostic containing Authorization: Bearer abc.def.ghi won't be redacted by the bearer branch since there's only whitespace, not :/=, between "Bearer" and the token (the JWT sub-pattern would only catch it if the token happens to look like a 3-part JWT). Given this module's stated purpose is to guarantee no credential-class substring reaches a report/prompt, this is a meaningful leak vector for the common Bearer <token> header shape.

🔒 Proposed fix: allow whitespace-only separators for select keywords
 const CREDENTIAL_PATTERNS: Array<[RegExp, string]> = [
   [/(token|secret|password|passwd|apikey|api_key|auth|bearer|session|cookie)(["']?\s*[:=]\s*["']?)[^\s"'&;]+/gi, '$1$2[redacted]'],
+  [/\bbearer\s+[^\s"'&;]+/gi, 'bearer [redacted]'],
   [/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, '[redacted-jwt]'],
📝 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
const CREDENTIAL_PATTERNS: Array<[RegExp, string]> = [
[/(token|secret|password|passwd|apikey|api_key|auth|bearer|session|cookie)(["']?\s*[:=]\s*["']?)[^\s"'&;]+/gi, '$1$2[redacted]'],
[/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, '[redacted-jwt]'],
[/\b(sk|pk|rk|ak)-[A-Za-z0-9_-]{12,}\b/g, '[redacted-key]'],
[/[?&](code|state|id_token|access_token|refresh_token)=[^\s&"']+/gi, '?[redacted-param]'],
];
const CREDENTIAL_PATTERNS: Array<[RegExp, string]> = [
[/(token|secret|password|passwd|apikey|api_key|auth|bearer|session|cookie)(["']?\s*[:=]\s*["']?)[^\s"'&;]+/gi, '$1$2[redacted]'],
[/\bbearer\s+[^\s"'&;]+/gi, 'bearer [redacted]'],
[/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, '[redacted-jwt]'],
[/\b(sk|pk|rk|ak)-[A-Za-z0-9_-]{12,}\b/g, '[redacted-key]'],
[/[?&](code|state|id_token|access_token|refresh_token)=[^\s&"']+/gi, '?[redacted-param]'],
];
🤖 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/errors.ts` around lines
44 - 49, Update CREDENTIAL_PATTERNS so the bearer credential form also redacts
tokens separated from “Bearer” by whitespace alone, while preserving the
existing colon/equal assignment handling for other credential keywords. Ensure
Authorization-style values such as “Bearer <token>” are fully replaced before
diagnostic text is emitted.

Comment on lines +63 to +74
const CODE_MATCHERS: Array<[RegExp, AttachErrorCode]> = [
[/timeout.*connectovercdp|connectovercdp.*timeout|cdp.*timeout/i, 'CDP_TIMEOUT'],
[/econnrefused|ws error|socket hang up|cdp.*unavailable/i, 'CDP_UNAVAILABLE'],
[/javascript through applescript is turned off/i, 'JS_DISABLED'],
[/not authorized to send apple events|accessibility/i, 'ACCESSIBILITY_DENIED'],
[/profile.*mismatch|wrong profile/i, 'PROFILE_MISMATCH'],
[/tab .* not found|window .* not found|target closed/i, 'TAB_CLOSED'],
[/navigation.*timeout|nav.*timeout|net::err_timed_out/i, 'NAV_TIMEOUT'],
[/policy|blocked|not allowed/i, 'POLICY_BLOCKED'],
[/lease/i, 'LEASE_DENIED'],
[/devtoolsactiveport.*malformed|stale.*port/i, 'STALE_PORT_FILE'],
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

/lease/i matcher will misclassify unrelated messages containing "please".

Line 72's /lease/i has no word boundary, so any thrown message containing the substring "please" (e.g. "please retry", "please wait") is incorrectly classified as LEASE_DENIED. Add \b anchors, or require the fuller "lease held"/"lease" phrasing actually thrown by lease.ts (attach lease held by pid ...).

🩹 Proposed fix
-  [/lease/i, 'LEASE_DENIED'],
+  [/\blease\b/i, 'LEASE_DENIED'],
📝 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
const CODE_MATCHERS: Array<[RegExp, AttachErrorCode]> = [
[/timeout.*connectovercdp|connectovercdp.*timeout|cdp.*timeout/i, 'CDP_TIMEOUT'],
[/econnrefused|ws error|socket hang up|cdp.*unavailable/i, 'CDP_UNAVAILABLE'],
[/javascript through applescript is turned off/i, 'JS_DISABLED'],
[/not authorized to send apple events|accessibility/i, 'ACCESSIBILITY_DENIED'],
[/profile.*mismatch|wrong profile/i, 'PROFILE_MISMATCH'],
[/tab .* not found|window .* not found|target closed/i, 'TAB_CLOSED'],
[/navigation.*timeout|nav.*timeout|net::err_timed_out/i, 'NAV_TIMEOUT'],
[/policy|blocked|not allowed/i, 'POLICY_BLOCKED'],
[/lease/i, 'LEASE_DENIED'],
[/devtoolsactiveport.*malformed|stale.*port/i, 'STALE_PORT_FILE'],
];
const CODE_MATCHERS: Array<[RegExp, AttachErrorCode]> = [
[/timeout.*connectovercdp|connectovercdp.*timeout|cdp.*timeout/i, 'CDP_TIMEOUT'],
[/econnrefused|ws error|socket hang up|cdp.*unavailable/i, 'CDP_UNAVAILABLE'],
[/javascript through applescript is turned off/i, 'JS_DISABLED'],
[/not authorized to send apple events|accessibility/i, 'ACCESSIBILITY_DENIED'],
[/profile.*mismatch|wrong profile/i, 'PROFILE_MISMATCH'],
[/tab .* not found|window .* not found|target closed/i, 'TAB_CLOSED'],
[/navigation.*timeout|nav.*timeout|net::err_timed_out/i, 'NAV_TIMEOUT'],
[/policy|blocked|not allowed/i, 'POLICY_BLOCKED'],
[/\blease\b/i, 'LEASE_DENIED'],
[/devtoolsactiveport.*malformed|stale.*port/i, 'STALE_PORT_FILE'],
];
🤖 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/errors.ts` around lines
63 - 74, Update the LEASE_DENIED matcher in CODE_MATCHERS to match “lease” as a
standalone word, or the specific lease-held message produced by lease.ts, so
unrelated text such as “please retry” is not classified as a lease denial.

Comment on lines +47 to +53
function defaultIsPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file structure first.
git ls-files registry/curated/system/browser-automation/src/attach | sed -n '1,120p'

echo
echo "== outline =="
ast-grep outline registry/curated/system/browser-automation/src/attach/lease.ts --view expanded || true

echo
echo "== file with line numbers =="
cat -n registry/curated/system/browser-automation/src/attach/lease.ts | sed -n '1,220p'

echo
echo "== references to defaultIsPidAlive / process.kill(pid, 0) =="
rg -n "defaultIsPidAlive|process\.kill\(pid,\s*0\)|isPidAlive" registry/curated/system/browser-automation/src -S

Repository: framerslab/agentos-extensions

Length of output: 7992


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect callers/usages of the lease module to understand the failure mode.
rg -n "lease\.ts|attach/lease|isPidAlive|defaultIsPidAlive|EPERM|ESRCH" registry/curated/system/browser-automation/src -S

Repository: framerslab/agentos-extensions

Length of output: 1789


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the minimal surrounding context in the file.
sed -n '1,220p' registry/curated/system/browser-automation/src/attach/lease.ts

Repository: framerslab/agentos-extensions

Length of output: 4081


Treat EPERM as live in defaultIsPidAlive.
process.kill(pid, 0) can throw EPERM for an existing PID you can’t signal. Returning false there lets a second process reclaim a live lease; return false only for ESRCH, true for EPERM, and rethrow unexpected errors.

🤖 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/lease.ts` around lines
47 - 53, Update defaultIsPidAlive to inspect the error code from
process.kill(pid, 0): return false only for ESRCH, return true for EPERM, and
rethrow any other unexpected error instead of treating all failures as dead
processes.

Comment on lines +70 to +90
export function acquireLease(opts: LeaseOptions): AttachLease {
const now = opts.now ?? Date.now;
const alive = opts.isPidAlive ?? defaultIsPidAlive;
const existing = existsSync(opts.file) ? readLease(opts.file) : undefined;
if (existing) {
const fresh = existing.expiresAt > now();
if (fresh && alive(existing.pid)) {
throw new AttachError(
'LEASE_DENIED',
`attach lease held by pid ${existing.pid} until ${new Date(existing.expiresAt).toISOString()}`,
);
}
}
const lease: AttachLease = {
pid: opts.pid ?? process.pid,
nonce: randomBytes(16).toString('hex'),
acquiredAt: now(),
expiresAt: now() + (opts.ttlMs ?? DEFAULT_TTL_MS),
};
mkdirSync(dirname(opts.file), { recursive: true });
writeFileSync(opts.file, JSON.stringify(lease));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Make acquisition and replacement atomic.

The existsSync → read → liveness check → writeFileSync sequence lets two processes both decide the lease is available and both return a lease; the later write merely overwrites the first. The same non-atomic read/validate/unlink pattern in releaseLease can delete a lease reclaimed by another process after the nonce check. Protect acquire, stale reclaim, and release with one atomic guard/CAS-style protocol rather than overwritable writes.

🤖 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/lease.ts` around lines
70 - 90, Update acquireLease and releaseLease to use one atomic CAS-style guard
for lease ownership: create the lease file exclusively when absent, atomically
reclaim stale leases only if the expected nonce/content still matches, and never
overwrite a competing live lease. Apply the same compare-and-delete protocol in
releaseLease so it cannot remove a lease replaced after validation, while
preserving existing liveness, expiry, and AttachError behavior.

Comment on lines +73 to +76
export async function withDeadline<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const gate = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new AttachError('NAV_TIMEOUT', `${label} exceeded ${ms}ms deadline`)), ms);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file and nearby callers/types.
git ls-files registry/curated/system/browser-automation/src/attach/state.ts
wc -l registry/curated/system/browser-automation/src/attach/state.ts
sed -n '1,160p' registry/curated/system/browser-automation/src/attach/state.ts

printf '\n--- callers ---\n'
rg -n "withDeadline\\(" registry/curated/system/browser-automation/src -S

printf '\n--- deadline-related validation ---\n'
rg -n "NAV_TIMEOUT|deadline|timeout" registry/curated/system/browser-automation/src -S

Repository: framerslab/agentos-extensions

Length of output: 6140


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- BrowserService timeout wiring ---'
sed -n '1,180p' registry/curated/system/browser-automation/src/BrowserService.ts

printf '\n%s\n' '--- index timeout wiring ---'
sed -n '1,140p' registry/curated/system/browser-automation/src/index.ts

printf '\n%s\n' '--- attach tests around withDeadline ---'
sed -n '100,150p' registry/curated/system/browser-automation/src/attach/__tests__/attach-primitives.test.ts

Repository: framerslab/agentos-extensions

Length of output: 12041


🏁 Script executed:

node - <<'JS'
const cases = [NaN, -5, 0, 0.2, 1, Infinity, 2_147_483_647, 2_147_483_648, 1e12];
for (const ms of cases) {
  const t = setTimeout(() => {}, ms);
  const info = {
    input: ms,
    _idleTimeout: t._idleTimeout,
  };
  clearTimeout(t);
  console.log(JSON.stringify(info));
}
JS

Repository: framerslab/agentos-extensions

Length of output: 1072


Reject invalid deadlines before scheduling.
setTimeout coerces NaN, <= 0, and values above 2_147_483_647 to ~1ms, so an invalid or oversized ms turns into an immediate NAV_TIMEOUT. Validate ms as a finite positive integer first.

🤖 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/state.ts` around lines
73 - 76, Validate ms at the start of withDeadline before constructing the gate
or scheduling setTimeout, requiring it to be a finite positive integer no
greater than 2,147,483,647. Reject invalid values with the existing
error-handling convention, while preserving the current timeout behavior for
valid deadlines.

Comment on lines +36 to +37
const PRIVATE_HOST_RE =
/^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|\[::1\]|.*\.local)$/i;

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and nearby tests
printf '\n== file list ==\n'
git ls-files 'registry/curated/system/browser-automation/src/attach/*' 'registry/curated/system/browser-automation/src/attach/**' | sed -n '1,120p'

printf '\n== outline ==\n'
ast-grep outline registry/curated/system/browser-automation/src/attach/url-policy.ts --view expanded || true

printf '\n== line-numbered source ==\n'
cat -n registry/curated/system/browser-automation/src/attach/url-policy.ts | sed -n '1,220p'

printf '\n== related tests/search ==\n'
rg -n "PRIVATE_HOST_RE|169\\.254|fe80|fc00|fd00|localhost|0\\.0\\.0\\.0|172\\.(1[6-9]|2\\d|3[01])" registry/curated/system/browser-automation/src -g '*.{ts,tsx,js,jsx}' || true

Repository: framerslab/agentos-extensions

Length of output: 5242


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the policy logic in detail if the file is short enough.
wc -l registry/curated/system/browser-automation/src/attach/url-policy.ts
sed -n '1,220p' registry/curated/system/browser-automation/src/attach/url-policy.ts

Repository: framerslab/agentos-extensions

Length of output: 3022


Block 169.254.0.0/16 in the private-host denylist

This policy still allows 169.254.169.254, the common cloud metadata endpoint. Add a regression test for that host.

🤖 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/url-policy.ts` around
lines 36 - 37, Update PRIVATE_HOST_RE to deny the entire 169.254.0.0/16
link-local range, including 169.254.169.254, and add a regression test verifying
that metadata endpoint is rejected.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
registry/curated/system/browser-automation/src/attach/__tests__/AttachController.test.ts (1)

12-41: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Spy backend doesn't model the real backends' lazy claim in probeIdentity. In cdp.ts/jxa.ts, probeIdentity() invokes claimAgentTab() when no tab is claimed, so the controller ends up claiming twice; this spy treats the two as independent, so the suite passes and can't catch that regression. Consider a spy whose probeIdentity calls claimAgentTab to assert the tab is claimed exactly once.

🤖 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/__tests__/AttachController.test.ts`
around lines 12 - 41, The makeBackend test spy does not model probeIdentity
lazily claiming a tab, so it cannot detect duplicate claims. Update
probeIdentity in makeBackend to invoke claimAgentTab when no tab has been
claimed, while tracking the claimed state so repeated calls reuse it; preserve
the existing identity result and enable tests to assert claimAgentTab occurs
exactly once.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@registry/curated/system/browser-automation/src/attach/AttachController.ts`:
- Line 142: Update the attach flow around AttachController and requireLease so
LEASE_DENIED errors from foreign takeover are caught before they escape,
recorded as lastError, and transition the controller state from ready to failed.
Ensure subsequent operations fail fast without re-probing the lease.

In `@registry/curated/system/browser-automation/src/attach/backends/cdp.ts`:
- Around line 90-97: Make claimAgentTab idempotent in both backends: in
registry/curated/system/browser-automation/src/attach/backends/cdp.ts lines
90-97, return MARKER immediately when this.page is already set; in
registry/curated/system/browser-automation/src/attach/backends/jxa.ts lines
151-157, return the existing claimed window/tab handle when this.claimed is set
instead of rerunning the claim command. Preserve the existing claim behavior
when no handle has been cached.

In `@registry/curated/system/browser-automation/src/attach/backends/jxa.ts`:
- Around line 76-81: The goto branch in the command handler must keep its
polling and trailing delay within the configured execTimeoutMs deadline. Update
the loop around t.loading() to use a poll budget below the default timeout, or
derive the iteration count from execTimeoutMs, while preserving the final return
of t.url() for slow but completed navigations.

---

Nitpick comments:
In
`@registry/curated/system/browser-automation/src/attach/__tests__/AttachController.test.ts`:
- Around line 12-41: The makeBackend test spy does not model probeIdentity
lazily claiming a tab, so it cannot detect duplicate claims. Update
probeIdentity in makeBackend to invoke claimAgentTab when no tab has been
claimed, while tracking the claimed state so repeated calls reuse it; preserve
the existing identity result and enable tests to assert claimAgentTab occurs
exactly once.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7c5ae1c7-b6ec-4ec4-a1ae-b0f3dc2d4293

📥 Commits

Reviewing files that changed from the base of the PR and between c6f5fd4 and 46f7a3a.

📒 Files selected for processing (5)
  • registry/curated/system/browser-automation/src/attach/AttachController.ts
  • registry/curated/system/browser-automation/src/attach/__tests__/AttachController.test.ts
  • registry/curated/system/browser-automation/src/attach/__tests__/jxa-args.test.ts
  • registry/curated/system/browser-automation/src/attach/backends/cdp.ts
  • registry/curated/system/browser-automation/src/attach/backends/jxa.ts

if (!verdict.allowed) {
throw new AttachError('POLICY_BLOCKED', `navigation to "${url}" rejected: ${verdict.reason}`);
}
requireLease(this.opts.leaseFile, this.lease!.nonce);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

LEASE_DENIED on foreign takeover leaves the controller in ready with a stale lastError. requireLease throws before the try/state transition, so after a detected takeover status().state stays ready and lastError is not updated. Consider recording the error and moving to failed so subsequent ops fail fast rather than re-probing the lease each call.

🤖 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` at
line 142, Update the attach flow around AttachController and requireLease so
LEASE_DENIED errors from foreign takeover are caught before they escape,
recorded as lastError, and transition the controller state from ready to failed.
Ensure subsequent operations fail fast without re-probing the lease.

Comment on lines +90 to +97
async claimAgentTab(): Promise<string> {
const ctx = this.browser?.contexts()[0];
if (!ctx) throw new AttachError('CDP_UNAVAILABLE', 'no default browser context on the attached browser');
const page = (await ctx.newPage()) as NonNullable<typeof this.page>;
await page.evaluate(`window.name = ${JSON.stringify(MARKER)}`);
this.page = page;
return MARKER;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make claimAgentTab() idempotent to stop the double-claim. AttachController.claim() calls probeIdentity() then claimAgentTab(), and both backends already claim the agent tab lazily inside probeIdentity() — so the controller's explicit claimAgentTab() is a second claim on an already-claimed session. Have each backend return the existing handle instead of re-claiming.

  • registry/curated/system/browser-automation/src/attach/backends/cdp.ts#L90-L97: return early with the existing tab when this.page is already set (e.g. if (this.page) return MARKER;) instead of always calling ctx.newPage(), which currently orphans the probed tab and repoints this.page at an empty new tab.
  • registry/curated/system/browser-automation/src/attach/backends/jxa.ts#L151-L157: return the cached handle when this.claimed is set (e.g. if (this.claimed) return \${this.claimed.wid} ${this.claimed.tid}`;) instead of re-running the claimcommand, which currently fails withNO_BLANK_TABor binds a different tab after the probe consumed theabout:blank` tab.
📍 Affects 2 files
  • registry/curated/system/browser-automation/src/attach/backends/cdp.ts#L90-L97 (this comment)
  • registry/curated/system/browser-automation/src/attach/backends/jxa.ts#L151-L157
🤖 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/cdp.ts` around
lines 90 - 97, Make claimAgentTab idempotent in both backends: in
registry/curated/system/browser-automation/src/attach/backends/cdp.ts lines
90-97, return MARKER immediately when this.page is already set; in
registry/curated/system/browser-automation/src/attach/backends/jxa.ts lines
151-157, return the existing claimed window/tab handle when this.claimed is set
instead of rerunning the claim command. Preserve the existing claim behavior
when no handle has been cached.

Comment on lines +76 to +81
if (cmd === 'goto') {
t.url = argv[4];
for (let i = 0; i < 80; i++) { delay(0.5); try { if (t.loading() === false) break; } catch (e) { break; } }
delay(1);
return t.url();
}

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 | 🟡 Minor | ⚡ Quick win

Driver poll budget exceeds execTimeoutMs. The goto poll runs 80 × delay(0.5) = 40s plus a trailing delay(1), so on a slow/hanging page the embedded loop needs ~41s while the default execTimeoutMs is 40s (Line 106) — osascript is killed before the loop can return the landed URL, turning every slow load into a spurious UNKNOWN/timeout. Bound the poll below the exec deadline (or derive it from execTimeoutMs).

🤖 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 76 - 81, The goto branch in the command handler must keep its polling and
trailing delay within the configured execTimeoutMs deadline. Update the loop
around t.loading() to use a poll budget below the default timeout, or derive the
iteration count from execTimeoutMs, while preserving the final return of t.url()
for slow but completed navigations.

jddunn added 2 commits July 20, 2026 04:41
…tration

Five tools over AttachController — status/claim/goto/read/release — and NO
raw-JS tool (arbitrary page script would defeat the read-only-commerce and
no-cookie-export contract). status/read are non-side-effecting so a
deny-side-effects policy still permits inspection while gating navigation.
Page reads are labeled untrusted. The pack registers them only when
attach.expectedIdentity is set, as default-off descriptors — launch-mode
tools are unchanged in both modes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@registry/curated/system/browser-automation/src/index.ts`:
- Around line 150-151: Update the backend selection around CdpBackend and
JxaBackend so an omitted transport never defaults to JxaBackend on non-macOS
hosts. Choose a platform-appropriate transport and profileRoot defaults, or
reject unsupported configurations during pack creation; preserve explicit
transport behavior while ensuring registered packs can successfully claim on
supported platforms.
- Around line 152-157: Retain the AttachController created in the pack
initialization flow at pack scope instead of limiting it to the local block,
then update onDeactivate to call detach() when a session was successfully
claimed. Add a regression test covering claim followed by deactivation and
verify that the attach lease is released.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d3b9eee1-dc57-466c-9b82-836935fa4e37

📥 Commits

Reviewing files that changed from the base of the PR and between 61b25e9 and 6a9fab4.

📒 Files selected for processing (4)
  • registry/curated/system/browser-automation/src/attach/__tests__/pack-registration.test.ts
  • registry/curated/system/browser-automation/src/attach/__tests__/tools.test.ts
  • registry/curated/system/browser-automation/src/attach/tools.ts
  • registry/curated/system/browser-automation/src/index.ts

Comment on lines +150 to +151
const backendOpts = { profileRoot: a.profileRoot, identityProbeUrl: a.identityProbeUrl };
const backend = a.transport === 'cdp' ? new CdpBackend(backendOpts) : new JxaBackend(backendOpts);

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

Do not select JXA by default on every platform.

When transport is omitted, this always creates JxaBackend, whose operations invoke macOS osascript. On non-macOS hosts, opt-in registration succeeds but claim cannot work. Select platform-appropriate defaults (including profile-root defaults), or reject unsupported configurations during pack 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/index.ts` around lines 150 -
151, Update the backend selection around CdpBackend and JxaBackend so an omitted
transport never defaults to JxaBackend on non-macOS hosts. Choose a
platform-appropriate transport and profileRoot defaults, or reject unsupported
configurations during pack creation; preserve explicit transport behavior while
ensuring registered packs can successfully claim on supported platforms.

Comment on lines +152 to +157
const controller = new AttachController({
backend,
leaseFile: a.leaseFile ?? join(homedir(), '.wunderland', 'attach.lease'),
expectedIdentity: a.expectedIdentity,
urlPolicy: a.allowHosts ? { allowHosts: a.allowHosts } : undefined,
});

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 | ⚡ Quick win

Release the attach session during pack deactivation.

controller is scoped to this block, so onDeactivate cannot call detach(). Deactivating after a successful claim leaves the attach lease held until process exit and may strand the transport. Retain the controller and detach it during deactivation; add a regression test that claims, deactivates, then verifies the lease is released.

Proposed cleanup wiring
 const attachDescriptors = [];
+let attachController: { detach(): Promise<void> } | undefined;

 // ...
 const controller = new AttachController({
   backend,
   leaseFile: a.leaseFile ?? join(homedir(), '.wunderland', 'attach.lease'),
   expectedIdentity: a.expectedIdentity,
   urlPolicy: a.allowHosts ? { allowHosts: a.allowHosts } : undefined,
 });
+attachController = controller;

 // ...
 onDeactivate: async () => {
-  await browser.shutdown();
-  sharedBrowser = null;
-  sharedCaptcha = null;
-  sharedProxy = null;
+  try {
+    await attachController?.detach();
+  } finally {
+    await browser.shutdown();
+    sharedBrowser = null;
+    sharedCaptcha = null;
+    sharedProxy = null;
+  }
 },
📝 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
const controller = new AttachController({
backend,
leaseFile: a.leaseFile ?? join(homedir(), '.wunderland', 'attach.lease'),
expectedIdentity: a.expectedIdentity,
urlPolicy: a.allowHosts ? { allowHosts: a.allowHosts } : undefined,
});
const controller = new AttachController({
backend,
leaseFile: a.leaseFile ?? join(homedir(), '.wunderland', 'attach.lease'),
expectedIdentity: a.expectedIdentity,
urlPolicy: a.allowHosts ? { allowHosts: a.allowHosts } : undefined,
});
attachController = controller;
🤖 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/index.ts` around lines 152 -
157, Retain the AttachController created in the pack initialization flow at pack
scope instead of limiting it to the local block, then update onDeactivate to
call detach() when a session was successfully claimed. Add a regression test
covering claim followed by deactivation and verify that the attach lease is
released.

@jddunn
jddunn merged commit 9363a1b into master Jul 21, 2026
4 checks passed
@jddunn
jddunn deleted the feat/browser-attach-primitives branch July 21, 2026 02:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant