diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index 926afd29ce..be63a7d46c 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -381,6 +381,10 @@ export async function createAgentRpcClient( const context = contextMap.get(param.contextId); return context.reloadAgentSchema(); }, + notifyReadinessChanged: async (param: { contextId: number }) => { + const context = contextMap.get(param.contextId); + return context.notifyReadinessChanged(); + }, storageRead: async (param: { contextId: number; session: boolean; diff --git a/ts/packages/agentRpc/src/server.ts b/ts/packages/agentRpc/src/server.ts index 854e794dab..ae011f5729 100644 --- a/ts/packages/agentRpc/src/server.ts +++ b/ts/packages/agentRpc/src/server.ts @@ -636,6 +636,11 @@ export function createAgentRpcServer( contextId, }); }, + notifyReadinessChanged: async (): Promise => { + return rpc.invoke("notifyReadinessChanged", { + contextId, + }); + }, }; } diff --git a/ts/packages/agentRpc/src/types.ts b/ts/packages/agentRpc/src/types.ts index 15c58cc0b6..9bac5a87bc 100644 --- a/ts/packages/agentRpc/src/types.ts +++ b/ts/packages/agentRpc/src/types.ts @@ -159,6 +159,7 @@ export type AgentContextInvokeFunctions = { }) => Promise; indexes: (param: { contextId: number; type: string }) => Promise; reloadAgentSchema: (param: { contextId: number }) => Promise; + notifyReadinessChanged: (param: { contextId: number }) => Promise; popupQuestion: (param: { contextId: number; message: string; diff --git a/ts/packages/agentSdk/src/agentInterface.ts b/ts/packages/agentSdk/src/agentInterface.ts index 9bab7d3f3d..652e6043b9 100644 --- a/ts/packages/agentSdk/src/agentInterface.ts +++ b/ts/packages/agentSdk/src/agentInterface.ts @@ -311,6 +311,18 @@ export interface SessionContext { // The dispatcher will call getDynamicSchema/getDynamicGrammar to get the updated content. reloadAgentSchema(): Promise; + // Notify the dispatcher that this agent's readiness state may have + // changed due to an external event (e.g. an extension client just + // connected, an OAuth token was refreshed). The dispatcher re-runs + // the agent's `checkReadiness` and updates its cache so the next + // pre-flight gate sees the fresh state — without the user having to + // run `@config agent refresh `. + // + // No-op for agents that don't implement `checkReadiness`. Best + // effort: errors are swallowed so a transient probe failure doesn't + // surface in the trigger path (the next refresh will retry). + notifyReadinessChanged(): Promise; + /** * Register a port this agent has just bound (typically with * `bind(0)` so the OS picks a free ephemeral port). The dispatcher diff --git a/ts/packages/agentServer/protocol/README.md b/ts/packages/agentServer/protocol/README.md index 108efe4eb5..154e8015c5 100644 --- a/ts/packages/agentServer/protocol/README.md +++ b/ts/packages/agentServer/protocol/README.md @@ -10,6 +10,12 @@ The fixed channel name for conversation lifecycle RPC is exported as `AgentServe export const AgentServerChannelName = "agent-server"; ``` +The fixed channel name for the read-only port discovery RPC is exported as `DiscoveryChannelName`: + +```typescript +export const DiscoveryChannelName = "discovery"; +``` + Session-namespaced channels (one pair per joined conversation) are constructed via helper functions: ```typescript @@ -67,6 +73,33 @@ getClientType(connectionId: string): string | undefined unregisterClient(connectionId: string): void ``` +## Discovery channel + +External clients (Chrome extension, VS Code extension, CLI) look up the live port of any in-process app-agent through a read-only RPC channel hosted at the well-known `discovery` channel name. The dispatcher's `PortRegistrar` is the source of truth; the channel only exposes a single `lookupPort` method: + +```typescript +export type DiscoveryInvokeFunctions = { + lookupPort: (param: { + agentName: string; + role?: string; + }) => Promise<{ port: number | null }>; +}; +``` + +`null` means "no allocation found, try again later" — clients should poll/back off rather than treat it as fatal. The well-known `agentName === "agent-server"` resolves the host's own listening port for clients that bootstrap from a different known port. + +To stay in lockstep across hosts, both the standalone `agentServer` process and the standalone Electron `shell` build their handler set from the shared factory: + +```typescript +import { createDiscoveryHandlers } from "@typeagent/agent-server-protocol"; + +createDiscoveryHandlers((agentName, role) => + portRegistrar.lookup(agentName, role), +); +``` + +Passing a lookup callback (rather than the `IPortRegistrar` itself) keeps this package free of an `agent-dispatcher` dependency. + --- ## Trademarks diff --git a/ts/packages/agentServer/protocol/src/index.ts b/ts/packages/agentServer/protocol/src/index.ts index a7648fe34d..0e56a2b470 100644 --- a/ts/packages/agentServer/protocol/src/index.ts +++ b/ts/packages/agentServer/protocol/src/index.ts @@ -10,6 +10,7 @@ export { AGENT_SERVER_DISCOVERY_NAME, DiscoveryChannelName, DiscoveryInvokeFunctions, + createDiscoveryHandlers, ConversationInfo, JoinConversationResult, UserIdentity, diff --git a/ts/packages/agentServer/protocol/src/protocol.ts b/ts/packages/agentServer/protocol/src/protocol.ts index 3522757313..e61d007a64 100644 --- a/ts/packages/agentServer/protocol/src/protocol.ts +++ b/ts/packages/agentServer/protocol/src/protocol.ts @@ -126,6 +126,30 @@ export type DiscoveryInvokeFunctions = { }) => Promise<{ port: number | null }>; }; +/** + * Build the read-only discovery RPC handler set from a lookup callback. + * + * Both the agent-server and the standalone Electron shell host this + * channel — the agent-server multiplexes it onto its main WS, the + * standalone shell stands up a dedicated WS for it. They share this + * factory so the wire-level behavior (including null-for-not-found + * normalization) stays in lockstep. + * + * The callback shape — rather than passing the `IPortRegistrar` + * directly — keeps this package free of an `agent-dispatcher` dep, + * which would otherwise create a downward dependency from the + * protocol-only package onto the dispatcher core. + */ +export function createDiscoveryHandlers( + lookup: (agentName: string, role?: string) => number | undefined, +): DiscoveryInvokeFunctions { + return { + lookupPort: async ({ agentName, role }) => ({ + port: lookup(agentName, role) ?? null, + }), + }; +} + /** Build the dispatcher channel name for a given conversation. */ export function getDispatcherChannelName(conversationId: string): string { return `dispatcher:${conversationId}`; diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index ccb94bba01..1f99281083 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -25,7 +25,7 @@ import { AGENT_SERVER_DEFAULT_PORT, AGENT_SERVER_DISCOVERY_NAME, DiscoveryChannelName, - DiscoveryInvokeFunctions, + createDiscoveryHandlers, DispatcherConnectOptions, UserIdentity, getDispatcherChannelName, @@ -448,23 +448,15 @@ async function main() { // on the same WS as agent-server so clients only need one // connection. Mutations to the registrar are NOT exposed // here — only agents themselves can register, via the - // in-process SessionContext.registerPort. - const discoveryFunctions: DiscoveryInvokeFunctions = { - lookupPort: async ({ agentName, role }) => { - // The agent-server's own port is registered as a - // real allocation under AGENT_SERVER_DISCOVERY_NAME - // / DEFAULT_ROLE (see registerSelfPort below), so - // no special-case is needed here — the lookup just - // works for both well-known and agent-defined - // names. - const port = portRegistrar.lookup(agentName, role); - return { port: port ?? null }; - }, - }; + // in-process SessionContext.registerPort. The handler + // factory is shared with the standalone Electron shell so + // both hosts speak the same protocol byte-for-byte. createRpc( "agent-server:discovery", channelProvider.createChannel(DiscoveryChannelName), - discoveryFunctions, + createDiscoveryHandlers((agentName, role) => + portRegistrar.lookup(agentName, role), + ), ); }, ); diff --git a/ts/packages/agents/browser/README.md b/ts/packages/agents/browser/README.md index 42697f3269..849869b50a 100644 --- a/ts/packages/agents/browser/README.md +++ b/ts/packages/agents/browser/README.md @@ -32,9 +32,19 @@ To build the browser extension, run `pnpm run build` in this folder. For debug s ### Agent WebSocket Server -The browser agent exposes a WebSocket server (`AgentWebSocketServer`) on port 8081. Two types of clients connect to it: - -- **Chrome extension** (`src/extension/serviceWorker/websocket.ts`) — connects from the browser's service worker using `chrome.runtime.id` as its client ID. +The browser agent exposes a WebSocket server (`AgentWebSocketServer`) on a +port assigned dynamically by the OS at bind time. The actual port is +published to the host's `PortRegistrar` under `(browser, default)` and +discovered by external clients via the discovery channel hosted at +`ws://localhost:8999/` (default). Both supported hosts publish this +channel: the standalone `agentServer` process and the standalone +Electron `shell` (which hosts an in-process discovery WS so the same +extension config works against either host). To pin the port for +debugging, set `BROWSER_WEBSOCKET_PORT=` before launching the host. + +Two types of clients connect to the browser agent: + +- **Chrome extension** (`src/extension/serviceWorker/websocket.ts`) — connects from the browser's service worker using `chrome.runtime.id` as its client ID. Calls `discoverPort("browser", "default")` to look up the live port before connecting. - **Inline browser** (`packages/shell/src/main/browserIpc.ts`) — connects from the Electron shell using `inlineBrowser` as its client ID. #### Connection URL format @@ -42,7 +52,7 @@ The browser agent exposes a WebSocket server (`AgentWebSocketServer`) on port 80 Every client embeds its identity in the WebSocket connection URL as query parameters: ``` -ws://localhost:8081?channel=browser&role=client&clientId=&sessionId= +ws://localhost:?channel=browser&role=client&clientId=&sessionId= ``` | Parameter | Description | diff --git a/ts/packages/agents/browser/package.json b/ts/packages/agents/browser/package.json index 9de7ca7b62..7b634fee9d 100644 --- a/ts/packages/agents/browser/package.json +++ b/ts/packages/agents/browser/package.json @@ -60,6 +60,7 @@ "@typeagent/agent-flows": "workspace:*", "@typeagent/agent-rpc": "workspace:*", "@typeagent/agent-sdk": "workspace:*", + "@typeagent/agent-server-client": "workspace:*", "@typeagent/agent-server-protocol": "workspace:*", "@typeagent/common-utils": "workspace:*", "@typeagent/config": "workspace:*", diff --git a/ts/packages/agents/browser/src/agent/agentWebSocketServer.mts b/ts/packages/agents/browser/src/agent/agentWebSocketServer.mts index 3ec4bc7dfc..39c159e45e 100644 --- a/ts/packages/agents/browser/src/agent/agentWebSocketServer.mts +++ b/ts/packages/agents/browser/src/agent/agentWebSocketServer.mts @@ -3,6 +3,8 @@ import { WebSocketServer, WebSocket } from "ws"; import { IncomingMessage } from "http"; +import { AddressInfo } from "net"; +import { isAllowedAgentOrigin } from "./originAllowlist.mjs"; import { createChannelProviderAdapter, type ChannelProviderAdapter, @@ -43,14 +45,93 @@ interface SessionHandlers { } export class AgentWebSocketServer { - private server: WebSocketServer; private clients = new Map>(); private sessionHandlers = new Map(); - constructor(port: number = 8081) { - this.server = new WebSocketServer({ port }); + /** + * @param server The underlying ws server, already bound and listening. + * @param port The actually bound port (OS-assigned when the caller + * passed 0). + * + * Construction is private — use {@link AgentWebSocketServer.start} + * so callers always get a server that is guaranteed to be bound + * before they read {@link port} or pass it to the registrar. + */ + private constructor( + private readonly server: WebSocketServer, + public readonly port: number, + ) { this.setupHandlers(); - debug(`Agent WebSocket server started on port ${port}`); + debug(`Agent WebSocket server listening on port ${port}`); + } + + /** + * Bind a new server on `port`. Resolves only after the + * `listening` event so callers can synchronously read + * {@link port}; rejects on the first `error` event so bind + * failures (EADDRINUSE under fixed-port overrides) surface + * loudly instead of being swallowed by an attached error + * handler. + * + * Pass `0` to let the OS pick a free ephemeral port; the + * actual port is then available via {@link port}. + * + * Origin allowlist is enforced via `verifyClient`: see + * `isAllowedAgentOrigin` for the policy. Connections from + * disallowed Origins are rejected with HTTP 403 before any + * `connection` event fires. + */ + public static start(port: number = 0): Promise { + return new Promise((resolve, reject) => { + const server = new WebSocketServer({ + port, + verifyClient: (info, cb) => { + const origin = info.origin || info.req.headers.origin; + if (isAllowedAgentOrigin(origin)) { + cb(true); + } else { + debug( + `Rejecting WebSocket upgrade from disallowed Origin: ${origin}`, + ); + cb(false, 403, "Origin not allowed"); + } + }, + }); + let settled = false; + const onError = (error: Error) => { + if (settled) { + debug("Server error after listening:", error); + return; + } + settled = true; + server.removeListener("listening", onListening); + debug("Server bind error:", error); + reject(error); + }; + const onListening = () => { + if (settled) return; + settled = true; + server.removeListener("error", onError); + const address = server.address() as AddressInfo | null; + if (!address || typeof address === "string") { + server.close(); + reject( + new Error( + "ws server.address() did not return an AddressInfo", + ), + ); + return; + } + // Re-attach a permanent error handler so post-listen errors + // are logged rather than crashing the process. + server.on("error", (error) => { + debug("Server error:", error); + }); + resolve(new AgentWebSocketServer(server, address.port)); + }; + server.once("error", onError); + server.once("listening", onListening); + }); } /** @@ -144,10 +225,6 @@ export class AgentWebSocketServer { this.server.on("connection", (ws: WebSocket, req: IncomingMessage) => { this.handleNewConnection(ws, req); }); - - this.server.on("error", (error) => { - console.error(`Agent WebSocket server error:`, error); - }); } private handleNewConnection(ws: WebSocket, req: IncomingMessage): void { @@ -469,8 +546,37 @@ export class AgentWebSocketServer { return false; } - public stop(): void { - this.server.close(); - debug("Agent WebSocket server stopped"); + /** + * Close all client connections and the underlying server. + * Resolves when the server has fully released its port — important + * for a rapid disable→enable cycle under a fixed-port override + * (`BROWSER_WEBSOCKET_PORT`), where a synchronous return would race + * the new bind into EADDRINUSE. + * + * Iterates every session's client map and closes each `WebSocket` + * before awaiting `server.close()`. Without this, a client whose + * session was never registered (connected before `registerSession` + * could fire) would survive `server.close()` waiting on the underlying + * socket. + */ + public close(): Promise { + debug("Closing AgentWebSocketServer"); + for (const sessionMap of this.clients.values()) { + for (const client of sessionMap.values()) { + if (client.channelProvider) { + client.channelProvider.notifyDisconnected(); + } + try { + client.socket.close(); + } catch { + // Already closed or never opened. + } + } + } + this.clients.clear(); + this.sessionHandlers.clear(); + return new Promise((resolve) => { + this.server.close(() => resolve()); + }); } } diff --git a/ts/packages/agents/browser/src/agent/browserActionHandler.mts b/ts/packages/agents/browser/src/agent/browserActionHandler.mts index ba790351d1..5518d8056e 100644 --- a/ts/packages/agents/browser/src/agent/browserActionHandler.mts +++ b/ts/packages/agents/browser/src/agent/browserActionHandler.mts @@ -148,8 +148,152 @@ const debugClientRouting = registerDebug("typeagent:browser:client-routing"); let _webFlowStore: any | undefined; let _webFlowStoreInitializing: Promise | undefined; -// Module-level singleton — one WebSocket server per agent process, shared across all session contexts. -const _agentWebSocketServer = new AgentWebSocketServer(8081); +// Shared WebSocket server that bridges this browser agent to the Chrome / +// Edge extension and the Electron shell's inline browser. Created on first +// session-enable, closed when the last session disables. Storing it +// per-session caused issues when an action ran on a different session than +// the one that originally created the server, and also masked EADDRINUSE +// failures from a second bind attempt on the configured port. +// +// Port allocation: by default the OS picks a free ephemeral port (port=0). +// Each session that uses the shared server registers it under its own +// `sessionContextId`, so the PortRegistrar's `closeSessionContext` backstop +// auto-releases per-session entries and `lookup("browser")` keeps returning +// the shared port as long as ≥1 session has it enabled. +// `BROWSER_WEBSOCKET_PORT` remains an explicit override (useful for pinning +// the port when debugging or when an external client expects a known +// address). +let sharedBrowserServer: AgentWebSocketServer | undefined; +let sharedStartingPromise: Promise | undefined; +let sharedClosingPromise: Promise | undefined; +let sharedBrowserRefCount = 0; + +// Returns the explicit port override (BROWSER_WEBSOCKET_PORT) if set and +// well-formed, else undefined. Useful for pinning the port when debugging +// or when an external client expects a known address. +function resolveBrowserPortOverride( + env: NodeJS.ProcessEnv, +): number | undefined { + const raw = env.BROWSER_WEBSOCKET_PORT; + if (raw === undefined) return undefined; + const n = parseInt(raw, 10); + if (Number.isFinite(n) && n > 0) return n; + return undefined; +} + +// Bind hint for the shared server. Returns the explicit override if +// BROWSER_WEBSOCKET_PORT is set; otherwise 0 so the OS picks a free port +// and the registrar/discovery channel publishes it. +// +// Note: we only validate the *shape* of the env var here (numeric, > 0). +// If the caller asks for a specific port and the OS can't bind it +// (EADDRINUSE), `AgentWebSocketServer.start()` rejects with that error +// and the schema-enable fails loudly — we deliberately do NOT silently +// fall back to an OS-assigned port, since the user explicitly asked for +// a specific one. +function getBrowserBindPort(): number { + const raw = process.env["BROWSER_WEBSOCKET_PORT"]; + if (raw === undefined) return 0; + const n = parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0) { + debug( + `Ignoring malformed BROWSER_WEBSOCKET_PORT=${raw}; using OS-assigned port instead`, + ); + return 0; + } + return n; +} + +// Returns the port the browser agent's WS server is/will be reachable on, +// for display in readiness/setup messaging. Two phases: +// - After bind: `getSharedBrowserPort()` returns the actual bound port +// (OS-assigned by default, or `BROWSER_WEBSOCKET_PORT` if set). +// - Before bind: no live port exists, so we fall back to the static +// prediction from `BROWSER_WEBSOCKET_PORT` if set, else `undefined`. +export function getKnownBrowserPort(): number | undefined { + return getSharedBrowserPort() ?? resolveBrowserPortOverride(process.env); +} + +// Exposed for readiness/test introspection. Undefined when the shared +// server isn't bound yet, otherwise the actual bound port. +export function getSharedBrowserPort(): number | undefined { + return sharedBrowserServer?.port; +} + +// Start (or attach to an in-flight start of) the shared WebSocket server. +// Concurrent enables from different sessions can race; serialize via +// sharedStartingPromise so only one bind attempt is in flight. +async function ensureSharedBrowserServer(): Promise { + // If a previous teardown is still releasing the port (matters under + // BROWSER_WEBSOCKET_PORT override), await it before binding again. + if (sharedClosingPromise !== undefined) { + await sharedClosingPromise; + } + if (sharedBrowserServer !== undefined) { + return sharedBrowserServer; + } + if (sharedStartingPromise !== undefined) { + return sharedStartingPromise; + } + sharedStartingPromise = (async () => { + try { + const server = + await AgentWebSocketServer.start(getBrowserBindPort()); + sharedBrowserServer = server; + return server; + } finally { + sharedStartingPromise = undefined; + } + })(); + return sharedStartingPromise; +} + +// Per-session cleanup, idempotent and safe to call from both the disable +// path (updateBrowserContext(false, ...)) and closeBrowserContext. Releases +// the per-session port registration, unregisters the session from the +// shared server, decrements the shared refcount, and closes the server when +// the last session disables. +async function cleanupBrowserSession( + agentContext: BrowserActionContext, +): Promise { + if (!agentContext.browserSchemaEnabled) { + // Either never enabled or already cleaned up. Both paths are + // benign — bail without touching shared state. + return; + } + agentContext.browserSchemaEnabled = false; + + const server = agentContext.agentWebSocketServer; + agentContext.agentWebSocketServer = undefined; + if (server) { + server.unregisterSession(agentContext.sessionId); + } + + // Release this session's registration before potentially closing the + // server. Release is idempotent and a no-op if already released by + // the closeSessionContext backstop. + agentContext.portRegistration?.release(); + agentContext.portRegistration = undefined; + + // Drop any session-scoped clients that hold a server reference so + // they don't outlive the shared server. + if (agentContext.externalBrowserControl) { + agentContext.externalBrowserControl.dispose(); + agentContext.externalBrowserControl = undefined; + } + + sharedBrowserRefCount = Math.max(0, sharedBrowserRefCount - 1); + if (sharedBrowserRefCount === 0 && sharedBrowserServer) { + const sharedToClose = sharedBrowserServer; + sharedBrowserServer = undefined; + // Track the in-flight close so a rapid re-enable awaits port + // release under a fixed-port override. + sharedClosingPromise = sharedToClose.close().finally(() => { + sharedClosingPromise = undefined; + }); + await sharedClosingPromise; + } +} // Track retry counts for dynamic display requests const dynamicDisplayRetryCounters = new Map(); @@ -373,7 +517,10 @@ async function initializeBrowserContext( clientBrowserControl === undefined ? "extension" : "electron", index: undefined, localHostPort, - agentWebSocketServer: _agentWebSocketServer, + // Shared WebSocket server is created lazily on the first + // updateBrowserContext(true, ...) call so the bind happens with + // an active session in scope (required for registerPort) and so + // EADDRINUSE bubbles out of the schema-enable surface. choiceManager: new ChoiceManager(), resolverSettings: { searchResolver: true, @@ -449,10 +596,39 @@ async function updateBrowserContext( await createViewServiceHost(context); } - if (!context.agentContext.agentWebSocketServer) { - throw new Error( - "AgentWebSocketServer not initialized in browser context.", + if (context.agentContext.browserSchemaEnabled) { + // Already enabled for this session — nothing to do on the + // shared-server side. (Defensive: dispatcher should not + // re-fire enable for the same schema, but we want to be + // refcount-safe.) + debug( + "Browser schema already enabled; skipping shared-server bind", ); + return; + } + try { + const server = await ensureSharedBrowserServer(); + context.agentContext.agentWebSocketServer = server; + context.agentContext.browserSchemaEnabled = true; + // Per-session registration: the registrar allows multiple + // entries for `(browser, default)` across sessions and lookup + // returns the most recent, so each active session + // independently keeps the shared port discoverable. The + // backstop in closeSessionContext releases ours if disable + // is skipped. + context.agentContext.portRegistration = context.registerPort( + "default", + server.port, + ); + sharedBrowserRefCount++; + } catch (e) { + // Roll back per-session bookkeeping so a subsequent retry + // sees a clean slate. Don't touch shared module state — the + // bind itself failed, so we never incremented the refcount + // or registered. + context.agentContext.browserSchemaEnabled = false; + context.agentContext.agentWebSocketServer = undefined; + throw e; } const sessionId = context.agentContext.sessionId; @@ -481,11 +657,26 @@ async function updateBrowserContext( // effort — see recordClientSeen for the swallow-on-fail // contract. void recordClientSeen(context.instanceStorage); + // Push a readiness refresh so the dispatcher's cached + // state flips from "setup-required" (cached at agent + // init time, before the extension was running) to + // "ready" without the user having to invoke + // `@config agent refresh browser` after launching the + // browser. notifyReadinessChanged is best-effort and + // swallows errors internally — safe to fire-and-forget. + void context.notifyReadinessChanged(); }, onClientDisconnected: (client: BrowserClient) => { if (client.type === "extension") { debug(`Extension client disconnected: ${client.id}`); } + // Mirror onClientConnected: when the last client for + // this session goes away, the readiness state flips + // back to setup-required. Refresh the cache so the + // user gets the friendly "open your browser" message + // on the next action instead of a downstream RPC + // timeout from a stale "ready" cache. + void context.notifyReadinessChanged(); }, onWebAgentMessage: async (client: BrowserClient, data: any) => { if ( @@ -581,22 +772,28 @@ async function updateBrowserContext( // shut down service if (context.agentContext.browserProcess) { context.agentContext.browserProcess.kill(); + context.agentContext.browserProcess = undefined; } if (context.agentContext.viewProcess) { context.agentContext.viewProcess.kill(); + context.agentContext.viewProcess = undefined; + // Reset to OS-assigned so a subsequent re-enable forks + // server.mjs with arg "0" instead of the stale port. The + // killed child may still hold the old port for a brief + // window (SIGTERM is async on Windows), so re-binding the + // same port races with EADDRINUSE. + context.agentContext.localHostPort = 0; } + + await cleanupBrowserSession(context.agentContext); } } async function closeBrowserContext( context: SessionContext, ) { - if (context.agentContext.agentWebSocketServer) { - context.agentContext.agentWebSocketServer.unregisterSession( - context.agentContext.sessionId, - ); - } + await cleanupBrowserSession(context.agentContext); if (context.agentContext.browserProcess) { context.agentContext.browserProcess.kill(); context.agentContext.browserProcess = undefined; @@ -604,6 +801,7 @@ async function closeBrowserContext( if (context.agentContext.viewProcess) { context.agentContext.viewProcess.kill(); context.agentContext.viewProcess = undefined; + context.agentContext.localHostPort = 0; } } @@ -2280,6 +2478,11 @@ async function createViewServiceHost( }); } catch (e: any) { console.error(e); + // Synchronous fork failure (e.g. ENOENT for server.mjs, + // permissions error). Reset the cached port back to OS- + // assigned so a subsequent retry doesn't re-use a stale + // value — mirrors the disable/close paths. + context.agentContext.localHostPort = 0; resolve(undefined); } }, diff --git a/ts/packages/agents/browser/src/agent/browserActions.mts b/ts/packages/agents/browser/src/agent/browserActions.mts index c62ddde3eb..b766c3c0f1 100644 --- a/ts/packages/agents/browser/src/agent/browserActions.mts +++ b/ts/packages/agents/browser/src/agent/browserActions.mts @@ -23,8 +23,8 @@ export type BrowserActionContext = { clientBrowserControl?: BrowserControl | undefined; externalBrowserControl?: ExternalBrowserClient | undefined; useExternalBrowserControl: boolean; - preferredClientType?: "extension" | "electron"; - agentWebSocketServer?: AgentWebSocketServer; + preferredClientType?: "extension" | "electron" | undefined; + agentWebSocketServer?: AgentWebSocketServer | undefined; browserControl?: BrowserControl; currentClient?: BrowserClient; extractionClients?: Map; @@ -52,6 +52,14 @@ export type BrowserActionContext = { }; searchProviders: SearchProvider[]; activeSearchProvider: SearchProvider; + // Handle returned by sessionContext.registerPort. Released on + // updateBrowserContext(false, ...) or closeAgentContext (the + // closeSessionContext backstop also releases it if both are skipped). + portRegistration?: { release: () => void } | undefined; + // Tracks whether the "browser" schema is currently enabled for this + // session — gates the shared-server refcount accounting so a redundant + // enable/disable doesn't double-count. + browserSchemaEnabled?: boolean | undefined; }; export function getBrowserControl(agentContext: BrowserActionContext) { diff --git a/ts/packages/agents/browser/src/agent/originAllowlist.mts b/ts/packages/agents/browser/src/agent/originAllowlist.mts new file mode 100644 index 0000000000..21c5c701ce --- /dev/null +++ b/ts/packages/agents/browser/src/agent/originAllowlist.mts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Origin allowlist for the browser agent's WebSocket server. + * + * Allowed: + * - `chrome-extension://...` and `moz-extension://...` (the typeagent + * Chrome / Edge browser extensions). + * - `http(s)://localhost(:port)`, `http(s)://127.0.0.1(:port)`, and + * `http(s)://[::1](:port)` (the Electron shell's inline browser, + * plus loopback dev clients on either IPv4 or IPv6). + * - **No Origin header** — Node `ws` clients (and any non-browser caller + * that hits the bridge over loopback) don't send Origin. The bridge + * binds to localhost, so this is loopback-restricted at the OS level. + * + * Anything else is rejected with HTTP 403 before the `connection` event + * fires. Per design §4.2, every per-agent listener migrated to the + * PortRegistrar must gate Origin to keep ephemeral ports from being + * dialed by arbitrary web pages on the same host. + */ +export function isAllowedAgentOrigin(origin: string | undefined): boolean { + if (origin === undefined || origin === "" || origin === "null") { + // No Origin header: legitimate for Node `ws` clients. + return true; + } + if ( + origin.startsWith("chrome-extension://") || + origin.startsWith("moz-extension://") + ) { + return true; + } + try { + const u = new URL(origin); + if (u.protocol !== "http:" && u.protocol !== "https:") { + return false; + } + // Node's URL parser preserves IPv6 brackets in `hostname` + // (e.g. `new URL("http://[::1]:8080").hostname === "[::1]"`), + // so match the bracketed form. + return ( + u.hostname === "localhost" || + u.hostname === "127.0.0.1" || + u.hostname === "[::1]" + ); + } catch { + return false; + } +} diff --git a/ts/packages/agents/browser/src/agent/readiness.mts b/ts/packages/agents/browser/src/agent/readiness.mts index ab5392dcdf..f1a3bb4244 100644 --- a/ts/packages/agents/browser/src/agent/readiness.mts +++ b/ts/packages/agents/browser/src/agent/readiness.mts @@ -4,8 +4,11 @@ // Readiness wiring for the browser agent. // // Architecture note: like the code agent, the browser agent IS the -// WebSocket server (port 8081, shared across sessions as a process -// singleton). "Ready" can mean two distinct things: +// WebSocket server (port assigned dynamically by the OS at bind time +// and published via the PortRegistrar; can be pinned with the +// BROWSER_WEBSOCKET_PORT env var for debugging). The server is a +// process singleton shared across sessions. "Ready" can mean two +// distinct things: // // 1. Electron-shell host mode — the host injects an in-process // BrowserControl via AppAgentInitSettings.options. The agent talks diff --git a/ts/packages/agents/browser/src/agent/websiteMemory.mts b/ts/packages/agents/browser/src/agent/websiteMemory.mts index 45fc4bf52e..eafd925c56 100644 --- a/ts/packages/agents/browser/src/agent/websiteMemory.mts +++ b/ts/packages/agents/browser/src/agent/websiteMemory.mts @@ -169,6 +169,7 @@ export async function resolveURLWithHistory( sessionContextId: "websiteMemory-mock", indexes: async () => [], reloadAgentSchema: async () => {}, + notifyReadinessChanged: async () => {}, }; // Use searchWebMemories with URL resolution optimized parameters diff --git a/ts/packages/agents/browser/src/extension/serviceWorker/index.ts b/ts/packages/agents/browser/src/extension/serviceWorker/index.ts index b2c4a29a76..fc853a1efb 100644 --- a/ts/packages/agents/browser/src/extension/serviceWorker/index.ts +++ b/ts/packages/agents/browser/src/extension/serviceWorker/index.ts @@ -276,15 +276,18 @@ function setupEventListeners(): void { // Storage changes chrome.storage.onChanged.addListener((changes, namespace) => { - if (namespace === "sync" && changes.websocketHost) { + if (namespace === "sync" && changes.agentServerHost) { console.log( - "WebSocket host changed:", - changes.websocketHost.newValue, + "Agent-server host changed:", + changes.agentServerHost.newValue, ); const webSocket = getWebSocket(); if (webSocket) { - // close the socket to force reconnect + // close the socket to force reconnect against the new + // agent-server URL. settings are re-read on every + // createWebSocket() call so no cache invalidation is + // needed here. try { webSocket.close(); } catch (error) { @@ -325,7 +328,13 @@ function setupEventListeners(): void { debugWebAgentProxy("Web page connected:", url); const handler = async (event: MessageEvent) => { const message = event.data; - const data = JSON.parse(message); + let data; + try { + data = JSON.parse(message); + } catch { + // Non-JSON or non-string payload — not a webAgent message. + return; + } if (isWebAgentMessageFromDispatcher(data)) { debugWebAgentProxy(`Dispatcher -> WebAgent (${url})`, data); port.postMessage(data); diff --git a/ts/packages/agents/browser/src/extension/serviceWorker/storage.ts b/ts/packages/agents/browser/src/extension/serviceWorker/storage.ts index db4e79b174..f306f47eb0 100644 --- a/ts/packages/agents/browser/src/extension/serviceWorker/storage.ts +++ b/ts/packages/agents/browser/src/extension/serviceWorker/storage.ts @@ -57,12 +57,16 @@ export async function clearRecordedActions(): Promise { } /** - * Gets settings from storage - * @returns The settings + * Gets settings from storage. + * + * `agentServerHost` is the URL of the agent-server's discovery / + * dispatcher endpoint (default `ws://localhost:8999/`). The browser + * agent's actual WebSocket port is discovered dynamically through + * that endpoint — we deliberately do NOT cache or expose a per-agent + * port here, because the registrar may assign different ports across + * restarts of the host process. */ export async function getSettings(): Promise> { - const settings = await chrome.storage.sync.get({ - websocketHost: "ws://localhost:8081/", - }); + const settings = await chrome.storage.sync.get(["agentServerHost"]); return settings; } diff --git a/ts/packages/agents/browser/src/extension/serviceWorker/types.ts b/ts/packages/agents/browser/src/extension/serviceWorker/types.ts index 3213495e1c..5213a3e57e 100644 --- a/ts/packages/agents/browser/src/extension/serviceWorker/types.ts +++ b/ts/packages/agents/browser/src/extension/serviceWorker/types.ts @@ -18,7 +18,7 @@ export interface BoundingBox { export interface Settings { [key: string]: any; - websocketHost?: string; + agentServerHost?: string; } export interface HTMLFragment { diff --git a/ts/packages/agents/browser/src/extension/serviceWorker/websocket.ts b/ts/packages/agents/browser/src/extension/serviceWorker/websocket.ts index 5212f412b3..0a899f3c9c 100644 --- a/ts/packages/agents/browser/src/extension/serviceWorker/websocket.ts +++ b/ts/packages/agents/browser/src/extension/serviceWorker/websocket.ts @@ -8,6 +8,8 @@ import { type ChannelProviderAdapter, } from "@typeagent/agent-rpc/channel"; import { createRpc } from "@typeagent/agent-rpc/rpc"; +import { discoverPort } from "@typeagent/agent-server-client/discovery"; +import { AGENT_SERVER_DEFAULT_URL } from "@typeagent/agent-server-protocol"; import { createExternalBrowserServer } from "./externalBrowserControlServer"; import type { BrowserAgentInvokeFunctions, @@ -20,10 +22,14 @@ const debugWebSocket = registerDebug("typeagent:browser:ws"); const debugWebSocketError = registerDebug("typeagent:browser:ws:error"); let webSocket: WebSocket | undefined; -let settings: Record; let connectionInProgress: boolean = false; let channelProvider: ChannelProviderAdapter | undefined; let agentRpc: any | undefined; +// Module-level guard so concurrent reconnect requests share a single +// retry interval. Without this each `webSocket.onclose` triggers a +// fresh `setInterval`, leaking timers and causing exponential retry +// pressure under sustained connectivity loss. +let reconnectTimer: ReturnType | undefined; /** * Gets the agentRpc client for invoking agent-side operations. @@ -69,32 +75,82 @@ async function parseWebSocketData(data: any): Promise { return ""; } +/** + * Resolves the URL of the browser agent's WebSocket server by asking + * the agent-server's discovery channel for the live port. + * + * The agent-server URL itself is configurable via the `agentServerHost` + * extension setting (default `ws://localhost:8999/`). Returns + * `undefined` if the agent-server is unreachable or the browser agent + * isn't currently registered — caller treats both as "retry on the + * reconnect loop". + */ +async function resolveBrowserEndpoint( + sessionId: string, +): Promise { + // Always re-read settings here (no module-level cache). The previous + // implementation cached settings on first connect and never + // invalidated, so a user changing `agentServerHost` would still see + // the old endpoint until the service worker restarted. + const settings = await getSettings(); + const agentServerUrl = + (settings.agentServerHost && settings.agentServerHost.trim()) || + AGENT_SERVER_DEFAULT_URL; + + const result = await discoverPort("browser", "default", { + url: agentServerUrl, + }); + if (result.kind === "found") { + // Browser agent's WS server binds to the same host as the + // agent-server (single-process), so we reuse the host portion of + // agentServerUrl and swap in the discovered port. + try { + const u = new URL(agentServerUrl); + return `${u.protocol}//${u.hostname}:${result.port}/?channel=browser&role=client&clientId=${chrome.runtime.id}&sessionId=${sessionId}`; + } catch (e) { + debugWebSocketError("Invalid agentServerHost URL: %s", e); + return undefined; + } + } + if (result.kind === "not-registered") { + debugWebSocket( + "Browser agent not registered with agent-server at %s", + agentServerUrl, + ); + } else { + debugWebSocketError( + "Agent-server discovery unreachable at %s: %s", + agentServerUrl, + result.error.message, + ); + } + return undefined; +} + /** * Creates a new WebSocket connection */ export async function createWebSocket(): Promise { - if (!settings) { - settings = await getSettings(); + const settings = await getSettings(); + const sessionId = (settings.sessionId as string) ?? "default"; + const socketEndpoint = await resolveBrowserEndpoint(sessionId); + if (!socketEndpoint) { + return undefined; } - - let socketEndpoint = settings.websocketHost ?? "ws://localhost:8081/"; - - const sessionId = settings.sessionId ?? "default"; - socketEndpoint += `?channel=browser&role=client&clientId=${chrome.runtime.id}&sessionId=${sessionId}`; - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const webSocket = new WebSocket(socketEndpoint); - debugWebSocket("Connected to: " + socketEndpoint); + debugWebSocket("Connecting to: " + socketEndpoint); - webSocket.onopen = (event: Event) => { + webSocket.onopen = (_event: Event) => { debugWebSocket("websocket open"); resolve(webSocket); }; - webSocket.onmessage = (event: MessageEvent) => {}; - webSocket.onclose = (event: CloseEvent) => { + webSocket.onmessage = (_event: MessageEvent) => {}; + webSocket.onclose = (_event: CloseEvent) => { debugWebSocket("websocket connection closed"); resolve(undefined); }; - webSocket.onerror = (event: Event) => { + webSocket.onerror = (_event: Event) => { debugWebSocketError("websocket error"); resolve(undefined); }; @@ -107,7 +163,7 @@ export async function createWebSocket(): Promise { export async function ensureWebsocketConnected(): Promise< WebSocket | undefined > { - return new Promise(async (resolve, reject) => { + return new Promise(async (resolve, _reject) => { if (connectionInProgress) { debugWebSocket("Connection attempt already in progress, skipping"); resolve(webSocket); @@ -249,13 +305,23 @@ export function keepWebSocketAlive(webSocket: WebSocket): void { } /** - * Attempts to reconnect the WebSocket periodically + * Attempts to reconnect the WebSocket periodically. Singleton — repeated + * calls (e.g., from successive `onclose` handlers under flapping + * connectivity) reuse the same retry interval rather than each + * scheduling a fresh one. */ export function reconnectWebSocket(): void { - const connectionCheckIntervalId = setInterval(async () => { + if (reconnectTimer !== undefined) { + debugWebSocket("Reconnect interval already running"); + return; + } + reconnectTimer = setInterval(async () => { if (webSocket && webSocket.readyState === WebSocket.OPEN) { debugWebSocket("Clearing reconnect retry interval"); - clearInterval(connectionCheckIntervalId); + if (reconnectTimer !== undefined) { + clearInterval(reconnectTimer); + reconnectTimer = undefined; + } showBadgeHealthy(); broadcastConnectionStatus(true); } else { @@ -293,17 +359,3 @@ export function getWebSocket(): WebSocket | undefined { export function setWebSocket(socket: WebSocket | undefined): void { webSocket = socket; } - -/** - * Gets the current settings - */ -export function getCurrentSettings(): Record { - return settings; -} - -/** - * Sets the current settings - */ -export function setCurrentSettings(newSettings: Record): void { - settings = newSettings; -} diff --git a/ts/packages/agents/browser/src/extension/views/options.html b/ts/packages/agents/browser/src/extension/views/options.html index 890d556f04..e9367f20bf 100644 --- a/ts/packages/agents/browser/src/extension/views/options.html +++ b/ts/packages/agents/browser/src/extension/views/options.html @@ -39,15 +39,19 @@

TypeAgent Knowledge Settings

- +
- Specify the WebSocket host address for TypeAgent connection + WebSocket URL of the TypeAgent agent server. Leave blank to use + the default (ws://localhost:8999/). The browser + agent's port is discovered automatically.
diff --git a/ts/packages/agents/browser/src/extension/views/options.ts b/ts/packages/agents/browser/src/extension/views/options.ts index 09d61916ee..cda6f18df3 100644 --- a/ts/packages/agents/browser/src/extension/views/options.ts +++ b/ts/packages/agents/browser/src/extension/views/options.ts @@ -10,7 +10,7 @@ function getChromeRpc() { } interface ExtensionSettings { - websocketHost: string; + agentServerHost: string; defaultExtractionMode: "basic" | "content" | "full"; maxConcurrentExtractions: number; qualityThreshold: number; @@ -28,7 +28,7 @@ interface AIModelStatus { } const DEFAULT_SETTINGS: ExtensionSettings = { - websocketHost: "ws://localhost:8081/", + agentServerHost: "", defaultExtractionMode: "content", maxConcurrentExtractions: 3, qualityThreshold: 0.3, @@ -106,8 +106,8 @@ class EnhancedOptionsPage { // Update form fields ( - document.getElementById("websocketHost") as HTMLInputElement - ).value = this.settings.websocketHost; + document.getElementById("agentServerHost") as HTMLInputElement + ).value = this.settings.agentServerHost; ( document.getElementById( "maxConcurrentExtractions", @@ -240,20 +240,24 @@ class EnhancedOptionsPage { private async saveOptions(e: Event) { e.preventDefault(); - // Validate WebSocket URL - const websocketHost = ( - document.getElementById("websocketHost") as HTMLInputElement + // Validate WebSocket URL — blank means "use default agent-server + // URL" (the service worker falls back to AGENT_SERVER_DEFAULT_URL). + const agentServerHost = ( + document.getElementById("agentServerHost") as HTMLInputElement ).value.trim(); - if (!this.isValidWebSocketUrl(websocketHost)) { + if ( + agentServerHost !== "" && + !this.isValidWebSocketUrl(agentServerHost) + ) { this.showStatus( - "Please enter a valid WebSocket URL (ws:// or wss://)", + "Please enter a valid WebSocket URL (ws:// or wss://) or leave blank", "danger", ); return; } // Update settings - this.settings.websocketHost = websocketHost; + this.settings.agentServerHost = agentServerHost; // Get selected mode const selectedMode = document.querySelector( diff --git a/ts/packages/agents/browser/test/serviceWorker/websocket.test.ts b/ts/packages/agents/browser/test/serviceWorker/websocket.test.ts index ca7048107a..0422ac71d8 100644 --- a/ts/packages/agents/browser/test/serviceWorker/websocket.test.ts +++ b/ts/packages/agents/browser/test/serviceWorker/websocket.test.ts @@ -4,11 +4,28 @@ jest.mock("../../src/extension/serviceWorker/storage", () => ({ getSettings: jest.fn().mockImplementation(() => Promise.resolve({ - websocketHost: "ws://localhost:8080/", + // Empty agentServerHost → service worker uses + // AGENT_SERVER_DEFAULT_URL (ws://localhost:8999/) for the + // discovery channel, then dials the discovered port for the + // actual browser-agent connection. + agentServerHost: "", }), ), })); +// `discoverPort` lives in @typeagent/agent-server-client/discovery and +// transitively pulls in isomorphic-ws / Node WebSocket — neither of +// which loads cleanly under jsdom. Mock the helper to a synchronous +// stub so the websocket module under test can use it without spinning +// a real network call. +jest.mock("@typeagent/agent-server-client/discovery", () => ({ + discoverPort: jest + .fn() + .mockImplementation(() => + Promise.resolve({ kind: "found", port: 8080 }), + ), +})); + jest.mock("../../src/extension/serviceWorker/ui", () => ({ showBadgeError: jest.fn(), showBadgeHealthy: jest.fn(), @@ -45,7 +62,7 @@ describe("WebSocket Module", () => { }); describe("createWebSocket", () => { - it("should create a WebSocket connection", async () => { + it("should create a WebSocket connection on the discovered port", async () => { const createWebSocket = websocketModule.createWebSocket; jest.useRealTimers(); const socket = await createWebSocket(); @@ -71,19 +88,30 @@ describe("WebSocket Module", () => { describe("reconnectWebSocket", () => { it("should set up a reconnection interval", () => { - // Make sure setInterval is properly mocked jest.spyOn(global, "setInterval"); const reconnectWebSocket = websocketModule.reconnectWebSocket; reconnectWebSocket(); - // Verify setInterval was called expect(setInterval).toHaveBeenCalled(); - // Test if the callback works correctly - // by advancing timers and checking what happens jest.advanceTimersByTime(5000); }); + + it("should not schedule a second interval when called twice (singleton)", () => { + const setIntervalSpy = jest.spyOn(global, "setInterval"); + + const reconnectWebSocket = websocketModule.reconnectWebSocket; + reconnectWebSocket(); + reconnectWebSocket(); + reconnectWebSocket(); + + // Only the first call should have scheduled an interval — + // the singleton guard short-circuits the rest. This is the + // bug we fixed in PR 3 (was creating a fresh interval on + // every onclose). + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + }); }); describe("sendActionToAgent", () => { diff --git a/ts/packages/agents/browser/test/unit/agentWebSocketServer.test.ts b/ts/packages/agents/browser/test/unit/agentWebSocketServer.test.ts index 2937cbfa78..adcde8c398 100644 --- a/ts/packages/agents/browser/test/unit/agentWebSocketServer.test.ts +++ b/ts/packages/agents/browser/test/unit/agentWebSocketServer.test.ts @@ -3,17 +3,40 @@ jest.mock("ws", () => { const connectionHandlers: Function[] = []; - const mockWss = { + const errorHandlers: Function[] = []; + const listeningHandlers: Function[] = []; + let lastVerifyClient: any; + const mockWss: any = { on: jest.fn((event: string, handler: Function) => { if (event === "connection") connectionHandlers.push(handler); + if (event === "error") errorHandlers.push(handler); + if (event === "listening") listeningHandlers.push(handler); }), - close: jest.fn(), + once: jest.fn((event: string, handler: Function) => { + if (event === "connection") connectionHandlers.push(handler); + if (event === "error") errorHandlers.push(handler); + if (event === "listening") listeningHandlers.push(handler); + }), + removeListener: jest.fn(), + close: jest.fn((cb?: () => void) => { + if (cb) cb(); + }), + address: jest.fn(() => ({ port: 8081, family: "IPv4", address: "" })), _triggerConnection: (ws: any, req: any) => { connectionHandlers.forEach((h) => h(ws, req)); }, + _triggerListening: () => { + listeningHandlers.forEach((h) => h()); + }, + _getVerifyClient: () => lastVerifyClient, }; return { - WebSocketServer: jest.fn(() => mockWss), + WebSocketServer: jest.fn((opts: any) => { + lastVerifyClient = opts?.verifyClient; + // Auto-fire 'listening' on next tick so start() resolves. + setTimeout(() => mockWss._triggerListening(), 0); + return mockWss; + }), WebSocket: { OPEN: 1 }, __mockWss: mockWss, }; @@ -39,6 +62,7 @@ jest.mock("debug", () => { }); import { AgentWebSocketServer } from "../../src/agent/agentWebSocketServer.mjs"; +import { isAllowedAgentOrigin } from "../../src/agent/originAllowlist.mjs"; function makeMockSocket() { const handlers: Record = {}; @@ -80,14 +104,14 @@ describe("AgentWebSocketServer", () => { let server: AgentWebSocketServer; let wss: any; - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); - server = new AgentWebSocketServer(8081); + server = await AgentWebSocketServer.start(8081); wss = getWss(); }); - afterEach(() => { - server.stop(); + afterEach(async () => { + await server.close(); }); describe("same clientId in different sessions don't collide", () => { @@ -308,4 +332,96 @@ describe("AgentWebSocketServer", () => { expect(socket.close).toHaveBeenCalled(); }); }); + + describe("Origin allowlist (verifyClient)", () => { + // verifyClient is a synchronous gate the WebSocket server runs on + // every upgrade. Anything we reject here never fires `connection`. + function verify(origin: string | undefined): { + ok: boolean; + code?: number; + } { + const verifyClient = wss._getVerifyClient(); + let result: { ok: boolean; code?: number } = { ok: false }; + verifyClient( + { origin, req: { headers: { origin } } } as any, + (ok: boolean, code?: number) => { + result = { ok, code }; + }, + ); + return result; + } + + it("accepts chrome-extension Origin", () => { + expect(verify("chrome-extension://abc123")).toEqual({ ok: true }); + }); + + it("accepts localhost http Origin", () => { + expect(verify("http://localhost:5173")).toEqual({ ok: true }); + }); + + it("accepts undefined Origin (Node ws clients)", () => { + expect(verify(undefined)).toEqual({ ok: true }); + }); + + it("rejects arbitrary web Origin with 403", () => { + expect(verify("https://evil.example.com")).toEqual({ + ok: false, + code: 403, + }); + }); + }); + + describe("close() tears down tracked clients", () => { + it("closes every client across every session map", async () => { + // Fresh server so we can assert close behavior independently + // of the suite's afterEach. + const local = await AgentWebSocketServer.start(0); + local.registerSession("s1", {}); + local.registerSession("s2", {}); + const a = connectClient(wss, "ext1", "s1"); + const b = connectClient(wss, "ext2", "s2"); + await local.close(); + expect(a.close).toHaveBeenCalled(); + expect(b.close).toHaveBeenCalled(); + }); + }); +}); + +describe("isAllowedAgentOrigin", () => { + it("returns true for chrome-extension:// origins", () => { + expect(isAllowedAgentOrigin("chrome-extension://abc")).toBe(true); + }); + it("returns true for moz-extension:// origins", () => { + expect(isAllowedAgentOrigin("moz-extension://xyz")).toBe(true); + }); + it("returns true for localhost http(s) origins", () => { + expect(isAllowedAgentOrigin("http://localhost")).toBe(true); + expect(isAllowedAgentOrigin("http://localhost:1234")).toBe(true); + expect(isAllowedAgentOrigin("https://localhost:5173")).toBe(true); + expect(isAllowedAgentOrigin("http://127.0.0.1:8081")).toBe(true); + }); + it("returns true for IPv6 loopback origins", () => { + // Browsers running on IPv6-first networks may report the + // origin with the bracketed `[::1]` host. Important for the + // Electron shell's inline browser when bound to ::1. + expect(isAllowedAgentOrigin("http://[::1]")).toBe(true); + expect(isAllowedAgentOrigin("http://[::1]:8081")).toBe(true); + expect(isAllowedAgentOrigin("https://[::1]:5173")).toBe(true); + }); + it("returns true for missing/null Origin", () => { + expect(isAllowedAgentOrigin(undefined)).toBe(true); + expect(isAllowedAgentOrigin("")).toBe(true); + expect(isAllowedAgentOrigin("null")).toBe(true); + }); + it("rejects arbitrary http(s) origins", () => { + expect(isAllowedAgentOrigin("https://evil.example.com")).toBe(false); + expect(isAllowedAgentOrigin("http://attacker.test:80")).toBe(false); + }); + it("rejects file://, ftp:// and other non-http schemes", () => { + expect(isAllowedAgentOrigin("file://localhost/etc/passwd")).toBe(false); + expect(isAllowedAgentOrigin("ftp://localhost")).toBe(false); + }); + it("rejects malformed Origin strings", () => { + expect(isAllowedAgentOrigin("not a url")).toBe(false); + }); }); diff --git a/ts/packages/agents/code/src/codeAgentWebSocketServer.ts b/ts/packages/agents/code/src/codeAgentWebSocketServer.ts index 221bda1957..a0ae6e97d1 100644 --- a/ts/packages/agents/code/src/codeAgentWebSocketServer.ts +++ b/ts/packages/agents/code/src/codeAgentWebSocketServer.ts @@ -4,6 +4,7 @@ import { WebSocketServer, WebSocket } from "ws"; import { AddressInfo } from "net"; import registerDebug from "debug"; +import { isAllowedAgentOrigin } from "./originAllowlist.js"; const debug = registerDebug("typeagent:code:websocket"); @@ -42,7 +43,25 @@ export class CodeAgentWebSocketServer { */ public static start(port: number = 0): Promise { return new Promise((resolve, reject) => { - const server = new WebSocketServer({ port }); + const server = new WebSocketServer({ + port, + // Per design §4.2, gate every upgrade on Origin so a random + // web page on the same host can't dial the ephemeral port + // assigned by the OS. `verifyClient` is invoked + // synchronously before the `connection` event fires; + // rejected requests get HTTP 403. + verifyClient: (info, cb) => { + const origin = info.req.headers.origin as + | string + | undefined; + if (isAllowedAgentOrigin(origin)) { + cb(true); + } else { + debug(`Rejecting WS upgrade from origin ${origin}`); + cb(false, 403, "Origin not allowed"); + } + }, + }); let settled = false; const onError = (error: Error) => { if (settled) { diff --git a/ts/packages/agents/code/src/originAllowlist.ts b/ts/packages/agents/code/src/originAllowlist.ts new file mode 100644 index 0000000000..9f11379704 --- /dev/null +++ b/ts/packages/agents/code/src/originAllowlist.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Origin allowlist for the code agent's WebSocket server. + * + * Allowed: + * - `vscode-webview://...`, `vscode-file://...`, and + * `vscode-resource://...` (the VS Code extension host's + * sandboxed surfaces). + * - `http(s)://localhost(:port)`, `http(s)://127.0.0.1(:port)`, and + * `http(s)://[::1](:port)` (loopback dev clients on either IPv4 or + * IPv6). + * - **No Origin header** — Node `ws` clients (and the VS Code + * extension's own `ws` client) don't send Origin. The server + * binds to localhost, so this is loopback-restricted at the OS + * level. + * + * Anything else is rejected with HTTP 403 before the `connection` + * event fires. Per design §4.2, every per-agent listener migrated to + * the PortRegistrar must gate Origin to keep ephemeral ports from + * being dialed by arbitrary web pages on the same host. + * + * Kept in sync with `agents/browser/src/agent/originAllowlist.mts`; + * duplicated rather than shared because the policies differ in which + * extension scheme prefixes are accepted (Chrome/Firefox vs. VS Code). + */ +export function isAllowedAgentOrigin(origin: string | undefined): boolean { + if (origin === undefined || origin === "" || origin === "null") { + // No Origin header: legitimate for Node `ws` clients (the + // VS Code extension uses one). + return true; + } + if ( + origin.startsWith("vscode-webview://") || + origin.startsWith("vscode-file://") || + origin.startsWith("vscode-resource://") + ) { + return true; + } + try { + const u = new URL(origin); + if (u.protocol !== "http:" && u.protocol !== "https:") { + return false; + } + // Node's URL parser preserves IPv6 brackets in `hostname` + // (e.g. `new URL("http://[::1]:8080").hostname === "[::1]"`), + // so match the bracketed form. + return ( + u.hostname === "localhost" || + u.hostname === "127.0.0.1" || + u.hostname === "[::1]" + ); + } catch { + return false; + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts index 8a0327eeea..d942c43ef5 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts @@ -232,6 +232,17 @@ export function createSessionContext( async reloadAgentSchema(): Promise { await context.agents.reloadAgentSchema(name, context); }, + async notifyReadinessChanged(): Promise { + // Best-effort: swallow errors so an event-driven trigger + // (e.g. WebSocket onClientConnected) never throws into the + // event-emitter path. The cache will reconcile on the next + // refresh. + try { + await context.agents.refreshReadiness(name); + } catch { + // ignore + } + }, async validateGrammarPatterns( request: GrammarValidationRequest, ): Promise { diff --git a/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts b/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts index 5f9db9c0d8..b747e3fd5f 100644 --- a/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts @@ -196,6 +196,59 @@ describe("beginAgentThread", () => { }); }); +describe("notifyReadinessChanged", () => { + function makeContextWithAgents( + refreshImpl: (name: string) => Promise, + ) { + const refreshCalls: string[] = []; + const ctx = { + session: { + getSessionDirPath: () => undefined, + getConfig: () => ({}), + }, + storageProvider: { getStorage: () => ({}) as any }, + persistDir: undefined, + instanceDir: undefined, + commandLock: async (fn: any) => fn(), + agents: { + getTransientState: () => undefined, + getSharedLocalHostPort: async () => undefined, + setLocalHostPort: () => {}, + refreshReadiness: async (name: string) => { + refreshCalls.push(name); + return refreshImpl(name); + }, + }, + clientIO: { + notify: () => {}, + question: async () => 0, + }, + translatorCache: { clear: () => {} }, + lastActionSchemaName: undefined, + conversationManager: undefined, + } as any; + return { ctx, refreshCalls }; + } + + test("delegates to agents.refreshReadiness with the agent's own name", async () => { + const { ctx, refreshCalls } = makeContextWithAgents(async () => ({ + kind: "ready", + })); + const sc = createSessionContext("myAgent", {}, ctx, false, "nrc-1"); + await sc.notifyReadinessChanged!(); + expect(refreshCalls).toEqual(["myAgent"]); + }); + + test("swallows errors from refreshReadiness so event-driven callers do not throw", async () => { + const { ctx, refreshCalls } = makeContextWithAgents(async () => { + throw new Error("boom"); + }); + const sc = createSessionContext("myAgent", {}, ctx, false, "nrc-2"); + await expect(sc.notifyReadinessChanged!()).resolves.toBeUndefined(); + expect(refreshCalls).toEqual(["myAgent"]); + }); +}); + describe("initializeCommandHandlerContext option validation", () => { test("instanceDir without storageProvider throws", async () => { await expect( diff --git a/ts/packages/shell/README.md b/ts/packages/shell/README.md index 910e47a0a2..b3c6573e87 100644 --- a/ts/packages/shell/README.md +++ b/ts/packages/shell/README.md @@ -67,6 +67,8 @@ If you have multiple clients connected (e.g., both a Shell and a CLI connected t In local mode (no agent server), only a single default conversation is available. Conversation switching and creation are not supported. +In local mode, the shell also hosts an in-process port-discovery WebSocket on `ws://localhost:8999/` (the same default the standalone `agentServer` uses) so external clients like the Chrome extension can find in-process agents — e.g. the browser agent's dynamically assigned WebSocket port — through the same discovery channel they would use against a real `agentServer`. If port 8999 is already in use (a real `agentServer` running, another local-mode shell, etc.) the bind fails loudly and the shell continues without discovery; external clients won't be able to find in-process agents until you restart the shell with no conflict. + ### Azure Speech to Text service (Optional) Currently, TypeAgent Shell optionally supports voice input via Azure Speech Services or [Local Whisper Service](../../../python/stt/whisperService/) in addition to keyboard input. diff --git a/ts/packages/shell/package.json b/ts/packages/shell/package.json index 81406e8d3f..d02f6bfd1f 100644 --- a/ts/packages/shell/package.json +++ b/ts/packages/shell/package.json @@ -62,6 +62,7 @@ "@typeagent/agent-rpc": "workspace:*", "@typeagent/agent-sdk": "workspace:*", "@typeagent/agent-server-client": "workspace:*", + "@typeagent/agent-server-protocol": "workspace:*", "@typeagent/common-utils": "workspace:*", "@typeagent/completion-ui": "workspace:*", "@typeagent/config": "workspace:*", @@ -83,6 +84,7 @@ "typeagent": "workspace:*", "typechat": "^0.1.1", "typechat-utils": "workspace:*", + "websocket-channel-server": "workspace:*", "websocket-utils": "workspace:*", "ws": "^8.17.1" }, diff --git a/ts/packages/shell/src/main/browserIpc.ts b/ts/packages/shell/src/main/browserIpc.ts index ba20591017..9321a07580 100644 --- a/ts/packages/shell/src/main/browserIpc.ts +++ b/ts/packages/shell/src/main/browserIpc.ts @@ -1,15 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { - WebSocketMessageV2, - createWebSocket, - keepWebSocketAlive, -} from "websocket-utils"; +import { WebSocketMessageV2, keepWebSocketAlive } from "websocket-utils"; import WebSocket from "ws"; +import { discoverPort } from "@typeagent/agent-server-client/discovery"; import registerDebug from "debug"; const debugBrowserIPC = registerDebug("typeagent:browser:ipc"); +const debugBrowserIPCError = registerDebug("typeagent:browser:ipc:error"); + +const AGENT_SERVER_DEFAULT_URL = "ws://localhost:8999/"; export class BrowserAgentIpc { private static instance: BrowserAgentIpc; @@ -58,13 +58,7 @@ export class BrowserAgentIpc { //create a new promise to establish the websocket connection this.webSocketPromise = new Promise( async (resolve) => { - this.webSocket = await createWebSocket( - "browser", - "client", - "inlineBrowser", - 8081, - "default", - ); + this.webSocket = await createInlineBrowserWebSocket(); if (!this.webSocket) { this.webSocketPromise = null; resolve(undefined); @@ -247,3 +241,75 @@ export class BrowserAgentIpc { return this.webSocket && this.webSocket.readyState === WebSocket.OPEN; } } + +/** + * Build the inline-browser → agent-server WebSocket URL by discovering the + * browser agent's port via the agent-server discovery channel, then opening + * the connection. Mirrors the chrome extension's resolveBrowserEndpoint / + * createWebSocket flow (see ts/packages/agents/browser/src/extension/ + * serviceWorker/websocket.ts) so both clients of the browser agent reach + * the same dynamic port. + * + * The agent-server discovery URL defaults to ws://localhost:8999/ but can + * be overridden with WEBSOCKET_HOST for non-default deployments. + * + * NOTE on WEBSOCKET_HOST semantics: this caller treats it as a *base URL* + * (protocol + host + port) and builds its own path/query. The legacy + * `createWebSocket` helper in `packages/utils/webSocketUtils/src/webSockets.ts` + * treats the same env var as a *complete endpoint replacement*. Set + * WEBSOCKET_HOST to a base URL without a path (e.g. `ws://example.com:9000/`) + * for predictable behavior across both call sites. + */ +async function createInlineBrowserWebSocket(): Promise { + const agentServerUrl = + process.env["WEBSOCKET_HOST"] || AGENT_SERVER_DEFAULT_URL; + const sessionId = "default"; + + const result = await discoverPort("browser", sessionId, { + url: agentServerUrl, + }); + if (result.kind !== "found") { + if (result.kind === "not-registered") { + debugBrowserIPC( + "Browser agent not registered with agent-server at %s", + agentServerUrl, + ); + } else { + debugBrowserIPCError( + "Agent-server discovery unreachable at %s: %s", + agentServerUrl, + result.error.message, + ); + } + return undefined; + } + + let endpoint: string; + try { + const u = new URL(agentServerUrl); + endpoint = `${u.protocol}//${u.hostname}:${result.port}/?channel=browser&role=client&clientId=inlineBrowser&sessionId=${sessionId}`; + } catch (e) { + debugBrowserIPCError("Invalid agent-server URL: %s", e); + return undefined; + } + + debugBrowserIPC("Connecting inlineBrowser to: %s", endpoint); + return new Promise((resolve) => { + const ws = new WebSocket(endpoint); + ws.onopen = () => { + debugBrowserIPC("inlineBrowser websocket open"); + resolve(ws); + }; + ws.onerror = (event: any) => { + debugBrowserIPCError( + "inlineBrowser websocket error: %s", + event?.message ?? "unknown", + ); + resolve(undefined); + }; + ws.onclose = () => { + debugBrowserIPC("inlineBrowser websocket closed"); + resolve(undefined); + }; + }); +} diff --git a/ts/packages/shell/src/main/discoveryServer.ts b/ts/packages/shell/src/main/discoveryServer.ts new file mode 100644 index 0000000000..37a6acfb31 --- /dev/null +++ b/ts/packages/shell/src/main/discoveryServer.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createWebSocketChannelServer } from "websocket-channel-server"; +import { createRpc } from "@typeagent/agent-rpc/rpc"; +import { + AGENT_SERVER_DISCOVERY_NAME, + DiscoveryChannelName, + createDiscoveryHandlers, +} from "@typeagent/agent-server-protocol"; +import { + type IPortRegistrar, + SYSTEM_SESSION_CONTEXT_ID, +} from "agent-dispatcher"; +import registerDebug from "debug"; + +const debug = registerDebug("typeagent:shell:discovery"); +const debugError = registerDebug("typeagent:shell:discovery:error"); + +export type StandaloneDiscoveryServer = { + /** Port the WS is bound on (always === requested port; bind is exact). */ + port: number; + /** Stop the server. Idempotent. */ + close: () => void; +}; + +/** + * Stand up a tiny WebSocket server in the standalone Electron shell that + * exposes the same `discovery` channel as the agent-server process. This + * lets the Chrome extension (and any other external client) call + * `discoverPort(...)` against the shell exactly the way it would against + * a real agent-server, so the in-process browser agent's dynamically + * assigned port can be located without hardcoding. + * + * The handler logic is shared with `agentServer/server/server.ts` via + * `createDiscoveryHandlers` so both hosts speak the same protocol. + * + * Bind is exact: if the port is already taken (real agent-server + * running, another shell instance, etc.) the bind throws EADDRINUSE + * which surfaces to the caller. Failing loudly is intentional — silent + * fallback to a random port would break the extension's default + * `agentServerHost` setting and the user wouldn't know why discovery + * stopped working. + */ +export async function startStandaloneDiscoveryServer( + port: number, + portRegistrar: IPortRegistrar, +): Promise { + const wss = await createWebSocketChannelServer( + { port }, + (channelProvider) => { + createRpc( + "shell:discovery", + channelProvider.createChannel(DiscoveryChannelName), + createDiscoveryHandlers((agentName, role) => + portRegistrar.lookup(agentName, role), + ), + ); + }, + ); + + // Mirror agent-server: register self under the well-known name so a + // client that bootstrapped from a different known port can still + // resolve back to this one. Use SYSTEM_SESSION_CONTEXT_ID so the + // entry survives real-session releases for the lifetime of the + // process. + try { + portRegistrar.register( + AGENT_SERVER_DISCOVERY_NAME, + "default", + port, + SYSTEM_SESSION_CONTEXT_ID, + ); + } catch (e) { + // Self-registration failure is non-fatal for discovery itself + // (lookups for other agents still work) but indicates a + // conflicting allocation, so log it. + debugError("self-registration failed: %s", (e as Error).message); + } + + debug("standalone discovery server listening on ws://localhost:%d", port); + + return { + port, + close: () => { + try { + wss.close(); + } catch (e) { + debugError("close failed: %s", (e as Error).message); + } + }, + }; +} diff --git a/ts/packages/shell/src/main/instance.ts b/ts/packages/shell/src/main/instance.ts index cceee3e905..98686f145d 100644 --- a/ts/packages/shell/src/main/instance.ts +++ b/ts/packages/shell/src/main/instance.ts @@ -36,6 +36,7 @@ import { ClientIO, createDispatcher, Dispatcher, + PortRegistrar, RequestId, } from "agent-dispatcher"; import { getStatusSummary } from "agent-dispatcher/helpers/status"; @@ -47,12 +48,17 @@ import { ensureAgentServer, connectAgentServer, stopAgentServer, + AGENT_SERVER_DEFAULT_PORT, } from "@typeagent/agent-server-client"; import type { AgentServerConnection } from "@typeagent/agent-server-client"; import { loadUserSettings, saveUserSettings, } from "agent-dispatcher/helpers/userSettings"; +import { + startStandaloneDiscoveryServer, + type StandaloneDiscoveryServer, +} from "./discoveryServer.js"; type ShellInstance = { shellWindow: ShellWindow; @@ -85,6 +91,12 @@ async function initializeDispatcher( // Make sure the previous cleanup is done. await cleanupP; } + // Hoisted above the try{} so the catch can clean up an already-bound + // discovery WS if a later step (createDispatcher, etc.) throws — + // otherwise the listening socket on AGENT_SERVER_DEFAULT_PORT would + // leak across re-init attempts and block the next launch with + // EADDRINUSE. + let standaloneDiscovery: StandaloneDiscoveryServer | undefined; try { const clientIOChannel = createChannelAdapter((message: any) => { shellWindow.chatView.webContents.send("clientio-rpc-call", message); @@ -461,6 +473,37 @@ async function initializeDispatcher( configName, ); + // Standalone shell hosts its own dispatcher in-process. Pre-build + // a PortRegistrar so we can hand the same instance to both the + // dispatcher (where agents register their dynamically assigned + // ports) and the discovery WS server below (which reads from it + // to answer external lookups). Without this shared instance, the + // dispatcher would silently make its own private registrar and + // the Chrome extension's discoverPort lookup would never find + // the browser agent's port. + const portRegistrar = new PortRegistrar(); + + // Stand up the discovery WS so the Chrome extension (and any + // other external client speaking the agent-server discovery + // protocol) can find in-process agents at parity with what + // they get when connecting to a real agent-server. Bind is + // exact on AGENT_SERVER_DEFAULT_PORT (8999): an EADDRINUSE + // here usually means a real agent-server is already running, + // and silently picking a random port would only confuse the + // user (their default-configured extension would still fail). + try { + standaloneDiscovery = await startStandaloneDiscoveryServer( + AGENT_SERVER_DEFAULT_PORT, + portRegistrar, + ); + } catch (e) { + debugShellError( + "Failed to start standalone discovery server on port %d: %s. External clients (e.g. Chrome extension) will not be able to discover in-process agent ports.", + AGENT_SERVER_DEFAULT_PORT, + (e as Error).message, + ); + } + newDispatcher = await createDispatcher("shell", { appAgentProviders: [ createShellAgentProvider(shellWindow), @@ -469,6 +512,7 @@ async function initializeDispatcher( agentInitOptions: { browser: browserControl.control, }, + portRegistrar, agentInstaller: getDefaultAppAgentInstaller(instanceDir), persistSession: true, persistDir: instanceDir, @@ -548,6 +592,10 @@ async function initializeDispatcher( if (connection !== undefined) { await connection.close(); } + if (standaloneDiscovery !== undefined) { + standaloneDiscovery.close(); + standaloneDiscovery = undefined; + } clientIOChannel.notifyDisconnected(); ipcMain.removeListener("clientio-rpc-reply", onClientIORpcReply); browserControl.close(); @@ -614,6 +662,12 @@ async function initializeDispatcher( rebindDispatcher, }; } catch (e: any) { + // Tear down the discovery WS if it was already bound before the + // failure — otherwise port AGENT_SERVER_DEFAULT_PORT stays held + // by this process and the next shell launch hits EADDRINUSE. + if (standaloneDiscovery !== undefined) { + standaloneDiscovery.close(); + } if (isTest) { // In test mode, avoid blocking dialogs so the process can exit cleanly console.error("Exception initializing dispatcher:", e.stack); diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index af1bcfdad0..d36e441bc0 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -1581,6 +1581,9 @@ importers: '@typeagent/agent-sdk': specifier: workspace:* version: link:../../agentSdk + '@typeagent/agent-server-client': + specifier: workspace:* + version: link:../../agentServer/client '@typeagent/agent-server-protocol': specifier: workspace:* version: link:../../agentServer/protocol @@ -5070,6 +5073,9 @@ importers: '@typeagent/agent-server-client': specifier: workspace:* version: link:../agentServer/client + '@typeagent/agent-server-protocol': + specifier: workspace:* + version: link:../agentServer/protocol '@typeagent/common-utils': specifier: workspace:* version: link:../utils/commonUtils @@ -5133,6 +5139,9 @@ importers: typechat-utils: specifier: workspace:* version: link:../utils/typechatUtils + websocket-channel-server: + specifier: workspace:* + version: link:../utils/webSocketChannelServer websocket-utils: specifier: workspace:* version: link:../utils/webSocketUtils @@ -18229,7 +18238,7 @@ snapshots: async: 3.2.6 cosmiconfig: 8.3.6(typescript@5.4.5) date-fns: 2.30.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.1 detect-indent: 6.1.0 find-up: 7.0.0 fs-extra: 11.3.0