From 670222d8b6b82af7a9514776d1fb4708a6b50e02 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 12 May 2026 15:38:40 -0700 Subject: [PATCH 1/7] feat(dispatcher): add PortRegistrar class with unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation piece for the port-registrar PR. Standalone in-memory registry keyed by (agentName, role, sessionContextId). API surface: register/release/releaseAllForSession/lookup/hasActiveAllocations. Rejects port 0 and out-of-range; warns (does not throw) on privileged ports or the agentServer's own port. Idempotent on the (agentName, role, sessionContextId) triple. Most-recent-wins lookup semantics match the legacy setLocalHostPort behavior the registrar will subsume in the next commit. Not wired into anything yet — pure class + 19 unit tests, all passing. Integration with appAgentManager / sessionContext / agent-server discovery channel follows in subsequent commits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dispatcher/src/context/portRegistrar.ts | 257 ++++++++++++++++++ .../dispatcher/test/portRegistrar.spec.ts | 162 +++++++++++ 2 files changed, 419 insertions(+) create mode 100644 ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts create mode 100644 ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts diff --git a/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts b/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts new file mode 100644 index 000000000..5bdee3e89 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import registerDebug from "debug"; + +const debug = registerDebug("typeagent:dispatcher:portRegistrar"); +const debugWarn = registerDebug("typeagent:dispatcher:portRegistrar:warn"); + +/** + * Opaque identifier returned from {@link PortRegistrar.register}. Pass it + * back to {@link PortRegistrar.release} to remove a single allocation. + */ +export type RegistrationId = string; + +/** + * One port advertised by an agent. The triple (`agentName`, `role`, + * `sessionContextId`) uniquely identifies an allocation; calling + * {@link PortRegistrar.register} a second time with the same triple + * updates the port in place and returns the same {@link RegistrationId}. + */ +export type Allocation = { + readonly agentName: string; + readonly role: string; + readonly sessionContextId: string; + port: number; +}; + +/** + * Conventional role used by the back-compat + * `sessionContext.setLocalHostPort` / `getSharedLocalHostPort` shims so + * that the legacy "one port per agent" callers (`montage`, `markdown`, + * `browser`) keep working through the new registrar without changes. + */ +export const DEFAULT_ROLE = "default"; + +/** + * In-memory port registry. Agents bind on `port=0`, the OS picks a free + * port, and the agent registers the resulting port here so other + * components (and out-of-process clients via the agentServer discovery + * channel) can look it up. + * + * Lifetime: one instance per dispatcher (i.e. per agentServer process, + * per shell, etc.). Not persisted across restarts — agents re-bind and + * re-register on each run. + * + * Thread-safety: Node single-threaded; the registrar is mutated only on + * the event-loop thread. No locking required. + */ +export class PortRegistrar { + /** All live allocations, keyed by their opaque registration id. */ + private readonly allocations = new Map(); + + /** + * Index from `(agentName, role, sessionContextId)` triple to its + * registration id, so re-registration is O(1) and idempotent. + */ + private readonly tripleIndex = new Map(); + + /** + * Optional self-port used by the SDK guard to flag agents that + * accidentally hard-coded the agentServer's own port. Set by the + * agent-server entry point; absent in non-server hosts. + */ + private agentServerPort: number | undefined; + + /** Register the agentServer's own listen port for the SDK guard. */ + public setAgentServerPort(port: number | undefined): void { + this.agentServerPort = port; + } + + public getAgentServerPort(): number | undefined { + return this.agentServerPort; + } + + /** + * Record a port that an agent has just bound. Idempotent on the + * `(agentName, role, sessionContextId)` triple: a second call with + * the same triple updates the stored port and returns the original + * {@link RegistrationId}. + * + * Validates the input but does not throw on suspicious values: + * `port < 1024` and `port === agentServerPort` log a warning under + * the `typeagent:dispatcher:portRegistrar:warn` debug namespace and + * still register, on the assumption that the agent is already bound + * and refusing to record the port would just hide the listener from + * lookups. + */ + public register( + agentName: string, + role: string, + port: number, + sessionContextId: string, + ): RegistrationId { + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error( + `Invalid port for ${agentName}/${role}: ${port} (must be an integer in [0, 65535])`, + ); + } + if (port === 0) { + throw new Error( + `Refusing to register port 0 for ${agentName}/${role} — pass the OS-assigned port returned by the bound listener, not the bind hint`, + ); + } + if (port < 1024) { + debugWarn( + `${agentName}/${role} registered well-known/privileged port ${port}; consider passing 0 to bind so the OS picks a free ephemeral port`, + ); + } + if ( + this.agentServerPort !== undefined && + port === this.agentServerPort + ) { + debugWarn( + `${agentName}/${role} registered the agentServer's own port ${port}; this is almost certainly a hard-coded mistake — pass 0 to bind`, + ); + } + + const tripleKey = this.makeTripleKey( + agentName, + role, + sessionContextId, + ); + const existing = this.tripleIndex.get(tripleKey); + if (existing !== undefined) { + const allocation = this.allocations.get(existing); + if (allocation !== undefined) { + debug( + `re-register ${agentName}/${role} session=${sessionContextId} port=${allocation.port}->${port} regId=${existing}`, + ); + allocation.port = port; + return existing; + } + // Index entry was stale; fall through to fresh insert. + this.tripleIndex.delete(tripleKey); + } + + const regId = randomUUID(); + this.allocations.set(regId, { + agentName, + role, + sessionContextId, + port, + }); + this.tripleIndex.set(tripleKey, regId); + debug( + `register ${agentName}/${role} session=${sessionContextId} port=${port} regId=${regId}`, + ); + return regId; + } + + /** + * Remove a single allocation by its registration id. Idempotent: a + * release of an unknown id is a no-op. + */ + public release(regId: RegistrationId): void { + const allocation = this.allocations.get(regId); + if (allocation === undefined) { + return; + } + this.allocations.delete(regId); + this.tripleIndex.delete( + this.makeTripleKey( + allocation.agentName, + allocation.role, + allocation.sessionContextId, + ), + ); + debug( + `release ${allocation.agentName}/${allocation.role} session=${allocation.sessionContextId} port=${allocation.port} regId=${regId}`, + ); + } + + /** + * Backstop for forgotten releases: remove every allocation whose + * `sessionContextId` matches. Called from the dispatcher's + * `closeSessionContext` finally block. + * + * Returns the number of allocations released. + */ + public releaseAllForSession(sessionContextId: string): number { + const toRelease: RegistrationId[] = []; + for (const [regId, allocation] of this.allocations) { + if (allocation.sessionContextId === sessionContextId) { + toRelease.push(regId); + } + } + for (const regId of toRelease) { + this.release(regId); + } + if (toRelease.length > 0) { + debug( + `releaseAllForSession session=${sessionContextId} released=${toRelease.length}`, + ); + } + return toRelease.length; + } + + /** + * Look up the most recently registered port for `(agentName, role)` + * across all sessions, or `undefined` if no live allocation matches. + * + * Most-recent semantics: when several sessions have registered the + * same `(agentName, role)` (e.g. multiple shells against the same + * agentServer), the latest registration wins. This matches the + * pre-existing `setLocalHostPort` "last writer wins" behavior for + * the `default` role. + * + * No permission check here — callers that need one (e.g. the + * cross-agent `getSharedLocalHostPort` shim, gated by + * `manifest.sharedLocalView`) layer it on top. + */ + public lookup(agentName: string, role: string): number | undefined { + // Walk in insertion order; randomUUID-keyed Map iteration order in + // V8 is insertion-order-stable, so the last matching entry is the + // most recent registration. + let port: number | undefined; + for (const allocation of this.allocations.values()) { + if ( + allocation.agentName === agentName && + allocation.role === role + ) { + port = allocation.port; + } + } + return port; + } + + /** + * True if any allocation is currently live. Used by the agentServer + * idle-shutdown timer so a server with active out-of-process clients + * (Chrome ext, VS Code ext, etc.) doesn't shut down on the + * "no-conversation-clients" timer alone. + */ + public hasActiveAllocations(): boolean { + return this.allocations.size > 0; + } + + /** + * Snapshot of all live allocations. Intended for diagnostics and + * tests — not the discovery hot path. + */ + public list(): readonly Allocation[] { + return Array.from(this.allocations.values()).map((a) => ({ ...a })); + } + + private makeTripleKey( + agentName: string, + role: string, + sessionContextId: string, + ): string { + // Use a delimiter that can't appear in any of the three fields — + // agent names and role names are TS identifiers / bare words, and + // sessionContextId is a UUID, so `\u0000` is unambiguously safe. + return `${agentName}\u0000${role}\u0000${sessionContextId}`; + } +} diff --git a/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts b/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts new file mode 100644 index 000000000..c1072fd0d --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { PortRegistrar } from "../src/context/portRegistrar.js"; + +describe("PortRegistrar", () => { + const SID_A = "00000000-0000-0000-0000-00000000000a"; + const SID_B = "00000000-0000-0000-0000-00000000000b"; + + describe("register", () => { + test("returns a registration id and stores the allocation", () => { + const r = new PortRegistrar(); + const id = r.register("browser", "ws-bridge", 51234, SID_A); + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + expect(r.lookup("browser", "ws-bridge")).toBe(51234); + }); + + test("re-register with the same triple updates port in place and returns same id", () => { + const r = new PortRegistrar(); + const id1 = r.register("browser", "ws-bridge", 51234, SID_A); + const id2 = r.register("browser", "ws-bridge", 51235, SID_A); + expect(id2).toBe(id1); + expect(r.lookup("browser", "ws-bridge")).toBe(51235); + expect(r.list()).toHaveLength(1); + }); + + test("different sessions for same (agent, role) get distinct ids", () => { + const r = new PortRegistrar(); + const id1 = r.register("browser", "ws-bridge", 51234, SID_A); + const id2 = r.register("browser", "ws-bridge", 51235, SID_B); + expect(id2).not.toBe(id1); + expect(r.list()).toHaveLength(2); + }); + + test("different roles for same (agent, session) get distinct ids", () => { + const r = new PortRegistrar(); + const id1 = r.register("browser", "ws-bridge", 51234, SID_A); + const id2 = r.register("browser", "http-debug", 51235, SID_A); + expect(id2).not.toBe(id1); + expect(r.lookup("browser", "ws-bridge")).toBe(51234); + expect(r.lookup("browser", "http-debug")).toBe(51235); + }); + + test("rejects port 0 (must pass the OS-assigned port, not the bind hint)", () => { + const r = new PortRegistrar(); + expect(() => r.register("browser", "ws", 0, SID_A)).toThrow( + /port 0/, + ); + }); + + test("rejects out-of-range ports", () => { + const r = new PortRegistrar(); + expect(() => r.register("browser", "ws", -1, SID_A)).toThrow(); + expect(() => r.register("browser", "ws", 65536, SID_A)).toThrow(); + expect(() => r.register("browser", "ws", 1.5, SID_A)).toThrow(); + }); + + test("warns but accepts privileged ports (does not throw)", () => { + const r = new PortRegistrar(); + expect(() => + r.register("browser", "ws", 80, SID_A), + ).not.toThrow(); + expect(r.lookup("browser", "ws")).toBe(80); + }); + + test("warns but accepts the agentServer's own port (does not throw)", () => { + const r = new PortRegistrar(); + r.setAgentServerPort(8999); + expect(() => + r.register("browser", "ws", 8999, SID_A), + ).not.toThrow(); + expect(r.lookup("browser", "ws")).toBe(8999); + }); + }); + + describe("release", () => { + test("removes the allocation", () => { + const r = new PortRegistrar(); + const id = r.register("browser", "ws", 51234, SID_A); + r.release(id); + expect(r.lookup("browser", "ws")).toBeUndefined(); + expect(r.list()).toHaveLength(0); + }); + + test("is idempotent on unknown id", () => { + const r = new PortRegistrar(); + expect(() => r.release("not-a-real-id")).not.toThrow(); + }); + + test("after release, re-register issues a fresh id", () => { + const r = new PortRegistrar(); + const id1 = r.register("browser", "ws", 51234, SID_A); + r.release(id1); + const id2 = r.register("browser", "ws", 51234, SID_A); + expect(id2).not.toBe(id1); + }); + }); + + describe("releaseAllForSession", () => { + test("releases only allocations belonging to the given session", () => { + const r = new PortRegistrar(); + r.register("browser", "ws", 1, SID_A); + r.register("code", "ws", 2, SID_A); + r.register("browser", "http", 3, SID_B); + const released = r.releaseAllForSession(SID_A); + expect(released).toBe(2); + expect(r.lookup("browser", "ws")).toBeUndefined(); + expect(r.lookup("code", "ws")).toBeUndefined(); + expect(r.lookup("browser", "http")).toBe(3); + }); + + test("returns 0 when no allocations match", () => { + const r = new PortRegistrar(); + r.register("browser", "ws", 1, SID_A); + expect(r.releaseAllForSession(SID_B)).toBe(0); + expect(r.lookup("browser", "ws")).toBe(1); + }); + }); + + describe("lookup", () => { + test("returns undefined for unknown (agent, role)", () => { + const r = new PortRegistrar(); + expect(r.lookup("nope", "default")).toBeUndefined(); + }); + + test("returns most recent registration when multiple sessions overlap", () => { + const r = new PortRegistrar(); + r.register("browser", "ws", 1, SID_A); + r.register("browser", "ws", 2, SID_B); + expect(r.lookup("browser", "ws")).toBe(2); + }); + + test("after the most-recent session releases, lookup falls back to the older one", () => { + const r = new PortRegistrar(); + r.register("browser", "ws", 1, SID_A); + const id2 = r.register("browser", "ws", 2, SID_B); + r.release(id2); + expect(r.lookup("browser", "ws")).toBe(1); + }); + }); + + describe("hasActiveAllocations", () => { + test("false when empty", () => { + const r = new PortRegistrar(); + expect(r.hasActiveAllocations()).toBe(false); + }); + + test("true with at least one allocation", () => { + const r = new PortRegistrar(); + r.register("browser", "ws", 1, SID_A); + expect(r.hasActiveAllocations()).toBe(true); + }); + + test("false again after all releases", () => { + const r = new PortRegistrar(); + const id = r.register("browser", "ws", 1, SID_A); + r.release(id); + expect(r.hasActiveAllocations()).toBe(false); + }); + }); +}); From 4d83ba3c517a3ca942f9f4923878760e3b119364 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 12 May 2026 16:09:09 -0700 Subject: [PATCH 2/7] refactor(dispatcher): integrate PortRegistrar into AppAgentManager + SDK Wire the PortRegistrar (added in 670222d8) into the dispatcher's app-agent lifecycle and SDK surface so agents can register OS-assigned ports through a single source of truth. - AppAgentManager now takes a PortRegistrar in its constructor; the legacy per-record 'port' field is replaced by a 'sessionContextId' UUID minted fresh on each initializeSessionContext and cleared in closeSessionContext. setLocalHostPort/getLocalHostPort/getSharedLocalHostPort are now thin shims over the registrar (DEFAULT_ROLE='default'); permission checks and the appAgent-undefined guard in getSharedLocalHostPort are preserved. - closeSessionContext gains a finally backstop that calls releaseAllForSession(sessionContextId) so a forgetful or crashing agent can never leak a registration past its lifetime, even if init itself rejected. - SessionContext SDK gets readonly sessionContextId + registerPort(role,port) returning {release()}. The legacy setLocalHostPort/getSharedLocalHostPort are kept and marked @deprecated so existing agents keep working unchanged. - agent-rpc proxies the new registerPort/releasePort and threads sessionContextId through ContextParams so out-of-process agents see the same view as in-process ones. Registration handles are tracked by regId on the dispatcher side. - DispatcherOptions accepts an optional shared PortRegistrar so a host (agentServer) can wire one instance across all conversations; standalone hosts get a process-private one by default. - Folds in the rubber-duck #3 fix to PortRegistrar.register: re-registering an existing (agent,role,session) triple now deletes+reinserts the entry so Map insertion order reflects recency for lookup tie-breaking, plus a regression test covering the ordering invariant. Mocks updated in dispatcher/test/sessionContext.spec.ts and browser/websiteMemory.mts. Full monorepo build succeeds; 27/27 tests pass (20 portRegistrar + 7 sessionContext). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ts/packages/agentRpc/src/client.ts | 23 +++ ts/packages/agentRpc/src/server.ts | 32 ++++ ts/packages/agentRpc/src/types.ts | 7 + ts/packages/agentSdk/src/agentInterface.ts | 47 ++++- .../browser/src/agent/websiteMemory.mts | 4 + .../dispatcher/src/context/appAgentManager.ts | 174 +++++++++++++----- .../src/context/commandHandlerContext.ts | 15 ++ .../dispatcher/src/context/portRegistrar.ts | 9 + .../dispatcher/src/execute/sessionContext.ts | 15 ++ .../dispatcher/test/portRegistrar.spec.ts | 13 ++ .../dispatcher/test/sessionContext.spec.ts | 15 +- 11 files changed, 297 insertions(+), 57 deletions(-) diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index a83af1eab..938b15e35 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -31,6 +31,7 @@ import { createRpc } from "./rpc.js"; import { ChannelProvider } from "./common.js"; import { getObjectProperty, uint8ArrayToBase64 } from "@typeagent/common-utils"; import { AgentInterfaceFunctionName } from "./server.js"; +import { randomUUID } from "crypto"; /** * Race a promise against an AbortSignal. If the signal fires before the @@ -184,6 +185,9 @@ export async function createAgentRpcClient( ) { const channel = channelProvider.createChannel(`agent:${name}`); const contextMap = createObjectMap>(); + // Tracks port registration handles returned by sessionContext.registerPort + // so the out-of-process agent can release them via the regId we sent back. + const registrationHandles = new Map void }>(); function getContextParam( context: SessionContext, ): ContextParams { @@ -192,6 +196,7 @@ export async function createAgentRpcClient( hasInstanceStorage: context.instanceStorage !== undefined, hasSessionStorage: context.sessionStorage !== undefined, agentContextId: context.agentContext?.contextId, + sessionContextId: context.sessionContextId, }; } @@ -324,6 +329,24 @@ export async function createAgentRpcClient( const context = contextMap.get(param.contextId); context.setLocalHostPort(param.port); }, + registerPort: async (param: { + contextId: number; + role: string; + port: number; + }) => { + const context = contextMap.get(param.contextId); + const handle = context.registerPort(param.role, param.port); + const regId = randomUUID(); + registrationHandles.set(regId, handle); + return { regId }; + }, + releasePort: async (param: { regId: string }) => { + const handle = registrationHandles.get(param.regId); + if (handle !== undefined) { + registrationHandles.delete(param.regId); + handle.release(); + } + }, indexes: async (param: { contextId: number; type: string }) => { const context = contextMap.get(param.contextId); return context.indexes(param.type as any); diff --git a/ts/packages/agentRpc/src/server.ts b/ts/packages/agentRpc/src/server.ts index 8b47d8fd6..068ed9256 100644 --- a/ts/packages/agentRpc/src/server.ts +++ b/ts/packages/agentRpc/src/server.ts @@ -412,6 +412,7 @@ export function createAgentRpcServer( contextId: number, hasInstanceStorage: boolean, hasSessionStorage: boolean, + sessionContextId: string, context: any, ): SessionContext { const dynamicAgentRpcServer = new Map void>(); @@ -424,6 +425,7 @@ export function createAgentRpcServer( instanceStorage: hasInstanceStorage ? getStorage(contextId, false) : undefined, + sessionContextId, notify: ( event: AppAgentEvent, message: string | DisplayContent, @@ -464,6 +466,29 @@ export function createAgentRpcServer( .invoke("setLocalHostPort", { contextId, port }) .catch(); }, + registerPort(role: string, port: number) { + // Fire-and-forget the invoke; resolve the regId lazily so + // release() waits for the round-trip if it gets called + // before the registration response arrives. + const regIdPromise: Promise = rpc + .invoke("registerPort", { contextId, role, port }) + .then((r: { regId: string }) => r.regId); + regIdPromise.catch(() => { + // Swallow registration failures here — they're logged + // on the dispatcher side via the registrar; throwing + // synchronously from registerPort would force every + // agent caller to add try/catch around the bind path. + }); + return { + release: () => { + void regIdPromise + .then((regId) => + rpc.invoke("releasePort", { regId }), + ) + .catch(); + }, + }; + }, addDynamicAgent: async ( name: string, manifest: AppAgentManifest, @@ -573,6 +598,7 @@ export function createAgentRpcServer( contextId, hasInstanceStorage, hasSessionStorage, + sessionContextId, agentContextId, } = param; if (contextId === undefined) { @@ -586,6 +612,11 @@ export function createAgentRpcServer( if (hasSessionStorage === undefined) { throw new Error("Invalid context param: missing hasSessionStorage"); } + if (sessionContextId === undefined) { + throw new Error( + "Invalid context param: missing sessionContextId", + ); + } const agentContext = agentContextId !== undefined @@ -596,6 +627,7 @@ export function createAgentRpcServer( contextId, hasInstanceStorage, hasSessionStorage, + sessionContextId, agentContext, ); } diff --git a/ts/packages/agentRpc/src/types.ts b/ts/packages/agentRpc/src/types.ts index 83e386b50..130ddee06 100644 --- a/ts/packages/agentRpc/src/types.ts +++ b/ts/packages/agentRpc/src/types.ts @@ -125,6 +125,12 @@ export type AgentContextInvokeFunctions = { contextId: number; port: number; }) => Promise; + registerPort: (param: { + contextId: number; + role: string; + port: number; + }) => Promise<{ regId: string }>; + releasePort: (param: { regId: string }) => Promise; indexes: (param: { contextId: number; type: string }) => Promise; reloadAgentSchema: (param: { contextId: number }) => Promise; popupQuestion: (param: { @@ -244,6 +250,7 @@ export type ContextParams = { hasInstanceStorage: boolean; hasSessionStorage: boolean; agentContextId: number | undefined; + sessionContextId: string; }; export type ActionContextParams = ContextParams & { diff --git a/ts/packages/agentSdk/src/agentInterface.ts b/ts/packages/agentSdk/src/agentInterface.ts index 4a449d785..0049b6332 100644 --- a/ts/packages/agentSdk/src/agentInterface.ts +++ b/ts/packages/agentSdk/src/agentInterface.ts @@ -195,6 +195,15 @@ export interface SessionContext { readonly sessionStorage: Storage | undefined; readonly instanceStorage: Storage | undefined; // storage that are preserved across sessions + /** + * Opaque identifier for the agent's current session-context lifetime. + * Re-generated each time the agent is (re-)initialized; stable for the + * lifetime of this `SessionContext` instance. Provided so out-of-process + * agents (via agent-rpc) can scope port registrations to their session; + * most in-process agents do not need to read this directly. + */ + readonly sessionContextId: string; + notify( event: AppAgentEvent, message: string | DisplayContent, @@ -226,12 +235,42 @@ export interface SessionContext { // The dispatcher will call getDynamicSchema/getDynamicGrammar to get the updated content. reloadAgentSchema(): Promise; - // Experimental: get the shared local host port - getSharedLocalHostPort(agentName: string): Promise; - - // Experimental: update this agent's bound local host port (used after OS port assignment) + /** + * Register a port this agent has just bound (typically with + * `bind(0)` so the OS picks a free ephemeral port). The dispatcher + * records the `(agent, role, port)` tuple in its `PortRegistrar` + * so other in-process agents and out-of-process clients (Chrome + * extension, VS Code extension, etc.) can discover it. + * + * `role` is a free-form string scoping the registration within + * this agent — e.g. `"ws-bridge"`, `"http-debug"`. Use distinct + * roles when an agent exposes multiple listeners. + * + * Returns a `release()` callback the agent should invoke when the + * listener is torn down. Forgetting to release is non-fatal — the + * dispatcher releases all of an agent's allocations as a backstop + * when the agent's session context closes — but explicit release + * keeps lookups accurate while the agent is still alive. + */ + registerPort(role: string, port: number): { release: () => void }; + + /** + * @deprecated Use {@link registerPort} with a meaningful role + * instead. Kept for backwards compatibility with agents that + * predate the multi-role registrar; routes through the registrar + * with `role="default"`. + */ setLocalHostPort(port: number): void; + /** + * @deprecated Use {@link lookupPort} (out-of-process discovery + * channel) or, for in-process cross-agent lookups, ask the target + * agent directly. Kept for backwards compatibility; routes through + * the registrar with `role="default"` and enforces the existing + * `manifest.sharedLocalView` permission check. + */ + getSharedLocalHostPort(agentName: string): Promise; + // Experimental: get the available indexes indexes(type: "image" | "email" | "website" | "all"): Promise; } diff --git a/ts/packages/agents/browser/src/agent/websiteMemory.mts b/ts/packages/agents/browser/src/agent/websiteMemory.mts index ff288c4f7..467951d96 100644 --- a/ts/packages/agents/browser/src/agent/websiteMemory.mts +++ b/ts/packages/agents/browser/src/agent/websiteMemory.mts @@ -158,6 +158,10 @@ export async function resolveURLWithHistory( forceCleanupDynamicAgent: async () => {}, getSharedLocalHostPort: async () => 0, setLocalHostPort: (_port: number) => {}, + registerPort: (_role: string, _port: number) => ({ + release: () => {}, + }), + sessionContextId: "websiteMemory-mock", indexes: async () => [], reloadAgentSchema: async () => {}, }; diff --git a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts index 08d9ab651..70c62f6d8 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts @@ -28,6 +28,7 @@ import { } from "../translation/actionSchemaSemanticMap.js"; import { ActionSchemaFileCache } from "../translation/actionSchemaFileCache.js"; import path from "node:path"; +import { randomUUID } from "node:crypto"; import { callEnsureError } from "../utils/exceptions.js"; import { AppAgentStateConfig, @@ -44,6 +45,11 @@ import { } from "action-grammar"; import fs from "node:fs"; import { FlowDefinition } from "../execute/flowInterpreter.js"; +import { + PortRegistrar, + DEFAULT_ROLE, + RegistrationId, +} from "./portRegistrar.js"; const debug = registerDebug("typeagent:dispatcher:agents"); const debugError = registerDebug("typeagent:dispatcher:agents:error"); @@ -60,7 +66,14 @@ type AppAgentRecord = { sessionContext?: SessionContext | undefined; sessionContextP?: Promise | undefined; schemaErrors: Map; - port?: number | undefined; + /** + * Fresh UUID generated on every call to {@link initializeSessionContext} + * and cleared by {@link closeSessionContext}. Identifies the agent's + * current session-context lifetime so the {@link PortRegistrar} can + * release any forgotten allocations as a backstop when the session + * context tears down. + */ + sessionContextId?: string | undefined; }; export type AppAgentStateSettings = Partial; @@ -128,6 +141,7 @@ export class AppAgentManager implements ActionConfigProvider { private readonly actionSchemaFileCache: ActionSchemaFileCache; public constructor( cacheDir: string | undefined, + public readonly portRegistrar: PortRegistrar, private readonly allowSharedLocalView?: string[], private readonly agentInitOptions?: Record, ) { @@ -164,14 +178,38 @@ export class AppAgentManager implements ActionConfigProvider { } public getLocalHostPort(appAgentName: string) { - const record = this.getRecord(appAgentName); - return record.port; + return this.portRegistrar.lookup(appAgentName, DEFAULT_ROLE); } - public setLocalHostPort(appAgentName: string, port: number) { + /** + * Back-compat shim for the legacy `setLocalHostPort` SDK method. + * Routes through {@link PortRegistrar.register} with `role="default"` + * using the agent's current `sessionContextId`. Throws if the agent + * has no live session context (i.e. nothing to scope the + * registration to) — this matches the prior behavior, where calling + * `setLocalHostPort` outside of an initialized agent context was a + * programming error that would silently mutate `record.port`. + * + * @returns the {@link RegistrationId} so callers that want explicit + * release control can use it; legacy callers ignore the return value + * and rely on the {@link closeSessionContext} backstop instead. + */ + public setLocalHostPort( + appAgentName: string, + port: number, + ): RegistrationId { const record = this.getRecord(appAgentName); - record.port = port; - debug(`Port ${port} assigned to ${appAgentName}`); + if (record.sessionContextId === undefined) { + throw new Error( + `Cannot register port for '${appAgentName}': no active session context`, + ); + } + return this.portRegistrar.register( + appAgentName, + DEFAULT_ROLE, + port, + record.sessionContextId, + ); } public getSharedLocalHostPort(requester: string, target: string) { @@ -189,16 +227,17 @@ export class AppAgentManager implements ActionConfigProvider { ); } - if (record.port === undefined) { - throw new Error(`Local view not available for agent '${target}'.`); - } - if (record.appAgent === undefined) { throw new Error( `Agent '${target}' is not initialized. Local view not available.`, ); } - return record.port; + + const port = this.portRegistrar.lookup(target, DEFAULT_ROLE); + if (port === undefined) { + throw new Error(`Local view not available for agent '${target}'.`); + } + return port; } public isAppAgentName(appAgentName: string) { @@ -511,12 +550,6 @@ export class AppAgentManager implements ActionConfigProvider { } } - const port = manifest.localView ? 0 : undefined; - - if (port !== undefined) { - debug(`Dynamic port (OS-assigned) reserved for ${appAgentName}`); - } - const record: AppAgentRecord = { name: appAgentName, provider, @@ -525,7 +558,6 @@ export class AppAgentManager implements ActionConfigProvider { schemaErrors, commands: false, manifest, - port, }; this.agents.set(appAgentName, record); @@ -1128,36 +1160,59 @@ export class AppAgentManager implements ActionConfigProvider { record: AppAgentRecord, context: CommandHandlerContext, ) { - const appAgent = await this.ensureAppAgent(record); - let agentContext: unknown | undefined; - if (appAgent.initializeAgentContext !== undefined) { - const options = this.agentInitOptions?.[record.name]; - let settings: AppAgentInitSettings | undefined = - record.port !== undefined - ? { - localHostPort: record.port, - } - : undefined; - - if (options !== undefined) { - if (settings === undefined) { - settings = {}; + // Generate the session-context lifetime id BEFORE we call into + // the agent's initializeAgentContext: the agent may call + // sessionContext.setLocalHostPort / registerPort during init + // (the existing localView pattern does exactly this), and those + // registrations need a sessionContextId to scope to. If init + // throws, the catch block below releases anything that was + // registered so we don't leak. + const sessionContextId = randomUUID(); + record.sessionContextId = sessionContextId; + try { + const appAgent = await this.ensureAppAgent(record); + let agentContext: unknown | undefined; + if (appAgent.initializeAgentContext !== undefined) { + const options = this.agentInitOptions?.[record.name]; + let settings: AppAgentInitSettings | undefined = + record.manifest.localView + ? { + // Tell the agent to bind on an OS-assigned + // port; the agent then reports the actual + // port back via sessionContext.setLocalHostPort + // (now: PortRegistrar.register). + localHostPort: 0, + } + : undefined; + + if (options !== undefined) { + if (settings === undefined) { + settings = {}; + } + settings.options = options; } - settings.options = options; + agentContext = await callEnsureError(() => + appAgent.initializeAgentContext!(settings), + ); } - agentContext = await callEnsureError(() => - appAgent.initializeAgentContext!(settings), + record.sessionContext = createSessionContext( + record.name, + agentContext, + context, + record.manifest.allowDynamicAgents === true, + sessionContextId, ); - } - record.sessionContext = createSessionContext( - record.name, - agentContext, - context, - record.manifest.allowDynamicAgents === true, - ); - debug(`Session context created for ${record.name}`); - return record.sessionContext; + debug(`Session context created for ${record.name}`); + return record.sessionContext; + } catch (e) { + // Init failed. Release any ports the partially-initialized + // agent managed to register before the failure, then clear + // the id so the next ensureSessionContext gets a fresh one. + this.portRegistrar.releaseAllForSession(sessionContextId); + record.sessionContextId = undefined; + throw e; + } } private async checkCloseSessionContext(record: AppAgentRecord) { @@ -1166,11 +1221,20 @@ export class AppAgentManager implements ActionConfigProvider { } } private async closeSessionContext(record: AppAgentRecord) { - if (record.sessionContextP !== undefined) { - const sessionContext = await record.sessionContextP; - record.sessionContext = undefined; - record.sessionContextP = undefined; + if (record.sessionContextP === undefined) { + return; + } + // Snapshot + clear up front so a re-entrant ensureSessionContext + // call (e.g. from inside an agent's closeAgentContext) gets a + // fresh init rather than racing with this teardown. + const sessionContextP = record.sessionContextP; + const sessionContextId = record.sessionContextId; + record.sessionContext = undefined; + record.sessionContextP = undefined; + record.sessionContextId = undefined; + try { try { + const sessionContext = await sessionContextP; // Since we have a session context, appAgent must be defined as well. const appAgent = record.appAgent!; if (appAgent.updateAgentContext !== undefined) { @@ -1193,6 +1257,20 @@ export class AppAgentManager implements ActionConfigProvider { ); // Ignore error } + } finally { + // Backstop: release any ports the agent registered but + // forgot to release. Runs even if sessionContextP rejected + // (partial init may have registered before throwing) and + // even if closeAgentContext threw. + if (sessionContextId !== undefined) { + const released = + this.portRegistrar.releaseAllForSession(sessionContextId); + if (released > 0) { + debug( + `Backstop released ${released} forgotten port allocation(s) for ${record.name}`, + ); + } + } } } diff --git a/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts b/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts index 7f9088891..1e3d32b52 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts @@ -61,6 +61,7 @@ import { getAppAgentStateSettings, SetStateResult, } from "./appAgentManager.js"; +import { PortRegistrar } from "./portRegistrar.js"; import { AppAgentInstaller, AppAgentProvider, @@ -129,6 +130,7 @@ export function ensureCommandResult( // Command Handler Context definition. export type CommandHandlerContext = { readonly agents: AppAgentManager; + readonly portRegistrar: PortRegistrar; readonly agentInstaller: AppAgentInstaller | undefined; session: Session; @@ -268,6 +270,16 @@ export type DispatcherOptions = DeepPartialUndefined & { // Agent port assignments allowSharedLocalView?: string[]; // agents that can access any shared local views, default to undefined + /** + * Optional pre-built {@link PortRegistrar} the host (e.g. agentServer) + * shares across all dispatchers in the process so external clients can + * discover any agent's port regardless of which conversation it's + * loaded into. If omitted, each dispatcher creates its own + * process-private registrar — the right default for standalone + * hosts (shell, CLI) that don't expose external discovery. + */ + portRegistrar?: PortRegistrar; + // Indexing service discovery indexingServiceRegistry?: IndexingServiceRegistry; // registry for indexing service discovery @@ -574,14 +586,17 @@ export async function initializeCommandHandlerContext( if (embeddingCacheDir) { ensureDirectory(embeddingCacheDir); } + const portRegistrar = options?.portRegistrar ?? new PortRegistrar(); const agents = new AppAgentManager( cacheDir, + portRegistrar, options?.allowSharedLocalView, options?.agentInitOptions, ); const constructionProvider = options?.constructionProvider; const context: CommandHandlerContext = { agents, + portRegistrar, agentInstaller: options?.agentInstaller, session, persistDir, diff --git a/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts b/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts index 5bdee3e89..85268d5b0 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts @@ -128,7 +128,16 @@ export class PortRegistrar { debug( `re-register ${agentName}/${role} session=${sessionContextId} port=${allocation.port}->${port} regId=${existing}`, ); + // Delete + reinsert so Map insertion order reflects + // recency. lookup() relies on insertion order to pick + // the most recent registration for a given (agentName, + // role) tuple; in-place mutation would leave the + // updated entry in its original slot and a more recent + // *different-session* registration would incorrectly + // win the lookup. allocation.port = port; + this.allocations.delete(existing); + this.allocations.set(existing, allocation); return existing; } // Index entry was stale; fall through to fresh insert. diff --git a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts index 2764078dc..2b852f369 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts @@ -17,6 +17,7 @@ export function createSessionContext( agentContext: T, context: CommandHandlerContext, allowDynamicAgent: boolean, + sessionContextId: string, ): SessionContext { const sessionDirPath = context.session.getSessionDirPath(); const storageProvider = context.storageProvider; @@ -90,6 +91,9 @@ export function createSessionContext( get instanceStorage() { return instanceStorage; }, + get sessionContextId() { + return sessionContextId; + }, notify( event: AppAgentEvent, message: string | DisplayContent, @@ -144,6 +148,17 @@ export function createSessionContext( setLocalHostPort(port: number) { context.agents.setLocalHostPort(name, port); }, + registerPort(role: string, port: number) { + const regId = context.portRegistrar.register( + name, + role, + port, + sessionContextId, + ); + return { + release: () => context.portRegistrar.release(regId), + }; + }, indexes(type: string): Promise { return new Promise((resolve, reject) => { const iidx: IndexData[] = diff --git a/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts b/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts index c1072fd0d..f7b7f6fbe 100644 --- a/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts @@ -138,6 +138,19 @@ describe("PortRegistrar", () => { r.release(id2); expect(r.lookup("browser", "ws")).toBe(1); }); + + test("re-registering an older session moves it to most-recent (regression: insertion-order)", () => { + // SID_A registers first, then SID_B (newer). Without the + // delete+reinsert fix, an SID_A re-register would update + // its port in place but leave SID_B as the most-recent + // entry by insertion order, so lookup would still return + // SID_B's port. + const r = new PortRegistrar(); + r.register("browser", "ws", 1, SID_A); + r.register("browser", "ws", 2, SID_B); + r.register("browser", "ws", 99, SID_A); // SID_A re-registers + expect(r.lookup("browser", "ws")).toBe(99); + }); }); describe("hasActiveAllocations", () => { diff --git a/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts b/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts index 54177b4bc..3f47dbec4 100644 --- a/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts @@ -31,6 +31,10 @@ function makeContext(overrides: { getSharedLocalHostPort: async () => undefined, setLocalHostPort: () => {}, }, + portRegistrar: { + register: () => "test-reg-id", + release: () => {}, + }, clientIO: { notify: () => {}, question: async () => 0, @@ -49,7 +53,7 @@ describe("createSessionContext storage routing", () => { instanceDir: "/global/instance", persistDir: "/session/persist", }); - createSessionContext("myAgent", {}, context, false); + createSessionContext("myAgent", {}, context, false, "test-session-id"); const instanceCall = calls.find((c) => c.name === "myAgent"); expect(instanceCall?.baseDir).toBe("/global/instance"); }); @@ -59,7 +63,7 @@ describe("createSessionContext storage routing", () => { instanceDir: undefined, persistDir: "/session/persist", }); - createSessionContext("myAgent", {}, context, false); + createSessionContext("myAgent", {}, context, false, "test-session-id"); const instanceCall = calls.find((c) => c.name === "myAgent"); expect(instanceCall?.baseDir).toBe("/session/persist"); }); @@ -73,8 +77,8 @@ describe("createSessionContext storage routing", () => { instanceDir: "/global/instance", persistDir: "/session/session-2", }); - createSessionContext("myAgent", {}, ctx1, false); - createSessionContext("myAgent", {}, ctx2, false); + createSessionContext("myAgent", {}, ctx1, false, "test-session-id"); + createSessionContext("myAgent", {}, ctx2, false, "test-session-id"); expect(calls1.find((c) => c.name === "myAgent")?.baseDir).toBe( "/global/instance", ); @@ -88,7 +92,7 @@ describe("createSessionContext storage routing", () => { instanceDir: undefined, persistDir: undefined, }); - const sessionCtx = createSessionContext("myAgent", {}, context, false); + const sessionCtx = createSessionContext("myAgent", {}, context, false, "test-session-id"); expect(sessionCtx.instanceStorage).toBeUndefined(); }); }); @@ -124,3 +128,4 @@ describe("initializeCommandHandlerContext option validation", () => { ); }); }); + From e2f6f88a33fc868fac6cca8cd1b5fbb9b585b7a7 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 12 May 2026 16:49:45 -0700 Subject: [PATCH 3/7] agentServer: add discovery channel + consolidate AGENT_SERVER_PORT Sub-PR C of port-registrar-foundation. Wires PortRegistrar (sub-PR B) into the agentServer host and exposes a read-only lookup API to external clients. Protocol (agent-server-protocol): - AGENT_SERVER_DEFAULT_PORT (8999), AGENT_SERVER_DEFAULT_URL constants - DiscoveryChannelName = 'discovery', DiscoveryInvokeFunctions { lookupPort } agentServer: - Constructs a process-wide PortRegistrar and threads it through baseOptions.portRegistrar to every conversation's dispatcher - Mounts the discovery channel alongside the agent-server channel on each WS connection (multiplexed on the same socket); lookupPort(agent, role) returns {port|null} - scheduleIdleShutdown() now bails when registrar.hasActiveAllocations() so cached extension clients can reconnect - Calls registrar.setAgentServerPort(port) once the WS server is listening so 'agent-server' itself is discoverable webSocketChannelServer: - New WebSocketChannelServerOptions.originAllowlist (case-insensitive, supports '*' suffix prefix-match; no-Origin always allowed for native clients). Permissive default for v1; agentServer opts in later. Port literal consolidation (8999 -> AGENT_SERVER_DEFAULT_PORT/_URL): - agent-server: stop.ts, status.ts - agent-server-client: agentServerClient.ts default args - CLI: 12 command files - vscode-shell, visualStudio webview (dispatcherConnection + main banner), browser extension service worker, uriHandler, commandExecutor, shell args Dispatcher exports PortRegistrar/PortAllocation/PortRegistrationId so hosts (agentServer today, future shell/web) can inject one. Build green; 27/27 portRegistrar+sessionContext tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../client/src/agentServerClient.ts | 10 ++-- ts/packages/agentServer/client/src/index.ts | 4 ++ ts/packages/agentServer/protocol/src/index.ts | 4 ++ .../agentServer/protocol/src/protocol.ts | 38 ++++++++++++ ts/packages/agentServer/server/src/server.ts | 49 ++++++++++++++- ts/packages/agentServer/server/src/status.ts | 10 +++- ts/packages/agentServer/server/src/stop.ts | 10 +++- .../serviceWorker/dispatcherConnection.ts | 3 +- .../host/webview/src/dispatcherConnection.ts | 3 +- .../visualStudio/host/webview/src/main.ts | 3 +- ts/packages/cli/src/commands/connect.ts | 3 +- .../cli/src/commands/conversations/create.ts | 7 ++- .../cli/src/commands/conversations/delete.ts | 7 ++- .../cli/src/commands/conversations/list.ts | 7 ++- .../cli/src/commands/conversations/rename.ts | 7 ++- ts/packages/cli/src/commands/replay.ts | 3 +- ts/packages/cli/src/commands/run/explain.ts | 3 +- ts/packages/cli/src/commands/run/request.ts | 3 +- ts/packages/cli/src/commands/run/translate.ts | 3 +- ts/packages/cli/src/commands/server/status.ts | 7 ++- ts/packages/cli/src/commands/server/stop.ts | 7 ++- ts/packages/cli/src/slashCommands.ts | 7 ++- .../commandExecutor/src/commandServer.ts | 7 ++- .../dispatcher/dispatcher/src/index.ts | 5 ++ ts/packages/shell/src/main/args.ts | 3 +- ts/packages/uriHandler/src/index.ts | 7 ++- .../webSocketChannelServer/src/server.ts | 59 ++++++++++++++++++- .../vscode-shell/src/agentServerBridge.ts | 3 +- 28 files changed, 243 insertions(+), 39 deletions(-) diff --git a/ts/packages/agentServer/client/src/agentServerClient.ts b/ts/packages/agentServer/client/src/agentServerClient.ts index 47aab5e8d..068113e0b 100644 --- a/ts/packages/agentServer/client/src/agentServerClient.ts +++ b/ts/packages/agentServer/client/src/agentServerClient.ts @@ -18,6 +18,7 @@ import registerDebug from "debug"; import { AgentServerInvokeFunctions, AgentServerChannelName, + AGENT_SERVER_DEFAULT_PORT, DispatcherConnectOptions, ConversationInfo, JoinConversationResult, @@ -435,7 +436,7 @@ async function waitForServer( } export async function ensureAgentServer( - port: number = 8999, + port: number = AGENT_SERVER_DEFAULT_PORT, hidden: boolean = false, idleTimeout: number = 0, ): Promise { @@ -459,7 +460,7 @@ export async function ensureAgentServer( export async function ensureAndConnectDispatcher( clientIO: ClientIO, - port: number = 8999, + port: number = AGENT_SERVER_DEFAULT_PORT, options?: DispatcherConnectOptions, onDisconnect?: () => void, hidden: boolean = false, @@ -471,7 +472,7 @@ export async function ensureAndConnectDispatcher( export async function ensureAndConnectConversation( clientIO: ClientIO, - port: number = 8999, + port: number = AGENT_SERVER_DEFAULT_PORT, options?: DispatcherConnectOptions, onDisconnect?: () => void, hidden: boolean = false, @@ -488,7 +489,7 @@ export async function ensureAndConnectConversation( } export async function stopAgentServer( - port: number = 8999, + port: number = AGENT_SERVER_DEFAULT_PORT, force: boolean = false, ): Promise { const url = `ws://localhost:${port}`; @@ -583,3 +584,4 @@ export async function connectDispatcher( }; return dispatcher; } + diff --git a/ts/packages/agentServer/client/src/index.ts b/ts/packages/agentServer/client/src/index.ts index a74f58ee7..616e25606 100644 --- a/ts/packages/agentServer/client/src/index.ts +++ b/ts/packages/agentServer/client/src/index.ts @@ -20,3 +20,7 @@ export type { JoinConversationResult, DispatcherConnectOptions, } from "@typeagent/agent-server-protocol"; +export { + AGENT_SERVER_DEFAULT_PORT, + AGENT_SERVER_DEFAULT_URL, +} from "@typeagent/agent-server-protocol"; diff --git a/ts/packages/agentServer/protocol/src/index.ts b/ts/packages/agentServer/protocol/src/index.ts index 182fe9781..ad52b1bf2 100644 --- a/ts/packages/agentServer/protocol/src/index.ts +++ b/ts/packages/agentServer/protocol/src/index.ts @@ -5,6 +5,10 @@ export { DispatcherConnectOptions, AgentServerInvokeFunctions, AgentServerChannelName, + AGENT_SERVER_DEFAULT_PORT, + AGENT_SERVER_DEFAULT_URL, + DiscoveryChannelName, + DiscoveryInvokeFunctions, ConversationInfo, JoinConversationResult, UserIdentity, diff --git a/ts/packages/agentServer/protocol/src/protocol.ts b/ts/packages/agentServer/protocol/src/protocol.ts index 6861854a4..f581850cf 100644 --- a/ts/packages/agentServer/protocol/src/protocol.ts +++ b/ts/packages/agentServer/protocol/src/protocol.ts @@ -67,6 +67,44 @@ export const DefaultUserIdentity: UserIdentity = { export const AgentServerChannelName = "agent-server"; +/** + * Channel name for the port-discovery RPC endpoint hosted by agent-server. + * External clients (browser extension, VS Code extension, CLI) open this + * channel to look up which port a given app-agent + role is currently bound + * to. The dispatcher's `PortRegistrar` is the source of truth. + */ +export const DiscoveryChannelName = "discovery"; + +/** + * Default TCP port the agent-server listens on. Centralized here so every + * client that defaults to "the local agent-server" stays in sync if we ever + * change it. Override via `--port`/`AGENT_SERVER_PORT` on the server side + * and via the `port` argument on the client side. + */ +export const AGENT_SERVER_DEFAULT_PORT = 8999; + +/** Convenience: the matching default WebSocket URL. */ +export const AGENT_SERVER_DEFAULT_URL = `ws://localhost:${AGENT_SERVER_DEFAULT_PORT}`; + +/** + * RPC surface for the discovery channel. Read-only on purpose: clients can + * ask "where is agent X's role Y?" but cannot mutate the registrar — only + * agents themselves (in-process, via SessionContext.registerPort) can do + * that. + */ +export type DiscoveryInvokeFunctions = { + /** + * Look up the port currently registered for `(agentName, role)`. + * Returns `null` (not undefined) so the JSON-RPC response is always a + * defined value; callers should treat null as "no allocation found, + * try again later" rather than a hard error. + */ + lookupPort: (param: { + agentName: string; + role: string; + }) => Promise<{ port: number | 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 1fddd4fa1..601a9cb32 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -22,6 +22,9 @@ import { createRpc } from "@typeagent/agent-rpc/rpc"; import { AgentServerInvokeFunctions, AgentServerChannelName, + AGENT_SERVER_DEFAULT_PORT, + DiscoveryChannelName, + DiscoveryInvokeFunctions, DispatcherConnectOptions, UserIdentity, getDispatcherChannelName, @@ -29,6 +32,7 @@ import { } from "@typeagent/agent-server-protocol"; import type { ChannelProvider } from "@typeagent/agent-rpc/channel"; import type { Dispatcher } from "agent-dispatcher"; +import { PortRegistrar } from "agent-dispatcher"; import dotenv from "dotenv"; import { writeServerPid, @@ -155,6 +159,14 @@ async function main() { configIdx !== -1 ? process.argv[configIdx + 1] : undefined; debugStartup("creating conversation manager (will lockInstanceDir)"); + // Single PortRegistrar shared across every conversation in this + // process. Lets external clients (browser extension, VS Code, CLI) + // discover any agent's port via the discovery channel regardless of + // which conversation that agent is loaded into. Standalone hosts + // (shell, CLI dispatcher) skip this and let each dispatcher mint + // its own — see DispatcherOptions.portRegistrar in agent-dispatcher. + const portRegistrar = new PortRegistrar(); + const conversationManager: ConversationManager = await createConversationManager( "agent server", @@ -178,6 +190,7 @@ async function main() { actionResultKnowledgeExtraction: false, }, collectCommandResult: true, + portRegistrar, }, instanceDir, ); @@ -195,7 +208,7 @@ async function main() { ? parseInt(process.argv[portIdx + 1], 10) : process.env.AGENT_SERVER_PORT ? parseInt(process.env.AGENT_SERVER_PORT, 10) - : 8999; + : AGENT_SERVER_DEFAULT_PORT; const idleShutdownIdx = process.argv.indexOf("--idle-timeout"); const idleShutdownMs = @@ -221,6 +234,17 @@ async function main() { if (idleShutdownMs <= 0 || connectionCount > 0) { return; } + // Don't tear the process down while an agent still has a port + // registered: out-of-process clients (Chrome/VS Code extension) + // may have cached that port and could try to reconnect at any + // moment. Once the agent releases (or its session-context + // backstop fires), the next disconnect will re-arm this timer. + if (portRegistrar.hasActiveAllocations()) { + debugStartup( + "skipping idle shutdown: PortRegistrar still has active allocations", + ); + return; + } idleShutdownTimer = setTimeout(async () => { console.log( "No clients connected — idle shutdown after " + @@ -416,9 +440,32 @@ async function main() { channelProvider.createChannel(AgentServerChannelName), invokeFunctions, ); + + // Discovery channel: read-only port lookup for external + // clients (browser extension, VS Code extension, CLI). Hosted + // 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 }) => { + const port = portRegistrar.lookup(agentName, role); + return { port: port ?? null }; + }, + }; + createRpc( + "agent-server:discovery", + channelProvider.createChannel(DiscoveryChannelName), + discoveryFunctions, + ); }, ); + // Tell the registrar which port we're bound to so it can warn agents + // that try to register the same one (a foot-gun: agent-server's WS + // would silently shadow the agent's listener). + portRegistrar.setAgentServerPort(port); + console.log(`Agent server started at ws://localhost:${port}`); writeServerPid(port, process.pid); scheduleIdleShutdown(); diff --git a/ts/packages/agentServer/server/src/status.ts b/ts/packages/agentServer/server/src/status.ts index 714c12e9e..0c25b2307 100644 --- a/ts/packages/agentServer/server/src/status.ts +++ b/ts/packages/agentServer/server/src/status.ts @@ -1,10 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { isServerRunning } from "@typeagent/agent-server-client"; +import { + isServerRunning, + AGENT_SERVER_DEFAULT_PORT, +} from "@typeagent/agent-server-client"; const portIdx = process.argv.indexOf("--port"); -const port = portIdx !== -1 ? parseInt(process.argv[portIdx + 1]) : 8999; +const port = + portIdx !== -1 + ? parseInt(process.argv[portIdx + 1]) + : AGENT_SERVER_DEFAULT_PORT; const running = await isServerRunning(`ws://localhost:${port}`); if (running) { diff --git a/ts/packages/agentServer/server/src/stop.ts b/ts/packages/agentServer/server/src/stop.ts index 54f3d3dfc..e21f2bcc1 100644 --- a/ts/packages/agentServer/server/src/stop.ts +++ b/ts/packages/agentServer/server/src/stop.ts @@ -1,9 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { stopAgentServer } from "@typeagent/agent-server-client"; +import { + stopAgentServer, + AGENT_SERVER_DEFAULT_PORT, +} from "@typeagent/agent-server-client"; const portIdx = process.argv.indexOf("--port"); -const port = portIdx !== -1 ? parseInt(process.argv[portIdx + 1]) : 8999; +const port = + portIdx !== -1 + ? parseInt(process.argv[portIdx + 1]) + : AGENT_SERVER_DEFAULT_PORT; await stopAgentServer(port); diff --git a/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts b/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts index 42fc39a2a..b29c2e472 100644 --- a/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts +++ b/ts/packages/agents/browser/src/extension/serviceWorker/dispatcherConnection.ts @@ -26,6 +26,7 @@ import { getDispatcherChannelName, getClientIOChannelName, AgentServerChannelName, + AGENT_SERVER_DEFAULT_URL, } from "@typeagent/agent-server-protocol"; import registerDebug from "debug"; @@ -33,7 +34,7 @@ import registerDebug from "debug"; const debug = registerDebug("typeagent:extension:dispatcher"); const debugErr = registerDebug("typeagent:extension:dispatcher:error"); -const DEFAULT_AGENT_SERVER_URL = "ws://localhost:8999"; +const DEFAULT_AGENT_SERVER_URL = AGENT_SERVER_DEFAULT_URL; // Module-level dispatcher state let dispatcher: Dispatcher | undefined; diff --git a/ts/packages/agents/visualStudio/host/webview/src/dispatcherConnection.ts b/ts/packages/agents/visualStudio/host/webview/src/dispatcherConnection.ts index af9e71f66..182b46cf4 100644 --- a/ts/packages/agents/visualStudio/host/webview/src/dispatcherConnection.ts +++ b/ts/packages/agents/visualStudio/host/webview/src/dispatcherConnection.ts @@ -13,6 +13,7 @@ import { createDispatcherRpcClient } from "@typeagent/dispatcher-rpc/dispatcher/ import type { ClientIO, Dispatcher } from "@typeagent/dispatcher-rpc/types"; import { AgentServerChannelName, + AGENT_SERVER_DEFAULT_URL, DispatcherConnectOptions, JoinConversationResult, AgentServerInvokeFunctions, @@ -22,7 +23,7 @@ import { import type { ChatPanel } from "chat-ui"; import type { DisplayAppendMode, DisplayContent } from "@typeagent/agent-sdk"; -const DEFAULT_AGENT_SERVER_URL = "ws://localhost:8999"; +const DEFAULT_AGENT_SERVER_URL = AGENT_SERVER_DEFAULT_URL; export interface DispatcherHandle { dispatcher: Dispatcher; diff --git a/ts/packages/agents/visualStudio/host/webview/src/main.ts b/ts/packages/agents/visualStudio/host/webview/src/main.ts index 95e0a1285..473e0bd4f 100644 --- a/ts/packages/agents/visualStudio/host/webview/src/main.ts +++ b/ts/packages/agents/visualStudio/host/webview/src/main.ts @@ -9,6 +9,7 @@ import { connectDispatcher, type DispatcherHandle, } from "./dispatcherConnection.js"; +import { AGENT_SERVER_DEFAULT_URL } from "@typeagent/agent-server-protocol"; let chatPanel: ChatPanel; let dispatcherHandle: DispatcherHandle | undefined; @@ -125,7 +126,7 @@ function setConnectionStatus(connected: boolean) { }, 3000); } else { banner.textContent = - "Not connected — ensure agent-server is running on ws://localhost:8999"; + `Not connected — ensure agent-server is running on ${AGENT_SERVER_DEFAULT_URL}`; banner.className = "connection-banner"; banner.style.display = ""; chatPanel.setEnabled(false); diff --git a/ts/packages/cli/src/commands/connect.ts b/ts/packages/cli/src/commands/connect.ts index 72b0e98d8..9bd7b4d9f 100644 --- a/ts/packages/cli/src/commands/connect.ts +++ b/ts/packages/cli/src/commands/connect.ts @@ -20,6 +20,7 @@ import { connectAgentServer, ensureAgentServer, AgentServerConnection, + AGENT_SERVER_DEFAULT_PORT, } from "@typeagent/agent-server-client"; import { getStatusSummary } from "@typeagent/dispatcher-types/helpers/status"; import * as crypto from "crypto"; @@ -95,7 +96,7 @@ export default class Connect extends Command { port: Flags.integer({ char: "p", description: "Port for type agent server", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), resume: Flags.boolean({ char: "r", diff --git a/ts/packages/cli/src/commands/conversations/create.ts b/ts/packages/cli/src/commands/conversations/create.ts index 44a9c1a0a..fbcdb34e8 100644 --- a/ts/packages/cli/src/commands/conversations/create.ts +++ b/ts/packages/cli/src/commands/conversations/create.ts @@ -2,7 +2,10 @@ // Licensed under the MIT License. import { Args, Command, Flags } from "@oclif/core"; -import { connectAgentServer } from "@typeagent/agent-server-client"; +import { + connectAgentServer, + AGENT_SERVER_DEFAULT_PORT, +} from "@typeagent/agent-server-client"; export default class ConversationsCreate extends Command { static description = @@ -10,7 +13,7 @@ export default class ConversationsCreate extends Command { static flags = { port: Flags.integer({ description: "Port for type agent server", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), }; static args = { diff --git a/ts/packages/cli/src/commands/conversations/delete.ts b/ts/packages/cli/src/commands/conversations/delete.ts index d7d95251e..383bec049 100644 --- a/ts/packages/cli/src/commands/conversations/delete.ts +++ b/ts/packages/cli/src/commands/conversations/delete.ts @@ -2,7 +2,10 @@ // Licensed under the MIT License. import { Args, Command, Flags } from "@oclif/core"; -import { connectAgentServer } from "@typeagent/agent-server-client"; +import { + connectAgentServer, + AGENT_SERVER_DEFAULT_PORT, +} from "@typeagent/agent-server-client"; import { createInterface } from "readline/promises"; export default class ConversationsDelete extends Command { @@ -11,7 +14,7 @@ export default class ConversationsDelete extends Command { static flags = { port: Flags.integer({ description: "Port for type agent server", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), yes: Flags.boolean({ char: "y", diff --git a/ts/packages/cli/src/commands/conversations/list.ts b/ts/packages/cli/src/commands/conversations/list.ts index 373f163a6..4957d47c9 100644 --- a/ts/packages/cli/src/commands/conversations/list.ts +++ b/ts/packages/cli/src/commands/conversations/list.ts @@ -2,7 +2,10 @@ // Licensed under the MIT License. import { Command, Flags } from "@oclif/core"; -import { connectAgentServer } from "@typeagent/agent-server-client"; +import { + connectAgentServer, + AGENT_SERVER_DEFAULT_PORT, +} from "@typeagent/agent-server-client"; import type { ConversationInfo } from "@typeagent/agent-server-client"; function formatTable(conversations: ConversationInfo[]): string { @@ -59,7 +62,7 @@ export default class ConversationsList extends Command { static flags = { port: Flags.integer({ description: "Port for type agent server", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), name: Flags.string({ description: diff --git a/ts/packages/cli/src/commands/conversations/rename.ts b/ts/packages/cli/src/commands/conversations/rename.ts index e42144665..13bcdb4e9 100644 --- a/ts/packages/cli/src/commands/conversations/rename.ts +++ b/ts/packages/cli/src/commands/conversations/rename.ts @@ -2,7 +2,10 @@ // Licensed under the MIT License. import { Args, Command, Flags } from "@oclif/core"; -import { connectAgentServer } from "@typeagent/agent-server-client"; +import { + connectAgentServer, + AGENT_SERVER_DEFAULT_PORT, +} from "@typeagent/agent-server-client"; export default class ConversationsRename extends Command { static description = @@ -10,7 +13,7 @@ export default class ConversationsRename extends Command { static flags = { port: Flags.integer({ description: "Port for type agent server", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), }; static args = { diff --git a/ts/packages/cli/src/commands/replay.ts b/ts/packages/cli/src/commands/replay.ts index 33415a285..5b48f22da 100644 --- a/ts/packages/cli/src/commands/replay.ts +++ b/ts/packages/cli/src/commands/replay.ts @@ -5,6 +5,7 @@ import { Args, Command, Flags } from "@oclif/core"; import { connectAgentServer, ensureAgentServer, + AGENT_SERVER_DEFAULT_PORT, } from "@typeagent/agent-server-client"; import { ChatHistoryInput, @@ -57,7 +58,7 @@ export default class ReplayCommand extends Command { port: Flags.integer({ char: "p", description: "Port for type agent server", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), show: Flags.boolean({ description: diff --git a/ts/packages/cli/src/commands/run/explain.ts b/ts/packages/cli/src/commands/run/explain.ts index 36d5cf92d..7a0dea4ee 100644 --- a/ts/packages/cli/src/commands/run/explain.ts +++ b/ts/packages/cli/src/commands/run/explain.ts @@ -8,6 +8,7 @@ import { connectAgentServer, ensureAgentServer, AgentServerConnection, + AGENT_SERVER_DEFAULT_PORT, } from "@typeagent/agent-server-client"; import { withConsoleClientIO } from "agent-dispatcher/helpers/console"; @@ -50,7 +51,7 @@ export default class ExplainCommand extends Command { port: Flags.integer({ char: "p", description: "Port for type agent server", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), show: Flags.boolean({ description: diff --git a/ts/packages/cli/src/commands/run/request.ts b/ts/packages/cli/src/commands/run/request.ts index c801e1aa9..cf897233b 100644 --- a/ts/packages/cli/src/commands/run/request.ts +++ b/ts/packages/cli/src/commands/run/request.ts @@ -6,6 +6,7 @@ import { connectAgentServer, ensureAgentServer, AgentServerConnection, + AGENT_SERVER_DEFAULT_PORT, } from "@typeagent/agent-server-client"; import { withConsoleClientIO } from "agent-dispatcher/helpers/console"; import { readFileSync, existsSync } from "fs"; @@ -29,7 +30,7 @@ export default class RequestCommand extends Command { port: Flags.integer({ char: "p", description: "Port for type agent server", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), show: Flags.boolean({ description: diff --git a/ts/packages/cli/src/commands/run/translate.ts b/ts/packages/cli/src/commands/run/translate.ts index 479a6c6bc..9ad495ac4 100644 --- a/ts/packages/cli/src/commands/run/translate.ts +++ b/ts/packages/cli/src/commands/run/translate.ts @@ -6,6 +6,7 @@ import { connectAgentServer, ensureAgentServer, AgentServerConnection, + AGENT_SERVER_DEFAULT_PORT, } from "@typeagent/agent-server-client"; import { withConsoleClientIO } from "agent-dispatcher/helpers/console"; @@ -24,7 +25,7 @@ export default class TranslateCommand extends Command { port: Flags.integer({ char: "p", description: "Port for type agent server", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), show: Flags.boolean({ description: diff --git a/ts/packages/cli/src/commands/server/status.ts b/ts/packages/cli/src/commands/server/status.ts index 1831a59b1..e53198f58 100644 --- a/ts/packages/cli/src/commands/server/status.ts +++ b/ts/packages/cli/src/commands/server/status.ts @@ -2,7 +2,10 @@ // Licensed under the MIT License. import { Command, Flags } from "@oclif/core"; -import { isServerRunning } from "@typeagent/agent-server-client"; +import { + isServerRunning, + AGENT_SERVER_DEFAULT_PORT, +} from "@typeagent/agent-server-client"; export default class ServerStatus extends Command { static description = "Show whether the TypeAgent server is running"; @@ -10,7 +13,7 @@ export default class ServerStatus extends Command { port: Flags.integer({ char: "p", description: "Port to check", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), }; async run(): Promise { diff --git a/ts/packages/cli/src/commands/server/stop.ts b/ts/packages/cli/src/commands/server/stop.ts index ca82e4aff..461aad0b5 100644 --- a/ts/packages/cli/src/commands/server/stop.ts +++ b/ts/packages/cli/src/commands/server/stop.ts @@ -2,7 +2,10 @@ // Licensed under the MIT License. import { Command, Flags } from "@oclif/core"; -import { stopAgentServer } from "@typeagent/agent-server-client"; +import { + stopAgentServer, + AGENT_SERVER_DEFAULT_PORT, +} from "@typeagent/agent-server-client"; export default class ServerStop extends Command { static description = "Stop the running TypeAgent server"; @@ -10,7 +13,7 @@ export default class ServerStop extends Command { port: Flags.integer({ char: "p", description: "Port the agent server is listening on", - default: 8999, + default: AGENT_SERVER_DEFAULT_PORT, }), force: Flags.boolean({ char: "f", diff --git a/ts/packages/cli/src/slashCommands.ts b/ts/packages/cli/src/slashCommands.ts index 79510bc91..d2576be13 100644 --- a/ts/packages/cli/src/slashCommands.ts +++ b/ts/packages/cli/src/slashCommands.ts @@ -3,7 +3,10 @@ import registerDebug from "debug"; import chalk from "chalk"; -import { stopAgentServer } from "@typeagent/agent-server-client"; +import { + stopAgentServer, + AGENT_SERVER_DEFAULT_PORT, +} from "@typeagent/agent-server-client"; import type { ConversationCommandContext } from "./conversationCommands.js"; import { handleConversationCommand } from "./conversationCommands.js"; @@ -184,7 +187,7 @@ const slashCommands: SlashCommand[] = [ name: "shutdown", description: "Shut down the agent server", handler: async () => { - const port = serverPort ?? 8999; + const port = serverPort ?? AGENT_SERVER_DEFAULT_PORT; console.log( chalk.dim( `Sending shutdown request to server on port ${port}...`, diff --git a/ts/packages/commandExecutor/src/commandServer.ts b/ts/packages/commandExecutor/src/commandServer.ts index bb4ada7ae..45e3dabea 100644 --- a/ts/packages/commandExecutor/src/commandServer.ts +++ b/ts/packages/commandExecutor/src/commandServer.ts @@ -5,7 +5,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod/v4"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { connectDispatcher } from "@typeagent/agent-server-client"; +import { + connectDispatcher, + AGENT_SERVER_DEFAULT_URL, +} from "@typeagent/agent-server-client"; import type { AgentSchemaInfo, ClientIO, @@ -338,7 +341,7 @@ export class CommandServer { this.agentServerUrl = agentServerUrl ?? process.env.AGENT_SERVER_URL ?? - "ws://localhost:8999"; + AGENT_SERVER_DEFAULT_URL; this.logger.log(`CommandServer initializing.`); this.logger.log(`TypeAgent server URL: ${this.agentServerUrl}`); diff --git a/ts/packages/dispatcher/dispatcher/src/index.ts b/ts/packages/dispatcher/dispatcher/src/index.ts index f8dd273e1..728224d3e 100644 --- a/ts/packages/dispatcher/dispatcher/src/index.ts +++ b/ts/packages/dispatcher/dispatcher/src/index.ts @@ -4,6 +4,11 @@ export { createDispatcher } from "./dispatcher.js"; export { IndexManager } from "./context/indexManager.js"; export type { DispatcherOptions } from "./context/commandHandlerContext.js"; +export { PortRegistrar } from "./context/portRegistrar.js"; +export type { + Allocation as PortAllocation, + RegistrationId as PortRegistrationId, +} from "./context/portRegistrar.js"; export type { AppAgentProvider, AppAgentInstaller, diff --git a/ts/packages/shell/src/main/args.ts b/ts/packages/shell/src/main/args.ts index 2a556d8dc..ead65d4dc 100644 --- a/ts/packages/shell/src/main/args.ts +++ b/ts/packages/shell/src/main/args.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { debugShell } from "./debug.js"; +import { AGENT_SERVER_DEFAULT_PORT } from "@typeagent/agent-server-client"; type ShellCommandLineArgs = { reset: boolean; @@ -163,7 +164,7 @@ export function parseShellCommandLine() { result.connect = port; } } else { - result.connect = 8999; // default port + result.connect = AGENT_SERVER_DEFAULT_PORT; // default port } continue; } diff --git a/ts/packages/uriHandler/src/index.ts b/ts/packages/uriHandler/src/index.ts index 0797faca0..9ab6bd078 100644 --- a/ts/packages/uriHandler/src/index.ts +++ b/ts/packages/uriHandler/src/index.ts @@ -2,10 +2,13 @@ // Licensed under the MIT License. import { withConsoleClientIO } from "agent-dispatcher/helpers/console"; -import { connectDispatcher } from "@typeagent/agent-server-client"; +import { + connectDispatcher, + AGENT_SERVER_DEFAULT_PORT, +} from "@typeagent/agent-server-client"; function parseArgs() { - let port: number = 8999; + let port: number = AGENT_SERVER_DEFAULT_PORT; let uri: string | undefined = undefined; for (let i = 2; i < process.argv.length; i++) { const arg = process.argv[i]; diff --git a/ts/packages/utils/webSocketChannelServer/src/server.ts b/ts/packages/utils/webSocketChannelServer/src/server.ts index 3799bd132..a6c4d3170 100644 --- a/ts/packages/utils/webSocketChannelServer/src/server.ts +++ b/ts/packages/utils/webSocketChannelServer/src/server.ts @@ -17,14 +17,69 @@ let nextId = 0; type WebSocketChannelServer = { close: () => void; }; + +/** + * Extra options layered on top of `ws.ServerOptions` for our transport. + */ +export type WebSocketChannelServerOptions = WebSocket.ServerOptions & { + /** + * Optional allowlist of acceptable Origin header values. If set, any + * upgrade with an Origin header NOT in this list (case-insensitive, + * exact match unless the entry ends in `*` for prefix match) is + * rejected with HTTP 403. Connections without an Origin header + * (native apps, CLI clients) are always allowed — Origin is a + * browser-set header, so its absence is not itself a signal of + * privilege. When omitted, the upgrade is accepted regardless of + * Origin (current default behavior). + */ + originAllowlist?: string[]; +}; + +function isOriginAllowed(origin: string, allowlist: string[]): boolean { + const lower = origin.toLowerCase(); + return allowlist.some((entry) => { + const e = entry.toLowerCase(); + if (e.endsWith("*")) { + return lower.startsWith(e.slice(0, -1)); + } + return lower === e; + }); +} + export async function createWebSocketChannelServer( - options: WebSocket.ServerOptions, + options: WebSocketChannelServerOptions, onConnection: ( channelProvider: ChannelProvider, closeFn: () => void, ) => void, ): Promise { - const wss = new WebSocketServer(options); + const { originAllowlist, ...wsOptions } = options; + // verifyClient runs synchronously during the HTTP upgrade; using it + // (rather than rejecting after `connection`) means denied clients + // never get to allocate a channelProvider or send any frames. + const wssOptions: WebSocket.ServerOptions = + originAllowlist !== undefined + ? { + ...wsOptions, + verifyClient: (info, cb) => { + const origin = info.origin; + if (!origin) { + // No Origin = native client (CLI, shell). Allow. + cb(true); + return; + } + if (isOriginAllowed(origin, originAllowlist)) { + cb(true); + return; + } + debugWssError( + `rejecting upgrade: origin '${origin}' not in allowlist`, + ); + cb(false, 403, "Origin not allowed"); + }, + } + : wsOptions; + const wss = new WebSocketServer(wssOptions); wss.on("connection", (ws) => { const id = nextId++; const debugId = `typeagent:transport:wss:ws-${id}`; diff --git a/ts/packages/vscode-shell/src/agentServerBridge.ts b/ts/packages/vscode-shell/src/agentServerBridge.ts index 0fb5d7b23..4bfa2d679 100644 --- a/ts/packages/vscode-shell/src/agentServerBridge.ts +++ b/ts/packages/vscode-shell/src/agentServerBridge.ts @@ -4,6 +4,7 @@ import * as vscode from "vscode"; import * as os from "os"; import { connectAgentServer } from "@typeagent/agent-server-client"; +import { AGENT_SERVER_DEFAULT_URL } from "@typeagent/agent-server-protocol"; import type { AgentServerConnection, ConversationDispatcher, @@ -440,7 +441,7 @@ export class AgentServerBridge { const config = vscode.workspace.getConfiguration("typeagent"); const serverUrl = config.get( "serverUrl", - "ws://localhost:8999", + AGENT_SERVER_DEFAULT_URL, ); try { From 3c066160149fa6fba208405702b6ae81916ba301 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 12 May 2026 19:17:28 -0700 Subject: [PATCH 4/7] address Copilot PR review comments on port-registrar PR 1. Discovery: special-case 'agent-server' agent name to return the live registered server port via portRegistrar.getAgentServerPort(), so external clients can discover the configured port even when it differs from the bootstrap port. New AGENT_SERVER_DISCOVERY_NAME constant in protocol. 2. Discovery: make 'role' optional on DiscoveryInvokeFunctions.lookupPort and on PortRegistrar.lookup (defaults to DEFAULT_ROLE), matching the documented intent and aligning with what setLocalHostPort registers. 3. agent-rpc client: track regIds per contextId in regIdsByContext and release any unreleased handles in the closeAgentContext wrapper. Prevents handle leaks when an out-of-process agent crashes or forgets to release. releasePort RPC param gains optional contextId so explicit releases also clean up the per-context index. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ts/packages/agentRpc/src/client.ts | 40 ++++++++++++++++++- ts/packages/agentRpc/src/server.ts | 5 ++- ts/packages/agentRpc/src/types.ts | 5 ++- ts/packages/agentServer/protocol/src/index.ts | 1 + .../agentServer/protocol/src/protocol.ts | 18 ++++++++- ts/packages/agentServer/server/src/server.ts | 10 +++++ .../dispatcher/src/context/portRegistrar.ts | 2 +- 7 files changed, 76 insertions(+), 5 deletions(-) diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index 3a904bc1f..fb399b0b3 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -190,6 +190,12 @@ export async function createAgentRpcClient( // Tracks port registration handles returned by sessionContext.registerPort // so the out-of-process agent can release them via the regId we sent back. const registrationHandles = new Map void }>(); + // Reverse index: which regIds belong to which agent context. Lets us + // release everything an out-of-process agent registered if it closes + // its context without calling releasePort for each one (crash, bug, + // forgetful agent). Without this, the agent could leak release closures + // for the lifetime of the RPC client. + const regIdsByContext = new Map>(); function getContextParam( context: SessionContext, ): ContextParams { @@ -340,12 +346,24 @@ export async function createAgentRpcClient( const handle = context.registerPort(param.role, param.port); const regId = randomUUID(); registrationHandles.set(regId, handle); + let regIds = regIdsByContext.get(param.contextId); + if (regIds === undefined) { + regIds = new Set(); + regIdsByContext.set(param.contextId, regIds); + } + regIds.add(regId); return { regId }; }, - releasePort: async (param: { regId: string }) => { + releasePort: async (param: { + regId: string; + contextId?: number; + }) => { const handle = registrationHandles.get(param.regId); if (handle !== undefined) { registrationHandles.delete(param.regId); + if (param.contextId !== undefined) { + regIdsByContext.get(param.contextId)?.delete(param.regId); + } handle.release(); } }, @@ -769,6 +787,26 @@ export async function createAgentRpcClient( const invokeCloseAgentContext = result.closeAgentContext; result.closeAgentContext = async (context: SessionContext) => { const result = await invokeCloseAgentContext?.(context); + const contextId = contextMap.getId(context); + // Backstop: release any port handles the out-of-process agent + // failed to release explicitly (crash, bug, or just forgot). Mirrors + // the dispatcher-side releaseAllForSession backstop so handles + // can't outlive the context they're scoped to. + const regIds = regIdsByContext.get(contextId); + if (regIds !== undefined) { + for (const regId of regIds) { + const handle = registrationHandles.get(regId); + if (handle !== undefined) { + registrationHandles.delete(regId); + try { + handle.release(); + } catch { + // Best-effort cleanup; swallow. + } + } + } + regIdsByContext.delete(contextId); + } contextMap.close(context); // Clean up the options RPC channel once this agent context is closed. // Options are agent-scoped (created once per initializeAgentContext call) diff --git a/ts/packages/agentRpc/src/server.ts b/ts/packages/agentRpc/src/server.ts index b706ffae4..710bcf5a1 100644 --- a/ts/packages/agentRpc/src/server.ts +++ b/ts/packages/agentRpc/src/server.ts @@ -548,7 +548,10 @@ export function createAgentRpcServer( release: () => { void regIdPromise .then((regId) => - rpc.invoke("releasePort", { regId }), + rpc.invoke("releasePort", { + regId, + contextId, + }), ) .catch(); }, diff --git a/ts/packages/agentRpc/src/types.ts b/ts/packages/agentRpc/src/types.ts index 0b5a9c578..15c58cc0b 100644 --- a/ts/packages/agentRpc/src/types.ts +++ b/ts/packages/agentRpc/src/types.ts @@ -153,7 +153,10 @@ export type AgentContextInvokeFunctions = { role: string; port: number; }) => Promise<{ regId: string }>; - releasePort: (param: { regId: string }) => Promise; + releasePort: (param: { + regId: string; + contextId?: number; + }) => Promise; indexes: (param: { contextId: number; type: string }) => Promise; reloadAgentSchema: (param: { contextId: number }) => Promise; popupQuestion: (param: { diff --git a/ts/packages/agentServer/protocol/src/index.ts b/ts/packages/agentServer/protocol/src/index.ts index ad52b1bf2..a7648fe34 100644 --- a/ts/packages/agentServer/protocol/src/index.ts +++ b/ts/packages/agentServer/protocol/src/index.ts @@ -7,6 +7,7 @@ export { AgentServerChannelName, AGENT_SERVER_DEFAULT_PORT, AGENT_SERVER_DEFAULT_URL, + AGENT_SERVER_DISCOVERY_NAME, DiscoveryChannelName, DiscoveryInvokeFunctions, ConversationInfo, diff --git a/ts/packages/agentServer/protocol/src/protocol.ts b/ts/packages/agentServer/protocol/src/protocol.ts index f581850cf..d40cb306e 100644 --- a/ts/packages/agentServer/protocol/src/protocol.ts +++ b/ts/packages/agentServer/protocol/src/protocol.ts @@ -86,6 +86,14 @@ export const AGENT_SERVER_DEFAULT_PORT = 8999; /** Convenience: the matching default WebSocket URL. */ export const AGENT_SERVER_DEFAULT_URL = `ws://localhost:${AGENT_SERVER_DEFAULT_PORT}`; +/** + * Well-known agent name for the agent-server itself. Used by external + * clients via `lookupPort` to discover the configured server port when + * they bootstrapped from a different known port. The agent-server + * special-cases this name in its discovery handler. + */ +export const AGENT_SERVER_DISCOVERY_NAME = "agent-server"; + /** * RPC surface for the discovery channel. Read-only on purpose: clients can * ask "where is agent X's role Y?" but cannot mutate the registrar — only @@ -95,13 +103,21 @@ export const AGENT_SERVER_DEFAULT_URL = `ws://localhost:${AGENT_SERVER_DEFAULT_P export type DiscoveryInvokeFunctions = { /** * Look up the port currently registered for `(agentName, role)`. + * `role` is optional; omit it (or pass undefined) to look up the + * default role — matches what `setLocalHostPort` registered for + * agents that pre-date the multi-role API. + * + * Special case: `agentName === "agent-server"` returns the + * agent-server's own listening port, so clients that bootstrap + * from a known port can discover the configured one. + * * Returns `null` (not undefined) so the JSON-RPC response is always a * defined value; callers should treat null as "no allocation found, * try again later" rather than a hard error. */ lookupPort: (param: { agentName: string; - role: string; + role?: string; }) => Promise<{ port: number | null }>; }; diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index 601a9cb32..da8347801 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -23,6 +23,7 @@ import { AgentServerInvokeFunctions, AgentServerChannelName, AGENT_SERVER_DEFAULT_PORT, + AGENT_SERVER_DISCOVERY_NAME, DiscoveryChannelName, DiscoveryInvokeFunctions, DispatcherConnectOptions, @@ -449,6 +450,15 @@ async function main() { // in-process SessionContext.registerPort. const discoveryFunctions: DiscoveryInvokeFunctions = { lookupPort: async ({ agentName, role }) => { + // Well-known: agent-server reports its own listening + // port so clients that bootstrap from a different + // known port can discover the configured one. This + // also keeps agent-server discoverable if its port + // ever becomes dynamic. + if (agentName === AGENT_SERVER_DISCOVERY_NAME) { + const port = portRegistrar.getAgentServerPort(); + return { port: port ?? null }; + } const port = portRegistrar.lookup(agentName, role); return { port: port ?? null }; }, diff --git a/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts b/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts index 85268d5b0..2adddf23d 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts @@ -219,7 +219,7 @@ export class PortRegistrar { * cross-agent `getSharedLocalHostPort` shim, gated by * `manifest.sharedLocalView`) layer it on top. */ - public lookup(agentName: string, role: string): number | undefined { + public lookup(agentName: string, role: string = DEFAULT_ROLE): number | undefined { // Walk in insertion order; randomUUID-keyed Map iteration order in // V8 is insertion-order-stable, so the last matching entry is the // most recent registration. From d884856a67fb8eba66371c8b9d6cd4243ce18984 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 12 May 2026 19:42:40 -0700 Subject: [PATCH 5/7] fix: prettier formatting for port-registrar PR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ts/packages/agentRpc/src/client.ts | 5 +---- ts/packages/agentRpc/src/server.ts | 4 +--- .../client/src/agentServerClient.ts | 1 - .../dispatcher/src/context/appAgentManager.ts | 20 +++++++++---------- .../dispatcher/src/context/portRegistrar.ts | 11 +++++----- .../dispatcher/test/portRegistrar.spec.ts | 4 +--- .../dispatcher/test/sessionContext.spec.ts | 9 +++++++-- 7 files changed, 25 insertions(+), 29 deletions(-) diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index fb399b0b3..8f8316898 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -354,10 +354,7 @@ export async function createAgentRpcClient( regIds.add(regId); return { regId }; }, - releasePort: async (param: { - regId: string; - contextId?: number; - }) => { + releasePort: async (param: { regId: string; contextId?: number }) => { const handle = registrationHandles.get(param.regId); if (handle !== undefined) { registrationHandles.delete(param.regId); diff --git a/ts/packages/agentRpc/src/server.ts b/ts/packages/agentRpc/src/server.ts index 710bcf5a1..854e794da 100644 --- a/ts/packages/agentRpc/src/server.ts +++ b/ts/packages/agentRpc/src/server.ts @@ -681,9 +681,7 @@ export function createAgentRpcServer( throw new Error("Invalid context param: missing hasSessionStorage"); } if (sessionContextId === undefined) { - throw new Error( - "Invalid context param: missing sessionContextId", - ); + throw new Error("Invalid context param: missing sessionContextId"); } const agentContext = diff --git a/ts/packages/agentServer/client/src/agentServerClient.ts b/ts/packages/agentServer/client/src/agentServerClient.ts index 068113e0b..220c287ba 100644 --- a/ts/packages/agentServer/client/src/agentServerClient.ts +++ b/ts/packages/agentServer/client/src/agentServerClient.ts @@ -584,4 +584,3 @@ export async function connectDispatcher( }; return dispatcher; } - diff --git a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts index 8b6d7c52c..937cdfd75 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts @@ -1421,16 +1421,16 @@ export class AppAgentManager implements ActionConfigProvider { let agentContext: unknown | undefined; if (appAgent.initializeAgentContext !== undefined) { const options = this.agentInitOptions?.[record.name]; - let settings: AppAgentInitSettings | undefined = - record.manifest.localView - ? { - // Tell the agent to bind on an OS-assigned - // port; the agent then reports the actual - // port back via sessionContext.setLocalHostPort - // (now: PortRegistrar.register). - localHostPort: 0, - } - : undefined; + let settings: AppAgentInitSettings | undefined = record.manifest + .localView + ? { + // Tell the agent to bind on an OS-assigned + // port; the agent then reports the actual + // port back via sessionContext.setLocalHostPort + // (now: PortRegistrar.register). + localHostPort: 0, + } + : undefined; if (options !== undefined) { if (settings === undefined) { diff --git a/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts b/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts index 2adddf23d..9076cffa9 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts @@ -116,11 +116,7 @@ export class PortRegistrar { ); } - const tripleKey = this.makeTripleKey( - agentName, - role, - sessionContextId, - ); + const tripleKey = this.makeTripleKey(agentName, role, sessionContextId); const existing = this.tripleIndex.get(tripleKey); if (existing !== undefined) { const allocation = this.allocations.get(existing); @@ -219,7 +215,10 @@ export class PortRegistrar { * cross-agent `getSharedLocalHostPort` shim, gated by * `manifest.sharedLocalView`) layer it on top. */ - public lookup(agentName: string, role: string = DEFAULT_ROLE): number | undefined { + public lookup( + agentName: string, + role: string = DEFAULT_ROLE, + ): number | undefined { // Walk in insertion order; randomUUID-keyed Map iteration order in // V8 is insertion-order-stable, so the last matching entry is the // most recent registration. diff --git a/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts b/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts index f7b7f6fbe..c2d7d8ce9 100644 --- a/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts @@ -58,9 +58,7 @@ describe("PortRegistrar", () => { test("warns but accepts privileged ports (does not throw)", () => { const r = new PortRegistrar(); - expect(() => - r.register("browser", "ws", 80, SID_A), - ).not.toThrow(); + expect(() => r.register("browser", "ws", 80, SID_A)).not.toThrow(); expect(r.lookup("browser", "ws")).toBe(80); }); diff --git a/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts b/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts index fd0d4baa8..5f9db9c0d 100644 --- a/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/sessionContext.spec.ts @@ -92,7 +92,13 @@ describe("createSessionContext storage routing", () => { instanceDir: undefined, persistDir: undefined, }); - const sessionCtx = createSessionContext("myAgent", {}, context, false, "test-session-id"); + const sessionCtx = createSessionContext( + "myAgent", + {}, + context, + false, + "test-session-id", + ); expect(sessionCtx.instanceStorage).toBeUndefined(); }); }); @@ -221,4 +227,3 @@ describe("initializeCommandHandlerContext option validation", () => { ); }); }); - From a8fd7cf7e260da07fb7bd3e769a904c350bdead2 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Tue, 12 May 2026 23:01:43 -0700 Subject: [PATCH 6/7] agent-server-client: add discoverPort helper + wire-protocol tests Symmetric client-side counterpart to the discovery channel introduced in commit 3 of this series. Without it, PR 1 ships a server-side WS-RPC handler with no callers and no end-to-end test of the actual wire format -- only an in-process unit test of the registrar. * New `discoverPort(agentName, role?, options?)` exported via a narrow `./discovery` subpath on `@typeagent/agent-server-client`. Returns a tagged result -- `found` / `not-registered` / `unreachable` -- so callers can distinguish "agent isn't loaded yet, retry" from "agentServer isn't running, fall back to a hardcoded default for back-compat" without parsing error strings. * Subpath rather than top-level export so external clients (browser extension, VS Code extension service workers) don't drag in the full main-client surface (fs / os / child_process / dispatcher RPC). The discovery module imports only `agent-rpc`, `isomorphic-ws`, and the small `agent-server-protocol` constants module. * 4 integration tests spin up a real `ws` server speaking the `agent-rpc` channel framing (createChannelProviderAdapter + createRpc) and exercise: `found`, `not-registered`, `unreachable` (server bound and immediately closed), and timeout (server accepts but never resolves). Bootstraps Jest in the package along the way -- one `jest.config.cjs` delegating to the shared root config, plus a `test/` tsconfig matching the convention used by every other test-bearing package in the repo. This unblocks the per-agent migration PRs (2--5): each will import `discoverPort` from the same subpath rather than re-implementing the discovery handshake. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentServer/client/jest.config.cjs | 4 + ts/packages/agentServer/client/package.json | 14 +- .../agentServer/client/src/discovery.ts | 174 +++++++++++++ .../agentServer/client/test/discovery.spec.ts | 151 +++++++++++ .../agentServer/client/test/tsconfig.json | 14 + ts/packages/agentServer/client/tsconfig.json | 2 +- ts/pnpm-lock.yaml | 245 ++---------------- 7 files changed, 382 insertions(+), 222 deletions(-) create mode 100644 ts/packages/agentServer/client/jest.config.cjs create mode 100644 ts/packages/agentServer/client/src/discovery.ts create mode 100644 ts/packages/agentServer/client/test/discovery.spec.ts create mode 100644 ts/packages/agentServer/client/test/tsconfig.json diff --git a/ts/packages/agentServer/client/jest.config.cjs b/ts/packages/agentServer/client/jest.config.cjs new file mode 100644 index 000000000..f475768a1 --- /dev/null +++ b/ts/packages/agentServer/client/jest.config.cjs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +module.exports = require("../../../jest.config.js"); diff --git a/ts/packages/agentServer/client/package.json b/ts/packages/agentServer/client/package.json index cc0efc56d..8f1ca53d1 100644 --- a/ts/packages/agentServer/client/package.json +++ b/ts/packages/agentServer/client/package.json @@ -12,7 +12,8 @@ "author": "Microsoft", "type": "module", "exports": { - ".": "./dist/index.js" + ".": "./dist/index.js", + "./discovery": "./dist/discovery.js" }, "files": [ "dist", @@ -21,9 +22,12 @@ "scripts": { "build": "npm run tsc", "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", + "jest-esm": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", "prettier": "prettier --check . --ignore-path ../../../.prettierignore", "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore", - "tsc": "tsc -b" + "test": "npm run test:local", + "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"", + "tsc": "tsc -b src test" }, "dependencies": { "@typeagent/agent-rpc": "workspace:*", @@ -34,9 +38,13 @@ }, "devDependencies": { "@types/debug": "^4.1.12", + "@types/jest": "^29.5.7", "@types/ws": "^8.5.10", + "jest": "^29.7.0", "prettier": "^3.5.3", "rimraf": "^6.0.1", - "typescript": "~5.4.5" + "typescript": "~5.4.5", + "websocket-channel-server": "workspace:*", + "ws": "^8.17.1" } } diff --git a/ts/packages/agentServer/client/src/discovery.ts b/ts/packages/agentServer/client/src/discovery.ts new file mode 100644 index 000000000..352713102 --- /dev/null +++ b/ts/packages/agentServer/client/src/discovery.ts @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Discovery client for the agent-server's read-only port lookup channel. + * + * Designed for **external clients** (Coda VS Code extension, Chrome / + * Edge browser extensions, the C# VS plugin, scaffolded onboarding + * agents) that need to discover the dynamically-assigned port of an + * in-process agent before connecting to its WebSocket server. + * + * Separate from the top-level `@typeagent/agent-server-client` entry + * because that pulls in `fs`, `os`, `child_process`, and the full + * dispatcher RPC surface — none of which an extension-side discovery + * caller needs. This module imports only: + * - agent-rpc framing (the same JSON-RPC over WebSocket the + * agentServer's discovery handler speaks), + * - isomorphic-ws (browser- and node-compatible WebSocket), + * - the small constants/types module + * `@typeagent/agent-server-protocol`. + * + * Usage: + * const port = await discoverPort("code"); + * if (port === null) throw new Error("code agent isn't running"); + * const ws = new WebSocket(`ws://localhost:${port}?...`); + */ + +import { createChannelProviderAdapter } from "@typeagent/agent-rpc/channel"; +import { createRpc } from "@typeagent/agent-rpc/rpc"; +import WebSocket from "isomorphic-ws"; +import { + AGENT_SERVER_DEFAULT_URL, + DiscoveryChannelName, + DiscoveryInvokeFunctions, +} from "@typeagent/agent-server-protocol"; + +export type DiscoverPortOptions = { + /** + * Agent-server URL. Defaults to `ws://localhost:8999` — matches + * `AGENT_SERVER_DEFAULT_URL`. Callers that honor an environment + * override (e.g. `AGENT_SERVER_URL`) should resolve it themselves + * and pass the result here. + */ + url?: string; + /** Per-attempt timeout in milliseconds. Default 5_000. */ + timeoutMs?: number; +}; + +/** What `discoverPort` returns. */ +export type DiscoverPortResult = + /** Agent has registered a port for `(agentName, role)`. */ + | { kind: "found"; port: number } + /** Discovery channel reached but no live allocation found. */ + | { kind: "not-registered" } + /** Discovery channel could not be reached (agentServer down, etc). */ + | { kind: "unreachable"; error: Error }; + +/** + * Look up the port currently registered for `(agentName, role)` via + * the agent-server's discovery WS channel. + * + * Returns a tagged result rather than throwing so callers can distinguish + * "agent isn't running yet — retry" from "agentServer isn't running — + * fall back to a hardcoded default for back-compat" without parsing + * error messages. + * + * Closes the underlying WS after the lookup completes (success or + * failure) — discovery connections are intentionally short-lived to + * keep the agentServer's idle-shutdown timer honest. + */ +export async function discoverPort( + agentName: string, + role?: string, + options?: DiscoverPortOptions, +): Promise { + const url = options?.url ?? AGENT_SERVER_DEFAULT_URL; + const timeoutMs = options?.timeoutMs ?? 5_000; + return new Promise((resolve) => { + let settled = false; + const settle = (result: DiscoverPortResult) => { + if (settled) return; + settled = true; + try { + ws.close(); + } catch { + // Already closed or never opened. + } + clearTimeout(timeoutHandle); + resolve(result); + }; + + const ws = new WebSocket(url); + + const timeoutHandle = setTimeout(() => { + settle({ + kind: "unreachable", + error: new Error( + `discoverPort(${agentName}, ${role ?? "default"}) timed out after ${timeoutMs}ms against ${url}`, + ), + }); + }, timeoutMs); + + const channel = createChannelProviderAdapter( + "discovery:client", + (message: any) => { + try { + ws.send(JSON.stringify(message)); + } catch (e: any) { + settle({ + kind: "unreachable", + error: e instanceof Error ? e : new Error(String(e)), + }); + } + }, + ); + + ws.onmessage = (event: WebSocket.MessageEvent) => { + try { + channel.notifyMessage(JSON.parse(event.data.toString())); + } catch (e: any) { + settle({ + kind: "unreachable", + error: e instanceof Error ? e : new Error(String(e)), + }); + } + }; + + ws.onerror = (event: WebSocket.ErrorEvent) => { + settle({ + kind: "unreachable", + error: new Error( + `discoverPort WS error against ${url}: ${event.message ?? "unknown"}`, + ), + }); + }; + + ws.onclose = () => { + channel.notifyDisconnected(); + // If the socket closes before we settled (rare race with + // a successful response landing concurrently), treat as + // unreachable; if already settled, this is a no-op. + settle({ + kind: "unreachable", + error: new Error( + `discoverPort WS closed before response from ${url}`, + ), + }); + }; + + ws.onopen = () => { + const rpc = createRpc( + "discovery:client", + channel.createChannel(DiscoveryChannelName), + ); + const params = + role === undefined ? { agentName } : { agentName, role }; + rpc.invoke("lookupPort", params).then( + (result) => { + if (result.port === null) { + settle({ kind: "not-registered" }); + } else { + settle({ kind: "found", port: result.port }); + } + }, + (e: unknown) => { + settle({ + kind: "unreachable", + error: e instanceof Error ? e : new Error(String(e)), + }); + }, + ); + }; + }); +} diff --git a/ts/packages/agentServer/client/test/discovery.spec.ts b/ts/packages/agentServer/client/test/discovery.spec.ts new file mode 100644 index 000000000..65425d8ff --- /dev/null +++ b/ts/packages/agentServer/client/test/discovery.spec.ts @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createChannelProviderAdapter } from "@typeagent/agent-rpc/channel"; +import { createRpc } from "@typeagent/agent-rpc/rpc"; +import { + DiscoveryChannelName, + DiscoveryInvokeFunctions, +} from "@typeagent/agent-server-protocol"; +import WebSocket, { AddressInfo, WebSocketServer } from "ws"; + +import { discoverPort } from "../src/discovery.js"; + +// Each test spins up a real ws server that speaks the agent-rpc +// discovery channel protocol so we exercise the actual wire format +// (createChannelProviderAdapter + createRpc) rather than mocking out +// the framing. This mirrors what `createWebSocketChannelServer` does +// internally but lets us read the bound port off the underlying ws +// server (its wrapper doesn't expose `address()`). +type LookupArgs = { agentName: string; role?: string }; + +async function startDiscoveryServer( + lookup: (args: LookupArgs) => { port: number | null }, +): Promise<{ url: string; close: () => Promise }> { + const wss = new WebSocketServer({ port: 0 }); + await new Promise((resolve, reject) => { + wss.once("listening", () => resolve()); + wss.once("error", reject); + }); + + wss.on("connection", (ws) => { + const channelProvider = createChannelProviderAdapter( + "test:discovery:server", + (message) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)); + } + }, + ); + ws.on("message", (data: Buffer) => { + try { + channelProvider.notifyMessage(JSON.parse(data.toString())); + } catch { + // ignore malformed + } + }); + ws.on("close", () => channelProvider.notifyDisconnected()); + + const functions: DiscoveryInvokeFunctions = { + // exactOptionalPropertyTypes makes `role?: string` reject an + // explicit `undefined`, so omit it conditionally. + lookupPort: async ({ agentName, role }) => { + const args: LookupArgs = + role === undefined ? { agentName } : { agentName, role }; + return lookup(args); + }, + }; + createRpc( + "test:discovery", + channelProvider.createChannel(DiscoveryChannelName), + functions, + ); + }); + + const port = (wss.address() as AddressInfo).port; + return { + url: `ws://localhost:${port}`, + close: () => + new Promise((resolve) => { + for (const client of wss.clients) { + client.terminate(); + } + wss.close(() => resolve()); + }), + }; +} + +describe("discoverPort", () => { + let teardown: (() => Promise) | undefined; + + afterEach(async () => { + if (teardown !== undefined) { + await teardown(); + teardown = undefined; + } + }); + + it("returns 'found' with the port when the agent is registered", async () => { + const started = await startDiscoveryServer(({ agentName, role }) => { + expect(agentName).toBe("code"); + expect(role).toBe("default"); + return { port: 54321 }; + }); + teardown = started.close; + + const result = await discoverPort("code", "default", { + url: started.url, + }); + expect(result).toEqual({ kind: "found", port: 54321 }); + }); + + it("returns 'not-registered' when the server reports a null port", async () => { + const started = await startDiscoveryServer(() => ({ port: null })); + teardown = started.close; + + const result = await discoverPort("code", undefined, { + url: started.url, + }); + expect(result).toEqual({ kind: "not-registered" }); + }); + + it("returns 'unreachable' when nothing is listening on the URL", async () => { + // Bind a server only to discover a free port, then immediately + // release it so the connect attempt is guaranteed to fail. The + // alternative (a hardcoded port like 1) is flaky across CI. + const wss = new WebSocketServer({ port: 0 }); + await new Promise((resolve) => wss.once("listening", resolve)); + const port = (wss.address() as AddressInfo).port; + await new Promise((resolve) => wss.close(() => resolve())); + + const result = await discoverPort("code", undefined, { + url: `ws://localhost:${port}`, + timeoutMs: 2_000, + }); + expect(result.kind).toBe("unreachable"); + if (result.kind === "unreachable") { + expect(result.error).toBeInstanceOf(Error); + } + }); + + it("returns 'unreachable' when the lookup exceeds timeoutMs", async () => { + // Accept the WS but never resolve the lookup, so the client + // hits the timeout branch. + const started = await startDiscoveryServer( + () => new Promise(() => {}) as never, + ); + teardown = started.close; + + const start = Date.now(); + const result = await discoverPort("code", undefined, { + url: started.url, + timeoutMs: 250, + }); + const elapsed = Date.now() - start; + expect(result.kind).toBe("unreachable"); + // Sanity-check the timer fired in roughly the configured window. + // Bounds are generous to avoid CI flakes. + expect(elapsed).toBeGreaterThanOrEqual(200); + expect(elapsed).toBeLessThan(2_000); + }); +}); diff --git a/ts/packages/agentServer/client/test/tsconfig.json b/ts/packages/agentServer/client/test/tsconfig.json new file mode 100644 index 000000000..895a6f0d2 --- /dev/null +++ b/ts/packages/agentServer/client/test/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node", "jest"] + }, + "include": ["./**/*"], + "ts-node": { + "esm": true + }, + "references": [{ "path": "../src" }] +} diff --git a/ts/packages/agentServer/client/tsconfig.json b/ts/packages/agentServer/client/tsconfig.json index acb9cb4a9..94dfc60bb 100644 --- a/ts/packages/agentServer/client/tsconfig.json +++ b/ts/packages/agentServer/client/tsconfig.json @@ -4,7 +4,7 @@ "composite": true }, "include": [], - "references": [{ "path": "./src" }], + "references": [{ "path": "./src" }, { "path": "./test" }], "ts-node": { "esm": true } diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index c8f676d71..61427d808 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -556,7 +556,7 @@ importers: version: 24.37.5(typescript@5.4.5) ts-node: specifier: ^10.9.1 - version: 10.9.2(@types/node@20.19.40)(typescript@5.4.5) + version: 10.9.2(@types/node@25.5.2)(typescript@5.4.5) xml2js: specifier: ^0.6.2 version: 0.6.2 @@ -1298,9 +1298,15 @@ importers: '@types/debug': specifier: ^4.1.12 version: 4.1.12 + '@types/jest': + specifier: ^29.5.7 + version: 29.5.14 '@types/ws': specifier: ^8.5.10 version: 8.18.1 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -1310,6 +1316,12 @@ importers: typescript: specifier: ~5.4.5 version: 5.4.5 + websocket-channel-server: + specifier: workspace:* + version: link:../../utils/webSocketChannelServer + ws: + specifier: ^8.17.1 + version: 8.19.0 packages/agentServer/protocol: dependencies: @@ -3495,7 +3507,7 @@ importers: version: link:../telemetry ts-node: specifier: ^10.9.1 - version: 10.9.2(@types/node@20.19.40)(typescript@5.4.5) + version: 10.9.2(@types/node@25.5.2)(typescript@5.4.5) typechat-utils: specifier: workspace:* version: link:../utils/typechatUtils @@ -3514,7 +3526,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -3523,7 +3535,7 @@ importers: version: 6.0.1 ts-jest: specifier: ^29.4.9 - version: 29.4.9(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)))(typescript@5.4.5) + version: 29.4.9(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)))(typescript@5.4.5) typescript: specifier: ~5.4.5 version: 5.4.5 @@ -4963,7 +4975,7 @@ importers: devDependencies: '@electron-toolkit/tsconfig': specifier: ^1.0.1 - version: 1.0.1(@types/node@20.19.40) + version: 1.0.1(@types/node@25.5.2) '@fontsource/lato': specifier: ^5.2.5 version: 5.2.5 @@ -4996,10 +5008,10 @@ importers: version: 26.8.1(dmg-builder@26.8.1) electron-vite: specifier: ^4.0.1 - version: 4.0.1(vite@6.4.2(@types/node@20.19.40)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.0.1(vite@6.4.2(@types/node@25.5.2)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3)) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) less: specifier: ^4.2.0 version: 4.3.0 @@ -5017,7 +5029,7 @@ importers: version: 5.4.5 vite: specifier: ^6.4.2 - version: 6.4.2(@types/node@20.19.40)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3) + version: 6.4.2(@types/node@25.5.2)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3) packages/telemetry: dependencies: @@ -8886,9 +8898,6 @@ packages: '@types/node@20.19.25': resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} - '@types/node@20.19.40': - resolution: {integrity: sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==} - '@types/node@22.15.18': resolution: {integrity: sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==} @@ -13192,9 +13201,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nan@2.26.2: - resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==} - nanocolors@0.2.13: resolution: {integrity: sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==} @@ -15314,9 +15320,6 @@ packages: resolution: {integrity: sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==} engines: {node: '>=0.10.0'} - usocket@0.3.0: - resolution: {integrity: sha512-V/H02RNiaOCJZuPoKont/y12VJaImC6C5xW7OzPFjYu9qnig0yv9hyp9E7Wqjm6d8yZuZouH3NAfDATVMgh2SQ==} - utf8-byte-length@1.0.4: resolution: {integrity: sha512-4+wkEYLBbWxqTahEsWrhxepcoVOJ+1z5PGIjPZxRkytcdSUaNjIjBM7Xn8E+pdSuV7SzvWovBFA54FO0JSoqhA==} @@ -17550,9 +17553,9 @@ snapshots: dependencies: electron: 40.8.5 - '@electron-toolkit/tsconfig@1.0.1(@types/node@20.19.40)': + '@electron-toolkit/tsconfig@1.0.1(@types/node@25.5.2)': dependencies: - '@types/node': 20.19.40 + '@types/node': 25.5.2 '@electron/asar@3.4.1': dependencies: @@ -18453,41 +18456,6 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.19.25 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5))': dependencies: '@jest/console': 29.7.0 @@ -20842,10 +20810,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@20.19.40': - dependencies: - undici-types: 6.21.0 - '@types/node@22.15.18': dependencies: undici-types: 6.21.0 @@ -20857,7 +20821,6 @@ snapshots: '@types/node@25.5.2': dependencies: undici-types: 7.18.2 - optional: true '@types/normalize-package-data@2.4.4': {} @@ -22577,21 +22540,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-jest@29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): dependencies: '@jest/types': 29.6.3 @@ -22947,7 +22895,6 @@ snapshots: jsbi: 2.0.5 long: 4.0.0 safe-buffer: 5.2.1 - usocket: 0.3.0 xml2js: 0.4.23 debounce@1.2.1: {} @@ -23257,7 +23204,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@4.0.1(vite@6.4.2(@types/node@20.19.40)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3)): + electron-vite@4.0.1(vite@6.4.2(@types/node@25.5.2)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.4) @@ -23265,7 +23212,7 @@ snapshots: esbuild: 0.25.11 magic-string: 0.30.17 picocolors: 1.1.1 - vite: 6.4.2(@types/node@20.19.40)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.2(@types/node@25.5.2)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -25055,25 +25002,6 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest-cli@29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) @@ -25205,37 +25133,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)): - dependencies: - '@babel/core': 7.28.4 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.4) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.25 - ts-node: 10.9.2(@types/node@20.19.40)(typescript@5.4.5) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@29.7.0(@types/node@20.19.25)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): dependencies: '@babel/core': 7.28.4 @@ -25298,37 +25195,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)): - dependencies: - '@babel/core': 7.28.4 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.4) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.40 - ts-node: 10.9.2(@types/node@20.19.40)(typescript@5.4.5) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): dependencies: '@babel/core': 7.28.4 @@ -25651,18 +25517,6 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest@29.7.0(@types/node@22.15.18)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) @@ -26805,8 +26659,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nan@2.26.2: {} - nanocolors@0.2.13: {} nanoid@3.3.11: {} @@ -29018,12 +28870,12 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.28.4) - ts-jest@29.4.9(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)))(typescript@5.4.5): + ts-jest@29.4.9(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)))(typescript@5.4.5): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 29.7.0(@types/node@20.19.40)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) + jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.4.5)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -29096,24 +28948,6 @@ snapshots: yn: 3.1.1 optional: true - ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.9 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.40 - acorn: 8.15.0 - acorn-walk: 8.3.0 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 5.4.5 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -29150,7 +28984,6 @@ snapshots: typescript: 5.4.5 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optional: true tslib@2.6.2: {} @@ -29284,8 +29117,7 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.18.2: - optional: true + undici-types@7.18.2: {} undici-types@7.24.4: {} @@ -29387,12 +29219,6 @@ snapshots: dependencies: os-homedir: 1.0.2 - usocket@0.3.0: - dependencies: - bindings: 1.5.0 - nan: 2.26.2 - node-gyp: 12.3.0 - utf8-byte-length@1.0.4: {} util-deprecate@1.0.2: {} @@ -29471,23 +29297,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vite@6.4.2(@types/node@20.19.40)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - esbuild: 0.25.11 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.14 - rollup: 4.59.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 20.19.40 - fsevents: 2.3.3 - jiti: 2.5.1 - less: 4.3.0 - terser: 5.39.2 - tsx: 4.21.0 - yaml: 2.8.3 - vite@6.4.2(@types/node@25.5.2)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.25.11 From b06f1d4378127a8f0acac2e671abfa057d34ddb3 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Wed, 13 May 2026 12:44:02 -0700 Subject: [PATCH 7/7] PortRegistrar: address PR #2332 review feedback - Extract IPortRegistrar interface; consumers (AppAgentManager, CommandHandlerContext, DispatcherOptions) now depend on the interface so tests/alt hosts can substitute implementations. - Replace string-concat triple-key with a keyOf(allocation) helper that takes an Allocation-shaped object. Field order is now enforced by the type, not by call-site discipline. - Unify the agent-server's own listen port into the registrar: register it as a normal allocation under AGENT_SERVER_DISCOVERY_NAME with a synthetic SYSTEM_SESSION_CONTEXT_ID. Drops the setAgentServerPort/getAgentServerPort side fields and the special-case in the discovery lookupPort handler. The collision guard still warns via lookup() against the same name. releaseAllForSession refuses to act on the system id so a stray session close can never cull the agent-server's port. - Defense-in-depth: PortRegistrar.release(regId, sessionContextId?) now verifies ownership when the second arg is supplied. The SDK shim and agentRpc release path both pass the owner so a buggy or compromised agent can't cancel another agent's registration by guessing/replaying a regId. - Document role namespace ownership in registerPort, discoverPort, and lookupPort JSDoc: roles are agent-defined free-form strings intentionally, not a central enum; agents own their role namespace and should publish role constants for callers. Tests: 2 new portRegistrar.spec cases (system allocation discoverable + protected from releaseAllForSession; release ownership check). Existing 669 dispatcher tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ts/packages/agentRpc/src/client.ts | 19 ++- ts/packages/agentSdk/src/agentInterface.ts | 11 +- .../agentServer/client/src/discovery.ts | 13 ++ .../agentServer/protocol/src/protocol.ts | 17 ++- ts/packages/agentServer/server/src/server.ts | 36 +++-- .../dispatcher/src/context/appAgentManager.ts | 4 +- .../src/context/commandHandlerContext.ts | 6 +- .../dispatcher/src/context/portRegistrar.ts | 133 ++++++++++++------ .../dispatcher/src/execute/sessionContext.ts | 3 +- .../dispatcher/dispatcher/src/index.ts | 6 +- .../dispatcher/test/portRegistrar.spec.ts | 50 ++++++- 11 files changed, 221 insertions(+), 77 deletions(-) diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index 8f8316898..926afd29c 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -356,13 +356,22 @@ export async function createAgentRpcClient( }, releasePort: async (param: { regId: string; contextId?: number }) => { const handle = registrationHandles.get(param.regId); - if (handle !== undefined) { - registrationHandles.delete(param.regId); - if (param.contextId !== undefined) { - regIdsByContext.get(param.contextId)?.delete(param.regId); + if (handle === undefined) { + return; + } + // Defense-in-depth: only honor releasePort when the calling + // context actually owns the regId. Without this, a buggy or + // compromised out-of-process agent could pass another + // agent's regId and cancel its registration. + if (param.contextId !== undefined) { + const owned = regIdsByContext.get(param.contextId); + if (owned === undefined || !owned.has(param.regId)) { + return; } - handle.release(); + owned.delete(param.regId); } + registrationHandles.delete(param.regId); + handle.release(); }, indexes: async (param: { contextId: number; type: string }) => { const context = contextMap.get(param.contextId); diff --git a/ts/packages/agentSdk/src/agentInterface.ts b/ts/packages/agentSdk/src/agentInterface.ts index 572b7f79a..9bab7d3f3 100644 --- a/ts/packages/agentSdk/src/agentInterface.ts +++ b/ts/packages/agentSdk/src/agentInterface.ts @@ -318,9 +318,14 @@ export interface SessionContext { * so other in-process agents and out-of-process clients (Chrome * extension, VS Code extension, etc.) can discover it. * - * `role` is a free-form string scoping the registration within - * this agent — e.g. `"ws-bridge"`, `"http-debug"`. Use distinct - * roles when an agent exposes multiple listeners. + * `role` is a free-form, agent-defined string scoping the + * registration within this agent — e.g. `"default"`, `"ws-bridge"`, + * `"http-debug"`. Each agent owns its role namespace and should + * export role constants for in-process and out-of-process callers + * to import; the SDK and discovery layer treat roles as opaque + * strings so adding a role to one agent never requires coordinated + * changes elsewhere. Use distinct roles when an agent exposes + * multiple listeners. * * Returns a `release()` callback the agent should invoke when the * listener is torn down. Forgetting to release is non-fatal — the diff --git a/ts/packages/agentServer/client/src/discovery.ts b/ts/packages/agentServer/client/src/discovery.ts index 352713102..67e9618e7 100644 --- a/ts/packages/agentServer/client/src/discovery.ts +++ b/ts/packages/agentServer/client/src/discovery.ts @@ -59,6 +59,19 @@ export type DiscoverPortResult = * Look up the port currently registered for `(agentName, role)` via * the agent-server's discovery WS channel. * + * **About `role`:** roles are agent-defined free-form strings — the + * registrar/discovery layer is intentionally generic and does not know + * which role names a given agent advertises. Each agent owns its own + * role namespace and SHOULD export role constants for callers to + * import (e.g. `code` could export + * `CODE_ROLES = { default: "default", debug: "debug" } as const`). + * Omit `role` (or pass undefined) to ask for the agent's default role, + * which matches what `setLocalHostPort` registered for legacy + * single-listener agents. We deliberately keep this as a string rather + * than a central enum so adding a new role on one agent doesn't force + * a coordinated change across every package, and so this function + * doesn't have to import every agent that exposes a port. + * * Returns a tagged result rather than throwing so callers can distinguish * "agent isn't running yet — retry" from "agentServer isn't running — * fall back to a hardcoded default for back-compat" without parsing diff --git a/ts/packages/agentServer/protocol/src/protocol.ts b/ts/packages/agentServer/protocol/src/protocol.ts index d40cb306e..352275731 100644 --- a/ts/packages/agentServer/protocol/src/protocol.ts +++ b/ts/packages/agentServer/protocol/src/protocol.ts @@ -103,13 +103,18 @@ export const AGENT_SERVER_DISCOVERY_NAME = "agent-server"; export type DiscoveryInvokeFunctions = { /** * Look up the port currently registered for `(agentName, role)`. - * `role` is optional; omit it (or pass undefined) to look up the - * default role — matches what `setLocalHostPort` registered for - * agents that pre-date the multi-role API. * - * Special case: `agentName === "agent-server"` returns the - * agent-server's own listening port, so clients that bootstrap - * from a known port can discover the configured one. + * `role` is an agent-defined free-form string — the discovery + * protocol does not enumerate valid values; each agent owns its + * own role namespace and should publish constants for callers to + * import. Omit `role` (or pass undefined) to look up the agent's + * default role, which matches what `setLocalHostPort` registered + * for agents that pre-date the multi-role API. + * + * Well-known: `agentName === "agent-server"` returns the + * agent-server's own listening port (registered as a regular + * allocation under that name), so clients that bootstrap from a + * known port can discover the configured one. * * Returns `null` (not undefined) so the JSON-RPC response is always a * defined value; callers should treat null as "no allocation found, diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index da8347801..aec086edb 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -33,7 +33,7 @@ import { } from "@typeagent/agent-server-protocol"; import type { ChannelProvider } from "@typeagent/agent-rpc/channel"; import type { Dispatcher } from "agent-dispatcher"; -import { PortRegistrar } from "agent-dispatcher"; +import { PortRegistrar, SYSTEM_SESSION_CONTEXT_ID } from "agent-dispatcher"; import dotenv from "dotenv"; import { writeServerPid, @@ -450,15 +450,12 @@ async function main() { // in-process SessionContext.registerPort. const discoveryFunctions: DiscoveryInvokeFunctions = { lookupPort: async ({ agentName, role }) => { - // Well-known: agent-server reports its own listening - // port so clients that bootstrap from a different - // known port can discover the configured one. This - // also keeps agent-server discoverable if its port - // ever becomes dynamic. - if (agentName === AGENT_SERVER_DISCOVERY_NAME) { - const port = portRegistrar.getAgentServerPort(); - return { port: port ?? null }; - } + // 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 }; }, @@ -471,10 +468,21 @@ async function main() { }, ); - // Tell the registrar which port we're bound to so it can warn agents - // that try to register the same one (a foot-gun: agent-server's WS - // would silently shadow the agent's listener). - portRegistrar.setAgentServerPort(port); + // Register the agent-server's own listen port as a regular + // allocation under the well-known AGENT_SERVER_DISCOVERY_NAME with + // the synthetic SYSTEM_SESSION_CONTEXT_ID. This gives discovery + // clients a uniform lookup path (no special-case in the discovery + // handler) and lets the registrar's collision guard flag agents + // that try to bind the same port via the same code path it uses + // for any other allocation. The system sessionContextId protects + // the entry from releaseAllForSession when real conversation + // sessions close — it lives for the lifetime of the process. + portRegistrar.register( + AGENT_SERVER_DISCOVERY_NAME, + "default", + port, + SYSTEM_SESSION_CONTEXT_ID, + ); console.log(`Agent server started at ws://localhost:${port}`); writeServerPid(port, process.pid); diff --git a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts index 937cdfd75..e4362bd1f 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/appAgentManager.ts @@ -50,7 +50,7 @@ import { import fs from "node:fs"; import { FlowDefinition } from "../execute/flowInterpreter.js"; import { - PortRegistrar, + IPortRegistrar, DEFAULT_ROLE, RegistrationId, } from "./portRegistrar.js"; @@ -175,7 +175,7 @@ export class AppAgentManager implements ActionConfigProvider { >(); public constructor( cacheDir: string | undefined, - public readonly portRegistrar: PortRegistrar, + public readonly portRegistrar: IPortRegistrar, private readonly allowSharedLocalView?: string[], private readonly agentInitOptions?: Record, ) { diff --git a/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts b/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts index 1e3d32b52..092c49a36 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/commandHandlerContext.ts @@ -61,7 +61,7 @@ import { getAppAgentStateSettings, SetStateResult, } from "./appAgentManager.js"; -import { PortRegistrar } from "./portRegistrar.js"; +import { IPortRegistrar, PortRegistrar } from "./portRegistrar.js"; import { AppAgentInstaller, AppAgentProvider, @@ -130,7 +130,7 @@ export function ensureCommandResult( // Command Handler Context definition. export type CommandHandlerContext = { readonly agents: AppAgentManager; - readonly portRegistrar: PortRegistrar; + readonly portRegistrar: IPortRegistrar; readonly agentInstaller: AppAgentInstaller | undefined; session: Session; @@ -278,7 +278,7 @@ export type DispatcherOptions = DeepPartialUndefined & { * process-private registrar — the right default for standalone * hosts (shell, CLI) that don't expose external discovery. */ - portRegistrar?: PortRegistrar; + portRegistrar?: IPortRegistrar; // Indexing service discovery indexingServiceRegistry?: IndexingServiceRegistry; // registry for indexing service discovery diff --git a/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts b/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts index 9076cffa9..50c5abf6f 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/portRegistrar.ts @@ -34,6 +34,49 @@ export type Allocation = { */ export const DEFAULT_ROLE = "default"; +/** + * Synthetic sessionContextId used for system-owned allocations whose + * lifetime equals the host process (e.g. the agent-server's own listen + * port). Distinguished from real session ids so + * {@link IPortRegistrar.releaseAllForSession} (called when a real + * conversation session closes) cannot accidentally release them. + * + * Not a UUID on purpose — the leading colon makes it obviously + * non-UUID-shaped if it ever shows up in a log or diagnostic dump. + */ +export const SYSTEM_SESSION_CONTEXT_ID = ":system"; + +/** + * Public surface of the in-memory port registry. Consumers should + * depend on this interface rather than the concrete {@link PortRegistrar} + * class so tests and alternative hosts can substitute their own + * implementation. + */ +export interface IPortRegistrar { + register( + agentName: string, + role: string, + port: number, + sessionContextId: string, + ): RegistrationId; + release(regId: RegistrationId, sessionContextId?: string): void; + releaseAllForSession(sessionContextId: string): number; + lookup(agentName: string, role?: string): number | undefined; + hasActiveAllocations(): boolean; + list(): readonly Allocation[]; +} + +/** + * Well-known agent name under which the agent-server registers its own + * listen port. Discovery clients that bootstrap from a known port can + * look this up to find the configured agent-server port. Kept here + * (not in @typeagent/agent-server-protocol) to avoid a dispatcher → + * agent-server-protocol dependency; the agent-server side imports the + * matching constant from its protocol package and the two are kept in + * sync by tests. + */ +export const AGENT_SERVER_REGISTRAR_NAME = "agent-server"; + /** * In-memory port registry. Agents bind on `port=0`, the OS picks a free * port, and the agent registers the resulting port here so other @@ -47,32 +90,17 @@ export const DEFAULT_ROLE = "default"; * Thread-safety: Node single-threaded; the registrar is mutated only on * the event-loop thread. No locking required. */ -export class PortRegistrar { +export class PortRegistrar implements IPortRegistrar { /** All live allocations, keyed by their opaque registration id. */ private readonly allocations = new Map(); /** * Index from `(agentName, role, sessionContextId)` triple to its * registration id, so re-registration is O(1) and idempotent. + * Key is built by {@link keyOf} from an Allocation-shaped object. */ private readonly tripleIndex = new Map(); - /** - * Optional self-port used by the SDK guard to flag agents that - * accidentally hard-coded the agentServer's own port. Set by the - * agent-server entry point; absent in non-server hosts. - */ - private agentServerPort: number | undefined; - - /** Register the agentServer's own listen port for the SDK guard. */ - public setAgentServerPort(port: number | undefined): void { - this.agentServerPort = port; - } - - public getAgentServerPort(): number | undefined { - return this.agentServerPort; - } - /** * Record a port that an agent has just bound. Idempotent on the * `(agentName, role, sessionContextId)` triple: a second call with @@ -80,8 +108,9 @@ export class PortRegistrar { * {@link RegistrationId}. * * Validates the input but does not throw on suspicious values: - * `port < 1024` and `port === agentServerPort` log a warning under - * the `typeagent:dispatcher:portRegistrar:warn` debug namespace and + * `port < 1024` and a collision with the agent-server's own + * registered port log a warning under the + * `typeagent:dispatcher:portRegistrar:warn` debug namespace and * still register, on the assumption that the agent is already bound * and refusing to record the port would just hide the listener from * lookups. @@ -108,15 +137,15 @@ export class PortRegistrar { ); } if ( - this.agentServerPort !== undefined && - port === this.agentServerPort + agentName !== AGENT_SERVER_REGISTRAR_NAME && + this.lookup(AGENT_SERVER_REGISTRAR_NAME) === port ) { debugWarn( `${agentName}/${role} registered the agentServer's own port ${port}; this is almost certainly a hard-coded mistake — pass 0 to bind`, ); } - const tripleKey = this.makeTripleKey(agentName, role, sessionContextId); + const tripleKey = keyOf({ agentName, role, sessionContextId }); const existing = this.tripleIndex.get(tripleKey); if (existing !== undefined) { const allocation = this.allocations.get(existing); @@ -157,20 +186,31 @@ export class PortRegistrar { /** * Remove a single allocation by its registration id. Idempotent: a * release of an unknown id is a no-op. + * + * If `sessionContextId` is provided, the release only succeeds when + * the allocation actually belongs to that session — a defense-in-depth + * check so a misbehaving agent can't release another agent's port by + * guessing/replaying a regId. Mismatches log under + * `typeagent:dispatcher:portRegistrar:warn` and are silently dropped. + * Omit `sessionContextId` only from trusted in-process callers (the + * registrar's own {@link releaseAllForSession} backstop). */ - public release(regId: RegistrationId): void { + public release(regId: RegistrationId, sessionContextId?: string): void { const allocation = this.allocations.get(regId); if (allocation === undefined) { return; } + if ( + sessionContextId !== undefined && + allocation.sessionContextId !== sessionContextId + ) { + debugWarn( + `release ignored: regId=${regId} belongs to session ${allocation.sessionContextId}, not ${sessionContextId}`, + ); + return; + } this.allocations.delete(regId); - this.tripleIndex.delete( - this.makeTripleKey( - allocation.agentName, - allocation.role, - allocation.sessionContextId, - ), - ); + this.tripleIndex.delete(keyOf(allocation)); debug( `release ${allocation.agentName}/${allocation.role} session=${allocation.sessionContextId} port=${allocation.port} regId=${regId}`, ); @@ -181,9 +221,17 @@ export class PortRegistrar { * `sessionContextId` matches. Called from the dispatcher's * `closeSessionContext` finally block. * + * System-owned allocations (sessionContextId === + * {@link SYSTEM_SESSION_CONTEXT_ID}) are never released here — their + * lifetime is tied to the host process, not any one session. + * * Returns the number of allocations released. */ public releaseAllForSession(sessionContextId: string): number { + if (sessionContextId === SYSTEM_SESSION_CONTEXT_ID) { + // Defensive: never let a stray call cull system allocations. + return 0; + } const toRelease: RegistrationId[] = []; for (const [regId, allocation] of this.allocations) { if (allocation.sessionContextId === sessionContextId) { @@ -191,6 +239,7 @@ export class PortRegistrar { } } for (const regId of toRelease) { + // Trusted internal caller: skip the ownership check. this.release(regId); } if (toRelease.length > 0) { @@ -251,15 +300,19 @@ export class PortRegistrar { public list(): readonly Allocation[] { return Array.from(this.allocations.values()).map((a) => ({ ...a })); } +} - private makeTripleKey( - agentName: string, - role: string, - sessionContextId: string, - ): string { - // Use a delimiter that can't appear in any of the three fields — - // agent names and role names are TS identifiers / bare words, and - // sessionContextId is a UUID, so `\u0000` is unambiguously safe. - return `${agentName}\u0000${role}\u0000${sessionContextId}`; - } +/** + * Build the index key for an allocation. Takes an Allocation-shaped + * object so callers don't have to remember the field order — and the + * compiler enforces that all three identity fields are supplied. + * + * Uses `\u0000` as the delimiter because agent names and role names are + * TS identifiers / bare words, and sessionContextId is a UUID (or the + * `:system` literal), so the NUL byte is unambiguously safe. + */ +function keyOf( + alloc: Pick, +): string { + return `${alloc.agentName}\u0000${alloc.role}\u0000${alloc.sessionContextId}`; } diff --git a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts index a1d01e1d0..8a0327eee 100644 --- a/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts +++ b/ts/packages/dispatcher/dispatcher/src/execute/sessionContext.ts @@ -202,7 +202,8 @@ export function createSessionContext( sessionContextId, ); return { - release: () => context.portRegistrar.release(regId), + release: () => + context.portRegistrar.release(regId, sessionContextId), }; }, indexes(type: string): Promise { diff --git a/ts/packages/dispatcher/dispatcher/src/index.ts b/ts/packages/dispatcher/dispatcher/src/index.ts index 728224d3e..69acfd9ac 100644 --- a/ts/packages/dispatcher/dispatcher/src/index.ts +++ b/ts/packages/dispatcher/dispatcher/src/index.ts @@ -4,8 +4,12 @@ export { createDispatcher } from "./dispatcher.js"; export { IndexManager } from "./context/indexManager.js"; export type { DispatcherOptions } from "./context/commandHandlerContext.js"; -export { PortRegistrar } from "./context/portRegistrar.js"; +export { + PortRegistrar, + SYSTEM_SESSION_CONTEXT_ID, +} from "./context/portRegistrar.js"; export type { + IPortRegistrar, Allocation as PortAllocation, RegistrationId as PortRegistrationId, } from "./context/portRegistrar.js"; diff --git a/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts b/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts index c2d7d8ce9..f6c04568c 100644 --- a/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/portRegistrar.spec.ts @@ -1,7 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { PortRegistrar } from "../src/context/portRegistrar.js"; +import { + AGENT_SERVER_REGISTRAR_NAME, + DEFAULT_ROLE, + PortRegistrar, + SYSTEM_SESSION_CONTEXT_ID, +} from "../src/context/portRegistrar.js"; describe("PortRegistrar", () => { const SID_A = "00000000-0000-0000-0000-00000000000a"; @@ -64,7 +69,12 @@ describe("PortRegistrar", () => { test("warns but accepts the agentServer's own port (does not throw)", () => { const r = new PortRegistrar(); - r.setAgentServerPort(8999); + r.register( + AGENT_SERVER_REGISTRAR_NAME, + DEFAULT_ROLE, + 8999, + SYSTEM_SESSION_CONTEXT_ID, + ); expect(() => r.register("browser", "ws", 8999, SID_A), ).not.toThrow(); @@ -72,6 +82,33 @@ describe("PortRegistrar", () => { }); }); + describe("system allocation", () => { + test("agent-server self-port is discoverable via lookup", () => { + const r = new PortRegistrar(); + r.register( + AGENT_SERVER_REGISTRAR_NAME, + DEFAULT_ROLE, + 8999, + SYSTEM_SESSION_CONTEXT_ID, + ); + expect(r.lookup(AGENT_SERVER_REGISTRAR_NAME)).toBe(8999); + }); + + test("releaseAllForSession does not release system allocations", () => { + const r = new PortRegistrar(); + r.register( + AGENT_SERVER_REGISTRAR_NAME, + DEFAULT_ROLE, + 8999, + SYSTEM_SESSION_CONTEXT_ID, + ); + // Even if a buggy caller passes the system id, it must be a + // no-op so the agent-server port can't be culled mid-process. + expect(r.releaseAllForSession(SYSTEM_SESSION_CONTEXT_ID)).toBe(0); + expect(r.lookup(AGENT_SERVER_REGISTRAR_NAME)).toBe(8999); + }); + }); + describe("release", () => { test("removes the allocation", () => { const r = new PortRegistrar(); @@ -86,6 +123,15 @@ describe("PortRegistrar", () => { expect(() => r.release("not-a-real-id")).not.toThrow(); }); + test("ownership check: release with mismatched sessionContextId is a no-op", () => { + const r = new PortRegistrar(); + const id = r.register("browser", "ws", 51234, SID_A); + r.release(id, SID_B); // wrong owner + expect(r.lookup("browser", "ws")).toBe(51234); + r.release(id, SID_A); // correct owner + expect(r.lookup("browser", "ws")).toBeUndefined(); + }); + test("after release, re-register issues a fresh id", () => { const r = new PortRegistrar(); const id1 = r.register("browser", "ws", 51234, SID_A);