diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb2515..1109126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -227,7 +227,8 @@ For now, you will still have the old interfaces available to ease the migration, ### Features * Update to 0.10.1 of the schema ([#36](https://github.com/agentclientprotocol/typescript-sdk/issues/36)) ([210392b](https://github.com/agentclientprotocol/typescript-sdk/commit/210392bfdcb95d2f515784af914323d2606194f6)) -* Unstable: add unstable forkSession support [#37](https://github.com/agentclientprotocol/typescript-sdk/pull/37) +* Unstable: add unstable forkSession support ([#37](https://github.com/agentclientprotocol/typescript-sdk/pull/37)) ([16262ef +](https://github.com/agentclientprotocol/typescript-sdk/commit/16262ef7b52892f935aa7fb39d98657895345ff4)) ## [0.8.0](https://github.com/agentclientprotocol/typescript-sdk/compare/v0.7.0...v0.8.0) (2025-12-08) @@ -252,7 +253,7 @@ This update provides much improved schema interfaces. The migration should be mi ## 0.5.1 (2025-10-24) - Add ability for agents and clients to provide information about their implementation -- Fix incorrectly serialized `_meta` field on `SetSessionModeResponse +- Fix incorrectly serialized `_meta` field on `SetSessionModeResponse` ## 0.5.0 (2025-10-24) diff --git a/src/examples/ws-client.ts b/src/examples/ws-client.ts index 744df7d..0f784a6 100644 --- a/src/examples/ws-client.ts +++ b/src/examples/ws-client.ts @@ -41,7 +41,7 @@ const authHeaders = { // uses the platform cookie jar; Node's `ws` uses constructor headers populated // from this store. const cookieStore = new MemoryAcpCookieStore(); -let savedSessionId: string | undefined; +let reconnectSessionId: string | undefined; function connect(): { readonly stream: acp.Stream; @@ -77,7 +77,7 @@ try { cwd: process.cwd(), mcpServers: [], }); - savedSessionId = session.sessionId; + reconnectSessionId = session.sessionId; const result = await ctx.request(acp.methods.agent.session.prompt, { sessionId: session.sessionId, @@ -94,7 +94,7 @@ try { console.log(`\nDone: ${result.stopReason}`); console.log( - `Saved session ${savedSessionId}; loadSession=${initialized.agentCapabilities?.loadSession === true}`, + `Saved session ${reconnectSessionId}; loadSession=${initialized.agentCapabilities?.loadSession === true}`, ); // Reconnect flow sketch: diff --git a/src/node-adapter.test.ts b/src/node-adapter.test.ts index b57c744..b442923 100644 --- a/src/node-adapter.test.ts +++ b/src/node-adapter.test.ts @@ -233,6 +233,7 @@ describe("createNodeHttpHandler", () => { }, bodyChunks: [ bodyBytes.slice(0, splitIndex + 1), + "", bodyBytes.slice(splitIndex + 1), ], }), @@ -245,6 +246,99 @@ describe("createNodeHttpHandler", () => { expect(seenBodies).toEqual([body]); }); + it("flushes pending UTF-8 bytes before non-empty string chunks", async () => { + const acpServer = new AcpServer({ + createAgent: () => createTestAgentApp(), + }); + const seenBodies: string[] = []; + const response = new CapturingServerResponse(); + + acpServer.handleRequest = async (req) => { + seenBodies.push(await req.text()); + return new Response("ok"); + }; + + createNodeHttpHandler(acpServer)( + fakeRequest({ + method: "POST", + headers: { + "content-type": "text/plain", + }, + bodyChunks: [ + new Uint8Array([0xf0]), + "x", + new Uint8Array([0x9f, 0x9a, 0x80]), + ], + }), + response as unknown as ServerResponse, + ); + + await response.finished; + + expect(response.statusCode).toBe(200); + expect(seenBodies).toEqual(["\uFFFDx\uFFFD\uFFFD\uFFFD"]); + }); + + it("falls back to localhost for invalid request host headers", async () => { + const acpServer = new AcpServer({ + createAgent: () => createTestAgentApp(), + }); + const seenUrls: string[] = []; + const response = new CapturingServerResponse(); + + acpServer.handleRequest = (req) => { + seenUrls.push(req.url); + return Promise.resolve(new Response("ok")); + }; + + createNodeHttpHandler(acpServer)( + fakeRequest({ + headers: { + host: "example.com/@internal", + }, + }), + response as unknown as ServerResponse, + ); + + await response.finished; + + expect(response.statusCode).toBe(200); + expect(seenUrls).toEqual(["http://localhost/acp"]); + }); + + it("preserves valid request host headers that URL parsing canonicalizes", async () => { + const acpServer = new AcpServer({ + createAgent: () => createTestAgentApp(), + }); + const seenUrls: string[] = []; + + acpServer.handleRequest = (req) => { + seenUrls.push(req.url); + return Promise.resolve(new Response("ok")); + }; + + for (const host of ["EXAMPLE.com", "example.com:80", "[::1]:80"]) { + const response = new CapturingServerResponse(); + + createNodeHttpHandler(acpServer)( + fakeRequest({ + headers: { host }, + }), + response as unknown as ServerResponse, + ); + + await response.finished; + + expect(response.statusCode).toBe(200); + } + + expect(seenUrls).toEqual([ + "http://example.com/acp", + "http://example.com/acp", + "http://[::1]/acp", + ]); + }); + it("rejects request bodies when Content-Length exceeds the configured limit", async () => { const acpServer = new AcpServer({ createAgent: () => createTestAgentApp(), @@ -491,7 +585,7 @@ function fakeRequest( options: { readonly method?: string; readonly headers?: Record; - readonly bodyChunks?: readonly Uint8Array[]; + readonly bodyChunks?: readonly (Uint8Array | string)[]; } = {}, ): IncomingMessage { const request = Object.assign(Readable.from(options.bodyChunks ?? []), { diff --git a/src/node-adapter.ts b/src/node-adapter.ts index 6761075..f5a9c81 100644 --- a/src/node-adapter.ts +++ b/src/node-adapter.ts @@ -280,7 +280,10 @@ async function readRequestBody( } if (typeof chunk === "string") { - body += decoder.decode(); + body += + chunk.length === 0 + ? decoder.decode(new Uint8Array(), { stream: true }) + : decoder.decode(); body += chunk; return; } @@ -377,8 +380,61 @@ class RequestBodyTooLargeError extends Error { } } +function sanitizedRequestHost(req: IncomingMessage): string { + const hostHeader = req.headers.host; + const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; + + if (host === undefined) { + return "localhost"; + } + + const normalized = host.trim(); + + if (normalized.length === 0 || hasInvalidRequestHostCharacter(normalized)) { + return "localhost"; + } + + try { + const parsed = new URL(`http://${normalized}`); + if ( + parsed.username !== "" || + parsed.password !== "" || + parsed.pathname !== "/" || + parsed.search !== "" || + parsed.hash !== "" + ) { + return "localhost"; + } + } catch { + return "localhost"; + } + + return normalized; +} + +function hasInvalidRequestHostCharacter(host: string): boolean { + for (const char of host) { + const codePoint = char.codePointAt(0); + if ( + codePoint === undefined || + codePoint <= 0x1f || + codePoint === 0x7f || + char.trim() === "" || + char === "/" || + char === "?" || + char === "#" || + char === "@" || + char === "\\" + ) { + return true; + } + } + + return false; +} + function nodeRequestUrl(req: IncomingMessage): string { - const host = req.headers.host ?? "localhost"; + const host = sanitizedRequestHost(req); return `http://${host}${req.url ?? "/"}`; }