@ap3x/a2a 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +27 -0
- package/dist/backend.d.ts +89 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/bindings/grpc.d.ts +121 -0
- package/dist/bindings/grpc.d.ts.map +1 -0
- package/dist/bindings/jsonrpc.d.ts +135 -0
- package/dist/bindings/jsonrpc.d.ts.map +1 -0
- package/dist/bindings/rest.d.ts +75 -0
- package/dist/bindings/rest.d.ts.map +1 -0
- package/dist/chunk-PYM7LV6R.js +1590 -0
- package/dist/client.d.ts +116 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/executor.d.ts +91 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/grpc-5DAU6EYQ.js +591 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +454 -0
- package/dist/model.d.ts +480 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/server.d.ts +60 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/task-store.d.ts +61 -0
- package/dist/task-store.d.ts.map +1 -0
- package/package.json +26 -15
- package/proto/a2a.proto +813 -0
- package/proto/google/api/annotations.proto +6 -0
- package/proto/google/api/client.proto +4 -0
- package/proto/google/api/field_behavior.proto +4 -0
- package/src/__tests__/a2a-auth-expired.test.ts +0 -84
- package/src/__tests__/a2a.test.ts +0 -87
- package/src/__tests__/handler-sendsubscribe-error.test.ts +0 -32
- package/src/__tests__/sendsubscribe-rejected-promise.test.ts +0 -89
- package/src/__tests__/server-apikey-warning.test.ts +0 -71
- package/src/__tests__/server-body-limit.test.ts +0 -54
- package/src/__tests__/server-security-headers.test.ts +0 -112
- package/src/__tests__/server-timing-safe-auth.test.ts +0 -45
- package/src/__tests__/sse-handler-error-propagation.test.ts +0 -69
- package/src/card/generator.ts +0 -18
- package/src/client/index.ts +0 -172
- package/src/errors.ts +0 -21
- package/src/index.ts +0 -15
- package/src/server/handler.ts +0 -90
- package/src/server/index.ts +0 -208
- package/src/server/sse.ts +0 -49
- package/src/swarm/a2a-agent.ts +0 -34
- package/src/types.ts +0 -50
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -4
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Minimal vendored stub of google/api/annotations.proto (Apache-2.0, googleapis).
|
|
2
|
+
// The A2A proto imports it only for the (google.api.http) method option, which
|
|
3
|
+
// protobufjs parses without needing the extension declaration. Empty stub keeps
|
|
4
|
+
// runtime loading deterministic without vendoring the full googleapis tree.
|
|
5
|
+
syntax = "proto3";
|
|
6
|
+
package google.api;
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { A2AClient } from "../client/index.js";
|
|
3
|
-
import { A2AAuthExpiredError } from "../errors.js";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* BUG-0284: A2AClient has no mechanism to surface expired auth credentials.
|
|
7
|
-
* OAuth2 tokens expire, causing generic 401 failures with no typed error.
|
|
8
|
-
*
|
|
9
|
-
* Fix: detect 401 responses and throw A2AAuthExpiredError so callers can
|
|
10
|
-
* catch it specifically and trigger token refresh / retry logic.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
describe("BUG-0284: A2AClient throws A2AAuthExpiredError on 401", () => {
|
|
14
|
-
const originalFetch = globalThis.fetch;
|
|
15
|
-
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
// Reset fetch mock before each test
|
|
18
|
-
vi.restoreAllMocks();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
globalThis.fetch = originalFetch;
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
function mock401() {
|
|
26
|
-
globalThis.fetch = vi.fn().mockResolvedValue(
|
|
27
|
-
new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function mock403() {
|
|
32
|
-
globalThis.fetch = vi.fn().mockResolvedValue(
|
|
33
|
-
new Response("Forbidden", { status: 403, statusText: "Forbidden" }),
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
it("getCard throws A2AAuthExpiredError on 401", async () => {
|
|
38
|
-
mock401();
|
|
39
|
-
const client = new A2AClient({ baseUrl: "http://localhost:3001" });
|
|
40
|
-
|
|
41
|
-
await expect(client.getCard()).rejects.toThrow(A2AAuthExpiredError);
|
|
42
|
-
await expect(client.getCard()).rejects.toMatchObject({
|
|
43
|
-
name: "A2AAuthExpiredError",
|
|
44
|
-
statusCode: 401,
|
|
45
|
-
url: "http://localhost:3001/.well-known/agent.json",
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("sendTask throws A2AAuthExpiredError on 401", async () => {
|
|
50
|
-
mock401();
|
|
51
|
-
const client = new A2AClient({ baseUrl: "http://localhost:3001" });
|
|
52
|
-
|
|
53
|
-
await expect(client.sendTask("hello")).rejects.toThrow(A2AAuthExpiredError);
|
|
54
|
-
await expect(client.sendTask("hello")).rejects.toMatchObject({
|
|
55
|
-
statusCode: 401,
|
|
56
|
-
url: "http://localhost:3001",
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("streamTask throws A2AAuthExpiredError on 401", async () => {
|
|
61
|
-
mock401();
|
|
62
|
-
const client = new A2AClient({ baseUrl: "http://localhost:3001" });
|
|
63
|
-
|
|
64
|
-
const gen = client.streamTask("hello");
|
|
65
|
-
await expect(gen.next()).rejects.toThrow(A2AAuthExpiredError);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("A2AAuthExpiredError extends Error for backward compatibility", () => {
|
|
69
|
-
const err = new A2AAuthExpiredError("test", "http://example.com");
|
|
70
|
-
expect(err).toBeInstanceOf(Error);
|
|
71
|
-
expect(err).toBeInstanceOf(A2AAuthExpiredError);
|
|
72
|
-
expect(err.name).toBe("A2AAuthExpiredError");
|
|
73
|
-
expect(err.statusCode).toBe(401);
|
|
74
|
-
expect(err.url).toBe("http://example.com");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("non-401 errors still throw generic Error (e.g. 403)", async () => {
|
|
78
|
-
mock403();
|
|
79
|
-
const client = new A2AClient({ baseUrl: "http://localhost:3001" });
|
|
80
|
-
|
|
81
|
-
await expect(client.sendTask("hello")).rejects.toThrow(Error);
|
|
82
|
-
await expect(client.sendTask("hello")).rejects.not.toThrow(A2AAuthExpiredError);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { generateAgentCard } from "../card/generator.js";
|
|
3
|
-
import { A2AClient } from "../client/index.js";
|
|
4
|
-
import { A2AServer } from "../server/index.js";
|
|
5
|
-
import { a2aAgent } from "../swarm/a2a-agent.js";
|
|
6
|
-
|
|
7
|
-
describe("generateAgentCard", () => {
|
|
8
|
-
it("generates a valid agent card", () => {
|
|
9
|
-
const card = generateAgentCard({
|
|
10
|
-
name: "Test Agent",
|
|
11
|
-
description: "A test agent",
|
|
12
|
-
url: "http://localhost:3001",
|
|
13
|
-
skills: [{ id: "test", name: "Test Skill" }],
|
|
14
|
-
});
|
|
15
|
-
expect(card.name).toBe("Test Agent");
|
|
16
|
-
expect(card.capabilities).toBeDefined();
|
|
17
|
-
expect(card.skills).toHaveLength(1);
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
describe("A2AClient.asToolDefinition", () => {
|
|
22
|
-
it("returns valid ToolDefinition shape", () => {
|
|
23
|
-
const client = new A2AClient({ baseUrl: "http://localhost:3001" });
|
|
24
|
-
const tool = client.asToolDefinition({ name: "remote_agent", description: "A remote agent" });
|
|
25
|
-
expect(tool.name).toBe("remote_agent");
|
|
26
|
-
expect(tool.schema.type).toBe("object");
|
|
27
|
-
expect(tool.schema.required).toContain("message");
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("A2AServer + A2AClient round trip", () => {
|
|
32
|
-
it("handles tasks/send in-process", async () => {
|
|
33
|
-
const card = generateAgentCard({
|
|
34
|
-
name: "Echo Agent",
|
|
35
|
-
description: "Echoes messages",
|
|
36
|
-
url: "http://test",
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
const server = new A2AServer(card, async (message) => `Echo: ${message}`);
|
|
40
|
-
const handler = server.requestHandler();
|
|
41
|
-
|
|
42
|
-
// Simulate a client request directly
|
|
43
|
-
const reqBody = {
|
|
44
|
-
jsonrpc: "2.0",
|
|
45
|
-
id: 1,
|
|
46
|
-
method: "tasks/send",
|
|
47
|
-
params: { id: "test-task", message: { role: "user", parts: [{ type: "text", text: "hello" }] } },
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const req = new Request("http://test/", {
|
|
51
|
-
method: "POST",
|
|
52
|
-
headers: { "Content-Type": "application/json" },
|
|
53
|
-
body: JSON.stringify(reqBody),
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const res = await handler(req);
|
|
57
|
-
expect(res.status).toBe(200);
|
|
58
|
-
const data = await res.json() as { result: { artifacts: Array<{ parts: Array<{ text: string }> }> } };
|
|
59
|
-
expect(data.result.artifacts[0]!.parts[0]!.text).toBe("Echo: hello");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("serves agent card at /.well-known/agent.json", async () => {
|
|
63
|
-
const card = generateAgentCard({ name: "Test", description: "Test", url: "http://test" });
|
|
64
|
-
const server = new A2AServer(card, async () => "ok");
|
|
65
|
-
const handler = server.requestHandler();
|
|
66
|
-
|
|
67
|
-
const req = new Request("http://test/.well-known/agent.json");
|
|
68
|
-
const res = await handler(req);
|
|
69
|
-
expect(res.status).toBe(200);
|
|
70
|
-
const returned = await res.json() as { name: string };
|
|
71
|
-
expect(returned.name).toBe("Test");
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe("a2aAgent", () => {
|
|
76
|
-
it("creates an agent def with correct shape", () => {
|
|
77
|
-
const agent = a2aAgent({
|
|
78
|
-
id: "remote-researcher",
|
|
79
|
-
role: "Researches topics using web search",
|
|
80
|
-
clientConfig: { baseUrl: "http://remote-agent:3000" },
|
|
81
|
-
});
|
|
82
|
-
expect(agent.id).toBe("remote-researcher");
|
|
83
|
-
expect(typeof agent.invoke).toBe("function");
|
|
84
|
-
const tool = agent.asTool();
|
|
85
|
-
expect(tool.name).toBe("remote-researcher");
|
|
86
|
-
});
|
|
87
|
-
});
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { handleJsonRPC } from "../server/handler.js";
|
|
3
|
-
import { generateAgentCard } from "../card/generator.js";
|
|
4
|
-
|
|
5
|
-
describe("handleJsonRPC — tasks/sendSubscribe", () => {
|
|
6
|
-
it("BUG-0001: should return JSON-RPC error when handler throws synchronously", async () => {
|
|
7
|
-
const card = generateAgentCard({ name: "Test", description: "Test", url: "http://test", streaming: true });
|
|
8
|
-
|
|
9
|
-
const throwingHandler = () => {
|
|
10
|
-
throw new Error("sync boom");
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const result = await handleJsonRPC(
|
|
14
|
-
{
|
|
15
|
-
jsonrpc: "2.0",
|
|
16
|
-
id: 1,
|
|
17
|
-
method: "tasks/sendSubscribe",
|
|
18
|
-
params: { id: "t1", message: { parts: [{ type: "text", text: "hi" }] } },
|
|
19
|
-
},
|
|
20
|
-
throwingHandler as any,
|
|
21
|
-
card,
|
|
22
|
-
);
|
|
23
|
-
|
|
24
|
-
// Should return a well-formed JSON-RPC error, not throw
|
|
25
|
-
expect(result.response).toBeDefined();
|
|
26
|
-
const res = result.response as { jsonrpc: string; id: unknown; error: { code: number; message: string } };
|
|
27
|
-
expect(res.jsonrpc).toBe("2.0");
|
|
28
|
-
expect(res.id).toBe(1);
|
|
29
|
-
expect(res.error.code).toBe(-32603);
|
|
30
|
-
expect(res.error.message).toBe("sync boom");
|
|
31
|
-
});
|
|
32
|
-
});
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression test for BUG-0361
|
|
3
|
-
*
|
|
4
|
-
* `handler()` for tasks/sendSubscribe was not awaited before checking
|
|
5
|
-
* [Symbol.asyncIterator]. If handler() returns a rejected Promise instead of
|
|
6
|
-
* throwing synchronously, the rejection bypassed the surrounding try/catch and
|
|
7
|
-
* surfaced as an unhandled rejection rather than a well-formed JSON-RPC error.
|
|
8
|
-
*
|
|
9
|
-
* Fix: await handler(messageText, taskId) so rejected Promises are caught by
|
|
10
|
-
* the existing try/catch block. Landed in commit ada9de7 on main.
|
|
11
|
-
*/
|
|
12
|
-
import { describe, it, expect } from "vitest";
|
|
13
|
-
import { handleJsonRPC } from "../server/handler.js";
|
|
14
|
-
import { generateAgentCard } from "../card/generator.js";
|
|
15
|
-
|
|
16
|
-
describe("BUG-0361: tasks/sendSubscribe — rejected Promise must be caught as JSON-RPC error", () => {
|
|
17
|
-
it("returns a JSON-RPC error when handler returns a rejected Promise", async () => {
|
|
18
|
-
const card = generateAgentCard({
|
|
19
|
-
name: "Test",
|
|
20
|
-
description: "Test",
|
|
21
|
-
url: "http://test",
|
|
22
|
-
streaming: true,
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
// A handler that returns a Promise.reject() rather than throwing synchronously.
|
|
26
|
-
// Before the fix, the rejection was not caught — handler() was called without
|
|
27
|
-
// await, so the rejected promise was never observed by the try/catch.
|
|
28
|
-
const rejectingHandler = () => Promise.reject(new Error("async rejection"));
|
|
29
|
-
|
|
30
|
-
const result = await handleJsonRPC(
|
|
31
|
-
{
|
|
32
|
-
jsonrpc: "2.0",
|
|
33
|
-
id: 42,
|
|
34
|
-
method: "tasks/sendSubscribe",
|
|
35
|
-
params: { id: "t1", message: { parts: [{ type: "text", text: "hello" }] } },
|
|
36
|
-
},
|
|
37
|
-
rejectingHandler as any,
|
|
38
|
-
card,
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
// Must return a well-formed JSON-RPC error response, not propagate the rejection
|
|
42
|
-
expect(result.response).toBeDefined();
|
|
43
|
-
expect(result.stream).toBeUndefined();
|
|
44
|
-
|
|
45
|
-
const res = result.response as {
|
|
46
|
-
jsonrpc: string;
|
|
47
|
-
id: unknown;
|
|
48
|
-
error: { code: number; message: string };
|
|
49
|
-
};
|
|
50
|
-
expect(res.jsonrpc).toBe("2.0");
|
|
51
|
-
expect(res.id).toBe(42);
|
|
52
|
-
expect(res.error).toBeDefined();
|
|
53
|
-
expect(res.error.code).toBe(-32603);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("still returns a stream when handler returns a valid async generator", async () => {
|
|
57
|
-
const card = generateAgentCard({
|
|
58
|
-
name: "Test",
|
|
59
|
-
description: "Test",
|
|
60
|
-
url: "http://test",
|
|
61
|
-
streaming: true,
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
async function* validHandler(): AsyncGenerator<string> {
|
|
65
|
-
yield "chunk-1";
|
|
66
|
-
yield "chunk-2";
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const result = await handleJsonRPC(
|
|
70
|
-
{
|
|
71
|
-
jsonrpc: "2.0",
|
|
72
|
-
id: 1,
|
|
73
|
-
method: "tasks/sendSubscribe",
|
|
74
|
-
params: { id: "t2", message: { parts: [{ type: "text", text: "hi" }] } },
|
|
75
|
-
},
|
|
76
|
-
validHandler as any,
|
|
77
|
-
card,
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
expect(result.stream).toBeDefined();
|
|
81
|
-
expect(result.response).toBeUndefined();
|
|
82
|
-
|
|
83
|
-
const chunks: string[] = [];
|
|
84
|
-
for await (const chunk of result.stream!) {
|
|
85
|
-
chunks.push(chunk);
|
|
86
|
-
}
|
|
87
|
-
expect(chunks).toEqual(["chunk-1", "chunk-2"]);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
import { generateAgentCard } from "../card/generator.js";
|
|
3
|
-
import { A2AServer } from "../server/index.js";
|
|
4
|
-
|
|
5
|
-
describe("A2AServer unauthenticated deployment warning (BUG-0256)", () => {
|
|
6
|
-
afterEach(() => {
|
|
7
|
-
vi.restoreAllMocks();
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it("BUG-0256: logs a warning to console.warn when no apiKey is configured", () => {
|
|
11
|
-
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
12
|
-
|
|
13
|
-
const card = generateAgentCard({
|
|
14
|
-
name: "Open Agent",
|
|
15
|
-
description: "No auth",
|
|
16
|
-
url: "http://test",
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
new A2AServer(card, async () => "ok");
|
|
20
|
-
|
|
21
|
-
expect(warnSpy).toHaveBeenCalledOnce();
|
|
22
|
-
const [msg] = warnSpy.mock.calls[0]!;
|
|
23
|
-
expect(typeof msg).toBe("string");
|
|
24
|
-
expect((msg as string).toLowerCase()).toContain("unauthenticated");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("BUG-0256: does NOT log a warning when apiKey is provided", () => {
|
|
28
|
-
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
29
|
-
|
|
30
|
-
const card = generateAgentCard({
|
|
31
|
-
name: "Secure Agent",
|
|
32
|
-
description: "With auth",
|
|
33
|
-
url: "http://test",
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
new A2AServer(card, async () => "ok", { apiKey: "my-secret-key" });
|
|
37
|
-
|
|
38
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("BUG-0256: unauthenticated server still handles requests (no regression)", async () => {
|
|
42
|
-
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
43
|
-
|
|
44
|
-
const card = generateAgentCard({
|
|
45
|
-
name: "Open Agent",
|
|
46
|
-
description: "No auth",
|
|
47
|
-
url: "http://test",
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
const server = new A2AServer(card, async (message) => `echo:${message}`);
|
|
51
|
-
const handler = server.requestHandler();
|
|
52
|
-
|
|
53
|
-
// Without apiKey, requests should be served with no auth gate
|
|
54
|
-
const req = new Request("http://test/", {
|
|
55
|
-
method: "POST",
|
|
56
|
-
headers: { "Content-Type": "application/json" },
|
|
57
|
-
body: JSON.stringify({
|
|
58
|
-
jsonrpc: "2.0",
|
|
59
|
-
id: 1,
|
|
60
|
-
method: "tasks/send",
|
|
61
|
-
params: {
|
|
62
|
-
id: "t1",
|
|
63
|
-
message: { role: "user", parts: [{ type: "text", text: "hello" }] },
|
|
64
|
-
},
|
|
65
|
-
}),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
const res = await handler(req);
|
|
69
|
-
expect(res.status).toBe(200);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { A2AServer } from "../server/index.js";
|
|
3
|
-
import { generateAgentCard } from "../card/generator.js";
|
|
4
|
-
|
|
5
|
-
describe("BUG-0077: A2A server rejects oversized request bodies", () => {
|
|
6
|
-
it("BUG-0077: should return 413 when Content-Length exceeds 1MB", async () => {
|
|
7
|
-
const card = generateAgentCard({
|
|
8
|
-
name: "Test",
|
|
9
|
-
description: "Test",
|
|
10
|
-
url: "http://test",
|
|
11
|
-
});
|
|
12
|
-
const server = new A2AServer(card, async () => "ok");
|
|
13
|
-
const handler = server.requestHandler();
|
|
14
|
-
|
|
15
|
-
// Create a request with Content-Length claiming > 1MB
|
|
16
|
-
const oversizedBody = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tasks/send", params: {} });
|
|
17
|
-
const req = new Request("http://test/", {
|
|
18
|
-
method: "POST",
|
|
19
|
-
headers: {
|
|
20
|
-
"Content-Type": "application/json",
|
|
21
|
-
"Content-Length": String(2 * 1024 * 1024), // 2MB
|
|
22
|
-
},
|
|
23
|
-
body: oversizedBody,
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
const res = await handler(req);
|
|
27
|
-
expect(res.status).toBe(413);
|
|
28
|
-
const data = await res.json() as { error: { message: string } };
|
|
29
|
-
expect(data.error.message).toContain("too large");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("BUG-0077: should return 413 when actual body text exceeds 1MB", async () => {
|
|
33
|
-
const card = generateAgentCard({
|
|
34
|
-
name: "Test",
|
|
35
|
-
description: "Test",
|
|
36
|
-
url: "http://test",
|
|
37
|
-
});
|
|
38
|
-
const server = new A2AServer(card, async () => "ok");
|
|
39
|
-
const handler = server.requestHandler();
|
|
40
|
-
|
|
41
|
-
// Create a request with body text > 1MB but no Content-Length header hint
|
|
42
|
-
const largePayload = "x".repeat(1.5 * 1024 * 1024);
|
|
43
|
-
const req = new Request("http://test/", {
|
|
44
|
-
method: "POST",
|
|
45
|
-
headers: { "Content-Type": "application/json" },
|
|
46
|
-
body: largePayload,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const res = await handler(req);
|
|
50
|
-
expect(res.status).toBe(413);
|
|
51
|
-
const data = await res.json() as { error: { message: string } };
|
|
52
|
-
expect(data.error.message).toContain("too large");
|
|
53
|
-
});
|
|
54
|
-
});
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { generateAgentCard } from "../card/generator.js";
|
|
3
|
-
import { A2AServer } from "../server/index.js";
|
|
4
|
-
|
|
5
|
-
// Suppress the "no apiKey" warning for tests that don't need auth
|
|
6
|
-
const silenceWarn = () => vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
7
|
-
|
|
8
|
-
const REQUIRED_SECURITY_HEADERS: [string, string][] = [
|
|
9
|
-
["x-content-type-options", "nosniff"],
|
|
10
|
-
["x-frame-options", "DENY"],
|
|
11
|
-
["content-security-policy", "default-src 'none'"],
|
|
12
|
-
];
|
|
13
|
-
|
|
14
|
-
function makeCard() {
|
|
15
|
-
return generateAgentCard({
|
|
16
|
-
name: "Security Header Test Agent",
|
|
17
|
-
description: "Tests security headers",
|
|
18
|
-
url: "http://test",
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe("A2AServer security headers (BUG-0257)", () => {
|
|
23
|
-
it("BUG-0257: includes security headers on a successful tasks/send response", async () => {
|
|
24
|
-
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
25
|
-
const server = new A2AServer(makeCard(), async (message) => `echo:${message}`);
|
|
26
|
-
const handler = server.requestHandler();
|
|
27
|
-
|
|
28
|
-
const req = new Request("http://test/", {
|
|
29
|
-
method: "POST",
|
|
30
|
-
headers: { "Content-Type": "application/json" },
|
|
31
|
-
body: JSON.stringify({
|
|
32
|
-
jsonrpc: "2.0",
|
|
33
|
-
id: 1,
|
|
34
|
-
method: "tasks/send",
|
|
35
|
-
params: {
|
|
36
|
-
id: "t1",
|
|
37
|
-
message: { role: "user", parts: [{ type: "text", text: "hello" }] },
|
|
38
|
-
},
|
|
39
|
-
}),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const res = await handler(req);
|
|
43
|
-
expect(res.status).toBe(200);
|
|
44
|
-
|
|
45
|
-
for (const [header, value] of REQUIRED_SECURITY_HEADERS) {
|
|
46
|
-
expect(
|
|
47
|
-
res.headers.get(header),
|
|
48
|
-
`Expected ${header}: ${value} on 200 response`,
|
|
49
|
-
).toBe(value);
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("BUG-0257: includes security headers on agent-card GET response", async () => {
|
|
54
|
-
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
55
|
-
const server = new A2AServer(makeCard(), async () => "ok");
|
|
56
|
-
const handler = server.requestHandler();
|
|
57
|
-
|
|
58
|
-
const req = new Request("http://test/.well-known/agent.json");
|
|
59
|
-
const res = await handler(req);
|
|
60
|
-
expect(res.status).toBe(200);
|
|
61
|
-
|
|
62
|
-
for (const [header, value] of REQUIRED_SECURITY_HEADERS) {
|
|
63
|
-
expect(
|
|
64
|
-
res.headers.get(header),
|
|
65
|
-
`Expected ${header}: ${value} on agent card response`,
|
|
66
|
-
).toBe(value);
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("BUG-0257: includes security headers on 401 Unauthorized response", async () => {
|
|
71
|
-
const server = new A2AServer(makeCard(), async () => "ok", {
|
|
72
|
-
apiKey: "secret",
|
|
73
|
-
});
|
|
74
|
-
const handler = server.requestHandler();
|
|
75
|
-
|
|
76
|
-
const req = new Request("http://test/", {
|
|
77
|
-
method: "POST",
|
|
78
|
-
headers: {
|
|
79
|
-
"Content-Type": "application/json",
|
|
80
|
-
Authorization: "Bearer wrong",
|
|
81
|
-
},
|
|
82
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tasks/send", params: {} }),
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
const res = await handler(req);
|
|
86
|
-
expect(res.status).toBe(401);
|
|
87
|
-
|
|
88
|
-
for (const [header, value] of REQUIRED_SECURITY_HEADERS) {
|
|
89
|
-
expect(
|
|
90
|
-
res.headers.get(header),
|
|
91
|
-
`Expected ${header}: ${value} on 401 response`,
|
|
92
|
-
).toBe(value);
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("BUG-0257: includes security headers on 405 Method Not Allowed response", async () => {
|
|
97
|
-
silenceWarn();
|
|
98
|
-
const server = new A2AServer(makeCard(), async () => "ok");
|
|
99
|
-
const handler = server.requestHandler();
|
|
100
|
-
|
|
101
|
-
const req = new Request("http://test/", { method: "DELETE" });
|
|
102
|
-
const res = await handler(req);
|
|
103
|
-
expect(res.status).toBe(405);
|
|
104
|
-
|
|
105
|
-
for (const [header, value] of REQUIRED_SECURITY_HEADERS) {
|
|
106
|
-
expect(
|
|
107
|
-
res.headers.get(header),
|
|
108
|
-
`Expected ${header}: ${value} on 405 response`,
|
|
109
|
-
).toBe(value);
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
});
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { generateAgentCard } from "../card/generator.js";
|
|
3
|
-
import { A2AServer } from "../server/index.js";
|
|
4
|
-
|
|
5
|
-
describe("A2AServer API key authentication", () => {
|
|
6
|
-
it("BUG-0236: should use crypto.timingSafeEqual for API key comparison", async () => {
|
|
7
|
-
const card = generateAgentCard({
|
|
8
|
-
name: "Auth Test",
|
|
9
|
-
description: "Test",
|
|
10
|
-
url: "http://test",
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
const server = new A2AServer(card, async () => "ok", {
|
|
14
|
-
apiKey: "secret-key-12345",
|
|
15
|
-
});
|
|
16
|
-
const handler = server.requestHandler();
|
|
17
|
-
|
|
18
|
-
// Request with correct key should succeed
|
|
19
|
-
const goodReq = new Request("http://test/.well-known/agent.json", {
|
|
20
|
-
headers: { Authorization: "Bearer secret-key-12345" },
|
|
21
|
-
});
|
|
22
|
-
const goodRes = await handler(goodReq);
|
|
23
|
-
expect(goodRes.status).toBe(200);
|
|
24
|
-
|
|
25
|
-
// Request with wrong key should be rejected
|
|
26
|
-
const badReq = new Request("http://test/.well-known/agent.json", {
|
|
27
|
-
headers: { Authorization: "Bearer wrong-key" },
|
|
28
|
-
});
|
|
29
|
-
const badRes = await handler(badReq);
|
|
30
|
-
expect(badRes.status).toBe(401);
|
|
31
|
-
|
|
32
|
-
// Request with no auth header should be rejected
|
|
33
|
-
const noAuthReq = new Request("http://test/.well-known/agent.json");
|
|
34
|
-
const noAuthRes = await handler(noAuthReq);
|
|
35
|
-
expect(noAuthRes.status).toBe(401);
|
|
36
|
-
|
|
37
|
-
// Request with key of same length but different content should be rejected
|
|
38
|
-
// (this is the case that timing attacks exploit — same-length strings)
|
|
39
|
-
const sameLenReq = new Request("http://test/.well-known/agent.json", {
|
|
40
|
-
headers: { Authorization: "Bearer secret-key-12346" },
|
|
41
|
-
});
|
|
42
|
-
const sameLenRes = await handler(sameLenReq);
|
|
43
|
-
expect(sameLenRes.status).toBe(401);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { handleJsonRPC } from "../server/handler.js";
|
|
3
|
-
import { createSSEResponse } from "../server/sse.js";
|
|
4
|
-
import { generateAgentCard } from "../card/generator.js";
|
|
5
|
-
|
|
6
|
-
describe("SSE stream error propagation", () => {
|
|
7
|
-
it("BUG-0054: handler generator errors produce a JSON-RPC error SSE event, not a 'working' state frame", async () => {
|
|
8
|
-
const card = generateAgentCard({
|
|
9
|
-
name: "Test",
|
|
10
|
-
description: "Test",
|
|
11
|
-
url: "http://test",
|
|
12
|
-
streaming: true,
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
// A streaming handler that yields one chunk then throws
|
|
16
|
-
const throwingStreamingHandler = async function* () {
|
|
17
|
-
yield "partial output";
|
|
18
|
-
throw new Error("boom mid-stream");
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const result = await handleJsonRPC(
|
|
22
|
-
{
|
|
23
|
-
jsonrpc: "2.0",
|
|
24
|
-
id: 1,
|
|
25
|
-
method: "tasks/sendSubscribe",
|
|
26
|
-
params: { id: "t1", message: { parts: [{ type: "text", text: "go" }] } },
|
|
27
|
-
},
|
|
28
|
-
throwingStreamingHandler as unknown as Parameters<typeof handleJsonRPC>[1],
|
|
29
|
-
card,
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
// The result must have a stream (not an inline error response)
|
|
33
|
-
expect(result.stream).toBeDefined();
|
|
34
|
-
expect(result.taskId).toBeDefined();
|
|
35
|
-
|
|
36
|
-
// Consume the SSE response through createSSEResponse, collecting all frames
|
|
37
|
-
const sseResponse = createSSEResponse(result.stream!, result.taskId!);
|
|
38
|
-
const reader = sseResponse.body!.getReader();
|
|
39
|
-
const decoder = new TextDecoder();
|
|
40
|
-
const frames: string[] = [];
|
|
41
|
-
try {
|
|
42
|
-
while (true) {
|
|
43
|
-
const { done, value } = await reader.read();
|
|
44
|
-
if (done) break;
|
|
45
|
-
frames.push(decoder.decode(value));
|
|
46
|
-
}
|
|
47
|
-
} catch {
|
|
48
|
-
// The stream errors after emitting the JSON-RPC error frame;
|
|
49
|
-
// controller.error() causes the read loop to throw. That's expected.
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const allSSE = frames.join("");
|
|
53
|
-
|
|
54
|
-
// Before the fix: safeStream caught the error and yielded it as a text string,
|
|
55
|
-
// so it appeared as a "working" state frame and the stream ended with "completed".
|
|
56
|
-
// After the fix: the error propagates to createSSEResponse's catch block,
|
|
57
|
-
// which emits a JSON-RPC error event with code -32603.
|
|
58
|
-
expect(allSSE).toContain('"code":-32603');
|
|
59
|
-
|
|
60
|
-
// The stream must NOT end with a "completed" state after an error
|
|
61
|
-
const lastDataFrame = allSSE.split("data: ").filter(Boolean).pop() ?? "";
|
|
62
|
-
expect(lastDataFrame).not.toContain('"state":"completed"');
|
|
63
|
-
|
|
64
|
-
// The error frame must not be disguised as a "working" status chunk
|
|
65
|
-
// (i.e., the error JSON should appear in an error-keyed object, not in status.message.parts)
|
|
66
|
-
expect(allSSE).toContain('"error"');
|
|
67
|
-
expect(allSSE).not.toMatch(/"state":"working"[^}]*"boom mid-stream"/);
|
|
68
|
-
});
|
|
69
|
-
});
|