Skip to content

feat(daemon): expose host_browser to LLM cross-client on web/iOS turns#29829

Merged
noanflaherty merged 3 commits into
mainfrom
credence/host-browser-cross-client-exposure
May 7, 2026
Merged

feat(daemon): expose host_browser to LLM cross-client on web/iOS turns#29829
noanflaherty merged 3 commits into
mainfrom
credence/host-browser-cross-client-exposure

Conversation

@credence-the-bot
Copy link
Copy Markdown
Contributor

@credence-the-bot credence-the-bot Bot commented May 6, 2026

What

Adds "host_browser" to CROSS_CLIENT_EXPOSED_CAPABILITIES so the LLM-exposure carve-out in isToolActiveForContext lets web and iOS turns see the tool when at least one host_browser-capable client (macOS or chrome-extension) is connected via the assistant event hub.

Why

Routing for host_browser shipped in PR #27489 (host-browser-via-macos-host-proxy), but the LLM-exposure gate in conversation-tool-setup.ts was still hardcoded to deny non-host-proxy transports — even though the underlying HostBrowserProxy has worked cross-client since the macOS bridge merged. That left a dead carve-out comment ("Re-evaluate when host browser via macOS host proxy ships (PR #27489)") and asymmetric capability surface vs. host_bash and host_file_*, which got the same treatment in PR #29632 last week.

With this change, a web or iOS user can drive their host browser end-to-end, matching the parity already extended to host_bash + host_file.

How

 const CROSS_CLIENT_EXPOSED_CAPABILITIES = new Set<HostProxyCapability>([
   "host_bash",
   "host_file",
+  "host_browser",
 ]);

Plus comment cleanup in two places (the doc block above the Set, and the inline note inside isToolActiveForContext) so the rationale stays accurate.

Tests

8 new cases under a new cross-client exposure for host_browser describe block, mirroring the existing host_file_* pattern:

Scenario Expected
web + capable client connected exposed
ios + capable client connected exposed
web + no capable client denied
ios + no capable client denied
hasNoClient: true (no approval UI) denied
macos transport (native host proxy) unaffected
chrome-extension transport (native executor) unaffected
Per-capability invariant (regression guard) denied when only host_bash/host_file clients are present
44 pass / 0 fail / 50 expect() calls

ESLint clean. Pre-existing @vellumai/ipc-server-utils typecheck errors from PR #29825 are unrelated and exist on clean main.

Defense-in-depth follow-up (deferred)

host_browser does not yet adopt the same-actor guard that host_bash / host_file / host_cu / host_transfer apply on their result routes. Two specific gaps:

  1. HostBrowserProxy.request() doesn't store targetClientId / targetActorPrincipalId in the pending request state.
  2. /v1/host-browser-result route does not validate x-vellum-client-id (host_cu does — see host-cu-routes.ts:77-103).

The chrome-extension client today does not send x-vellum-client-id on its result POST, so enforcing the guard requires a coordinated extension change. The current requireGuardian gate on /v1/host-browser-result is the active same-binding boundary. Tracking issue: TBD.

Related


Open in Devin Review

Adds "host_browser" to CROSS_CLIENT_EXPOSED_CAPABILITIES so the LLM-
exposure carve-out in isToolActiveForContext lets web and iOS turns
see the tool when at least one host_browser-capable client (macOS or
chrome-extension) is connected via the assistant event hub.

Routing for host_browser shipped in PR #27489 (host-browser-via-macos-
host-proxy), but the LLM-exposure gate in conversation-tool-setup.ts
was still hardcoded to deny non-host-proxy transports. With this
change the user can drive their host browser from any interactive
interface, matching the parity already extended to host_bash + host_file
in PR #29632.

Tests: 8 new cases mirror the host_file_* block (web exposed, ios
exposed, web denied without client, ios denied without client, hasNoClient
gate honored, macos and chrome-extension transports unaffected, per-
capability invariant guard against hardcoded-capability regressions).
Total: 44 pass / 0 fail.

