Skip to content

feat(agent): implement CDP port exposure mode (ADR 0004)#361

Merged
pando85 merged 17 commits into
masterfrom
feat/agent-cdp-port-exposure
Jul 25, 2026
Merged

feat(agent): implement CDP port exposure mode (ADR 0004)#361
pando85 merged 17 commits into
masterfrom
feat/agent-cdp-port-exposure

Conversation

@pando85

@pando85 pando85 commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Summary

Adds browser_cdp_expose = "port" option for delegated-session profiles, allowing external tools (Playwright MCP) to connect directly to the agent browser via CDP WebSocket on localhost.

Changes

  • CdpExposeMode enum (Pipe/Port) with config fields browser_cdp_expose and browser_cdp_port
  • Conditional browser launch: port mode uses --remote-debugging-port with 127.0.0.1 binding instead of Unix pipes
  • DevToolsActivePort discovery + cdp-endpoint file (mode 0600, owned by principal user)
  • browser-status returns cdp_endpoint in port mode
  • browser-control blocked in port mode with actionable error (includes CDP URL)
  • Reject --remote-debugging-* in browser_command extra args (prevent override)
  • Audit trail records exposure mode
  • 11 new unit tests, docs updated (configuration, delegated-session, security)

Trust model

  • Credential private key never leaves the daemon
  • Trust boundary is the authenticated session (cookies), not the CDP channel
  • WebSocket UUID path acts as bearer token
  • Loopback-only binding enforced (--remote-debugging-address=127.0.0.1)
  • In port mode, browser_user may equal principal_user or be omitted

Related

  • ADR: docs/decisions/0004-external-cdp-endpoint-exposure.md
  • Plan: docs/plans/cdp-port-exposure-implementation.md

pando85 and others added 4 commits July 24, 2026 16:29
Define two browser trust models for delegated-session profiles:
- Isolated (pipe): daemon-mediated CDP, separate browser user, full audit
- Trusted (port): direct CDP WebSocket, same-user, Playwright MCP compatible

Includes detailed implementation plan with 13 verified steps.
Add browser_cdp_expose = "port" option for delegated-session profiles,
allowing external tools (Playwright MCP) to connect directly to the
agent browser via CDP WebSocket on localhost.

Changes:
- CdpExposeMode enum (Pipe/Port) with config fields
- Conditional browser launch: port mode uses --remote-debugging-port
  with 127.0.0.1 binding instead of Unix pipes
- DevToolsActivePort discovery + cdp-endpoint file (mode 0600)
- browser-status returns cdp_endpoint in port mode
- browser-control blocked in port mode with actionable error
- Reject --remote-debugging-* in extra_args (prevent override)
- Audit trail records exposure mode
- 11 new unit tests, docs updated

Trust model: credential private key never leaves daemon; trust boundary
is the authenticated session (cookies). WebSocket UUID path acts as
bearer token. Loopback-only binding enforced.
The daemon no longer requires euid==0 to start the agent subsystem.
The real gate is /dev/uhid accessibility, which can be granted via
group permissions (e.g. fido group). This allows running the daemon
as a user service with proper udev rules instead of requiring a
system service running as root.
Clippy's derivable_impls lint flags the manual Default implementation
as it can be replaced with #[derive(Default)] and #[default] on the
Pipe variant.
@forkline-bot

forkline-bot Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Fix pushed:

Fixed flaky test by changing gettid() to getpid() in seqpacket_socketpair_peer_cred - SO_PEERCRED returns the process ID, not the thread ID, which caused failures in cargo's multi-threaded test runner.

forkline-dev[bot] and others added 7 commits July 24, 2026 16:23
SO_PEERCRED on Linux returns the thread ID (TID), not the process ID.
When tests run in parallel, each test thread has a different TID,
causing the assertion to fail. Use libc::gettid() to get the current
thread ID for comparison.
cmsg_buf was allocated inside the if-block and dropped before sendmsg
read it via hdr.msg_control, causing EINVAL from the kernel. Move the
Vec declaration outside the block so it lives until after sendmsg.
When the daemon runs as a regular user (not root), the principal
process runs as the same user. Skip setuid/setgid/setgroups in
HardenedChildSetup::apply() when target matches daemon identity.

