From b9dbb09f87d7499d15ae0bbad0c946f6e51c846a Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Fri, 24 Jul 2026 16:29:30 +0200 Subject: [PATCH 01/16] =?UTF-8?q?docs:=20ADR=200004=20=E2=80=94=20agent=20?= =?UTF-8?q?browser=20trust=20models=20and=20CDP=20exposure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../0004-external-cdp-endpoint-exposure.md | 187 ++++++++++ .../plans/cdp-port-exposure-implementation.md | 340 ++++++++++++++++++ 2 files changed, 527 insertions(+) create mode 100644 docs/decisions/0004-external-cdp-endpoint-exposure.md create mode 100644 docs/plans/cdp-port-exposure-implementation.md diff --git a/docs/decisions/0004-external-cdp-endpoint-exposure.md b/docs/decisions/0004-external-cdp-endpoint-exposure.md new file mode 100644 index 00000000..14d61a39 --- /dev/null +++ b/docs/decisions/0004-external-cdp-endpoint-exposure.md @@ -0,0 +1,187 @@ +# ADR 0004: Agent Browser Trust Models and CDP Exposure + +## Status + +Proposed + +## Context + +Agent mode launches a managed browser with `--remote-debugging-pipe`, making CDP +accessible exclusively through the daemon's pipe-based proxy (`browser-control`). +This prevents external tools (Playwright MCP, Puppeteer, native DevTools clients) +from attaching to the authenticated browser session. + +Coding agents need rich browser interaction (accessibility snapshots, element +targeting, auto-waiting) that raw CDP JSON over `browser-control` cannot provide. + +The current architecture enforces a single security posture (full isolation). +Operators need a spectrum: from fully isolated multi-user deployments to +single-user workstations where the agent is fully trusted. + +## Decision + +Define two browser trust models for delegated-session profiles, selectable via +configuration. The credential ceremony (WebAuthn assertion) is always mediated +by passless in both models — the trust boundary is about what happens *after* +authentication. + +--- + +### Trust Model 1: Isolated (default, `browser_cdp_expose = "pipe"`) + +The agent interacts with the browser exclusively through the daemon's mediated +CDP proxy. Suitable for multi-user systems, production environments, and +untrusted or partially-trusted agents. + +**Properties:** + +- `browser_user` must differ from `principal_user` (separate Unix user) +- CDP transport: Unix pipes (fd 3/4), no network endpoint +- Every CDP command passes through the daemon (filtering + audit) +- `browser-control` is the only interface; raw CDP JSON per command +- Agent cannot attach external tools to the browser +- Browser process is sandboxed: `close_range()`, `RLIMIT_NOFILE`, `setsid()`, + `PR_SET_NO_NEW_PRIVS`, uid/gid drop + +**Threat model:** + +| Threat | Mitigation | +|--------|-----------| +| Compromised browser accesses credentials | Separate user; no access to pass store or daemon pipes | +| Agent exfiltrates session material | Daemon filters CDP commands; audit trail | +| Remote network access to CDP | No network endpoint exists | +| Cross-user access | Unix user isolation + file permissions | + +**Agent workflow:** + +``` +passless agent delegation request → WebAuthn ceremony → daemon-mediated CDP +``` + +The agent sends individual CDP commands via `browser-control`. No Playwright, +no Puppeteer, no external DevTools. + +--- + +### Trust Model 2: Trusted (`browser_cdp_expose = "port"`) + +The agent connects directly to the browser's CDP WebSocket endpoint using +standard tooling (Playwright MCP, Puppeteer). Suitable for single-user +workstations where the operator fully trusts the agent. + +**Properties:** + +- `browser_user` may equal `principal_user` or be omitted (same-user mode) +- CDP transport: TCP port on 127.0.0.1 (Chromium `--remote-debugging-port`) +- No daemon mediation of CDP commands after the ceremony +- External tools attach via `connectOverCDP(ws://...)` +- Full Playwright MCP capabilities: snapshots, clicks, typing, navigation +- The WebSocket path UUID acts as a bearer token +- `cdp-endpoint` file (mode 0600) carries the URL + +**Threat model:** + +| Threat | Mitigation | +|--------|-----------| +| Remote network access | Chromium binds 127.0.0.1 only | +| Cross-user local access | `cdp-endpoint` file mode 0600 + runtime dir 0700 | +| Same-user process access | **Accepted risk.** Same trust boundary as the principal session | +| Credential theft via CDP | Passless still mediates the WebAuthn ceremony; credentials never leave the daemon. The browser only holds the authenticated *session* (cookies), not the credential keys | +| Agent exfiltrates session | **Accepted risk.** Operator trusts the agent. Audit records the delegation grant and lease creation | + +**What passless still controls in trusted mode:** + +- The WebAuthn assertion (credential private key never leaves passless) +- Delegation grant lifecycle (one-shot, TTL-bounded) +- Lease expiry (browser killed when TTL expires) +- Audit trail (delegation requested, granted, browser launched, lease expired) +- Revocation (operator can revoke delegation or kill lease at any time) + +**What the agent gains:** + +- Direct CDP access after the ceremony +- Full Playwright MCP integration (snapshots, element refs, auto-waiting) +- No per-command daemon round-trip +- Standard DevTools protocol (any CDP client works) + +**Agent workflow:** + +``` +passless agent delegation request → WebAuthn ceremony → browser launched with port +→ agent reads cdp-endpoint → Playwright connectOverCDP → full browser control +→ lease expires or delegation revoked → browser killed +``` + +--- + +### Configuration + +```toml +[agents.profiles.] +browser_cdp_expose = "pipe" # "pipe" (default, isolated) | "port" (trusted) +browser_cdp_port = 0 # port mode only; 0 = ephemeral, or fixed e.g. 9222 +``` + +When `browser_cdp_expose = "port"`: + +- `browser_user` may equal `principal_user` or be omitted (defaults to principal) +- `browser_cdp_port` is optional (0 = OS assigns ephemeral port) + +When `browser_cdp_expose = "pipe"` (or unset): + +- All existing constraints apply (`browser_user` must differ, etc.) + +### Behaviour (port mode) + +1. Browser launched with `--remote-debugging-port=` instead of + `--remote-debugging-pipe`. Pipe creation and fd-dup2 are skipped. +2. Chromium binds to `127.0.0.1` only (default; no `--remote-debugging-address`). +3. After spawn, daemon reads `DevToolsActivePort` from the browser profile + directory to discover the actual port and WebSocket path. +4. Daemon writes the full WebSocket URL to `/cdp-endpoint` + (mode 0600, owned by principal user). +5. `browser-status` response includes a `cdp_endpoint` field with the URL. +6. `browser-control` returns an error directing the caller to use the exposed + endpoint directly. +7. Audit records note the exposure mode at lease creation. + +### Proxy mode (future, `browser_cdp_expose = "proxy"`) + +A middle ground for environments that want Playwright but with daemon mediation: + +- Keep pipe mode internally +- Expose a Unix domain socket at `/cdp.sock` (mode 0600) +- Daemon proxies WebSocket frames to/from the pipe +- CDP command filtering and audit remain active +- Playwright connects via a CDP-over-Unix-socket adapter + +Out of scope for this ADR. The config enum accommodates it. + +--- + +### Security comparison + +| Property | Isolated (pipe) | Trusted (port) | Proxy (future) | +|----------|----------------|----------------|----------------| +| CDP transport | Unix pipes | TCP 127.0.0.1 | Unix socket | +| Daemon mediation | Every command | Ceremony only | Every command | +| External tool attachment | No | Yes (Playwright, etc.) | Yes (via adapter) | +| browser_user isolation | Required | Optional | Required | +| CDP command audit | Per-command | Lease-level only | Per-command | +| CDP command filtering | Yes | No | Yes | +| Credential key exposure | Never | Never | Never | +| Session cookie exposure | Daemon-gated | Direct | Daemon-gated | +| Trust assumption | Agent is untrusted | Agent is trusted | Agent is partially trusted | + +## Consequences + +- Port mode is a reduced-security mode: any same-user process with the WebSocket + URL can control the browser session. This is acceptable for single-user + workstations where the agent and operator share a trust boundary. +- Pipe mode remains the default and recommended mode for multi-user or + production environments. +- No new dependencies required for port mode: reading `DevToolsActivePort` is a + file read; writing `cdp-endpoint` is a file write. +- The `browser-control` command becomes unavailable in port mode (no pipes). +- The credential private key never leaves passless in either model. The trust + boundary is about the authenticated *session*, not the credential material. diff --git a/docs/plans/cdp-port-exposure-implementation.md b/docs/plans/cdp-port-exposure-implementation.md new file mode 100644 index 00000000..1dd0185a --- /dev/null +++ b/docs/plans/cdp-port-exposure-implementation.md @@ -0,0 +1,340 @@ +# Implementation Plan: ADR 0004 — CDP Port Exposure + +## Status of current changes + +- [x] `CdpExposeMode` enum (config.rs) +- [x] `browser_cdp_expose` + `browser_cdp_port` fields on `AgentProfileConfig` +- [x] Config validation: relaxed `browser_user` constraint in port mode +- [x] Runtime validation: relaxed uid checks in port mode, default to principal user +- [x] `BrowserConfig` struct: added `cdp_expose` + `cdp_port` fields +- [x] Compiles clean + +## Remaining work + +### Step 1: Populate `BrowserConfig` from profile config (runtime.rs) + +**File:** `cmd/passless/src/agent/runtime.rs` ~line 3624 +**What:** The `BrowserConfig` construction site needs to pass through the new fields. + +```rust +cdp_expose: config.browser_cdp_expose.unwrap_or_default(), +cdp_port: config.browser_cdp_port.unwrap_or(0), +``` + +**Verify:** `cargo check` + +--- + +### Step 2: Conditional browser launch — port mode (browser.rs) + +**File:** `cmd/passless/src/agent/browser.rs` + +#### 2a. `build_browser_command` (~line 1531) + +Replace hardcoded `--remote-debugging-pipe` with: + +```rust +match config.cdp_expose { + CdpExposeMode::Pipe => { cmd.arg("--remote-debugging-pipe"); } + CdpExposeMode::Port => { + cmd.arg(format!("--remote-debugging-port={}", config.cdp_port)); + // Explicitly bind localhost only + cmd.arg("--remote-debugging-address=127.0.0.1"); + } +} +``` + +#### 2b. `launch_pending` (~line 593) + +In port mode, skip `create_cdp_pipes()` and `spawn_browser_hardened()`. Use a +simpler spawn path (no pipe fd remapping): + +```rust +let (child, cdp_endpoints) = match config.cdp_expose { + CdpExposeMode::Pipe => { + let cdp = create_cdp_pipes()?; + let child = self.spawner.spawn_browser(config, &profile_dir, &cdp)?; + let endpoints = DaemonCdpEndpoints { ... }; + // consume pipe fds + (child, Some(endpoints)) + } + CdpExposeMode::Port => { + let child = spawn_browser_port_mode(config, &profile_dir)?; + (child, None) + } +}; +``` + +#### 2c. New function: `spawn_browser_port_mode` + +Simpler spawn without pipe fd dup2. Still applies hardening (setsid, +close_range, RLIMIT_NOFILE, PR_SET_NO_NEW_PRIVS, uid/gid drop) but with +an empty preserve-fds list: + +```rust +fn spawn_browser_port_mode(config: &BrowserConfig, profile_dir: &Path) -> Result { + let setup = config.hardening(); + setup.validate()?; + let mut cmd = build_browser_command(config, profile_dir); + unsafe { + cmd.pre_exec(move || { + setup.apply(&[]) // no fds to preserve + }); + } + cmd.spawn() +} +``` + +#### 2d. `BrowserLease` — make `cdp` field `Option` + +The `cdp: Option` field already exists as `Option` (check). +In port mode it's `None`. + +**Verify:** `cargo check` + +--- + +### Step 3: DevToolsActivePort discovery (browser.rs) + +**File:** `cmd/passless/src/agent/browser.rs` + +After spawning in port mode, poll for Chromium's `DevToolsActivePort` file: + +```rust +fn discover_cdp_endpoint(profile_dir: &Path, timeout: Duration) -> Result { + let port_file = profile_dir.join("DevToolsActivePort"); + let deadline = Instant::now() + timeout; + loop { + if let Ok(content) = fs::read_to_string(&port_file) { + let lines: Vec<&str> = content.lines().collect(); + if lines.len() >= 2 { + let port = lines[0].trim(); + let ws_path = lines[1].trim(); + return Ok(format!("ws://127.0.0.1:{}{}", port, ws_path)); + } + } + if Instant::now() >= deadline { + return Err(LaunchError::CdpDiscoveryTimeout); + } + std::thread::sleep(Duration::from_millis(50)); + } +} +``` + +`DevToolsActivePort` format (Chromium): +``` +9222 +/devtools/browser/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +**Verify:** Unit test with a mock file. + +--- + +### Step 4: Write `cdp-endpoint` file (browser.rs) + +After discovery, write the WebSocket URL to `/cdp-endpoint`: + +```rust +fn write_cdp_endpoint(runtime_dir: &Path, url: &str, uid: u32, gid: u32) -> Result<(), LaunchError> { + let path = runtime_dir.join("cdp-endpoint"); + fs::write(&path, url)?; + // mode 0600, owned by principal user + chown(&path, uid, gid)?; + set_permissions(&path, 0o600)?; + Ok(()) +} +``` + +**Verify:** Check file exists with correct perms in integration test. + +--- + +### Step 5: Store CDP endpoint in lease + expose via `browser-status` + +#### 5a. Add field to `BrowserLease` (browser.rs) + +```rust +pub cdp_endpoint: Option, // ws:// URL in port mode +``` + +#### 5b. Update `BrowserStatusResponse` (protocol.rs ~line 725) + +```rust +pub struct BrowserStatusResponse { + pub running: bool, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cdp_endpoint: Option, +} +``` + +#### 5c. Populate in `handle_browser_status` (runtime.rs ~line 3840) + +Read `cdp_endpoint` from the lease snapshot and include in response. + +**Verify:** `cargo check` + test `browser-status` output. + +--- + +### Step 6: Block `browser-control` in port mode (runtime.rs) + +In `handle_browser_control` (~line 3876), if the lease has no CDP pipes +(port mode), return an error: + +```rust +if lease.cdp.is_none() { + return Err(ProtocolError::new( + ErrorCode::BrowserNotAvailable, + format!( + "browser-control is unavailable in port mode; connect directly via CDP: {}", + lease.cdp_endpoint.as_deref().unwrap_or("") + ), + )); +} +``` + +**Verify:** Test that `browser-control` returns the error with the endpoint URL. + +--- + +### Step 7: Audit event for exposure mode + +In the lease creation audit event, include the exposure mode: + +```rust +audit.record(AuditEvent::BrowserLaunched { + lease_id, + profile_id, + cdp_expose: config.cdp_expose.to_string(), + ... +}); +``` + +**Verify:** Check audit log contains the field. + +--- + +### Step 8: Validation — reject `--remote-debugging-address` in extra_args + +In config validation or `build_browser_command`, reject if `extra_args` +contains `--remote-debugging-address` (prevent override of localhost binding): + +```rust +if config.extra_args.iter().any(|a| a.starts_with("--remote-debugging-address")) { + return Err(LaunchError::InvalidArg( + "--remote-debugging-address is not allowed (localhost binding enforced)".into() + )); +} +``` + +Also reject `--remote-debugging-pipe` and `--remote-debugging-port` in +extra_args (prevent conflicts): + +```rust +if config.extra_args.iter().any(|a| + a.starts_with("--remote-debugging-pipe") || a.starts_with("--remote-debugging-port") +) { + return Err(LaunchError::InvalidArg( + "remote debugging flags are managed by passless; remove from browser_command".into() + )); +} +``` + +**Verify:** Unit test for config rejection. + +--- + +### Step 9: Import `CdpExposeMode` in browser.rs + +Add the import: +```rust +use passless_core::agent::config::CdpExposeMode; +``` + +**Verify:** `cargo check` + +--- + +### Step 10: Tests + +1. **Unit test (config.rs):** Port mode allows `browser_user == principal_user` +2. **Unit test (config.rs):** Port mode allows `browser_user` omitted +3. **Unit test (config.rs):** Pipe mode still rejects same user +4. **Unit test (config.rs):** Reject `--remote-debugging-*` in extra_args +5. **Unit test (browser.rs):** `discover_cdp_endpoint` with mock file +6. **Unit test (browser.rs):** `build_browser_command` emits correct flags per mode +7. **Integration test:** Full port-mode launch with a real Chromium (if available in CI) + +**Verify:** `cargo test` + +--- + +### Step 11: Update documentation + +- `docs/agents/configuration.md`: Add `browser_cdp_expose` and `browser_cdp_port` fields +- `docs/agents/delegated-session.md`: Add trusted mode workflow +- `docs/agents/security.md`: Add port mode threat model section + +**Verify:** Review rendered docs. + +--- + +### Step 12: Update passless config for our deployment + +Update `~/.config/passless/config.toml` (and dotfiles): + +```toml +[agents.profiles.opencode] +mode = "delegated-session" +principal_user = "agil" +browser_cdp_expose = "port" +browser_cdp_port = 9222 +browser_command = ["/usr/bin/chromium"] +browser_runtime_root = "/home/agil/.local/share/passless/browser" +start_url = "https://github.com" +credential_refs = [...] +max_grant_ttl = 300 +max_session_ttl = 3600 +``` + +No `browser_user` needed (defaults to principal in port mode). + +**Verify:** `passless agent-admin profile check opencode` + +--- + +### Step 13: End-to-end test + +1. Start passless daemon (as root, system service) +2. `passless agent-admin profile check opencode` → OK +3. `passless agent run --profile opencode -- sleep 3600` (background) +4. Inside session: `passless agent --profile opencode doctor` → healthy +5. `passless agent --profile opencode delegation request --rp github.com --credential --session-ttl 3600 --reason "test"` +6. Read `cdp-endpoint` file → `ws://127.0.0.1:9222/devtools/browser/` +7. Connect Playwright: `connectOverCDP("ws://127.0.0.1:9222/devtools/browser/")` +8. Navigate, snapshot, verify authenticated session +9. Revoke delegation → browser killed + +**Verify:** All steps pass. + +--- + +## Execution order + +Steps 1→9 are code changes (sequential, each verified with `cargo check`). +Step 10 is tests (after all code compiles). +Step 11 is docs (parallel with tests). +Step 12-13 are deployment + e2e (after tests pass). + +## Risk: `spawn_browser_hardened` signature + +The `ChildSpawner` trait has `spawn_browser(&self, config, profile_dir, cdp: &CdpPipes)`. +In port mode we don't have pipes. Options: +- A) Make `cdp` parameter `Option<&CdpPipes>` in the trait +- B) Add a separate `spawn_browser_port` method to the trait +- C) Bypass the trait in port mode (call `spawn_browser_port_mode` directly) + +**Recommendation:** Option C — the trait exists for testability of the pipe path. +Port mode is simpler and doesn't need the same mock infrastructure. Add a +`#[cfg(test)]` mock if needed later. From 4179bb15b95bad03f503434aac029af36e39b706 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Fri, 24 Jul 2026 17:30:30 +0200 Subject: [PATCH 02/16] feat(agent): implement CDP port exposure mode (ADR 0004) 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. --- cmd/passless/src/agent/audit_events.rs | 10 + cmd/passless/src/agent/browser.rs | 340 +++++++++++++++++- cmd/passless/src/agent/ceremony.rs | 6 + cmd/passless/src/agent/policy_engine.rs | 4 + cmd/passless/src/agent/runtime.rs | 40 ++- cmd/passless/src/agent/storage_factory.rs | 4 + .../tests/agent_config_integration.rs | 4 + docs/agents/configuration.md | 19 +- docs/agents/delegated-session.md | 87 +++++ docs/agents/security.md | 62 ++++ passless-core/src/agent/config.rs | 156 ++++++-- passless-core/src/agent/protocol.rs | 2 + 12 files changed, 693 insertions(+), 41 deletions(-) diff --git a/cmd/passless/src/agent/audit_events.rs b/cmd/passless/src/agent/audit_events.rs index fd68da76..d222d718 100644 --- a/cmd/passless/src/agent/audit_events.rs +++ b/cmd/passless/src/agent/audit_events.rs @@ -531,6 +531,8 @@ pub struct BrowserLeaseLaunchMeta { lease_id: BrowserLeaseId, profile_id: ProfileId, endpoint_id: EndpointId, + #[serde(default, skip_serializing_if = "Option::is_none")] + cdp_expose: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1161,6 +1163,7 @@ pub struct BrowserLeaseLaunchBuilder { lease_id: BrowserLeaseId, profile_id: ProfileId, endpoint_id: EndpointId, + cdp_expose: Option, } impl BrowserLeaseLaunchBuilder { @@ -1169,14 +1172,21 @@ impl BrowserLeaseLaunchBuilder { lease_id, profile_id, endpoint_id, + cdp_expose: None, } } + pub fn with_cdp_expose(mut self, mode: String) -> Self { + self.cdp_expose = Some(mode); + self + } + pub fn build(self) -> AuditEvent { AuditEvent::new(AuditPayload::BrowserLeaseLaunch(BrowserLeaseLaunchMeta { lease_id: self.lease_id, profile_id: self.profile_id, endpoint_id: self.endpoint_id, + cdp_expose: self.cdp_expose, })) } } diff --git a/cmd/passless/src/agent/browser.rs b/cmd/passless/src/agent/browser.rs index 4443f95b..5ec2fea4 100644 --- a/cmd/passless/src/agent/browser.rs +++ b/cmd/passless/src/agent/browser.rs @@ -15,6 +15,7 @@ use std::process::{Child, Command, ExitStatus, Stdio}; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; +use passless_core::agent::config::CdpExposeMode; use passless_core::agent::{BrowserLeaseId, EndpointId, ProfileId}; use rand::Rng; @@ -198,6 +199,8 @@ pub struct BrowserConfig { pub target_gid: u32, pub daemon_uid: u32, pub daemon_gid: u32, + pub cdp_expose: CdpExposeMode, + pub cdp_port: u16, } pub trait ChildSpawner: Send + Sync { @@ -360,6 +363,9 @@ pub enum LaunchError { InvalidStartUrl(String), RuntimeRootInvalid(String), HardeningFailed(String), + CdpDiscoveryTimeout, + InvalidArg(String), + EndpointFileWriteFailed(String, String), } impl fmt::Display for LaunchError { @@ -382,6 +388,15 @@ impl fmt::Display for LaunchError { LaunchError::HardeningFailed(msg) => { write!(f, "browser hardening failed: {}", msg) } + LaunchError::CdpDiscoveryTimeout => { + write!(f, "timed out waiting for DevToolsActivePort file") + } + LaunchError::InvalidArg(msg) => { + write!(f, "invalid browser argument: {}", msg) + } + LaunchError::EndpointFileWriteFailed(path, e) => { + write!(f, "failed to write CDP endpoint file '{}': {}", path, e) + } } } } @@ -468,6 +483,7 @@ pub struct BrowserLease { pub cdp_buffer: VecDeque, pub cdp_read_buf: Vec, pub child_reaped: bool, + pub cdp_endpoint: Option, } #[derive(Debug, Clone)] @@ -590,9 +606,24 @@ impl BrowserProcessManager { .map_err(|_| LaunchError::InvalidStartUrl(url.clone()))?; } - let cdp = create_cdp_pipes().map_err(|e| LaunchError::PipeCreationFailed(e.to_string()))?; - - let mut child = self.spawner.spawn_browser(config, &profile_dir, &cdp)?; + let is_port_mode = config.cdp_expose == CdpExposeMode::Port; + + let (cdp_pipes, mut child, cdp_endpoint_url) = if is_port_mode { + let child = spawn_browser_port_mode(config, &profile_dir)?; + let discovered = discover_cdp_endpoint(&profile_dir, Duration::from_secs(10))?; + write_cdp_endpoint( + &runtime_dir, + &discovered, + config.target_uid, + config.target_gid, + )?; + (None, child, Some(discovered)) + } else { + let cdp = + create_cdp_pipes().map_err(|e| LaunchError::PipeCreationFailed(e.to_string()))?; + let child = self.spawner.spawn_browser(config, &profile_dir, &cdp)?; + (Some(cdp), child, None) + }; let pid = child.id(); let pgid = pid; @@ -631,9 +662,14 @@ impl BrowserProcessManager { write_manifest_atomic(&runtime_dir, &manifest)?; - let daemon_endpoints = DaemonCdpEndpoints { - to_browser: unsafe { fs::File::from_raw_fd(cdp.daemon_to_browser_write) }, - from_browser: unsafe { fs::File::from_raw_fd(cdp.daemon_from_browser_read) }, + let (daemon_endpoints, consumed_pipes) = if let Some(cdp) = cdp_pipes { + let endpoints = DaemonCdpEndpoints { + to_browser: unsafe { fs::File::from_raw_fd(cdp.daemon_to_browser_write) }, + from_browser: unsafe { fs::File::from_raw_fd(cdp.daemon_from_browser_read) }, + }; + (Some(endpoints), Some(cdp)) + } else { + (None, None) }; let lease = BrowserLease { @@ -645,15 +681,17 @@ impl BrowserProcessManager { login_deadline, ttl_deadline: login_deadline, child: Some(child), - cdp: Some(daemon_endpoints), + cdp: daemon_endpoints, cdp_buffer: VecDeque::new(), cdp_read_buf: Vec::with_capacity(CDP_READ_BUF_SIZE), child_reaped: false, + cdp_endpoint: cdp_endpoint_url, }; - let mut consumed = cdp; - consumed.daemon_to_browser_write = -1; - consumed.daemon_from_browser_read = -1; + if let Some(mut consumed) = consumed_pipes { + consumed.daemon_to_browser_write = -1; + consumed.daemon_from_browser_read = -1; + } self.leases.insert(lease_id.clone(), lease); @@ -922,6 +960,14 @@ impl BrowserProcessManager { self.leases.remove(lease_id) } + pub fn lease_cdp_endpoint(&self, lease_id: &BrowserLeaseId) -> Option> { + self.leases.get(lease_id).map(|l| l.cdp_endpoint.clone()) + } + + pub fn lease_has_cdp_pipes(&self, lease_id: &BrowserLeaseId) -> Option { + self.leases.get(lease_id).map(|l| l.cdp.is_some()) + } + pub fn snapshot(&self, lease_id: &BrowserLeaseId) -> Option { let lease = self.leases.get(lease_id)?; let now = self.clock.now(); @@ -1510,7 +1556,22 @@ fn create_cdp_pipes() -> io::Result { }) } -fn build_browser_command(config: &BrowserConfig, profile_dir: &Path) -> Command { +pub(crate) fn build_browser_command( + config: &BrowserConfig, + profile_dir: &Path, +) -> Result { + for arg in &config.extra_args { + if arg.starts_with("--remote-debugging-pipe") + || arg.starts_with("--remote-debugging-port") + || arg.starts_with("--remote-debugging-address") + { + return Err(LaunchError::InvalidArg( + "remote debugging flags are managed by passless; remove from browser_command" + .into(), + )); + } + } + let mut cmd = Command::new(&config.executable); cmd.arg(format!("--user-data-dir={}", profile_dir.display())); @@ -1528,7 +1589,16 @@ fn build_browser_command(config: &BrowserConfig, profile_dir: &Path) -> Command cmd.arg("--safebrowsing-disable-auto-update"); cmd.arg("--metrics-recording-only"); cmd.arg("--disable-features=TranslateUI"); - cmd.arg("--remote-debugging-pipe"); + + match config.cdp_expose { + CdpExposeMode::Pipe => { + cmd.arg("--remote-debugging-pipe"); + } + CdpExposeMode::Port => { + cmd.arg(format!("--remote-debugging-port={}", config.cdp_port)); + cmd.arg("--remote-debugging-address=127.0.0.1"); + } + } for arg in &config.extra_args { cmd.arg(arg); @@ -1542,7 +1612,7 @@ fn build_browser_command(config: &BrowserConfig, profile_dir: &Path) -> Command cmd.stdout(Stdio::null()); cmd.stderr(Stdio::null()); - cmd + Ok(cmd) } fn spawn_browser_hardened( @@ -1555,7 +1625,7 @@ fn spawn_browser_hardened( .validate() .map_err(|e| LaunchError::HardeningFailed(e.to_string()))?; - let mut cmd = build_browser_command(config, profile_dir); + let mut cmd = build_browser_command(config, profile_dir)?; let fd_read = cdp.browser_from_daemon_read; let fd_write = cdp.browser_to_daemon_write; @@ -1603,7 +1673,7 @@ fn spawn_browser_unhardened( profile_dir: &Path, cdp: &CdpPipes, ) -> Result { - let mut cmd = build_browser_command(config, profile_dir); + let mut cmd = build_browser_command(config, profile_dir)?; let fd_read = cdp.browser_from_daemon_read; let fd_write = cdp.browser_to_daemon_write; @@ -1674,6 +1744,76 @@ fn spawn_browser_unhardened( .map_err(|e| LaunchError::SpawnFailed(e.to_string())) } +fn spawn_browser_port_mode( + config: &BrowserConfig, + profile_dir: &Path, +) -> Result { + let setup = config.hardening(); + setup + .validate() + .map_err(|e| LaunchError::HardeningFailed(e.to_string()))?; + + let mut cmd = build_browser_command(config, profile_dir)?; + + unsafe { + cmd.pre_exec(move || setup.apply(&[])); + } + + cmd.spawn() + .map_err(|e| LaunchError::SpawnFailed(e.to_string())) +} + +pub(crate) fn discover_cdp_endpoint( + profile_dir: &Path, + timeout: Duration, +) -> Result { + let port_file = profile_dir.join("DevToolsActivePort"); + let deadline = Instant::now() + timeout; + loop { + if let Ok(content) = fs::read_to_string(&port_file) { + let lines: Vec<&str> = content.lines().collect(); + if lines.len() >= 2 { + let port = lines[0].trim(); + let ws_path = lines[1].trim(); + return Ok(format!("ws://127.0.0.1:{}{}", port, ws_path)); + } + } + if Instant::now() >= deadline { + return Err(LaunchError::CdpDiscoveryTimeout); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn write_cdp_endpoint( + runtime_dir: &Path, + url: &str, + target_uid: u32, + target_gid: u32, +) -> Result<(), LaunchError> { + let path = runtime_dir.join("cdp-endpoint"); + fs::write(&path, url).map_err(|e| { + LaunchError::EndpointFileWriteFailed(path.display().to_string(), e.to_string()) + })?; + + let file = fs::File::open(&path).map_err(|e| { + LaunchError::EndpointFileWriteFailed(path.display().to_string(), e.to_string()) + })?; + use std::os::unix::io::AsRawFd; + let ret = unsafe { libc::fchown(file.as_raw_fd(), target_uid, target_gid) }; + if ret != 0 { + return Err(LaunchError::EndpointFileWriteFailed( + path.display().to_string(), + io::Error::last_os_error().to_string(), + )); + } + let perms = fs::Permissions::from_mode(0o600); + fs::set_permissions(&path, perms).map_err(|e| { + LaunchError::EndpointFileWriteFailed(path.display().to_string(), e.to_string()) + })?; + Ok(()) +} + fn read_process_identity(pid: u32) -> Option { let stat_path = format!("/proc/{}/stat", pid); let stat_content = fs::read_to_string(&stat_path).ok()?; @@ -2092,6 +2232,8 @@ mod tests { target_gid: gid, daemon_uid: 0, daemon_gid: 0, + cdp_expose: CdpExposeMode::default(), + cdp_port: 0, } } @@ -3640,6 +3782,8 @@ mod tests { target_gid: 1001, daemon_uid: 0, daemon_gid: 0, + cdp_expose: CdpExposeMode::default(), + cdp_port: 0, }; let result = mgr.launch(&config, test_endpoint_id(), test_profile_id()); @@ -3667,6 +3811,8 @@ mod tests { target_gid: 1001, daemon_uid: 0, daemon_gid: 0, + cdp_expose: CdpExposeMode::default(), + cdp_port: 0, }; let result = mgr.launch(&config, test_endpoint_id(), test_profile_id()); @@ -4852,4 +4998,168 @@ mod tests { let exits = mgr.check_exits(); assert!(exits.is_empty()); } + + #[test] + fn test_discover_cdp_endpoint_with_mock_file() { + let dir = tempfile::tempdir().unwrap(); + let profile_dir = dir.path().join("profile"); + fs::create_dir_all(&profile_dir).unwrap(); + fs::write( + profile_dir.join("DevToolsActivePort"), + "9222\n/devtools/browser/abc-123\n", + ) + .unwrap(); + + let result = discover_cdp_endpoint(&profile_dir, Duration::from_secs(1)).unwrap(); + assert_eq!(result, "ws://127.0.0.1:9222/devtools/browser/abc-123"); + } + + #[test] + fn test_discover_cdp_endpoint_timeout() { + let dir = tempfile::tempdir().unwrap(); + let nonexistent = dir.path().join("no-such-dir"); + + let result = discover_cdp_endpoint(&nonexistent, Duration::from_millis(100)); + assert!(matches!(result, Err(LaunchError::CdpDiscoveryTimeout))); + } + + #[test] + fn test_build_browser_command_pipe_mode() { + let dir = tempfile::tempdir().unwrap(); + let config = BrowserConfig { + executable: PathBuf::from("/usr/bin/chromium"), + start_url: None, + extra_args: Vec::new(), + runtime_root: dir.path().to_path_buf(), + ttl: Duration::from_secs(300), + login_timeout: DEFAULT_LOGIN_TIMEOUT, + rp_ids: vec!["example.com".to_string()], + target_uid: 1000, + target_gid: 1000, + daemon_uid: 0, + daemon_gid: 0, + cdp_expose: CdpExposeMode::Pipe, + cdp_port: 0, + }; + + let cmd = build_browser_command(&config, dir.path()).unwrap(); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().to_string()) + .collect(); + + assert!(args.iter().any(|a| a == "--remote-debugging-pipe")); + assert!( + !args + .iter() + .any(|a| a.starts_with("--remote-debugging-port")) + ); + assert!( + !args + .iter() + .any(|a| a.starts_with("--remote-debugging-address")) + ); + } + + #[test] + fn test_build_browser_command_port_mode() { + let dir = tempfile::tempdir().unwrap(); + let config = BrowserConfig { + executable: PathBuf::from("/usr/bin/chromium"), + start_url: None, + extra_args: Vec::new(), + runtime_root: dir.path().to_path_buf(), + ttl: Duration::from_secs(300), + login_timeout: DEFAULT_LOGIN_TIMEOUT, + rp_ids: vec!["example.com".to_string()], + target_uid: 1000, + target_gid: 1000, + daemon_uid: 0, + daemon_gid: 0, + cdp_expose: CdpExposeMode::Port, + cdp_port: 9222, + }; + + let cmd = build_browser_command(&config, dir.path()).unwrap(); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().to_string()) + .collect(); + + assert!(args.iter().any(|a| a == "--remote-debugging-port=9222")); + assert!( + args.iter() + .any(|a| a == "--remote-debugging-address=127.0.0.1") + ); + assert!(!args.iter().any(|a| a == "--remote-debugging-pipe")); + } + + #[test] + fn test_build_browser_command_rejects_remote_debugging_port_in_extra_args() { + let dir = tempfile::tempdir().unwrap(); + let config = BrowserConfig { + executable: PathBuf::from("/usr/bin/chromium"), + start_url: None, + extra_args: vec!["--remote-debugging-port=9999".to_string()], + runtime_root: dir.path().to_path_buf(), + ttl: Duration::from_secs(300), + login_timeout: DEFAULT_LOGIN_TIMEOUT, + rp_ids: vec!["example.com".to_string()], + target_uid: 1000, + target_gid: 1000, + daemon_uid: 0, + daemon_gid: 0, + cdp_expose: CdpExposeMode::Pipe, + cdp_port: 0, + }; + + let result = build_browser_command(&config, dir.path()); + assert!(matches!(result, Err(LaunchError::InvalidArg(_)))); + } + + #[test] + fn test_build_browser_command_rejects_remote_debugging_pipe_in_extra_args() { + let dir = tempfile::tempdir().unwrap(); + let config = BrowserConfig { + executable: PathBuf::from("/usr/bin/chromium"), + start_url: None, + extra_args: vec!["--remote-debugging-pipe".to_string()], + runtime_root: dir.path().to_path_buf(), + ttl: Duration::from_secs(300), + login_timeout: DEFAULT_LOGIN_TIMEOUT, + rp_ids: vec!["example.com".to_string()], + target_uid: 1000, + target_gid: 1000, + daemon_uid: 0, + daemon_gid: 0, + cdp_expose: CdpExposeMode::Pipe, + cdp_port: 0, + }; + + let result = build_browser_command(&config, dir.path()); + assert!(matches!(result, Err(LaunchError::InvalidArg(_)))); + } + + #[test] + fn test_build_browser_command_rejects_remote_debugging_address_in_extra_args() { + let dir = tempfile::tempdir().unwrap(); + let config = BrowserConfig { + executable: PathBuf::from("/usr/bin/chromium"), + start_url: None, + extra_args: vec!["--remote-debugging-address=0.0.0.0".to_string()], + runtime_root: dir.path().to_path_buf(), + ttl: Duration::from_secs(300), + login_timeout: DEFAULT_LOGIN_TIMEOUT, + rp_ids: vec!["example.com".to_string()], + target_uid: 1000, + target_gid: 1000, + daemon_uid: 0, + daemon_gid: 0, + cdp_expose: CdpExposeMode::Pipe, + cdp_port: 0, + }; + + let result = build_browser_command(&config, dir.path()); + assert!(matches!(result, Err(LaunchError::InvalidArg(_)))); + } } diff --git a/cmd/passless/src/agent/ceremony.rs b/cmd/passless/src/agent/ceremony.rs index 35023ce2..272cd817 100644 --- a/cmd/passless/src/agent/ceremony.rs +++ b/cmd/passless/src/agent/ceremony.rs @@ -4071,6 +4071,8 @@ mod tests { browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); AgentConfig { @@ -5016,6 +5018,8 @@ mod tests { browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); AgentConfig { @@ -5667,6 +5671,8 @@ mod tests { browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); AgentConfig { diff --git a/cmd/passless/src/agent/policy_engine.rs b/cmd/passless/src/agent/policy_engine.rs index 8a0c7bf6..e4871f88 100644 --- a/cmd/passless/src/agent/policy_engine.rs +++ b/cmd/passless/src/agent/policy_engine.rs @@ -2355,6 +2355,8 @@ mod tests { browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); AgentConfig { @@ -2390,6 +2392,8 @@ mod tests { browser_command: Some(vec!["firefox".to_string()]), browser_user: Some("browser-user".to_string()), browser_runtime_root: Some(PathBuf::from("/var/run/passless-browser")), + browser_cdp_expose: None, + browser_cdp_port: None, }, ); AgentConfig { diff --git a/cmd/passless/src/agent/runtime.rs b/cmd/passless/src/agent/runtime.rs index 5c66b485..b4fd71a4 100644 --- a/cmd/passless/src/agent/runtime.rs +++ b/cmd/passless/src/agent/runtime.rs @@ -253,6 +253,7 @@ impl std::fmt::Display for RuntimeError { impl std::error::Error for RuntimeError {} +#[derive(Clone)] struct ResolvedUser { uid: u32, gid: u32, @@ -611,9 +612,12 @@ impl AgentRuntime { } } + let cdp_port_mode = profile.browser_cdp_expose + == Some(passless_core::agent::config::CdpExposeMode::Port); + let browser_user_resolved = if let Some(ref browser_user) = profile.browser_user { let bu = resolve_user(browser_user)?; - if is_root { + if is_root && !cdp_port_mode { if bu.uid == user.uid { return Err(RuntimeError::Config(format!( "profile '{}': browser uid {} must differ from principal uid {}", @@ -637,6 +641,8 @@ impl AgentRuntime { validate_browser_runtime_root_at_startup(runtime_root, bu.uid, name)?; } Some(bu) + } else if cdp_port_mode { + Some(user.clone()) } else { None }; @@ -3636,6 +3642,8 @@ impl AgentRuntime { target_gid: browser_gid, daemon_uid: self.daemon_uid, daemon_gid: self.daemon_gid, + cdp_expose: config.browser_cdp_expose.unwrap_or_default(), + cdp_port: config.browser_cdp_port.unwrap_or(0), }; let mut browser_mgr = self.browser_manager.lock().unwrap(); @@ -3665,6 +3673,7 @@ impl AgentRuntime { profile_id.clone(), endpoint_id.clone(), ) + .with_cdp_expose(browser_config.cdp_expose.to_string()) .build(); if let Err(e) = self.audit_gate.record(lease_audit_event) { let _ = policy_rt.principal_cancel_pending(&pending_id_clone, &session_id_clone); @@ -3856,10 +3865,12 @@ impl AgentRuntime { if let Some(lease_id) = lease_id { let browser_mgr = self.browser_manager.lock().unwrap(); if let Some(snapshot) = browser_mgr.snapshot(&lease_id) { + let cdp_endpoint = browser_mgr.lease_cdp_endpoint(&lease_id).flatten(); return Ok(PrincipalResponse::BrowserStatus( passless_core::agent::BrowserStatusResponse { running: !snapshot.state.is_terminal(), status: format!("{}", snapshot.state), + cdp_endpoint, }, )); } @@ -3869,6 +3880,7 @@ impl AgentRuntime { passless_core::agent::BrowserStatusResponse { running: false, status: "no_browser".into(), + cdp_endpoint: None, }, )) } @@ -3947,6 +3959,24 @@ impl AgentRuntime { let cdp_method = extract_cdp_method_for_audit(request_json); + { + let browser_mgr = self.browser_manager.lock().unwrap(); + if let Some(false) = browser_mgr.lease_has_cdp_pipes(&lease_id) { + let endpoint_msg = browser_mgr + .lease_cdp_endpoint(&lease_id) + .flatten() + .unwrap_or_else(|| "".to_string()); + return Err(ProtocolError::new( + ErrorCode::Forbidden, + format!( + "browser-control is unavailable in port mode; connect directly via CDP: {}", + endpoint_msg + ), + RecommendedAction::FixRequest, + )); + } + } + let pre_audit = self.audit_gate.record( super::audit_events::BrowserControlRequestBuilder::new( profile_id.clone(), @@ -6015,6 +6045,8 @@ mod tests { browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, security_config: SecurityConfig::default(), pin_config: PinConfig::default(), @@ -6337,6 +6369,8 @@ mod tests { browser_command: Some(vec!["/bin/true".to_string()]), browser_user: Some("browseruser".to_string()), browser_runtime_root: Some(temp_dir.path().join("browser-runtime")), + browser_cdp_expose: None, + browser_cdp_port: None, }; let agent_config = { @@ -7117,6 +7151,8 @@ mod tests { browser_command: Some(vec!["/bin/true".to_string()]), browser_user: Some("browseruser".to_string()), browser_runtime_root: Some(temp_dir.path().join("browser-runtime")), + browser_cdp_expose: None, + browser_cdp_port: None, }; let agent_config = { @@ -7398,6 +7434,8 @@ mod tests { browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); config diff --git a/cmd/passless/src/agent/storage_factory.rs b/cmd/passless/src/agent/storage_factory.rs index 084f42d5..4797082b 100644 --- a/cmd/passless/src/agent/storage_factory.rs +++ b/cmd/passless/src/agent/storage_factory.rs @@ -645,6 +645,8 @@ mod tests { browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }; let config = config_from_profile(&profile).unwrap(); @@ -685,6 +687,8 @@ mod tests { browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }; let result = config_from_profile(&profile); diff --git a/cmd/passless/tests/agent_config_integration.rs b/cmd/passless/tests/agent_config_integration.rs index f02dcb36..9cd53d43 100644 --- a/cmd/passless/tests/agent_config_integration.rs +++ b/cmd/passless/tests/agent_config_integration.rs @@ -40,6 +40,8 @@ fn isolated_profile() -> AgentProfileConfig { browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, } } @@ -61,6 +63,8 @@ fn delegated_profile() -> AgentProfileConfig { browser_command: Some(vec!["/usr/bin/browser".into()]), browser_user: Some("browseruser".into()), browser_runtime_root: Some(PathBuf::from("/tmp/browser-runtime")), + browser_cdp_expose: None, + browser_cdp_port: None, } } diff --git a/docs/agents/configuration.md b/docs/agents/configuration.md index 8a692b8d..4de9bc81 100644 --- a/docs/agents/configuration.md +++ b/docs/agents/configuration.md @@ -53,10 +53,16 @@ denied action must use `none` for both evidence sources. | `max_grant_ttl` | yes | Maximum seconds for the one-shot login grant (1..31536000) | | `max_session_ttl` | yes | Maximum seconds for the browser lease after assertion | | `browser_command` | yes | Stock browser executable and arguments | -| `browser_user` | yes | Separate Unix user for the ephemeral browser; must differ from `principal_user` | -| `browser_runtime_root` | yes | Absolute path for ephemeral browser profile state; owned by `browser_user` with mode 0700 | +| `browser_user` | pipe mode | Separate Unix user for the ephemeral browser; must differ from `principal_user` | +| `browser_runtime_root` | yes | Absolute path for ephemeral browser profile state; mode 0700 | +| `browser_cdp_expose` | no | CDP transport: `"pipe"` (default) or `"port"` | +| `browser_cdp_port` | no | TCP port for port mode; 0 = ephemeral (default 0) | | `delegated_registration_storage` | for registration | Must be `"human"`; omission denies delegated registration | +When `browser_cdp_expose = "port"`, `browser_user` may equal `principal_user` or be omitted +(defaults to principal). When `browser_cdp_expose = "pipe"` (or unset), `browser_user` must +differ from `principal_user`. See [delegated-session](delegated-session.md#trusted-port-mode). + ### Isolated fields | Field | Required | Description | @@ -117,10 +123,13 @@ product_id = 22136 - `enabled = true` requires `audit_path`. - `delegated-session` requires non-empty `credential_refs` for authentication, positive - `max_grant_ttl` and `max_session_ttl`, `browser_command`, `browser_user`, and - `browser_runtime_root`. + `max_grant_ttl` and `max_session_ttl`, `browser_command`, and `browser_runtime_root`. - `isolated` requires `storage` and must not set `browser_user` or `browser_runtime_root`. -- `browser_user` must differ from `principal_user`. +- `browser_cdp_expose = "pipe"` (or unset): `browser_user` must differ from `principal_user`. +- `browser_cdp_expose = "port"`: `browser_user` may equal `principal_user` or be omitted. +- `browser_cdp_port` is only valid with `browser_cdp_expose = "port"`. +- `browser_command` extra args must not contain `--remote-debugging-port` or + `--remote-debugging-address`; these are set by the daemon in port mode. - `browser_runtime_root` must be absolute and contain no NUL bytes. - `start_url` must use HTTPS and its host must match exactly one rule. - The legacy experimental `rp_ids`, `registration_allowed`, and `require_uv` fields remain a diff --git a/docs/agents/delegated-session.md b/docs/agents/delegated-session.md index 44551421..9f4ca98d 100644 --- a/docs/agents/delegated-session.md +++ b/docs/agents/delegated-session.md @@ -94,3 +94,90 @@ passless agent-admin delegation list --profile opencode passless agent-admin delegation revoke --confirm passless agent-admin session revoke --confirm ``` + +## Trusted (port) mode + +By default, delegated-session mode uses pipe-based CDP transport (`browser_cdp_expose = "pipe"`). +The daemon mediates every CDP command through Unix pipes, and `browser_user` must differ from +`principal_user`. + +Setting `browser_cdp_expose = "port"` switches to a trusted trust model where the agent connects +directly to the browser's CDP WebSocket endpoint. This enables external tools like Playwright MCP, +Puppeteer, and native DevTools clients. + +### Configuration + +```toml +[agents.profiles.opencode] +mode = "delegated-session" +principal_user = "alice" +browser_cdp_expose = "port" +browser_cdp_port = 9222 +credential_refs = [""] +max_grant_ttl = 120 +max_session_ttl = 900 +browser_command = ["chromium", "--user-data-dir", ""] +start_url = "https://github.com/dashboard" +browser_runtime_root = "/var/run/passless-browser" + +[[agents.profiles.opencode.rules]] +rp_id = "github.com" +register = { authorization = "deny", user_presence = "none", user_verification = "none" } +authenticate = { authorization = "allow", user_presence = "policy", user_verification = "policy" } + +[agents.profiles.opencode.device] +name = "passless-agent-opencode" +phys = "opencode-phys" +uniq = "opencode-uniq" +vendor_id = 4660 +product_id = 22136 +``` + +- `browser_user` is optional; if omitted, defaults to `principal_user`. +- `browser_cdp_port` is optional; 0 (default) lets the OS assign an ephemeral port. +- `browser_command` extra args must not include `--remote-debugging-port` or + `--remote-debugging-address`; the daemon sets these automatically. + +### Workflow + +1. Operator configures the profile with `browser_cdp_expose = "port"`. +2. Operator launches the principal session: + ```bash + passless agent run --profile opencode -- /usr/local/bin/agent-command + ``` +3. Inside the session, the principal requests delegation: + ```bash + passless agent --profile opencode delegation request \ + --rp github.com --credential \ + --session-ttl 900 --reason "Playwright automation" + ``` +4. The daemon performs the WebAuthn ceremony and launches Chromium with + `--remote-debugging-port= --remote-debugging-address=127.0.0.1`. +5. The daemon reads Chromium's `DevToolsActivePort` file to discover the WebSocket URL. +6. The daemon writes the full WebSocket URL to `/cdp-endpoint` + (mode 0600, owned by principal user). +7. The agent reads the CDP endpoint and connects with Playwright: + ```javascript + const browser = await chromium.connectOverCDP('ws://127.0.0.1:9222/devtools/browser/'); + ``` +8. The agent has full browser control: snapshots, clicks, typing, navigation. +9. Lease expiry or revocation kills the browser process. + +### What changes in port mode + +- `browser-control` returns an error directing the caller to use the CDP endpoint directly. +- `browser-status` includes a `cdp_endpoint` field with the WebSocket URL. +- No daemon mediation of CDP commands after the ceremony. +- Audit records the exposure mode at lease creation. +- The credential private key never leaves the daemon; only the authenticated session (cookies) + is exposed to the agent. + +### When to use port mode + +- Single-user workstations where the operator fully trusts the agent. +- Automation that requires rich browser interaction (Playwright MCP, accessibility snapshots, + element targeting, auto-waiting). +- Environments where per-command daemon round-trips are unacceptable. + +Do not use port mode for multi-user systems, production environments, or untrusted agents. +Use pipe mode instead. See [security](security.md#port-mode-threat-model) for the full threat model. diff --git a/docs/agents/security.md b/docs/agents/security.md index fdc8e5ca..4b2d4b3f 100644 --- a/docs/agents/security.md +++ b/docs/agents/security.md @@ -112,3 +112,65 @@ For unattended or narrowly scoped automation, prefer RP-supported mechanisms: These mechanisms express actor, audience, scope, lifetime, and revocation independently from the browser session. + +## Port mode threat model + +When `browser_cdp_expose = "port"`, the daemon exposes the browser's CDP WebSocket on +`127.0.0.1` instead of mediating CDP through Unix pipes. The trust boundary shifts from +the CDP channel to the authenticated session. + +### Trust boundary + +The trust boundary is the authenticated session (cookies), not the CDP channel. Any +same-user process with the WebSocket URL can control the browser session. This is an +accepted risk for single-user workstations where the agent and operator share a trust +boundary. + +### Credential isolation + +The credential private key never leaves the daemon. Passless mediates the WebAuthn +ceremony; the browser only holds the authenticated session (cookies), not the credential +material. Even if the agent exfiltrates the entire browser session, it cannot extract +the private key. + +### WebSocket UUID as bearer token + +The CDP WebSocket URL includes a UUID path component (e.g., +`ws://127.0.0.1:9222/devtools/browser/`). This UUID acts as a bearer token: +possession of the URL grants access. The URL is written to `/cdp-endpoint` +with mode 0600, owned by the principal user. The runtime directory has mode 0700. + +### Loopback-only binding + +Chromium binds to `127.0.0.1` only. The daemon does not pass `--remote-debugging-address`; +Chromium's default is loopback. This is enforced, not configurable. Remote network access +to the CDP endpoint is not possible. + +### Extra args rejection + +The daemon rejects `--remote-debugging-port` and `--remote-debugging-address` in +`browser_command` extra args. These flags are set by the daemon in port mode. Attempting +to override them causes configuration load failure. + +### Audit trail + +Audit records note the exposure mode (`pipe` or `port`) at lease creation. In port mode, +audit records the delegation grant, lease creation, and lease expiry. Per-command CDP +audit is not available; the agent has direct CDP access after the ceremony. + +### Comparison to pipe mode + +| Property | Pipe mode | Port mode | +|----------|-----------|-----------| +| CDP transport | Unix pipes | TCP 127.0.0.1 | +| Daemon mediation | Every command | Ceremony only | +| External tool attachment | No | Yes | +| `browser_user` isolation | Required | Optional | +| CDP command audit | Per-command | Lease-level | +| CDP command filtering | Yes | No | +| Credential key exposure | Never | Never | +| Session cookie exposure | Daemon-gated | Direct | +| Trust assumption | Agent is untrusted | Agent is trusted | + +Use pipe mode for multi-user systems, production environments, or untrusted agents. +Use port mode only for single-user workstations where the operator fully trusts the agent. diff --git a/passless-core/src/agent/config.rs b/passless-core/src/agent/config.rs index 1d6c2228..2821cfc2 100644 --- a/passless-core/src/agent/config.rs +++ b/passless-core/src/agent/config.rs @@ -58,6 +58,43 @@ impl std::str::FromStr for AgentMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CdpExposeMode { + Pipe, + Port, +} + +impl fmt::Display for CdpExposeMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pipe => f.write_str("pipe"), + Self::Port => f.write_str("port"), + } + } +} + +impl std::str::FromStr for CdpExposeMode { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + match s { + "pipe" => Ok(CdpExposeMode::Pipe), + "port" => Ok(CdpExposeMode::Port), + _ => Err(format!( + "Invalid CDP expose mode '{}'. Must be: pipe, port", + s + )), + } + } +} + +impl Default for CdpExposeMode { + fn default() -> Self { + Self::Pipe + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AgentAuthorization { @@ -491,6 +528,10 @@ pub struct AgentProfileConfig { pub browser_user: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub browser_runtime_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub browser_cdp_expose: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub browser_cdp_port: Option, } impl AgentProfileConfig { @@ -657,27 +698,32 @@ impl AgentProfileConfig { ))); } - let browser_user = self.browser_user.as_ref().ok_or_else(|| { - Error::Config(format!( - "agent profile '{}': delegated-session requires browser_user", - profile_id - )) - })?; - if browser_user.is_empty() { - return Err(Error::Config(format!( - "agent profile '{}': browser_user must not be empty", - profile_id - ))); - } - if browser_user.contains('\0') { - return Err(Error::Config(format!( - "agent profile '{}': browser_user must not contain NUL bytes", - profile_id - ))); - } - if *browser_user == self.principal_user { + let cdp_port_mode = self.browser_cdp_expose == Some(CdpExposeMode::Port); + + if let Some(ref browser_user) = self.browser_user { + if browser_user.is_empty() { + return Err(Error::Config(format!( + "agent profile '{}': browser_user must not be empty", + profile_id + ))); + } + if browser_user.contains('\0') { + return Err(Error::Config(format!( + "agent profile '{}': browser_user must not contain NUL bytes", + profile_id + ))); + } + if !cdp_port_mode && *browser_user == self.principal_user { + return Err(Error::Config(format!( + "agent profile '{}': browser_user must differ from principal_user \ + (unless browser_cdp_expose = \"port\")", + profile_id + ))); + } + } else if !cdp_port_mode { return Err(Error::Config(format!( - "agent profile '{}': browser_user must differ from principal_user", + "agent profile '{}': delegated-session requires browser_user \ + (or set browser_cdp_expose = \"port\" to use principal_user)", profile_id ))); } @@ -1253,6 +1299,8 @@ mod tests { browser_command: Some(vec!["firefox".to_string()]), browser_user: Some("browser-user".to_string()), browser_runtime_root: Some(PathBuf::from("/var/run/passless-browser")), + browser_cdp_expose: None, + browser_cdp_port: None, } } @@ -1283,6 +1331,8 @@ mod tests { browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, } } @@ -1694,6 +1744,8 @@ verbose = false browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); profiles.insert( @@ -1718,6 +1770,8 @@ verbose = false browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); let config = AgentConfig { @@ -1760,6 +1814,8 @@ verbose = false browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); let config = AgentConfig { @@ -1806,6 +1862,8 @@ verbose = false browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); let config = AgentConfig { @@ -2111,6 +2169,8 @@ product_id = 2 browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); let config = AgentConfig { @@ -2155,6 +2215,8 @@ product_id = 2 browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); } @@ -2284,6 +2346,8 @@ backend_type = "local" browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); let config = AgentConfig { @@ -2340,6 +2404,8 @@ backend_type = "local" browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); let config = AgentConfig { @@ -2388,6 +2454,8 @@ backend_type = "local" browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); let config = AgentConfig { @@ -2766,6 +2834,8 @@ pin_path = "/var/lib/passless-agent/secure/pin" browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); profiles.insert( @@ -2796,6 +2866,8 @@ pin_path = "/var/lib/passless-agent/secure/pin" browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); let config = AgentConfig { @@ -2869,6 +2941,8 @@ pin_path = "/var/lib/passless-agent/secure/pin" browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); profiles.insert( @@ -2899,6 +2973,8 @@ pin_path = "/var/lib/passless-agent/secure/pin" browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); let config = AgentConfig { @@ -2943,6 +3019,8 @@ pin_path = "/var/lib/passless-agent/secure/pin" browser_command: None, browser_user: None, browser_runtime_root: None, + browser_cdp_expose: None, + browser_cdp_port: None, }, ); let config = AgentConfig { @@ -3146,4 +3224,42 @@ portable = true other => panic!("expected Tpm storage, got: {:?}", other), } } + + #[test] + fn test_port_mode_allows_same_browser_and_principal_user() { + let mut profile = make_delegated_profile(); + profile.browser_cdp_expose = Some(CdpExposeMode::Port); + profile.browser_user = Some(profile.principal_user.clone()); + let pid = ProfileId::new("test").unwrap(); + assert!(profile.validate(&pid).is_ok()); + } + + #[test] + fn test_port_mode_allows_browser_user_omitted() { + let mut profile = make_delegated_profile(); + profile.browser_cdp_expose = Some(CdpExposeMode::Port); + profile.browser_user = None; + let pid = ProfileId::new("test").unwrap(); + assert!(profile.validate(&pid).is_ok()); + } + + #[test] + fn test_pipe_mode_rejects_same_browser_and_principal_user() { + let mut profile = make_delegated_profile(); + profile.browser_cdp_expose = Some(CdpExposeMode::Pipe); + profile.browser_user = Some(profile.principal_user.clone()); + let pid = ProfileId::new("test").unwrap(); + let err = profile.validate(&pid).unwrap_err(); + assert!(err.to_string().contains("browser_user must differ")); + } + + #[test] + fn test_pipe_mode_default_rejects_same_browser_and_principal_user() { + let mut profile = make_delegated_profile(); + profile.browser_cdp_expose = None; + profile.browser_user = Some(profile.principal_user.clone()); + let pid = ProfileId::new("test").unwrap(); + let err = profile.validate(&pid).unwrap_err(); + assert!(err.to_string().contains("browser_user must differ")); + } } diff --git a/passless-core/src/agent/protocol.rs b/passless-core/src/agent/protocol.rs index bc67fab8..fa871739 100644 --- a/passless-core/src/agent/protocol.rs +++ b/passless-core/src/agent/protocol.rs @@ -725,6 +725,8 @@ impl ProfileDiagnosticReport { pub struct BrowserStatusResponse { pub running: bool, pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cdp_endpoint: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] From 13a3379d17fcf639a7389578a1236d5ae9452034 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Fri, 24 Jul 2026 17:48:42 +0200 Subject: [PATCH 03/16] fix(agent): make euid check non-fatal when UHID accessible via group 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. --- cmd/passless/src/agent/doctor.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/cmd/passless/src/agent/doctor.rs b/cmd/passless/src/agent/doctor.rs index e09e1333..79089850 100644 --- a/cmd/passless/src/agent/doctor.rs +++ b/cmd/passless/src/agent/doctor.rs @@ -242,12 +242,9 @@ pub fn run_startup_diagnostics( message: if euid_ok { "daemon euid is root".into() } else { - "daemon euid is not root; cannot manage profiles".into() + "daemon euid is not root; relying on group-based UHID access".into() }, }); - if !euid_ok { - fatal.push("daemon euid is not root".into()); - } } if has_profiles { @@ -832,13 +829,18 @@ mod tests { } #[test] - fn test_not_root_is_fatal_when_profiles() { + fn test_not_root_nonfatal_when_uhid_accessible() { let probes = MockProbes::not_root(); let profiles = vec![("test".into(), make_profile_info())]; let result = run_startup_diagnostics(&probes, true, Some(Path::new("/tmp/audit")), &profiles); - assert!(!result.is_ok()); - assert!(result.fatal.iter().any(|f| f.contains("root"))); + assert!(result.is_ok()); + assert!( + result + .checks + .iter() + .any(|c| c.name == "daemon_euid_root" && !c.passed) + ); } #[test] From 8a56bf894a2f2b594b43b9583e5aa2be59dc16aa Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Fri, 24 Jul 2026 16:00:43 +0000 Subject: [PATCH 04/16] fix: replace manual Default impl with derive for CdpExposeMode Clippy's derivable_impls lint flags the manual Default implementation as it can be replaced with #[derive(Default)] and #[default] on the Pipe variant. --- passless-core/src/agent/config.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/passless-core/src/agent/config.rs b/passless-core/src/agent/config.rs index 2821cfc2..6dcc4e66 100644 --- a/passless-core/src/agent/config.rs +++ b/passless-core/src/agent/config.rs @@ -58,9 +58,10 @@ impl std::str::FromStr for AgentMode { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum CdpExposeMode { + #[default] Pipe, Port, } @@ -89,12 +90,6 @@ impl std::str::FromStr for CdpExposeMode { } } -impl Default for CdpExposeMode { - fn default() -> Self { - Self::Pipe - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AgentAuthorization { From 4a8d6ba92c30d638b99e4e7c5962794267c7af05 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Fri, 24 Jul 2026 16:23:59 +0000 Subject: [PATCH 05/16] fix: use thread ID instead of process ID in peer cred test 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. --- passless-core/src/agent/protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/passless-core/src/agent/protocol.rs b/passless-core/src/agent/protocol.rs index fa871739..1bcd5f41 100644 --- a/passless-core/src/agent/protocol.rs +++ b/passless-core/src/agent/protocol.rs @@ -2589,7 +2589,7 @@ mod tests { fn seqpacket_socketpair_peer_cred() { let (fd0, fd1) = create_seqpacket_pair(); let cred = PeerCred::from_fd(fd1).unwrap(); - assert_eq!(cred.pid, std::process::id() as i32); + assert_eq!(cred.pid, unsafe { libc::gettid() }); assert_eq!(cred.uid, unsafe { libc::getuid() }); assert_eq!(cred.gid, unsafe { libc::getgid() }); let _ = fd0; From 82e6c8f60e397ab17b8245bec506c7b04ed1a73c Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Fri, 24 Jul 2026 18:25:57 +0200 Subject: [PATCH 06/16] fix(core): use-after-free in send_msg_with_fds cmsg buffer 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. --- passless-core/src/agent/protocol.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/passless-core/src/agent/protocol.rs b/passless-core/src/agent/protocol.rs index 1bcd5f41..e6193006 100644 --- a/passless-core/src/agent/protocol.rs +++ b/passless-core/src/agent/protocol.rs @@ -1348,10 +1348,11 @@ impl SeqpacketCodec { hdr.msg_iov = &mut iov; hdr.msg_iovlen = 1; + let mut cmsg_buf: Vec = Vec::new(); if !fds.is_empty() { let fds_len = std::mem::size_of_val(fds); let cmsg_space = unsafe { libc::CMSG_SPACE(fds_len as libc::c_uint) } as usize; - let mut cmsg_buf = vec![0u8; cmsg_space]; + cmsg_buf = vec![0u8; cmsg_space]; hdr.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void; hdr.msg_controllen = cmsg_space; From dea6b9c2460141781f15270d2e044f39707dac40 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Fri, 24 Jul 2026 18:30:24 +0200 Subject: [PATCH 07/16] fix(core): silence clippy unused-assignment in send_msg_with_fds --- passless-core/src/agent/protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/passless-core/src/agent/protocol.rs b/passless-core/src/agent/protocol.rs index e6193006..81d75dfe 100644 --- a/passless-core/src/agent/protocol.rs +++ b/passless-core/src/agent/protocol.rs @@ -1352,7 +1352,7 @@ impl SeqpacketCodec { if !fds.is_empty() { let fds_len = std::mem::size_of_val(fds); let cmsg_space = unsafe { libc::CMSG_SPACE(fds_len as libc::c_uint) } as usize; - cmsg_buf = vec![0u8; cmsg_space]; + cmsg_buf.resize(cmsg_space, 0); hdr.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void; hdr.msg_controllen = cmsg_space; From dcd7fe5a3cca0b517a9c0e99c593c2235ec60341 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Fri, 24 Jul 2026 18:52:45 +0200 Subject: [PATCH 08/16] fix(agent): allow same-user principal spawn for non-root daemon 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. --- cmd/passless/src/agent/launcher.rs | 67 +++++++++++++++--------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/cmd/passless/src/agent/launcher.rs b/cmd/passless/src/agent/launcher.rs index 87a5c1ee..dabd95d2 100644 --- a/cmd/passless/src/agent/launcher.rs +++ b/cmd/passless/src/agent/launcher.rs @@ -650,27 +650,25 @@ pub struct HardenedChildSetup { impl HardenedChildSetup { pub fn validate(&self) -> Result<(), LauncherError> { - if self.target_uid == self.daemon_uid { - return Err(LauncherError::SameUserFallback); - } - if self.target_gid == self.daemon_gid { - return Err(LauncherError::IdentityMismatch { - field: "gid".to_string(), - expected: format!("!= {}", self.daemon_gid), - got: self.target_gid.to_string(), - }); - } - if self.daemon_uid != 0 { - return Err(LauncherError::PrivilegeInsufficient { - detail: format!( - "daemon uid {} is not root; cannot setuid/setgid for child", - self.daemon_uid - ), - }); + if self.daemon_uid == 0 { + if self.target_uid == self.daemon_uid { + return Err(LauncherError::SameUserFallback); + } + if self.target_gid == self.daemon_gid { + return Err(LauncherError::IdentityMismatch { + field: "gid".to_string(), + expected: format!("!= {}", self.daemon_gid), + got: self.target_gid.to_string(), + }); + } } Ok(()) } + fn same_user(&self) -> bool { + self.target_uid == self.daemon_uid && self.target_gid == self.daemon_gid + } + /// # Safety /// /// Must be called in the forked child before `exec`. `preserved_fds` must @@ -686,16 +684,18 @@ impl HardenedChildSetup { return Err(io::Error::last_os_error()); } - if unsafe { libc::setgroups(0, std::ptr::null()) } < 0 { - return Err(io::Error::last_os_error()); - } + if !self.same_user() { + if unsafe { libc::setgroups(0, std::ptr::null()) } < 0 { + return Err(io::Error::last_os_error()); + } - if unsafe { libc::setgid(self.target_gid) } < 0 { - return Err(io::Error::last_os_error()); - } + if unsafe { libc::setgid(self.target_gid) } < 0 { + return Err(io::Error::last_os_error()); + } - if unsafe { libc::setuid(self.target_uid) } < 0 { - return Err(io::Error::last_os_error()); + if unsafe { libc::setuid(self.target_uid) } < 0 { + return Err(io::Error::last_os_error()); + } } if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } < 0 { @@ -2013,14 +2013,13 @@ mod tests { } #[test] - fn test_spawn_config_validate_privilege_required() { + fn test_spawn_config_validate_non_root_different_target_ok() { let mut config = default_spawn_config(); config.daemon_uid = 1000; config.daemon_gid = 1000; config.target_uid = 1001; config.target_gid = 1001; - let err = config.validate().unwrap_err(); - assert!(matches!(err, LauncherError::PrivilegeInsufficient { .. })); + assert!(config.validate().is_ok()); } #[test] @@ -2169,7 +2168,10 @@ mod tests { }; let err = spawn_principal(config).unwrap_err(); - assert!(matches!(err, LauncherError::PrivilegeInsufficient { .. })); + assert!(matches!( + err, + LauncherError::SpawnFailed { .. } | LauncherError::PostExecVerification { .. } + )); } #[test] @@ -2258,7 +2260,7 @@ mod tests { } #[test] - fn test_hardened_child_setup_validate_privilege_required() { + fn test_hardened_child_setup_validate_non_root_different_target_ok() { let setup = HardenedChildSetup { target_uid: 1001, target_gid: 1001, @@ -2269,8 +2271,7 @@ mod tests { rlimit_core: DEFAULT_RLIMIT_CORE, rlimit_as: DEFAULT_RLIMIT_AS, }; - let err = setup.validate().unwrap_err(); - assert!(matches!(err, LauncherError::PrivilegeInsufficient { .. })); + assert!(setup.validate().is_ok()); } #[test] @@ -2409,7 +2410,7 @@ mod tests { let err = spawn_principal(config).unwrap_err(); assert!(matches!( err, - LauncherError::PrivilegeInsufficient { .. } | LauncherError::SameUserFallback + LauncherError::SpawnFailed { .. } | LauncherError::PostExecVerification { .. } )); } From f0b1c9cd083e021933115024f5adb3fc05d38ca4 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Fri, 24 Jul 2026 19:06:47 +0200 Subject: [PATCH 09/16] fix(agent): fall back to daemon ns inodes when child proc unreadable With ptrace_scope=1, a non-root daemon cannot read /proc//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. --- cmd/passless/src/agent/launcher.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/passless/src/agent/launcher.rs b/cmd/passless/src/agent/launcher.rs index dabd95d2..abdfdba9 100644 --- a/cmd/passless/src/agent/launcher.rs +++ b/cmd/passless/src/agent/launcher.rs @@ -1134,6 +1134,7 @@ pub fn spawn_principal(config: SpawnConfig) -> Result Result ns, + Err(_) if is_same_user => read_namespace_inodes(std::process::id() as i32, proc_root) + .map_err(cleanup_on_failure)?, + Err(e) => return Err(cleanup_on_failure(e)), + }; let (actual_uid, actual_gid) = read_proc_uid_gid(child_pid, proc_root).map_err(cleanup_on_failure)?; From 51c162c81b8e47e4b5b301f452b2dc02578ebe30 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Fri, 24 Jul 2026 19:21:00 +0200 Subject: [PATCH 10/16] fix(cli): reconnect admin client for each wait_principal poll 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. --- cmd/passless/src/commands/agent.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/passless/src/commands/agent.rs b/cmd/passless/src/commands/agent.rs index 61a90d99..eceb0a83 100644 --- a/cmd/passless/src/commands/agent.rs +++ b/cmd/passless/src/commands/agent.rs @@ -831,7 +831,12 @@ fn dispatch_run(output: OutputFormat, profile: &str, command: &[PathBuf]) -> Res break; } - match client.wait_principal(&session_id, 1000) { + let mut wait_client = match connect_admin() { + Ok(c) => c, + Err(_) => break, + }; + + match wait_client.wait_principal(&session_id, 1000) { Ok(AdminResponse::PrincipalWait(wait_resp)) => { if !wait_resp.running { exit_code = wait_resp.exit_code; @@ -842,7 +847,8 @@ fn dispatch_run(output: OutputFormat, profile: &str, command: &[PathBuf]) -> Res Ok(_) => break, Err(ClientError::Io(ref e)) if e.kind() == std::io::ErrorKind::ConnectionReset - || e.kind() == std::io::ErrorKind::UnexpectedEof => + || e.kind() == std::io::ErrorKind::UnexpectedEof + || e.kind() == std::io::ErrorKind::BrokenPipe => { break; } From ec7c80e67c5d434d9772663400df8e3de0c9c387 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Fri, 24 Jul 2026 22:12:11 +0200 Subject: [PATCH 11/16] fix(agent): preserve stdio fds in close_range during principal spawn 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). --- cmd/passless/src/agent/launcher.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/passless/src/agent/launcher.rs b/cmd/passless/src/agent/launcher.rs index abdfdba9..15a4bf64 100644 --- a/cmd/passless/src/agent/launcher.rs +++ b/cmd/passless/src/agent/launcher.rs @@ -1158,7 +1158,7 @@ pub fn spawn_principal(config: SpawnConfig) -> Result Date: Sat, 25 Jul 2026 06:47:42 +0000 Subject: [PATCH 12/16] fix(agent): preserve stdio fds in browser spawn close_range 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. --- cmd/passless/src/agent/browser.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/passless/src/agent/browser.rs b/cmd/passless/src/agent/browser.rs index 5ec2fea4..f9e4b948 100644 --- a/cmd/passless/src/agent/browser.rs +++ b/cmd/passless/src/agent/browser.rs @@ -1656,7 +1656,7 @@ fn spawn_browser_hardened( return Err(io::Error::last_os_error()); } - setup.apply(&[CDP_FD_READ, CDP_FD_WRITE]) + setup.apply(&[0, 1, 2, CDP_FD_READ, CDP_FD_WRITE]) }); } @@ -1756,7 +1756,7 @@ fn spawn_browser_port_mode( let mut cmd = build_browser_command(config, profile_dir)?; unsafe { - cmd.pre_exec(move || setup.apply(&[])); + cmd.pre_exec(move || setup.apply(&[0, 1, 2])); } cmd.spawn() From 0f43a2808a13241edaed8ac52f47a4708a851419 Mon Sep 17 00:00:00 2001 From: "forkline-dev[bot]" Date: Sat, 25 Jul 2026 06:58:36 +0000 Subject: [PATCH 13/16] fix(test): use getpid() instead of gettid() for SO_PEERCRED comparison 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. --- passless-core/src/agent/protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/passless-core/src/agent/protocol.rs b/passless-core/src/agent/protocol.rs index 81d75dfe..09a19ed7 100644 --- a/passless-core/src/agent/protocol.rs +++ b/passless-core/src/agent/protocol.rs @@ -2590,7 +2590,7 @@ mod tests { fn seqpacket_socketpair_peer_cred() { let (fd0, fd1) = create_seqpacket_pair(); let cred = PeerCred::from_fd(fd1).unwrap(); - assert_eq!(cred.pid, unsafe { libc::gettid() }); + assert_eq!(cred.pid, unsafe { libc::getpid() }); assert_eq!(cred.uid, unsafe { libc::getuid() }); assert_eq!(cred.gid, unsafe { libc::getgid() }); let _ = fd0; From 821160de0faf9492b8e587905b70174a219cbb40 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Sat, 25 Jul 2026 09:04:55 +0200 Subject: [PATCH 14/16] fix(agent): skip close_range and rlimits in same-user mode 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). --- cmd/passless/src/agent/launcher.rs | 64 +++++++++++++++--------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/cmd/passless/src/agent/launcher.rs b/cmd/passless/src/agent/launcher.rs index 15a4bf64..cdd05fe7 100644 --- a/cmd/passless/src/agent/launcher.rs +++ b/cmd/passless/src/agent/launcher.rs @@ -674,13 +674,11 @@ impl HardenedChildSetup { /// Must be called in the forked child before `exec`. `preserved_fds` must /// contain only descriptors intentionally transferred to the principal. pub unsafe fn apply(&self, preserved_fds: &[RawFd]) -> Result<(), io::Error> { - unsafe { close_range_preserving(preserved_fds)? }; - - if unsafe { libc::setsid() } < 0 { - return Err(io::Error::last_os_error()); + if !self.same_user() { + unsafe { close_range_preserving(preserved_fds)? }; } - if unsafe { libc::setpgid(0, 0) } < 0 { + if unsafe { libc::setsid() } < 0 { return Err(io::Error::last_os_error()); } @@ -702,36 +700,38 @@ impl HardenedChildSetup { return Err(io::Error::last_os_error()); } - let rlim_nofile = libc::rlimit { - rlim_cur: self.rlimit_nofile, - rlim_max: self.rlimit_nofile, - }; - if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &rlim_nofile) } < 0 { - return Err(io::Error::last_os_error()); - } + if !self.same_user() { + let rlim_nofile = libc::rlimit { + rlim_cur: self.rlimit_nofile, + rlim_max: self.rlimit_nofile, + }; + if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &rlim_nofile) } < 0 { + return Err(io::Error::last_os_error()); + } - let rlim_nproc = libc::rlimit { - rlim_cur: self.rlimit_nproc, - rlim_max: self.rlimit_nproc, - }; - if unsafe { libc::setrlimit(libc::RLIMIT_NPROC, &rlim_nproc) } < 0 { - return Err(io::Error::last_os_error()); - } + let rlim_nproc = libc::rlimit { + rlim_cur: self.rlimit_nproc, + rlim_max: self.rlimit_nproc, + }; + if unsafe { libc::setrlimit(libc::RLIMIT_NPROC, &rlim_nproc) } < 0 { + return Err(io::Error::last_os_error()); + } - let rlim_core = libc::rlimit { - rlim_cur: self.rlimit_core, - rlim_max: self.rlimit_core, - }; - if unsafe { libc::setrlimit(libc::RLIMIT_CORE, &rlim_core) } < 0 { - return Err(io::Error::last_os_error()); - } + let rlim_core = libc::rlimit { + rlim_cur: self.rlimit_core, + rlim_max: self.rlimit_core, + }; + if unsafe { libc::setrlimit(libc::RLIMIT_CORE, &rlim_core) } < 0 { + return Err(io::Error::last_os_error()); + } - let rlim_as = libc::rlimit { - rlim_cur: self.rlimit_as, - rlim_max: self.rlimit_as, - }; - if unsafe { libc::setrlimit(libc::RLIMIT_AS, &rlim_as) } < 0 { - return Err(io::Error::last_os_error()); + let rlim_as = libc::rlimit { + rlim_cur: self.rlimit_as, + rlim_max: self.rlimit_as, + }; + if unsafe { libc::setrlimit(libc::RLIMIT_AS, &rlim_as) } < 0 { + return Err(io::Error::last_os_error()); + } } Ok(()) From 10f4185f3025b4ecb80aae084c695b71ee5f8efd Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Sat, 25 Jul 2026 10:22:16 +0200 Subject: [PATCH 15/16] feat(agent): relax executable ownership and add admin RequestDelegation - 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 --credential --session-ttl 3600 --- cmd/passless/src/agent/browser.rs | 7 ++ cmd/passless/src/agent/launcher.rs | 35 ++++---- cmd/passless/src/agent/runtime.rs | 125 ++++++++++++++++++++++++++-- cmd/passless/src/commands/agent.rs | 6 +- passless-core/src/agent/protocol.rs | 15 ++++ 5 files changed, 160 insertions(+), 28 deletions(-) diff --git a/cmd/passless/src/agent/browser.rs b/cmd/passless/src/agent/browser.rs index f9e4b948..2412cd8c 100644 --- a/cmd/passless/src/agent/browser.rs +++ b/cmd/passless/src/agent/browser.rs @@ -576,6 +576,11 @@ impl BrowserProcessManager { endpoint_id: EndpointId, profile_id: ProfileId, ) -> Result { + config + .hardening() + .validate() + .map_err(|e| LaunchError::HardeningFailed(e.to_string()))?; + let lease_id = BrowserLeaseId::new(); let now = self.clock.now(); let monotonic_secs = self.clock.monotonic_secs(); @@ -3884,6 +3889,8 @@ mod tests { let mut config = test_config(dir.path()); config.daemon_uid = my_uid; config.daemon_gid = my_uid; + config.target_uid = my_uid + 1; + config.target_gid = my_uid + 1; let result = mgr.launch(&config, test_endpoint_id(), test_profile_id()); assert!(matches!(result, Err(LaunchError::HardeningFailed(_)))); diff --git a/cmd/passless/src/agent/launcher.rs b/cmd/passless/src/agent/launcher.rs index cdd05fe7..93c0e73d 100644 --- a/cmd/passless/src/agent/launcher.rs +++ b/cmd/passless/src/agent/launcher.rs @@ -661,6 +661,13 @@ impl HardenedChildSetup { got: self.target_gid.to_string(), }); } + } else if !self.same_user() { + return Err(LauncherError::PrivilegeInsufficient { + detail: format!( + "non-root daemon (uid={}) cannot spawn as different user (uid={})", + self.daemon_uid, self.target_uid + ), + }); } Ok(()) } @@ -2019,13 +2026,14 @@ mod tests { } #[test] - fn test_spawn_config_validate_non_root_different_target_ok() { + fn test_spawn_config_validate_non_root_different_target_fails() { let mut config = default_spawn_config(); config.daemon_uid = 1000; config.daemon_gid = 1000; config.target_uid = 1001; config.target_gid = 1001; - assert!(config.validate().is_ok()); + let err = config.validate().unwrap_err(); + assert!(matches!(err, LauncherError::PrivilegeInsufficient { .. })); } #[test] @@ -2160,8 +2168,8 @@ mod tests { let config = SpawnConfig { program: PathBuf::from("/bin/true"), args: vec![], - target_uid: 1001, - target_gid: 1001, + target_uid: my_uid + 1, + target_gid: my_uid + 1, daemon_uid: my_uid, daemon_gid: my_uid, rlimit_nofile: DEFAULT_RLIMIT_NOFILE, @@ -2174,10 +2182,7 @@ mod tests { }; let err = spawn_principal(config).unwrap_err(); - assert!(matches!( - err, - LauncherError::SpawnFailed { .. } | LauncherError::PostExecVerification { .. } - )); + assert!(matches!(err, LauncherError::PrivilegeInsufficient { .. })); } #[test] @@ -2266,7 +2271,7 @@ mod tests { } #[test] - fn test_hardened_child_setup_validate_non_root_different_target_ok() { + fn test_hardened_child_setup_validate_non_root_different_target_fails() { let setup = HardenedChildSetup { target_uid: 1001, target_gid: 1001, @@ -2277,7 +2282,8 @@ mod tests { rlimit_core: DEFAULT_RLIMIT_CORE, rlimit_as: DEFAULT_RLIMIT_AS, }; - assert!(setup.validate().is_ok()); + let err = setup.validate().unwrap_err(); + assert!(matches!(err, LauncherError::PrivilegeInsufficient { .. })); } #[test] @@ -2400,8 +2406,8 @@ mod tests { let config = SpawnConfig { program: PathBuf::from("/bin/true"), args: vec![], - target_uid: 1001, - target_gid: 1001, + target_uid: my_uid + 1, + target_gid: my_uid + 1, daemon_uid: my_uid, daemon_gid: my_uid, rlimit_nofile: DEFAULT_RLIMIT_NOFILE, @@ -2414,10 +2420,7 @@ mod tests { }; let err = spawn_principal(config).unwrap_err(); - assert!(matches!( - err, - LauncherError::SpawnFailed { .. } | LauncherError::PostExecVerification { .. } - )); + assert!(matches!(err, LauncherError::PrivilegeInsufficient { .. })); } #[test] diff --git a/cmd/passless/src/agent/runtime.rs b/cmd/passless/src/agent/runtime.rs index b4fd71a4..01af61f7 100644 --- a/cmd/passless/src/agent/runtime.rs +++ b/cmd/passless/src/agent/runtime.rs @@ -354,7 +354,10 @@ fn validate_browser_runtime_root_at_startup( Ok(()) } -fn validate_principal_executable(path_str: &str) -> Result { +fn validate_principal_executable( + path_str: &str, + daemon_uid: u32, +) -> Result { use std::os::unix::fs::{MetadataExt, PermissionsExt}; let path = std::path::Path::new(path_str); @@ -391,13 +394,15 @@ fn validate_principal_executable(path_str: &str) -> Result 1 { command[1..].to_vec() } else { @@ -5290,6 +5295,85 @@ impl AgentRuntime { )) } + fn handle_admin_request_delegation( + &self, + profile_id: &ProfileId, + rp_id: &str, + credential_ref: &passless_core::agent::CredentialRef, + max_session_ttl: u64, + reason: Option<&str>, + ) -> Result { + let profile = self.profiles.get(profile_id).ok_or_else(|| { + ProtocolError::new( + ErrorCode::NotFound, + format!("profile '{}' not found", profile_id), + RecommendedAction::FixRequest, + ) + })?; + + let session_slot = self.managed_sessions.get(profile_id).ok_or_else(|| { + ProtocolError::new( + ErrorCode::NotFound, + "no session slot for profile", + RecommendedAction::Abort, + ) + })?; + + let managed_guard = session_slot.lock().unwrap(); + let managed = managed_guard.as_ref().ok_or_else(|| { + ProtocolError::new( + ErrorCode::Conflict, + "no active principal session for this profile; launch one first with `passless agent run`", + RecommendedAction::FixRequest, + ) + })?; + + let _lifecycle = profile.lifecycle_lock.lock().unwrap(); + { + let eid_guard = profile.endpoint_id.lock().unwrap(); + if eid_guard.is_none() { + drop(eid_guard); + match self.create_profile_endpoint(&profile.endpoint_spec) { + Ok(eid) => { + let mut eid_guard = profile.endpoint_id.lock().unwrap(); + *eid_guard = Some(eid); + } + Err(e) => { + return Err(ProtocolError::new( + ErrorCode::Internal, + format!("failed to create endpoint for delegation: {}", e), + RecommendedAction::Retry, + )); + } + } + } + } + let eid = profile.endpoint_id.lock().unwrap().clone().unwrap(); + + let resp = self.handle_request_delegation(RequestDelegationParams { + profile_id, + session_id: &managed.session_id, + endpoint_id: &eid, + rp_id, + credential_ref, + max_session_ttl, + principal_reason: reason.map(|s| s.to_string()), + profile, + session_digest: &managed.process_digest, + })?; + + match resp { + PrincipalResponse::DelegationRequested { request_id } => { + Ok(AdminResponse::DelegationRequested { request_id }) + } + _ => Err(ProtocolError::new( + ErrorCode::Internal, + "unexpected response from delegation handler", + RecommendedAction::Retry, + )), + } + } + fn handle_shutdown(&self) -> Result { let shutdown_event = super::audit_events::AdminShutdownRequestBuilder::new(std::process::id()).build(); @@ -5385,6 +5469,19 @@ impl AdminHandler for AgentRuntime { AdminRequest::AuditVerify => self.handle_audit_verify(), AdminRequest::AuditExport { format } => self.handle_audit_export(format), AdminRequest::ProfileCheck { profile_id } => self.handle_profile_check(profile_id), + AdminRequest::RequestDelegation { + profile_id, + rp_id, + credential_ref, + max_session_ttl, + reason, + } => self.handle_admin_request_delegation( + profile_id, + rp_id, + credential_ref, + *max_session_ttl, + reason.as_deref(), + ), AdminRequest::Shutdown => self.handle_shutdown(), } } @@ -5786,20 +5883,20 @@ mod tests { #[test] fn validate_principal_executable_rejects_relative_path() { - let err = validate_principal_executable("relative/path").unwrap_err(); + let err = validate_principal_executable("relative/path", 0).unwrap_err(); assert!(err.to_string().contains("absolute")); } #[test] fn validate_principal_executable_rejects_nonexistent_path() { - let err = validate_principal_executable("/nonexistent/binary").unwrap_err(); + let err = validate_principal_executable("/nonexistent/binary", 0).unwrap_err(); assert!(err.to_string().contains("cannot be resolved")); } #[test] fn validate_principal_executable_rejects_directory() { let dir = tempfile::tempdir().unwrap(); - let err = validate_principal_executable(dir.path().to_str().unwrap()).unwrap_err(); + let err = validate_principal_executable(dir.path().to_str().unwrap(), 0).unwrap_err(); assert!(err.to_string().contains("not a regular file")); } @@ -5807,7 +5904,7 @@ mod tests { fn validate_principal_executable_accepts_root_owned_executable() { use std::os::unix::fs::MetadataExt; let meta = std::fs::symlink_metadata("/bin/true").unwrap(); - let result = validate_principal_executable("/bin/true"); + let result = validate_principal_executable("/bin/true", 0); if meta.uid() == 0 { assert!( result.is_ok(), @@ -5821,6 +5918,16 @@ mod tests { } } + #[test] + fn validate_principal_executable_accepts_daemon_uid_owned() { + let my_uid = unsafe { libc::getuid() }; + let result = validate_principal_executable("/bin/true", my_uid); + assert!( + result.is_ok(), + "/bin/true owned by daemon uid should be accepted" + ); + } + #[test] fn active_browser_lease_stores_session_and_deadline() { let lease_id = BrowserLeaseId::new(); diff --git a/cmd/passless/src/commands/agent.rs b/cmd/passless/src/commands/agent.rs index eceb0a83..5315b5d2 100644 --- a/cmd/passless/src/commands/agent.rs +++ b/cmd/passless/src/commands/agent.rs @@ -397,8 +397,8 @@ fn dispatch_delegation( session_ttl, reason, } => { - let mut client = connect_principal(profile)?; - let req = passless_core::agent::PrincipalRequest::RequestDelegation { + let mut client = connect_admin()?; + let req = passless_core::agent::AdminRequest::RequestDelegation { profile_id: parse_profile_id(profile)?, rp_id: rp.clone(), credential_ref: parse_credential_ref(credential)?, @@ -407,7 +407,7 @@ fn dispatch_delegation( }; let resp = client.request(req).map_err(client_error_to_result)?; match resp { - passless_core::agent::PrincipalResponse::DelegationRequested { request_id } => { + passless_core::agent::AdminResponse::DelegationRequested { request_id } => { #[derive(Serialize)] struct DelegationRequestedOut { request_id: String, diff --git a/passless-core/src/agent/protocol.rs b/passless-core/src/agent/protocol.rs index 09a19ed7..2fe562ea 100644 --- a/passless-core/src/agent/protocol.rs +++ b/passless-core/src/agent/protocol.rs @@ -358,6 +358,14 @@ pub enum AdminRequest { ProfileCheck { profile_id: ProfileId, }, + RequestDelegation { + profile_id: ProfileId, + rp_id: String, + credential_ref: CredentialRef, + max_session_ttl: u64, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + }, Shutdown, } @@ -395,6 +403,7 @@ pub enum AdminResponse { PrincipalTerminated, PrincipalWait(PrincipalWaitResponse), ProfileCheck(ProfileDiagnosticReport), + DelegationRequested { request_id: PendingRequestId }, ShutdownAccepted, } @@ -1116,6 +1125,12 @@ impl Validate for AdminRequest { } } Self::TerminatePrincipal { .. } => {} + Self::RequestDelegation { rp_id, reason, .. } => { + check_str(rp_id, "rp_id", MAX_RP_ID_LEN, &mut errors); + if let Some(r) = reason { + check_str(r, "reason", MAX_REASON_LEN, &mut errors); + } + } Self::WaitPrincipal { timeout_ms, .. } => { if *timeout_ms > 5000 { errors.push(format!( From 0b2b5c3a4bb058d412cb70ad7d6c51477f01b0e7 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Sat, 25 Jul 2026 13:09:19 +0200 Subject: [PATCH 16/16] fix(agent): fix CDP port discovery for fixed-port mode 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). --- cmd/passless/src/agent/browser.rs | 65 ++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/cmd/passless/src/agent/browser.rs b/cmd/passless/src/agent/browser.rs index 2412cd8c..12459694 100644 --- a/cmd/passless/src/agent/browser.rs +++ b/cmd/passless/src/agent/browser.rs @@ -615,7 +615,11 @@ impl BrowserProcessManager { let (cdp_pipes, mut child, cdp_endpoint_url) = if is_port_mode { let child = spawn_browser_port_mode(config, &profile_dir)?; - let discovered = discover_cdp_endpoint(&profile_dir, Duration::from_secs(10))?; + let discovered = if config.cdp_port > 0 { + wait_for_cdp_port(config.cdp_port, Duration::from_secs(30))? + } else { + discover_cdp_endpoint(&profile_dir, Duration::from_secs(30))? + }; write_cdp_endpoint( &runtime_dir, &discovered, @@ -1790,6 +1794,65 @@ pub(crate) fn discover_cdp_endpoint( } } +fn wait_for_cdp_port(port: u16, timeout: Duration) -> Result { + use std::io::{Read, Write}; + use std::net::TcpStream; + + let deadline = Instant::now() + timeout; + let addr = format!("127.0.0.1:{}", port); + loop { + if let Ok(mut stream) = + TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(200)) + { + let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); + let request = format!( + "GET /json/version HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", + addr + ); + if stream.write_all(request.as_bytes()).is_ok() { + let mut response = String::new(); + let mut buf = [0u8; 4096]; + let read_deadline = Instant::now() + Duration::from_secs(5); + loop { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + response.push_str(&String::from_utf8_lossy(&buf[..n])); + if let Some(ws_url) = extract_web_socket_debugger_url(&response) { + return Ok(ws_url); + } + } + Err(ref e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + if Instant::now() >= read_deadline { + break; + } + continue; + } + Err(_) => break, + } + } + } + } + if Instant::now() >= deadline { + return Err(LaunchError::CdpDiscoveryTimeout); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn extract_web_socket_debugger_url(http_response: &str) -> Option { + let key = "webSocketDebuggerUrl"; + let key_pos = http_response.find(key)?; + let after_key = &http_response[key_pos + key.len()..]; + let ws_start = after_key.find("ws://")?; + let value = &after_key[ws_start..]; + let end = value.find('"').unwrap_or(value.len()); + Some(value[..end].to_string()) +} + fn write_cdp_endpoint( runtime_dir: &Path, url: &str,