Defense-in-depth follow-up (deferred): host_browser does not yet adopt
the same-actor guard that host_bash / host_file / host_cu / host_transfer
apply on their result routes. The chrome-extension client today does
not send `x-vellum-client-id` on its result POST; enforcing the guard
requires a coordinated extension change. The current `requireGuardian`
gate on `/v1/host-browser-result` is the active same-binding boundary.
Copy link
Copy Markdown

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

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6dcadca45b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

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

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

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const CROSS_CLIENT_EXPOSED_CAPABILITIES = new Set<HostProxyCapability>([
"host_bash",
"host_file",
"host_browser",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Gate host_browser cross-client exposure by actor identity

Adding "host_browser" to the cross-client exposure set allows web/iOS turns to surface this tool whenever any host-browser-capable client is connected, but the host-browser execution path still lacks the same actor-binding protections used by other host proxies (HostBrowserProxy.request does not persist targetClientId/targetActorPrincipalId, and /v1/host-browser-result does not validate x-vellum-client-id). In a multi-actor daemon, this can route one actor’s browser request onto another actor’s connected extension/macOS client, which is a cross-user host-action boundary violation introduced by this exposure change.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +376 to +382
* Defense-in-depth follow-up (deferred): host_browser does not yet adopt
* the same-actor guard that host_bash / host_file / host_cu / host_transfer
* apply to their result routes. The chrome-extension client today does not
* send `x-vellum-client-id` on its result POST, so enforcing the guard
* requires a coordinated extension change. The current `requireGuardian`
* gate on `/v1/host-browser-result` is the same-binding boundary. Tracking
* issue: TBD.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Deferred same-actor guard gap is explicitly acknowledged

The comment block at assistant/src/daemon/conversation-tool-setup.ts:376-382 explicitly documents that host_browser does not yet have the same-actor guard that host_bash/host_file/host_cu/host_transfer apply to their result routes. The current security boundary is requireGuardian: true on /v1/host-browser-result (visible at assistant/src/runtime/routes/host-browser-routes.ts:251). This is a known gap with a tracking issue placeholder (TBD). Since the chrome-extension client doesn't send x-vellum-client-id, enforcing the guard requires a coordinated extension update. This is a reasonable deferral but worth tracking.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…ctor

The original cross-client exposure change opened host_browser to web/iOS
turns whenever any host-browser-capable client is connected, but did not
extend the same-user actor binding that already protects host_bash,
host_file, and host_cu. Codex flagged this on the parent commit as a
multi-actor cross-user host-action boundary violation; Devin tracked the
same gap as a deferral.

This commit closes the gap by mirroring the host-cu pattern at every
layer:

Daemon proxy (host-browser-proxy.ts):
  - resolveTargetClient(sourceActorPrincipalId) filters host_browser
    candidate clients by actorPrincipalId before applying the
    chrome-extension-first interface preference. When no actor is
    supplied (legacy/internal flows), falls back to the prior
    getPreferredClientByCapability path.
  - request() accepts optional sourceActorPrincipalId, refuses to
    dispatch to a different-actor client via enforceSameActorOrErrorResult,
    persists targetClientId/targetActorPrincipalId on the pending
    interaction, and broadcasts with targetClientId so the client-id
    binding rides on the SSE envelope.

Result route (host-browser-routes.ts):
  - resolveHostBrowserResultByRequestId now reads x-vellum-client-id
    and x-vellum-actor-principal-id headers, validates the submitter
    matches peeked.targetClientId (400 missing / 403 mismatch), and
    runs enforceSameActorOrThrow against the persisted
    targetActorPrincipalId.
  - On a 403 the pending interaction is preserved so the legitimate
    client can still submit. Untargeted requests pass through unchanged.
  - OpenAPI gains additionalResponses for 400/403/404/409.

CDP client plumbing (extension-cdp-client.ts, factory.ts):
  - ExtensionCdpClient takes optional sourceActorPrincipalId and
    forwards it on every proxy.request call.
  - buildCandidateList and buildPinnedCandidateList read
    sourceActorPrincipalId from ToolContext (already populated from
    conversation.trustContext.guardianPrincipalId) and thread it
    through createExtensionCdpClient.

Client-side header emission:
  - clients/shared/Network/HostProxyClient.swift (macOS): postBrowserResult
    now sends X-Vellum-Client-Id, mirroring postCuResult / postTransferResult.
  - clients/chrome-extension/background/worker.ts: both the SSE-path POST
    and the self-hosted fallback POST add X-Vellum-Client-Id from
    getClientId(). The Chrome-extension WS host_browser_result code path
    is untouched and remains dead code on the server side.

SameActorOp union gains "host_browser" so the structured-warn audit log
entry uses the same shape as the other host-proxy capabilities.

Test fixtures:
  - assistant/src/__tests__/fixtures/mock-chrome-extension.ts now sends
    X-Vellum-Client-Id on its result POST so e2e tests do not 400.

Tests added:
  - assistant/src/__tests__/host-browser-proxy.test.ts: 6 new tests in
    a "same-actor binding" describe — persists target binding when
    actor matches, rejects when only different-actor clients are
    connected, prefers chrome-extension over macos among same-actor
    clients, falls back to macOS bridge for same actor, legacy path
    still works, rejects when the only candidates are different-actor.
  - assistant/src/__tests__/host-browser-routes.test.ts: new
    "handleHostBrowserResult — same-actor guard" describe block covering
    targeted-correct-headers, whitespace trim, missing/wrong client-id,
    actor-principal mismatch, and untargeted regressions.
  - assistant/src/tools/browser/cdp-client/__tests__/factory.test.ts:
    asserts the factory threads sourceActorPrincipalId from ToolContext
    into createExtensionCdpClient on the extension code path.
  - factory.test.ts also adopts the spread-real-module mock pattern so
    its createExtensionCdpClient stub does not clobber ExtensionCdpClient
    for downstream test files (defensive, since CI test.sh already runs
    each file in its own bun process).

Backwards compatibility:
  - Chrome-extension WS host_browser_result code path is dead on the
    server side; only the HTTP POST path is exercised by both clients.
  - Untargeted requests with no targetClientId continue to work, so
    pre-update Chrome extensions (which now will start sending the
    header) do not regress.
  - Legacy callers without sourceActorPrincipalId hit the
    getPreferredClientByCapability fallback unchanged.

Refs: Codex P1 / Devin deferral on PR #29829.
@credence-the-bot
Copy link
Copy Markdown
Contributor Author

Bundled the same-actor binding fix into this PR per Noa's greenlight, addressing Codex's P1 boundary-violation finding and Devin's matching deferral.

What changed (commit ab370e77b)

Layer Change
assistant/src/daemon/host-browser-proxy.ts New resolveTargetClient(sourceActorPrincipalId) filters host_browser candidates by actor before applying the chrome-extension-first interface preference. request() accepts sourceActorPrincipalId, refuses cross-actor dispatch via enforceSameActorOrErrorResult, and persists targetClientId + targetActorPrincipalId on the pending interaction.
assistant/src/runtime/routes/host-browser-routes.ts resolveHostBrowserResultByRequestId now reads x-vellum-client-id + x-vellum-actor-principal-id, validates submitter matches peeked.targetClientId (400 missing / 403 mismatch), and runs enforceSameActorOrThrow against the persisted target actor. Pending interaction is preserved on 403. OpenAPI gains 400/403/404/409 additionalResponses.
assistant/src/tools/browser/cdp-client/{extension-cdp-client,factory}.ts ExtensionCdpClient takes optional sourceActorPrincipalId and forwards it on every proxy.request. Both buildCandidateList and buildPinnedCandidateList thread sourceActorPrincipalId from ToolContext (already populated from conversation.trustContext.guardianPrincipalId).
assistant/src/runtime/auth/same-actor.ts SameActorOp union gains "host_browser" so audit log entries match other host-proxy capabilities.
clients/shared/Network/HostProxyClient.swift postBrowserResult sends X-Vellum-Client-Id, mirroring postCuResult/postTransferResult.
clients/chrome-extension/background/worker.ts Both the SSE-path POST and the self-hosted fallback POST add X-Vellum-Client-Id from getClientId(). (The Chrome-ext WS host_browser_result path is dead code on the server side and remains untouched.)

Why bundle vs follow-up PR:

  • The Chrome-ext WS host_browser_result server-side path is dead — only the HTTP POST is exercised by both clients, so daemon enforcement and both client header additions can ship atomically.
  • Both clients live in the monorepo, so the boundary tightens cleanly in one commit.
  • Pre-update Chrome extensions auto-update fast, and rejected POSTs surface as request timeouts not crashes.

Tests added

  • host-browser-proxy.test.ts: 6 new tests in a same-actor binding describe — persistence, cross-actor reject, chrome-extension preference among same-actor clients, macOS-only fallback for same actor, legacy passthrough, reject when only different-actor clients are connected.
  • host-browser-routes.test.ts: new handleHostBrowserResult — same-actor guard describe covering targeted + correct headers (200), whitespace trim, missing client-id (400), wrong client-id (403, interaction preserved), actor-principal mismatch (403), and untargeted regression (200).
  • factory.test.ts: asserts sourceActorPrincipalId threads from ToolContext into createExtensionCdpClient. Also adopted the spread-real-module mock pattern defensively.
  • fixtures/mock-chrome-extension.ts: now sends X-Vellum-Client-Id so e2e fixtures don't 400.

Local verification (per-file via scripts/test.sh style):

  • host-browser-proxy.test.ts: 22 pass
  • host-browser-routes.test.ts: 20 pass
  • host-browser-event-routes.test.ts: 16 pass
  • factory.test.ts: 87 pass
  • extension-cdp-client.test.ts: 23 pass
  • conversation-tool-setup.test.ts: 44 pass
  • Broader sweep (12 related files): all pass
  • Typecheck (assistant): clean
  • ESLint (assistant): clean
  • Chrome-extension typecheck + lint + test: clean

cc @chatgpt-codex-connector @devin-ai-integration — the actor-binding gap you flagged is closed. Ready for re-review.

…-binding responses

Adds 400/403/404/409 entries for /v1/host-browser-result. No spec authoring
changes — `bun run generate:openapi` output for the additionalResponses
introduced in the previous commit.
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

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

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

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@credence-the-bot
Copy link
Copy Markdown
Contributor Author

@devin-ai-integration please re-review — bundled the same-actor binding fix at commit ab370e77b (with openapi regen at b94debab6) to address your earlier deferral of Codex's P1 boundary-violation finding. Comment above has the full layer-by-layer diff summary. CI green; Codex re-review came back clean post-bundle.

@noanflaherty noanflaherty merged commit 8cf6a81 into main May 7, 2026
14 checks passed
@noanflaherty noanflaherty deleted the credence/host-browser-cross-client-exposure branch May 7, 2026 02:50
noanflaherty pushed a commit that referenced this pull request May 10, 2026
#30163)

* refactor(host-browser): remove dead WS resolveResult path

The chrome extension migrated to HTTP POST in PR #29829. There is no
/v1/browser-relay WebSocket endpoint in the current runtime — no WS
handler calls HostBrowserProxy.resolveResult. Removing the public method
eliminates the foot-gun of a future WS handler bypassing the kind-check
and same-actor guard that the HTTP route enforces.

The route now inlines pendingInteractions.resolve() + rpcResolve directly.
Tests are updated to use a local resolveResult helper that mirrors the
same call sequence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(host-proxy-preactivation): add cross-client drain-path coverage

PR #30154 enabled web/iOS turns to drive host_app_control and host_cu on
a connected macOS client (cross-client routing). The existing drain-path
tests cover macOS-native + chrome-extension + slack; this adds the missing
cross-client cases:

- web source + listClientsByCapability returns 1 macOS client → both
  app-control and computer-use skills re-added
- web source + listClientsByCapability returns [] → neither re-added

Introduces a per-test mockCapabilityClients variable (with afterEach reset)
so hub state doesn't bleed across tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: credence-the-bot[bot] <test@test.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants