diff --git a/assistant/src/__tests__/conversation-confirmation-signals.test.ts b/assistant/src/__tests__/conversation-confirmation-signals.test.ts index 4e6ef2e12af..5d80952357f 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,77 @@ 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 (interactive desktop 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("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 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); + + // 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/__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; 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 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..fd3665e7018 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,37 @@ 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 + * 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. + * + * The no-arg form `supportsHostProxy(id)` asks "does this interface support + * the full desktop host proxy set?" — it returns `true` only for macOS, which + * 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, + 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..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,10 +313,27 @@ 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 - // 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/conversation.ts b/assistant/src/daemon/conversation.ts index 8f16f6a75f4..6f68b8baac9 100644 --- a/assistant/src/daemon/conversation.ts +++ b/assistant/src/daemon/conversation.ts @@ -545,6 +545,27 @@ 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. + * + * 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 { + this.hostBrowserProxy?.updateSender(this.sendToClient, true); + } + setSubagentAllowedTools(tools: Set | undefined): void { this.subagentAllowedTools = tools; } 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..2daa4f19586 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); @@ -1208,8 +1218,25 @@ 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 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) { conversation.updateClient(onEvent, false); + } else if ( + persistInterface && + !supportsHostProxy(persistInterface) && + supportsHostProxy(persistInterface, "host_browser") + ) { + 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 cef41d5e281..05bfdd4fdd2 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,20 +1198,35 @@ 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"); + // 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