The privilege-drop checks in validate() now only apply when the
daemon is root. Non-root daemons can spawn principals as themselves
without needing CAP_SETUID.
With ptrace_scope=1, a non-root daemon cannot read /proc/<pid>/ns/*
of a child that called setsid(). In same-user mode the child shares
the daemon's namespaces anyway, so fall back to reading the daemon's
own namespace inodes.
The admin IPC server handles one request per connection then closes
it. The wait loop in dispatch_run was reusing the same connection,
causing EPIPE on the second iteration. Create a fresh AdminClient
for each poll.
close_range_preserving was only keeping CONTROL_FD (fd 3) open,
closing stdin/stdout/stderr (fds 0-2) that Rust's Command already
set up. The child exec'd with no stdio and glibc aborted (SIGABRT).
Repository owner deleted a comment from forkline-bot Bot Jul 25, 2026
@pando85

pando85 commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

@forkline /reset

The close_range_preserving calls in browser spawn functions were not
preserving stdin/stdout/stderr (fds 0-2), causing child processes to
lose their standard I/O streams that Rust's Command had already set up.

Add fds 0, 1, 2 to the preserved list in both spawn_browser_hardened
and spawn_browser_port_mode to match the pattern used in launcher.rs.
@forkline-bot

forkline-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Fixed the CI failure by preserving stdin/stdout/stderr (fds 0-2) in browser spawn functions. The close_range_preserving calls in spawn_browser_hardened and spawn_browser_port_mode were closing standard I/O file descriptors that Rust's Command had already set up, causing child processes to fail. Added fds 0, 1, 2 to the preserved list to match the pattern used in launcher.rs.

forkline-dev[bot] and others added 2 commits July 25, 2026 06:58
SO_PEERCRED returns the process ID (tgid), not the thread ID.
In cargo test's multi-threaded runner, gettid() differs from getpid(),
causing the assertion to fail.
close_range_preserving allocates a Vec post-fork (UB in multithreaded
programs) and closes Rust runtime internal fds, causing SIGABRT in
the child before exec. In same-user mode there's no privilege
boundary, so skip close_range, setgroups/setgid/setuid, and rlimits.

Also remove redundant setpgid after setsid (setsid already creates
a new process group; setpgid on a session leader returns EPERM).
@forkline-bot

forkline-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

⏸️ CI Auto-Fix paused

A user commit was detected after an automated fix. CI auto-fix is now paused
to prevent conflicts with your changes.

To resume CI auto-fix, use the /reset or /reset-ci-auto-fix command.

- validate_principal_executable now accepts executables owned by the
  daemon's own uid (same-user mode), not just root-owned binaries.

- Add AdminRequest::RequestDelegation so delegation (and thus browser
  launch) can be triggered from the admin socket without needing fd 3
  inside the principal session.

Workflow:
  passless agent run --profile opencode -- /bin/sleep 3600
  passless agent delegation request --profile opencode \
    --rp <rp> --credential <ref> --session-ttl 3600
@pando85
pando85 force-pushed the feat/agent-cdp-port-exposure branch from a119693 to 10f4185 Compare July 25, 2026 09:53
pando85 added 2 commits July 25, 2026 13:09
Chromium does not write DevToolsActivePort when a fixed
--remote-debugging-port is specified. Add wait_for_cdp_port() that
polls /json/version over HTTP/1.1 and extracts webSocketDebuggerUrl.

Also fix extract_web_socket_debugger_url() to handle whitespace
after the JSON colon (Chromium pretty-prints with spaces).
@pando85
pando85 enabled auto-merge (squash) July 25, 2026 11:31
@pando85
pando85 merged commit 09fc630 into master Jul 25, 2026
6 checks passed
@pando85
pando85 deleted the feat/agent-cdp-port-exposure branch July 25, 2026 11:34
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