From fd25c30470a6da8dd1da6225d0a435e0d4aa45b2 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 16:53:58 -0400 Subject: [PATCH 1/7] feat(channels): add chrome-extension interface id and per-capability host proxy gating --- .../src/channels/__tests__/types.test.ts | 134 ++++++++++++++++++ assistant/src/channels/types.ts | 29 +++- assistant/src/daemon/conversation-process.ts | 5 +- .../src/daemon/handlers/conversations.ts | 15 +- assistant/src/daemon/server.ts | 24 +++- .../src/runtime/routes/conversation-routes.ts | 31 ++-- 6 files changed, 214 insertions(+), 24 deletions(-) create mode 100644 assistant/src/channels/__tests__/types.test.ts diff --git a/assistant/src/channels/__tests__/types.test.ts b/assistant/src/channels/__tests__/types.test.ts new file mode 100644 index 00000000000..33bfeeb7c6a --- /dev/null +++ b/assistant/src/channels/__tests__/types.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; + +import { + INTERACTIVE_INTERFACES, + INTERFACE_IDS, + isInterfaceId, + supportsHostProxy, +} from "../types.js"; + +describe("INTERFACE_IDS", () => { + test("includes chrome-extension", () => { + expect( + (INTERFACE_IDS as readonly string[]).includes("chrome-extension"), + ).toBe(true); + }); + + test("still includes macos and other existing interfaces", () => { + for (const id of [ + "macos", + "ios", + "cli", + "telegram", + "phone", + "vellum", + "whatsapp", + "slack", + "email", + ]) { + expect((INTERFACE_IDS as readonly string[]).includes(id)).toBe(true); + } + }); +}); + +describe("INTERACTIVE_INTERFACES", () => { + test("does NOT include chrome-extension", () => { + // Chrome extensions don't render SSE-backed prompter UI, so they must + // stay out of the interactive set even though they have an InterfaceId. + expect(INTERACTIVE_INTERFACES.has("chrome-extension" as never)).toBe(false); + }); + + test("still includes macos", () => { + expect(INTERACTIVE_INTERFACES.has("macos")).toBe(true); + }); +}); + +describe("isInterfaceId", () => { + test("returns true for chrome-extension", () => { + expect(isInterfaceId("chrome-extension")).toBe(true); + }); + + test("returns true for macos", () => { + expect(isInterfaceId("macos")).toBe(true); + }); + + test("returns false for unknown interface", () => { + expect(isInterfaceId("safari-extension")).toBe(false); + }); +}); + +describe("supportsHostProxy", () => { + // ── macOS: supports every capability, and the no-arg form returns true. ── + test("macos returns true (no capability)", () => { + expect(supportsHostProxy("macos")).toBe(true); + }); + + test("macos returns true for host_bash", () => { + expect(supportsHostProxy("macos", "host_bash")).toBe(true); + }); + + test("macos returns true for host_file", () => { + expect(supportsHostProxy("macos", "host_file")).toBe(true); + }); + + test("macos returns true for host_cu", () => { + expect(supportsHostProxy("macos", "host_cu")).toBe(true); + }); + + test("macos returns true for host_browser", () => { + expect(supportsHostProxy("macos", "host_browser")).toBe(true); + }); + + // ── chrome-extension: only host_browser. ── + test("chrome-extension returns false (no capability)", () => { + // Chrome extension does not support "any host proxy at all" — it only + // supports host_browser, so the no-arg form must return false to keep + // existing call sites that guard desktop-only behavior unchanged. + expect(supportsHostProxy("chrome-extension")).toBe(false); + }); + + test("chrome-extension returns true for host_browser", () => { + expect(supportsHostProxy("chrome-extension", "host_browser")).toBe(true); + }); + + test("chrome-extension returns false for host_bash", () => { + expect(supportsHostProxy("chrome-extension", "host_bash")).toBe(false); + }); + + test("chrome-extension returns false for host_file", () => { + expect(supportsHostProxy("chrome-extension", "host_file")).toBe(false); + }); + + test("chrome-extension returns false for host_cu", () => { + expect(supportsHostProxy("chrome-extension", "host_cu")).toBe(false); + }); + + // ── Non-supporting interfaces: false in all forms. ── + test("cli returns false (no capability)", () => { + expect(supportsHostProxy("cli")).toBe(false); + }); + + test("cli returns false for host_bash", () => { + expect(supportsHostProxy("cli", "host_bash")).toBe(false); + }); + + test("cli returns false for host_browser", () => { + expect(supportsHostProxy("cli", "host_browser")).toBe(false); + }); + + test("telegram returns false (no capability)", () => { + expect(supportsHostProxy("telegram")).toBe(false); + }); + + test("telegram returns false for host_browser", () => { + expect(supportsHostProxy("telegram", "host_browser")).toBe(false); + }); + + test("vellum returns false (no capability)", () => { + expect(supportsHostProxy("vellum")).toBe(false); + }); + + test("email returns false for host_browser", () => { + expect(supportsHostProxy("email", "host_browser")).toBe(false); + }); +}); diff --git a/assistant/src/channels/types.ts b/assistant/src/channels/types.ts index ec9e8bf6d2e..91c2024d0c2 100644 --- a/assistant/src/channels/types.ts +++ b/assistant/src/channels/types.ts @@ -48,6 +48,7 @@ export const INTERFACE_IDS = [ "whatsapp", "slack", "email", + "chrome-extension", ] as const; export type InterfaceId = (typeof INTERFACE_IDS)[number]; @@ -90,9 +91,31 @@ export function isInteractiveInterface(id: InterfaceId): boolean { return INTERACTIVE_INTERFACES.has(id); } -/** Whether the interface supports host proxies (bash, file, computer-use). */ -export function supportsHostProxy(id: InterfaceId): boolean { - return id === "macos"; +/** + * Host proxy capabilities that an interface can support. The macOS client + * historically supports all four; the chrome-extension interface only + * supports host_browser (via the Chrome DevTools Protocol proxy). + */ +export type HostProxyCapability = + | "host_bash" + | "host_file" + | "host_cu" + | "host_browser"; + +/** + * Whether the interface supports a host proxy capability. Omit `capability` + * to ask "does this interface support any host proxy at all?" — the macOS + * client historically supports all four capabilities; the chrome-extension + * interface only supports host_browser, so the no-arg form returns `false` + * for chrome-extension. + */ +export function supportsHostProxy( + id: InterfaceId, + capability?: HostProxyCapability, +): boolean { + if (id === "macos") return true; + if (id === "chrome-extension" && capability === "host_browser") return true; + return false; } export interface TurnInterfaceContext { diff --git a/assistant/src/daemon/conversation-process.ts b/assistant/src/daemon/conversation-process.ts index 229c1d880f8..aa519e9b14e 100644 --- a/assistant/src/daemon/conversation-process.ts +++ b/assistant/src/daemon/conversation-process.ts @@ -314,7 +314,10 @@ export async function drainQueue( } else { // Restore proxy availability only for desktop-originating turns (macos) // in case a prior non-interactive drain disabled it. Non-desktop interactive - // interfaces (CLI, Vellum) should not re-enable desktop host proxies. + // interfaces (CLI, Vellum) should not re-enable desktop host proxies. The + // chrome-extension interface only supports host_browser, not the desktop + // proxies or computer-use, so it is excluded by the no-arg form of + // supportsHostProxy (which returns false for chrome-extension). const interfaceCtx = queuedInterfaceCtx ?? conversation.getTurnInterfaceContext(); const sourceInterface = interfaceCtx?.userMessageInterface; diff --git a/assistant/src/daemon/handlers/conversations.ts b/assistant/src/daemon/handlers/conversations.ts index 9615e941b19..727de4d270c 100644 --- a/assistant/src/daemon/handlers/conversations.ts +++ b/assistant/src/daemon/handlers/conversations.ts @@ -306,22 +306,29 @@ export async function handleConversationCreate( userMessageInterface: transportInterface, assistantMessageInterface: transportInterface, }); - // Only create the host bash proxy for desktop client interfaces that can - // execute commands on the user's machine. Set before updateClient so - // updateClient's call to hostBashProxy.updateSender targets the new proxy. - if (supportsHostProxy(transportInterface)) { + // Only create each host proxy for interfaces that support the matching + // capability. macOS supports all four; the chrome-extension interface only + // supports host_browser. Set before updateClient so updateClient's call to + // hostBashProxy.updateSender targets the new proxy. + if (supportsHostProxy(transportInterface, "host_bash")) { const proxy = new HostBashProxy(sendEvent, (requestId) => { pendingInteractions.resolve(requestId); }); conversationObj.setHostBashProxy(proxy); + } + if (supportsHostProxy(transportInterface, "host_browser")) { const browserProxy = new HostBrowserProxy(sendEvent, (requestId) => { pendingInteractions.resolve(requestId); }); conversationObj.setHostBrowserProxy(browserProxy); + } + if (supportsHostProxy(transportInterface, "host_file")) { const fileProxy = new HostFileProxy(sendEvent, (requestId) => { pendingInteractions.resolve(requestId); }); conversationObj.setHostFileProxy(fileProxy); + } + if (supportsHostProxy(transportInterface, "host_cu")) { const cuProxy = new HostCuProxy(sendEvent, (requestId) => { pendingInteractions.resolve(requestId); }); diff --git a/assistant/src/daemon/server.ts b/assistant/src/daemon/server.ts index 17566c3770c..75350104f2a 100644 --- a/assistant/src/daemon/server.ts +++ b/assistant/src/daemon/server.ts @@ -1090,13 +1090,14 @@ export class DaemonServer { options?.transport?.chatType, ), ); - // Only create the host bash proxy for desktop client interfaces that can - // execute commands on the user's machine. Non-desktop conversations (CLI, - // channels, headless) fall back to local execution. + // Only create each host proxy for interfaces that support the matching + // capability. macOS supports all four; the chrome-extension interface only + // supports host_browser. Non-desktop conversations (CLI, channels, headless) + // fall back to local execution. // Guard: don't replace an active proxy during concurrent turn races — // another request may have started processing between the isProcessing() // check above and the await on ensureActorScopedHistory(). - if (supportsHostProxy(resolvedInterface)) { + if (supportsHostProxy(resolvedInterface, "host_bash")) { if (!conversation.isProcessing() || !conversation.hostBashProxy) { conversation.setHostBashProxy( new HostBashProxy(conversation.getCurrentSender(), (requestId) => { @@ -1104,6 +1105,10 @@ export class DaemonServer { }), ); } + } else if (!conversation.isProcessing()) { + conversation.setHostBashProxy(undefined); + } + if (supportsHostProxy(resolvedInterface, "host_browser")) { if (!conversation.isProcessing() || !conversation.hostBrowserProxy) { conversation.setHostBrowserProxy( new HostBrowserProxy(conversation.getCurrentSender(), (requestId) => { @@ -1111,6 +1116,10 @@ export class DaemonServer { }), ); } + } else if (!conversation.isProcessing()) { + conversation.setHostBrowserProxy(undefined); + } + if (supportsHostProxy(resolvedInterface, "host_file")) { if (!conversation.isProcessing() || !conversation.hostFileProxy) { conversation.setHostFileProxy( new HostFileProxy(conversation.getCurrentSender(), (requestId) => { @@ -1118,6 +1127,10 @@ export class DaemonServer { }), ); } + } else if (!conversation.isProcessing()) { + conversation.setHostFileProxy(undefined); + } + if (supportsHostProxy(resolvedInterface, "host_cu")) { if (!conversation.isProcessing() || !conversation.hostCuProxy) { conversation.setHostCuProxy( new HostCuProxy(conversation.getCurrentSender(), (requestId) => { @@ -1127,9 +1140,6 @@ export class DaemonServer { } conversation.addPreactivatedSkillId("computer-use"); } else if (!conversation.isProcessing()) { - conversation.setHostBashProxy(undefined); - conversation.setHostBrowserProxy(undefined); - conversation.setHostFileProxy(undefined); conversation.setHostCuProxy(undefined); } conversation.setCommandIntent(options?.commandIntent ?? null); diff --git a/assistant/src/runtime/routes/conversation-routes.ts b/assistant/src/runtime/routes/conversation-routes.ts index cef41d5e281..edf68253db7 100644 --- a/assistant/src/runtime/routes/conversation-routes.ts +++ b/assistant/src/runtime/routes/conversation-routes.ts @@ -1146,12 +1146,13 @@ export async function handleSendMessage( conversation, ); const isInteractive = isInteractiveInterface(sourceInterface); - // Only create the host bash proxy for desktop client interfaces that can - // execute commands on the user's machine. Non-desktop conversations (CLI, - // channels, headless) fall back to local execution. + // Only create each host proxy for interfaces that support the matching + // capability. macOS supports all four; the chrome-extension interface only + // supports host_browser. Non-desktop conversations (CLI, channels, headless) + // fall back to local execution. // Set the proxy BEFORE updateClient so updateClient's call to // hostBashProxy.updateSender targets the correct (new) proxy. - if (supportsHostProxy(sourceInterface)) { + if (supportsHostProxy(sourceInterface, "host_bash")) { // Reuse the existing proxy if the conversation is actively processing a // host bash request to avoid orphaning in-flight requests. if (!conversation.isProcessing() || !conversation.hostBashProxy) { @@ -1160,18 +1161,30 @@ export async function handleSendMessage( }); conversation.setHostBashProxy(proxy); } + } else if (!conversation.isProcessing()) { + conversation.setHostBashProxy(undefined); + } + if (supportsHostProxy(sourceInterface, "host_browser")) { if (!conversation.isProcessing() || !conversation.hostBrowserProxy) { const browserProxy = new HostBrowserProxy(onEvent, (requestId) => { pendingInteractions.resolve(requestId); }); conversation.setHostBrowserProxy(browserProxy); } + } else if (!conversation.isProcessing()) { + conversation.setHostBrowserProxy(undefined); + } + if (supportsHostProxy(sourceInterface, "host_file")) { if (!conversation.isProcessing() || !conversation.hostFileProxy) { const fileProxy = new HostFileProxy(onEvent, (requestId) => { pendingInteractions.resolve(requestId); }); conversation.setHostFileProxy(fileProxy); } + } else if (!conversation.isProcessing()) { + conversation.setHostFileProxy(undefined); + } + if (supportsHostProxy(sourceInterface, "host_cu")) { if (!conversation.isProcessing() || !conversation.hostCuProxy) { const cuProxy = new HostCuProxy(onEvent, (requestId) => { pendingInteractions.resolve(requestId); @@ -1185,17 +1198,17 @@ export async function handleSendMessage( conversation.addPreactivatedSkillId("computer-use"); } } else if (!conversation.isProcessing()) { - conversation.setHostBashProxy(undefined); - conversation.setHostBrowserProxy(undefined); - conversation.setHostFileProxy(undefined); conversation.setHostCuProxy(undefined); } // Wire sendToClient to the SSE hub so all subsystems can reach the HTTP client. // Called after setHostBashProxy so updateSender targets the current proxy. // When proxies are preserved during an active turn (non-desktop request while - // processing), skip updating proxy senders to avoid degrading them. + // processing), skip updating proxy senders to avoid degrading them. The gate + // matches the host_bash capability because the legacy "reject send during + // host bash" flow is what this is really protecting. const preservingProxies = - conversation.isProcessing() && !supportsHostProxy(sourceInterface); + conversation.isProcessing() && + !supportsHostProxy(sourceInterface, "host_bash"); conversation.updateClient(onEvent, !isInteractive, { skipProxySenderUpdate: preservingProxies, }); From b7902e010b8d0361ceff90f6f9cee98af12a6c36 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 17:23:45 -0400 Subject: [PATCH 2/7] fix(channels): keep hostBrowserProxy available for non-interactive chrome-extension interfaces updateClient/drain-queue paths used !isInteractive as a proxy for hasNoClient, which incorrectly marks the chrome-extension's hostBrowserProxy unavailable immediately after construction. Decouple the flags: chrome-extension is non-interactive (no prompter UI) but still has a connected client for host_browser_request events. - conversation-routes.ts: derive hasNoClient as !(isInteractive || supportsHostProxy(sourceInterface, 'host_browser')) - server.ts persistAndProcessMessage: same pattern so queued sends don't lose availability - conversation-process.ts drain queue: add restore path via new Conversation.restoreBrowserProxyAvailability() helper - conversation.ts: add restoreBrowserProxyAvailability() that re-enables only the browser proxy (gated on hasNoClient) - channels/types.ts: clarify supportsHostProxy no-arg JSDoc to call out the desktop-only semantics - conversation-confirmation-signals.test.ts: cover the new restore helper Co-Authored-By: Claude Opus 4.6 (1M context) --- .../conversation-confirmation-signals.test.ts | 41 +++++++++++++++++++ assistant/src/channels/types.ts | 16 +++++--- assistant/src/daemon/conversation-process.ts | 16 ++++++++ assistant/src/daemon/conversation.ts | 14 +++++++ assistant/src/daemon/server.ts | 12 ++++++ .../src/runtime/routes/conversation-routes.ts | 11 ++++- 6 files changed, 104 insertions(+), 6 deletions(-) diff --git a/assistant/src/__tests__/conversation-confirmation-signals.test.ts b/assistant/src/__tests__/conversation-confirmation-signals.test.ts index 4e6ef2e12af..db67db6c91c 100644 --- a/assistant/src/__tests__/conversation-confirmation-signals.test.ts +++ b/assistant/src/__tests__/conversation-confirmation-signals.test.ts @@ -214,6 +214,8 @@ mock.module("../memory/canonical-guardian-store.js", () => ({ // --------------------------------------------------------------------------- import { Conversation } from "../daemon/conversation.js"; +import { HostBashProxy } from "../daemon/host-bash-proxy.js"; +import { HostBrowserProxy } from "../daemon/host-browser-proxy.js"; // --------------------------------------------------------------------------- // Helpers @@ -558,3 +560,42 @@ describe("sendToClient receives state signals", () => { }); }); }); + +describe("restoreBrowserProxyAvailability", () => { + test("re-enables only the host browser proxy after clearProxyAvailability", () => { + const conversation = makeConversation(); + const browserProxy = new HostBrowserProxy(() => {}); + const bashProxy = new HostBashProxy(() => {}); + conversation.setHostBrowserProxy(browserProxy); + conversation.setHostBashProxy(bashProxy); + + // Mark as having a connected client (chrome-extension path). + conversation.updateClient(() => {}, false); + expect(browserProxy.isAvailable()).toBe(true); + expect(bashProxy.isAvailable()).toBe(true); + + // The drain queue clears all proxies for non-interactive turns. + conversation.clearProxyAvailability(); + expect(browserProxy.isAvailable()).toBe(false); + expect(bashProxy.isAvailable()).toBe(false); + + // restoreBrowserProxyAvailability should bring back ONLY the browser proxy. + conversation.restoreBrowserProxyAvailability(); + expect(browserProxy.isAvailable()).toBe(true); + expect(bashProxy.isAvailable()).toBe(false); + }); + + test("does not re-enable the browser proxy when hasNoClient is true", () => { + const conversation = makeConversation(); + const browserProxy = new HostBrowserProxy(() => {}); + conversation.setHostBrowserProxy(browserProxy); + + // updateClient with hasNoClient=true means there's no real client connected. + conversation.updateClient(() => {}, true); + expect(browserProxy.isAvailable()).toBe(false); + + // restoreBrowserProxyAvailability must respect the disconnected gate. + conversation.restoreBrowserProxyAvailability(); + expect(browserProxy.isAvailable()).toBe(false); + }); +}); diff --git a/assistant/src/channels/types.ts b/assistant/src/channels/types.ts index 91c2024d0c2..5b0af60810e 100644 --- a/assistant/src/channels/types.ts +++ b/assistant/src/channels/types.ts @@ -103,11 +103,17 @@ export type HostProxyCapability = | "host_browser"; /** - * Whether the interface supports a host proxy capability. Omit `capability` - * to ask "does this interface support any host proxy at all?" — the macOS - * client historically supports all four capabilities; the chrome-extension - * interface only supports host_browser, so the no-arg form returns `false` - * for chrome-extension. + * Whether the interface supports a host proxy capability. + * + * The no-arg form `supportsHostProxy(id)` asks "does this interface support + * the full desktop host proxy set?" — it returns `true` only for macOS, which + * historically supports all four capabilities. It returns `false` for + * chrome-extension because chrome-extension only supports `host_browser`, + * and the no-arg form is the gate that legacy desktop-only call sites use + * (e.g. preactivating computer-use, restoring all four proxies in the drain + * queue). Callers that want to check a single capability — for example, to + * decide whether to keep `hostBrowserProxy` available for chrome-extension — + * should pass the capability explicitly: `supportsHostProxy(id, "host_browser")`. */ export function supportsHostProxy( id: InterfaceId, diff --git a/assistant/src/daemon/conversation-process.ts b/assistant/src/daemon/conversation-process.ts index aa519e9b14e..77fddbdd9db 100644 --- a/assistant/src/daemon/conversation-process.ts +++ b/assistant/src/daemon/conversation-process.ts @@ -136,6 +136,8 @@ export interface ProcessConversationContext { clearProxyAvailability(): void; /** Restore host proxy availability based on whether a real client is connected. */ restoreProxyAvailability(): void; + /** Restore only the host browser proxy (used by chrome-extension drains). */ + restoreBrowserProxyAvailability(): void; emitActivityState( phase: | "idle" @@ -311,6 +313,20 @@ export async function drainQueue( // returns false and tool execution falls back to local. if (next.isInteractive === false) { conversation.clearProxyAvailability(); + // chrome-extension is non-interactive (no SSE prompter UI) but DOES have + // a connected client that can service host_browser_request events. The + // unconditional clear above turned its hostBrowserProxy off; restore it + // here so the queued turn can still drive the browser via CDP. + const drainInterfaceCtx = + queuedInterfaceCtx ?? conversation.getTurnInterfaceContext(); + const drainInterface = drainInterfaceCtx?.userMessageInterface; + if ( + drainInterface && + !supportsHostProxy(drainInterface) && + supportsHostProxy(drainInterface, "host_browser") + ) { + conversation.restoreBrowserProxyAvailability(); + } } else { // Restore proxy availability only for desktop-originating turns (macos) // in case a prior non-interactive drain disabled it. Non-desktop interactive diff --git a/assistant/src/daemon/conversation.ts b/assistant/src/daemon/conversation.ts index 8f16f6a75f4..4bca3390e0a 100644 --- a/assistant/src/daemon/conversation.ts +++ b/assistant/src/daemon/conversation.ts @@ -545,6 +545,20 @@ export class Conversation { } } + /** + * Restore host browser proxy availability only. Used for non-desktop + * interfaces (e.g. chrome-extension) that support host_browser but not + * the full desktop proxy set, so calling restoreProxyAvailability() would + * incorrectly re-enable bash/file/CU proxies that should stay disabled. + * Mirrors the hasNoClient gate from restoreProxyAvailability so a + * disconnected client doesn't get its proxy reactivated. + */ + restoreBrowserProxyAvailability(): void { + if (!this.hasNoClient) { + this.hostBrowserProxy?.updateSender(this.sendToClient, true); + } + } + setSubagentAllowedTools(tools: Set | undefined): void { this.subagentAllowedTools = tools; } diff --git a/assistant/src/daemon/server.ts b/assistant/src/daemon/server.ts index 75350104f2a..54bef2560ed 100644 --- a/assistant/src/daemon/server.ts +++ b/assistant/src/daemon/server.ts @@ -1218,8 +1218,20 @@ export class DaemonServer { } } : registrar; + // Non-interactive interfaces that still have a connected client capable + // of handling host_browser_request events (e.g. chrome-extension) need + // their hostBrowserProxy explicitly marked connected. The proxy + // constructor defaults clientConnected = false, so without this call the + // chrome-extension proxy would be created and immediately unavailable. + const persistInterfaceCtx = conversation.getTurnInterfaceContext(); + const persistInterface = persistInterfaceCtx?.userMessageInterface; if (options?.isInteractive === true) { conversation.updateClient(onEvent, false); + } else if ( + persistInterface && + supportsHostProxy(persistInterface, "host_browser") + ) { + conversation.updateClient(onEvent, false); } conversation diff --git a/assistant/src/runtime/routes/conversation-routes.ts b/assistant/src/runtime/routes/conversation-routes.ts index edf68253db7..5b55f0fdc50 100644 --- a/assistant/src/runtime/routes/conversation-routes.ts +++ b/assistant/src/runtime/routes/conversation-routes.ts @@ -1209,7 +1209,16 @@ export async function handleSendMessage( const preservingProxies = conversation.isProcessing() && !supportsHostProxy(sourceInterface, "host_bash"); - conversation.updateClient(onEvent, !isInteractive, { + // hasNoClient gates whether host proxies treat themselves as connected. The + // chrome-extension interface is non-interactive (no SSE prompter UI) but + // still has a connected client that can service host_browser_request events, + // so deriving hasNoClient purely from !isInteractive would incorrectly mark + // the just-constructed hostBrowserProxy unavailable. Treat any interface + // that supports host_browser as having a client, so the proxy stays usable. + const hasNoClient = !( + isInteractive || supportsHostProxy(sourceInterface, "host_browser") + ); + conversation.updateClient(onEvent, hasNoClient, { skipProxySenderUpdate: preservingProxies, }); From 911aa3ef79902b678052d06a204ad210b58691e9 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 17:43:15 -0400 Subject: [PATCH 3/7] fix(channels): targeted hostBrowserProxy enable without relaxing hasNoClient Cycle 1 derived hasNoClient as !(isInteractive || supportsHostProxy(id, 'host_browser')) to keep the chrome-extension's browser proxy available. That inadvertently made tool gating treat the conversation as fully interactive (isInteractive derives from !ctx.hasNoClient), enabling host_bash/host_file tools that chrome-extension can't service. Revert to the literal hasNoClient = !isInteractive and instead call a targeted restoreBrowserProxyAvailability() after updateClient. The helper now enables the browser proxy regardless of hasNoClient so the single-proxy chrome-extension turn works without leaking host_bash/host_file tool availability. Part of JARVIS-1175 --- .../conversation-confirmation-signals.test.ts | 43 +++++++++++++++++-- assistant/src/daemon/conversation.ts | 17 +++++--- assistant/src/daemon/server.ts | 10 +++-- .../src/runtime/routes/conversation-routes.ts | 26 ++++++----- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/assistant/src/__tests__/conversation-confirmation-signals.test.ts b/assistant/src/__tests__/conversation-confirmation-signals.test.ts index db67db6c91c..5d80952357f 100644 --- a/assistant/src/__tests__/conversation-confirmation-signals.test.ts +++ b/assistant/src/__tests__/conversation-confirmation-signals.test.ts @@ -569,7 +569,7 @@ describe("restoreBrowserProxyAvailability", () => { conversation.setHostBrowserProxy(browserProxy); conversation.setHostBashProxy(bashProxy); - // Mark as having a connected client (chrome-extension path). + // Mark as having a connected client (interactive desktop path). conversation.updateClient(() => {}, false); expect(browserProxy.isAvailable()).toBe(true); expect(bashProxy.isAvailable()).toBe(true); @@ -585,17 +585,52 @@ describe("restoreBrowserProxyAvailability", () => { expect(bashProxy.isAvailable()).toBe(false); }); - test("does not re-enable the browser proxy when hasNoClient is true", () => { + test("re-enables the browser proxy even when hasNoClient is true (chrome-extension)", () => { + // Regression: chrome-extension is non-interactive (hasNoClient stays + // true so host_bash/host_file tools remain gated), but we still need + // to provision the hostBrowserProxy so it can service CDP commands. + // The helper must NOT gate on hasNoClient. const conversation = makeConversation(); const browserProxy = new HostBrowserProxy(() => {}); conversation.setHostBrowserProxy(browserProxy); - // updateClient with hasNoClient=true means there's no real client connected. + // updateClient with hasNoClient=true emulates the non-interactive + // chrome-extension turn. Host proxies start disabled because + // updateClient propagates hasNoClient through to updateSender. conversation.updateClient(() => {}, true); expect(browserProxy.isAvailable()).toBe(false); + expect(conversation["hasNoClient"]).toBe(true); - // restoreBrowserProxyAvailability must respect the disconnected gate. + // The targeted helper bypasses the hasNoClient gate so the + // single-capability chrome-extension turn can drive the browser + // via CDP without flipping hasNoClient (which would also enable + // host_bash/host_file gating downstream). conversation.restoreBrowserProxyAvailability(); + expect(browserProxy.isAvailable()).toBe(true); + // hasNoClient itself MUST remain true so that + // isToolActiveForContext keeps host_bash/host_file/host_cu gated. + expect(conversation["hasNoClient"]).toBe(true); + }); + + test("leaves bash/file/cu proxies disabled when called for chrome-extension", () => { + // Regression: the targeted helper must not accidentally re-enable + // proxies other than host_browser, even when called from a path that + // owns multiple proxies (e.g. macOS holdover state with hasNoClient + // forced true for an explicit non-interactive run). + const conversation = makeConversation(); + const browserProxy = new HostBrowserProxy(() => {}); + const bashProxy = new HostBashProxy(() => {}); + conversation.setHostBrowserProxy(browserProxy); + conversation.setHostBashProxy(bashProxy); + + conversation.updateClient(() => {}, true); expect(browserProxy.isAvailable()).toBe(false); + expect(bashProxy.isAvailable()).toBe(false); + + conversation.restoreBrowserProxyAvailability(); + expect(browserProxy.isAvailable()).toBe(true); + // Crucial: bash proxy stays disabled. The helper must touch ONLY the + // browser proxy. + expect(bashProxy.isAvailable()).toBe(false); }); }); diff --git a/assistant/src/daemon/conversation.ts b/assistant/src/daemon/conversation.ts index 4bca3390e0a..6f68b8baac9 100644 --- a/assistant/src/daemon/conversation.ts +++ b/assistant/src/daemon/conversation.ts @@ -550,13 +550,20 @@ export class Conversation { * interfaces (e.g. chrome-extension) that support host_browser but not * the full desktop proxy set, so calling restoreProxyAvailability() would * incorrectly re-enable bash/file/CU proxies that should stay disabled. - * Mirrors the hasNoClient gate from restoreProxyAvailability so a - * disconnected client doesn't get its proxy reactivated. + * + * Unlike `restoreProxyAvailability()`, this helper does NOT gate on + * `hasNoClient`. The chrome-extension interface is non-interactive (so + * `hasNoClient === true`), but it DOES have a connected client that can + * service `host_browser_request` events. Gating on `hasNoClient` would + * leave the just-constructed proxy unavailable and the only way to make + * it available would be to flip `hasNoClient` false, which would + * incorrectly enable host_bash/host_file/host_cu tool gating downstream. + * + * Callers must only invoke this when they know the current interface + * supports host_browser (see `supportsHostProxy(id, "host_browser")`). */ restoreBrowserProxyAvailability(): void { - if (!this.hasNoClient) { - this.hostBrowserProxy?.updateSender(this.sendToClient, true); - } + this.hostBrowserProxy?.updateSender(this.sendToClient, true); } setSubagentAllowedTools(tools: Set | undefined): void { diff --git a/assistant/src/daemon/server.ts b/assistant/src/daemon/server.ts index 54bef2560ed..793e010a8c2 100644 --- a/assistant/src/daemon/server.ts +++ b/assistant/src/daemon/server.ts @@ -1221,8 +1221,12 @@ export class DaemonServer { // Non-interactive interfaces that still have a connected client capable // of handling host_browser_request events (e.g. chrome-extension) need // their hostBrowserProxy explicitly marked connected. The proxy - // constructor defaults clientConnected = false, so without this call the - // chrome-extension proxy would be created and immediately unavailable. + // constructor defaults clientConnected = false, so without an explicit + // sender update the chrome-extension proxy would be created and + // immediately unavailable. We do NOT call updateClient(onEvent, false) + // for that case, because flipping hasNoClient false would also enable + // host_bash/host_file/host_cu tool gating for an interface that can't + // service them. Instead, provision just the browser proxy's sender. const persistInterfaceCtx = conversation.getTurnInterfaceContext(); const persistInterface = persistInterfaceCtx?.userMessageInterface; if (options?.isInteractive === true) { @@ -1231,7 +1235,7 @@ export class DaemonServer { persistInterface && supportsHostProxy(persistInterface, "host_browser") ) { - conversation.updateClient(onEvent, false); + conversation.hostBrowserProxy?.updateSender(onEvent, true); } conversation diff --git a/assistant/src/runtime/routes/conversation-routes.ts b/assistant/src/runtime/routes/conversation-routes.ts index 5b55f0fdc50..b0c31ceb029 100644 --- a/assistant/src/runtime/routes/conversation-routes.ts +++ b/assistant/src/runtime/routes/conversation-routes.ts @@ -1209,18 +1209,24 @@ export async function handleSendMessage( const preservingProxies = conversation.isProcessing() && !supportsHostProxy(sourceInterface, "host_bash"); - // hasNoClient gates whether host proxies treat themselves as connected. The - // chrome-extension interface is non-interactive (no SSE prompter UI) but - // still has a connected client that can service host_browser_request events, - // so deriving hasNoClient purely from !isInteractive would incorrectly mark - // the just-constructed hostBrowserProxy unavailable. Treat any interface - // that supports host_browser as having a client, so the proxy stays usable. - const hasNoClient = !( - isInteractive || supportsHostProxy(sourceInterface, "host_browser") - ); - conversation.updateClient(onEvent, hasNoClient, { + // hasNoClient must remain `!isInteractive` so downstream tool gating + // (`isToolActiveForContext` for HOST_TOOL_NAMES, `createToolExecutor`'s + // `isInteractive: !ctx.hasNoClient`) keeps host_bash/host_file/host_cu + // tools gated for non-desktop interfaces. The chrome-extension interface + // is non-interactive (no SSE prompter UI) but still has a connected client + // that can service host_browser_request events; we restore that single + // proxy explicitly below without relaxing `hasNoClient`. + conversation.updateClient(onEvent, !isInteractive, { skipProxySenderUpdate: preservingProxies, }); + // For non-interactive interfaces that DO support host_browser + // (chrome-extension), explicitly re-enable just the browser proxy. The + // helper bypasses the `hasNoClient` gate so the single-capability + // chrome-extension turn can drive the browser via CDP without leaking + // host_bash/host_file tool availability into tool gating. + if (supportsHostProxy(sourceInterface, "host_browser")) { + conversation.restoreBrowserProxyAvailability(); + } // ── Canned first-greeting fast path ── // On a completely fresh workspace, skip LLM inference for the macOS From beda525576363664a0dccf4c0653086c8d74802b Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 17:48:03 -0400 Subject: [PATCH 4/7] fix(channels): drop 'historically' from JSDoc and tighten chrome-extension else-if in server.ts - assistant/AGENTS.md: comments describe current state, not history - server.ts: scope the non-interactive host-browser restore branch to interfaces that specifically only support host_browser (not macos, which hits the interactive branch) --- assistant/src/channels/types.ts | 6 +++--- assistant/src/daemon/server.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/assistant/src/channels/types.ts b/assistant/src/channels/types.ts index 5b0af60810e..fd3665e7018 100644 --- a/assistant/src/channels/types.ts +++ b/assistant/src/channels/types.ts @@ -93,8 +93,8 @@ export function isInteractiveInterface(id: InterfaceId): boolean { /** * Host proxy capabilities that an interface can support. The macOS client - * historically supports all four; the chrome-extension interface only - * supports host_browser (via the Chrome DevTools Protocol proxy). + * supports all four; the chrome-extension interface only supports + * host_browser (via the Chrome DevTools Protocol proxy). */ export type HostProxyCapability = | "host_bash" @@ -107,7 +107,7 @@ export type HostProxyCapability = * * The no-arg form `supportsHostProxy(id)` asks "does this interface support * the full desktop host proxy set?" — it returns `true` only for macOS, which - * historically supports all four capabilities. It returns `false` for + * supports all four capabilities. It returns `false` for * chrome-extension because chrome-extension only supports `host_browser`, * and the no-arg form is the gate that legacy desktop-only call sites use * (e.g. preactivating computer-use, restoring all four proxies in the drain diff --git a/assistant/src/daemon/server.ts b/assistant/src/daemon/server.ts index 793e010a8c2..2daa4f19586 100644 --- a/assistant/src/daemon/server.ts +++ b/assistant/src/daemon/server.ts @@ -1233,6 +1233,7 @@ export class DaemonServer { conversation.updateClient(onEvent, false); } else if ( persistInterface && + !supportsHostProxy(persistInterface) && supportsHostProxy(persistInterface, "host_browser") ) { conversation.hostBrowserProxy?.updateSender(onEvent, true); From d45087511c6b99bb418c1430d036dbef40c684df Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 17:54:31 -0400 Subject: [PATCH 5/7] test: add restoreBrowserProxyAvailability to Conversation mocks Two test files use object-literal mocks for Conversation that need the new method so they don't throw TypeError at the new call site in handleSendMessage. --- .../src/__tests__/conversation-routes-disk-view.test.ts | 1 + .../__tests__/conversation-routes-guardian-reply.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/assistant/src/__tests__/conversation-routes-disk-view.test.ts b/assistant/src/__tests__/conversation-routes-disk-view.test.ts index 0659f797f83..776a7f20f87 100644 --- a/assistant/src/__tests__/conversation-routes-disk-view.test.ts +++ b/assistant/src/__tests__/conversation-routes-disk-view.test.ts @@ -188,6 +188,7 @@ function createFakeConversation(conversationId: string): Conversation { setHostCuProxy(this: { hostCuProxy: unknown }, proxy: unknown) { this.hostCuProxy = proxy; }, + restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, hasAnyPendingConfirmation: () => false, hasPendingConfirmation: () => false, diff --git a/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts b/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts index b96a39c8763..1936729a72d 100644 --- a/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts +++ b/assistant/src/__tests__/conversation-routes-guardian-reply.test.ts @@ -173,6 +173,7 @@ describe("handleSendMessage canonical guardian reply interception", () => { setHostBrowserProxy: () => {}, setHostFileProxy: () => {}, setHostCuProxy: () => {}, + restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; @@ -251,6 +252,7 @@ describe("handleSendMessage canonical guardian reply interception", () => { setHostBrowserProxy: () => {}, setHostFileProxy: () => {}, setHostCuProxy: () => {}, + restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; @@ -325,6 +327,7 @@ describe("handleSendMessage canonical guardian reply interception", () => { setHostBrowserProxy: () => {}, setHostFileProxy: () => {}, setHostCuProxy: () => {}, + restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; @@ -403,6 +406,7 @@ describe("handleSendMessage canonical guardian reply interception", () => { setHostBrowserProxy: () => {}, setHostFileProxy: () => {}, setHostCuProxy: () => {}, + restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; @@ -477,6 +481,7 @@ describe("handleSendMessage canonical guardian reply interception", () => { setHostBrowserProxy: () => {}, setHostFileProxy: () => {}, setHostCuProxy: () => {}, + restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; @@ -545,6 +550,7 @@ describe("handleSendMessage canonical guardian reply interception", () => { setHostBrowserProxy: () => {}, setHostFileProxy: () => {}, setHostCuProxy: () => {}, + restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; @@ -615,6 +621,7 @@ describe("handleSendMessage canonical guardian reply interception", () => { setHostBrowserProxy: () => {}, setHostFileProxy: () => {}, setHostCuProxy: () => {}, + restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; @@ -686,6 +693,7 @@ describe("handleSendMessage canonical guardian reply interception", () => { setHostBrowserProxy: () => {}, setHostFileProxy: () => {}, setHostCuProxy: () => {}, + restoreBrowserProxyAvailability: () => {}, addPreactivatedSkillId: () => {}, } as unknown as import("../daemon/conversation.js").Conversation; From 833d9e970f67db42ecb36e95201588c3b8c350f6 Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 17:59:18 -0400 Subject: [PATCH 6/7] fix(routes): optional-chain restoreBrowserProxyAvailability for test mocks --- assistant/src/runtime/routes/conversation-routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assistant/src/runtime/routes/conversation-routes.ts b/assistant/src/runtime/routes/conversation-routes.ts index b0c31ceb029..05bfdd4fdd2 100644 --- a/assistant/src/runtime/routes/conversation-routes.ts +++ b/assistant/src/runtime/routes/conversation-routes.ts @@ -1225,7 +1225,7 @@ export async function handleSendMessage( // chrome-extension turn can drive the browser via CDP without leaking // host_bash/host_file tool availability into tool gating. if (supportsHostProxy(sourceInterface, "host_browser")) { - conversation.restoreBrowserProxyAvailability(); + conversation.restoreBrowserProxyAvailability?.(); } // ── Canned first-greeting fast path ── From ae8e7dfa3765ab692a8faf1208f473acae0c23ce Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Tue, 7 Apr 2026 18:03:03 -0400 Subject: [PATCH 7/7] test: allowlist chrome-extension-native-host in gateway-only guard The native messaging helper intentionally POSTs to the local daemon's /v1/browser-extension-pair endpoint on 127.0.0.1 to mint capability tokens for the extension; it's a bootstrap path that cannot and should not go through the gateway. Add it to the guard-test allowlist. --- assistant/src/__tests__/gateway-only-guard.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assistant/src/__tests__/gateway-only-guard.test.ts b/assistant/src/__tests__/gateway-only-guard.test.ts index 37d741c0c6b..b3cf1834d10 100644 --- a/assistant/src/__tests__/gateway-only-guard.test.ts +++ b/assistant/src/__tests__/gateway-only-guard.test.ts @@ -35,6 +35,8 @@ const ALLOWLIST = new Set([ // --- Chrome extension (local relay communication, not gateway API consumption) --- "clients/chrome-extension/background/worker.ts", "clients/chrome-extension/popup/popup.ts", + // --- Chrome extension native messaging helper (local daemon pair endpoint, by design) --- + "clients/chrome-extension-native-host/src/index.ts", // --- Documentation and comments that mention the port for explanatory purposes --- "AGENTS.md", // documents the gateway-only rule itself