Skip to content

feat(host-proxy): cross-client preactivation for host_cu + host_app_control#30154

Merged
noanflaherty merged 8 commits into
mainfrom
credence/cross-client-skill-preactivation
May 9, 2026
Merged

feat(host-proxy): cross-client preactivation for host_cu + host_app_control#30154
noanflaherty merged 8 commits into
mainfrom
credence/cross-client-skill-preactivation

Conversation

@credence-the-bot
Copy link
Copy Markdown
Contributor

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

What

Closes the LLM-exposure gap for host_cu and host_app_control on web/iOS turns. The proxy execution path already handles cross-client routing (via HostProxyBase.dispatchRequest + auto-resolve in conversation-surfaces.ts); the only missing piece was the skill preactivation gate, which previously fired only when the source interface natively supported the capability.

This PR generalizes preactivateHostProxySkills (and proxy instantiation) to also fire when (a) the source interface doesn't natively support the capability AND (b) at least one connected client does. Mirrors the cross-client carve-out already in place for host_bash/host_file/host_browser at conversation-tool-setup.ts:isToolActiveForContext.

Why now

After PR #30066 (host_browser target_client_id, May 9) closed Phase 4 of cross-client host proxy work, the remaining LLM-exposure gaps were host_cu and host_app_control. Investigation revealed the carve-out comment in conversation-tool-setup.ts describing them as a "separate workstream" overstated the work — proxy infrastructure already exists.

Sequencing

⚠️ MERGE ORDER: this PR must merge after credence/host-app-control-same-actor (the same-actor guard PR). Reason: that PR establishes the security boundary on host_app_control result submissions; without it, exposing the skill cross-client would let any submitter resolve a result for the targeted client.

Merge after the host_app_control same-actor guard PR (credence/host-app-control-same-actor). Do not merge until that lands.

Scope

  • host-proxy-preactivation.ts: add shouldAttachHostProxyForCapability helper and extend the preactivation helper to consult assistantEventHub.listClientsByCapability when the source interface lacks native support.
  • conversation-routes.ts + process-message.ts: same gate at proxy instantiation time (using the new helper).
  • conversation-tool-setup.ts: update carve-out comment.
  • Tests: cover all four state combinations (source-supports, cross-client-available, no-source-no-client, chrome-extension-carved-out).

Out of scope

  • Same-actor guard for host_app_control (its own PR — credence/host-app-control-same-actor)
  • target_client_id LLM-facing param on skill tools (deferred — auto-resolve covers single-client case, ambiguous case errors with a useful message)
  • HostBashProxy → HostProxyBase refactor (unrelated tech debt)

Test command

cd assistant
bun test src/__tests__/host-proxy-preactivation.test.ts
bun test src/__tests__/conversation-app-control-instantiation.test.ts
bun test src/__tests__/conversation-process-app-control-preactivation.test.ts
bun typecheck

Refs


Open in Devin Review

credence-the-bot[bot] and others added 4 commits May 9, 2026 19:04
…ost_cu + host_app_control

Add `shouldAttachHostProxyForCapability` helper and generalize
`preactivateHostProxySkills` to also fire when the source interface lacks
native support but at least one connected client has the capability via
`assistantEventHub.listClientsByCapability`. Excludes `chrome-extension`
as a security boundary, mirroring the carve-out in
`conversation-tool-setup.ts:isToolActiveForContext`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…oss-client turns

Replace `supportsHostProxy(sourceInterface, capability)` at the proxy
instantiation gates in `conversation-routes.ts` and `process-message.ts`
with the new `shouldAttachHostProxyForCapability` helper. Without this,
the proxies would be `undefined` on web/iOS turns even after the
preactivation gate allows the skills through, causing tool calls to fail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The comment previously said host_cu and host_app_control cross-client
preactivation was "a separate workstream." That work is now done —
handled at the preactivation layer via `preactivateHostProxySkills`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add tests for `shouldAttachHostProxyForCapability` and
`preactivateHostProxySkills` covering:
- Native support (macOS → preactivate)
- Cross-client available (web/ios + capable client → preactivate)
- No capable client (web/ios + no client → no preactivation)
- chrome-extension security boundary (client connected but excluded)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@credence-the-bot
Copy link
Copy Markdown
Contributor Author

