Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 82 additions & 7 deletions src/jsonrpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1011,7 +1011,7 @@ describe("JSON-RPC request cancellation", () => {
});

describe("JSON-RPC malformed peer messages", () => {
it("rejects the pending request when a response's error member is not an object", async () => {
it("rejects a matching malformed response without replying to it", async () => {
const [clientStream, serverStream] = memoryStreamPair();
const client = Connection.builder().connect(clientStream);
const serverReader = serverStream.readable.getReader();
Expand All @@ -1029,20 +1029,97 @@ describe("JSON-RPC malformed peer messages", () => {

await expect(response).rejects.toMatchObject({ code: -32600 });

const notification = client.sendNotification("example/after-response", {});
await expect(serverReader.read()).resolves.toMatchObject({
value: {
jsonrpc: "2.0",
method: "example/after-response",
},
});
await notification;

serverReader.releaseLock();
serverWriter.releaseLock();
client.close();
await client.closed;
});

it("ignores non-object messages without tearing down the connection", async () => {
it("does not reply to unknown malformed response-shaped messages", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const [clientStream, serverStream] = memoryStreamPair();
const processed = Promise.withResolvers<void>();
const client = Connection.builder()
.onReceiveNotification(
"example/barrier",
(params) => params,
() => {
processed.resolve();
},
)
.connect(clientStream);
const serverReader = serverStream.readable.getReader();
const serverWriter = serverStream.writable.getWriter();

await serverWriter.write({
jsonrpc: "2.0",
id: 999,
error: null,
} as unknown as AnyMessage);
await serverWriter.write({
jsonrpc: "2.0",
result: true,
} as unknown as AnyMessage);
await serverWriter.write({
jsonrpc: "2.0",
error: null,
} as unknown as AnyMessage);
await serverWriter.write({
jsonrpc: "2.0",
method: "example/barrier",
});
await processed.promise;

const notification = client.sendNotification("example/after-responses", {});
await expect(serverReader.read()).resolves.toMatchObject({
value: {
jsonrpc: "2.0",
method: "example/after-responses",
},
});
await notification;

serverReader.releaseLock();
serverWriter.releaseLock();
consoleError.mockRestore();
client.close();
await client.closed;
});

it("returns Invalid Request for malformed call-shaped values and stays open", async () => {
const [clientStream, serverStream] = memoryStreamPair();
const client = Connection.builder().connect(clientStream);
const serverReader = serverStream.readable.getReader();
const serverWriter = serverStream.writable.getWriter();

await serverWriter.write(42 as unknown as AnyMessage);
for (const malformed of [
null,
42,
{},
{ jsonrpc: "1.0", id: 1, method: "example/test" },
{ jsonrpc: "2.0", id: 1, method: 42 },
{ jsonrpc: "2.0", id: {}, method: "example/test" },
]) {
await serverWriter.write(malformed as unknown as AnyWireMessage);
await expect(serverReader.read()).resolves.toMatchObject({
value: {
jsonrpc: "2.0",
id: null,
error: { code: -32600 },
},
});
}

const response = client.sendRequest("example/test", {});
const { value: request } = await serverReader.read();
Expand All @@ -1053,11 +1130,9 @@ describe("JSON-RPC malformed peer messages", () => {
} as AnyMessage);

await expect(response).resolves.toEqual({ ok: true });
expect(consoleError).toHaveBeenCalledWith("Invalid message", {
message: 42,
});

consoleError.mockRestore();
serverReader.releaseLock();
serverWriter.releaseLock();
client.close();
await client.closed;
});
Expand Down
42 changes: 27 additions & 15 deletions src/jsonrpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1217,9 +1217,6 @@ export class Connection {
if (done) {
break;
}
if (!message) {
continue;
}

this.receiveWireMessage(message);
}
Expand Down Expand Up @@ -1251,8 +1248,14 @@ export class Connection {
return;
}

if (!isRecord(message)) {
console.error("Invalid message", { message });
if (
!isRequestMessage(message) &&
!isNotificationMessage(message) &&
!isResponseShapedMessage(message)
) {
void this.sendWireMessage(
protocolErrorResponse(RequestError.invalidRequest(message)),
).catch(() => {});
return;
}

Expand All @@ -1261,11 +1264,9 @@ export class Connection {

private receiveBatch(batch: unknown[]): void {
if (batch.length === 0) {
void this.sendWireMessage({
jsonrpc: "2.0",
id: null,
error: RequestError.invalidRequest(batch).toErrorResponse(),
}).catch(() => {});
void this.sendWireMessage(
protocolErrorResponse(RequestError.invalidRequest(batch)),
).catch(() => {});
return;
}

Expand Down Expand Up @@ -1314,11 +1315,9 @@ export class Connection {
}

if (!isRequestMessage(message) && !isNotificationMessage(message)) {
void collectResponse({
jsonrpc: "2.0",
id: null,
error: RequestError.invalidRequest(message).toErrorResponse(),
}).catch(() => {});
void collectResponse(
protocolErrorResponse(RequestError.invalidRequest(message)),
).catch(() => {});
continue;
}

Expand Down Expand Up @@ -1800,3 +1799,16 @@ export class RequestError extends Error {
};
}
}

/**
* Creates a JSON-RPC error response for a request whose ID cannot be known.
*
* @internal
*/
export function protocolErrorResponse(error: RequestError): AnyResponse {
return {
jsonrpc: "2.0",
id: null,
error: error.toErrorResponse(),
};
}
52 changes: 52 additions & 0 deletions src/server-websocket-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,58 @@ function createProtocolAgent(
}

describe("AcpServer prepared WebSocket upgrades", () => {
it("returns JSON-RPC errors for malformed frames and remains usable", async () => {
const registry = new ConnectionRegistry();
const agent = createProtocolAgent(1);
const socket = new FakeServerSocket();

try {
handleWebSocketConnection(socket, { registry, agent });

socket.receive("not json");
await expect(readSentMessage(socket)).resolves.toMatchObject({
jsonrpc: "2.0",
id: null,
error: { code: -32700 },
});

socket.receive("42");
await expect(readSentMessage(socket)).resolves.toMatchObject({
jsonrpc: "2.0",
id: null,
error: { code: -32600 },
});
expect(socket.closeCount).toBe(0);

socket.receive(JSON.stringify(initializeRequest));
await expect(readSentMessage(socket)).resolves.toMatchObject({
jsonrpc: "2.0",
id: initializeRequest.id,
result: { protocolVersion: 1 },
});

socket.receive("{ malformed");
await expect(readSentMessage(socket)).resolves.toMatchObject({
jsonrpc: "2.0",
id: null,
error: { code: -32700 },
});

socket.receive(JSON.stringify(sessionNewRequest));
await expect(readSentMessage(socket)).resolves.toMatchObject({
jsonrpc: "2.0",
id: sessionNewRequest.id,
result: {
sessionId: expect.stringMatching(/^[0-9a-f-]{36}$/),
},
});
expect(socket.closeCount).toBe(0);
} finally {
socket.close();
await registry.closeAll();
}
});

it("uses the default factory when no per-upgrade override is provided", async () => {
const createdBy: string[] = [];
const server = new AcpServer({
Expand Down
56 changes: 20 additions & 36 deletions src/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,48 +175,32 @@ describe("ndJsonStream", () => {
expect(messages).toEqual([msg]);
});

it("skips malformed lines and continues parsing", async () => {
const error = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
const msg1 = { jsonrpc: "2.0" as const, id: 1, method: "before" };
const msg2 = { jsonrpc: "2.0" as const, id: 2, method: "after" };
it("responds to malformed and primitive lines and continues parsing", async () => {
const outputChunks: Uint8Array[] = [];
const output = new WritableStream<Uint8Array>({
write(chunk) {
outputChunks.push(chunk);
},
});
const msg = { jsonrpc: "2.0" as const, id: 2, method: "after" };
const input = streamFromChunks([
JSON.stringify(msg1) +
"\n" +
"not valid json\n" +
JSON.stringify(msg2) +
"\n",
"not valid json\n42\n\n" + JSON.stringify(msg) + "\n",
]);

const { readable } = ndJsonStream(nullWritable, input);
const { readable } = ndJsonStream(output, input);
const messages = await collectStream(readable);
const responses = outputChunks
.map((chunk) => new TextDecoder().decode(chunk))
.join("")
.trim()
.split("\n")
.map((line) => JSON.parse(line));

expect(messages).toEqual([msg1, msg2]);
expect(error).toHaveBeenCalledOnce();

error.mockRestore();
});

it("skips non-object JSON lines that would break the connection layer", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const msg1 = { jsonrpc: "2.0" as const, id: 1, method: "before" };
const msg2 = { jsonrpc: "2.0" as const, id: 2, method: "after" };
const input = streamFromChunks([
JSON.stringify(msg1) +
"\n" +
'42\n"str"\nnull\n' +
JSON.stringify(msg2) +
"\n",
expect(messages).toEqual([msg]);
expect(responses).toMatchObject([
{ jsonrpc: "2.0", id: null, error: { code: -32700 } },
{ jsonrpc: "2.0", id: null, error: { code: -32600 } },
]);

const { readable } = ndJsonStream(nullWritable, input);
const messages = await collectStream(readable);

expect(messages).toEqual([msg1, msg2]);
expect(warn).toHaveBeenCalledTimes(3);

warn.mockRestore();
});

it("passes through object messages without validating their shape", async () => {
Expand Down
Loading