feat(agent): implement CDP port exposure mode (ADR 0004)#361
Conversation
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.
|
✅ Fix pushed: Fixed flaky test by changing |
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).
|
@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.
|
Fixed the CI failure by preserving stdin/stdout/stderr (fds 0-2) in browser spawn functions. The |
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).
|
⏸️ CI Auto-Fix paused A user commit was detected after an automated fix. CI auto-fix is now paused To resume CI auto-fix, use the |
- 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
a119693 to
10f4185
Compare
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).
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
CdpExposeModeenum (Pipe/Port) with config fieldsbrowser_cdp_exposeandbrowser_cdp_port--remote-debugging-portwith127.0.0.1binding instead of Unix pipesDevToolsActivePortdiscovery +cdp-endpointfile (mode 0600, owned by principal user)browser-statusreturnscdp_endpointin port modebrowser-controlblocked in port mode with actionable error (includes CDP URL)--remote-debugging-*inbrowser_commandextra args (prevent override)Trust model
--remote-debugging-address=127.0.0.1)browser_usermay equalprincipal_useror be omittedRelated
docs/decisions/0004-external-cdp-endpoint-exposure.mddocs/plans/cdp-port-exposure-implementation.md