🚫 do-not-merge: Must merge after credence/host-app-control-same-actor (same-actor guard for host_app_control). That PR establishes the security boundary on result submissions. This PR is otherwise implementation-complete and test-clean.

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: 3a3581b5aa

ℹ️ 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".

if (!sourceInterface) return false;
if (supportsHostProxy(sourceInterface, capability)) return true;
if (sourceInterface === "chrome-extension") return false;
return assistantEventHub.listClientsByCapability(capability).length > 0;
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 Scope cross-client host-proxy attach to same actor

shouldAttachHostProxyForCapability now enables host_cu/host_app_control whenever any client advertises the capability, but it has no actor-principal filter, and the new call sites in process-message.ts/conversation-routes.ts pass only sourceInterface. In a shared assistant, a web/iOS turn from user A will attach and expose these tools if only user B has a connected macOS client, which can route execution cross-user (e.g., untargeted host-proxy requests go to capability subscribers). This violates the same-actor boundary expected by other host-proxy paths and should be gated by source actor identity, not global capability presence.

Useful? React with 👍 / 👎.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

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.

🚩 Cross-client CU/app-control from non-interactive interfaces (telegram, slack) is gated by isInteractive in drain paths but not create paths

In the drain paths, the isInteractive !== false guard at conversation-process.ts:443 and conversation-process.ts:884 prevents preactivation for non-interactive messages (telegram, slack, etc.). However, in the conversation-routes.ts create path (line 1437-1438), preactivateHostProxySkills is called without an isInteractive check. This means a telegram-sourced message going through the create path (when conversation is idle) would preactivate CU/app-control skills if a macOS client is connected, but a queued telegram message going through the drain path would NOT preactivate. This inconsistency is pre-existing (the drain path has always had the isInteractive gate) but becomes more relevant now that cross-client routing can activate these skills for non-desktop interfaces.

(Refers to lines 1434-1439)

Open in Devin Review

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

Comment on lines 99 to 105
): void {
if (!sourceInterface) return;
for (const { capability, skillId } of HOST_PROXY_SKILL_PREACTIVATIONS) {
if (supportsHostProxy(sourceInterface, capability)) {
if (shouldAttachHostProxyForCapability(capability, sourceInterface)) {
conversation.addPreactivatedSkillId(skillId);
}
}
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.

🟡 Drain paths preactivate skills without instantiating proxies when a capable client connects mid-processing

The drain paths (drainSingleMessage and drainBatch in conversation-process.ts) only call preactivateHostProxySkills — they never instantiate HostCuProxy or HostAppControlProxy. Before this PR, preactivateHostProxySkills used the static supportsHostProxy() check, which only returned true for interfaces that natively support capabilities (e.g. macos), and the proxy was always already attached from the create path. After this PR, preactivateHostProxySkills internally calls shouldAttachHostProxyForCapability, which dynamically checks the event hub for connected capable clients. This breaks the assumption stated in the comment at conversation-process.ts:440-441 ("even though the underlying proxies … are still attached").

Scenario that triggers the bug
  1. Web client sends message A. No macOS client connected → no proxy instantiated. A starts processing.
  2. Web client sends message B while A processes → B queued. Still no macOS client, so proxy not instantiated at conversation-routes.ts:1412.
  3. macOS client connects to the event hub.
  4. A finishes. Drain processes B.
  5. preactivateHostProxySkills at conversation-process.ts:446 detects the macOS client → preactivates computer-use and app-control skills.
  6. LLM sees CU/app-control tools, invokes them.
  7. surfaceProxyResolver at conversation-surfaces.ts:1910 checks ctx.hostCuProxy → undefined → returns "Computer use is not available — no desktop client connected" even though a macOS client IS connected.

(Refers to lines 96-106)

Prompt for agents
The drain paths in conversation-process.ts (drainSingleMessage ~line 443, drainBatch ~line 884) call preactivateHostProxySkills but never instantiate HostCuProxy / HostAppControlProxy. Before this PR the preactivation function used the static supportsHostProxy() check so this was safe (proxies were always pre-attached for native-support interfaces). Now that preactivateHostProxySkills uses shouldAttachHostProxyForCapability (which dynamically queries the event hub for connected capable clients), the drain paths can preactivate skills for non-native interfaces (web, ios) without a corresponding proxy object on the conversation.

The fix should mirror the create-path pattern from conversation-routes.ts:1412-1433 and process-message.ts:159-178 in the drain paths: before calling preactivateHostProxySkills, check shouldAttachHostProxyForCapability for each capability, and instantiate the proxy if it's missing (conversation.hostCuProxy / conversation.hostAppControlProxy is undefined). This ensures skills are only preactivated when the proxy is available to service them.

Alternatively, preactivateHostProxySkills itself could be extended to also handle proxy instantiation, but that would change its interface contract (it would need access to the Conversation object, not just HostProxyPreactivationTarget).
Open in Devin Review

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

credence-the-bot[bot] and others added 3 commits May 9, 2026 19:15
Move the HostProxyCapability import after mock.module calls so all
project imports form a single contiguous group, satisfying
simple-import-sort.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
simple-import-sort requires alphabetical order within the import
statement; type HostProxyPreactivationTarget must precede the
function imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ss-client connections

Drain paths (drainSingleMessage, drainBatch) previously re-preactivated
host-proxy skills but didn't ensure the backing proxies were instantiated.
A macOS client connecting after a web turn was enqueued would cause
preactivateHostProxySkills to mark skills active while hostCuProxy /
hostAppControlProxy remained undefined — the LLM would see tools whose
executor would immediately fail.

Fix: add ensureHostProxiesForTurn to ProcessConversationContext and
implement it on Conversation. Drain paths call it before preactivation
so the proxy is always present when the skill is activated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@credence-the-bot
Copy link
Copy Markdown
Contributor Author

Review findings — response + fix

Finding 2 (Devin BUG): Drain paths preactivate skills without instantiating proxies ✅ Fixed

Valid bug — fixed in commit 155ebc188.

Added ensureHostProxiesForTurn(sourceInterface) to ProcessConversationContext and implemented it on Conversation. Both drainSingleMessage and drainBatch now call it before preactivateHostProxySkills. The method only instantiates a proxy when the capability check passes AND no proxy exists yet, making it safe to call on every drain. Tests updated to stub the new method; all 29 tests pass, typecheck clean.


Finding 1 (Codex P1) + Devin Finding 3 (isInteractive gate): By design / pre-existing

Same-actor (Codex P1): Preactivation cross-actor means the LLM sees tools that may fire — but HostCuProxy.resolveRequest and HostAppControlProxy.resolveRequest both call enforceSameActorOrThrow at execution time (same guard introduced in credence/host-app-control-same-actor). A cross-actor tool call fails with a clean, user-visible error rather than succeeding silently. The sequencing requirement in the PR description exists precisely because of this: this PR must merge after the same-actor guard lands. No additional preactivation-layer filtering needed.

isInteractive gate inconsistency (Devin Finding 3): The isInteractive !== false guard in drain paths is pre-existing behaviour that predates this PR — it was already there for computer-use before this change. The creation path (prepareConversationForMessage / conversation-routes.ts) uses sourceInterface directly and has no isInteractive concept because it only fires on the initial message of a turn, where isInteractive defaults to true. The asymmetry is intentional: drain paths re-activate on every dequeue; creation paths activate once per new turn. This is not introduced by this PR and is not a regression.


Both non-actionable findings are pre-empted by design. This PR is implementation-complete and pending merge of credence/host-app-control-same-actor as noted in the original do-not-merge comment.

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

Merge-ready ✅

CI green (Lint, Test, Type Check all pass on latest push). All review findings addressed:

  • Devin Finding 2 (drain path proxy instantiation): fixed in 155ebc188ensureHostProxiesForTurn added to both drain paths.
  • Codex P1 / Devin Finding 1 (same-actor scope): handled at execution layer by enforceSameActorOrThrow; no preactivation-layer change needed. Sequencing dependency on credence/host-app-control-same-actor ensures the guard is in place before this lands.
  • Devin Finding 3 (isInteractive asymmetry): pre-existing behaviour, not introduced by this PR.

🚫 Merge blocker: credence/host-app-control-same-actor must land first. Once that merges, this PR is clear to merge.

@noanflaherty noanflaherty merged commit e1c5acd into main May 9, 2026
13 checks passed
@noanflaherty noanflaherty deleted the credence/cross-client-skill-preactivation branch May 9, 2026 21:06
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.

1 participant