Skip to content

Commit f939324

Browse files
authored
feat(cloud): agent server (#749)
1 parent 6fa64f4 commit f939324

File tree

18 files changed

+2213
-74
lines changed

18 files changed

+2213
-74
lines changed

packages/agent/package.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,15 @@
3939
"./adapters/claude/conversion/tool-use-to-acp": {
4040
"types": "./dist/adapters/claude/conversion/tool-use-to-acp.d.ts",
4141
"import": "./dist/adapters/claude/conversion/tool-use-to-acp.js"
42+
},
43+
"./server": {
44+
"types": "./dist/server/agent-server.d.ts",
45+
"import": "./dist/server/agent-server.js"
4246
}
4347
},
48+
"bin": {
49+
"agent-server": "./dist/server/bin.js"
50+
},
4451
"type": "module",
4552
"keywords": [
4653
"posthog",
@@ -68,18 +75,24 @@
6875
"@types/bun": "latest",
6976
"@types/tar": "^6.1.13",
7077
"minimatch": "^10.0.3",
78+
"msw": "^2.12.7",
7179
"tsup": "^8.5.1",
7280
"tsx": "^4.20.6",
7381
"typescript": "^5.5.0",
7482
"vitest": "^2.1.8"
7583
},
7684
"dependencies": {
77-
"@posthog/shared": "workspace:*",
7885
"@twig/git": "workspace:*",
7986
"@agentclientprotocol/sdk": "^0.13.1",
87+
"@hono/node-server": "^1.19.9",
88+
"@types/jsonwebtoken": "^9.0.10",
89+
"hono": "^4.11.7",
90+
"jsonwebtoken": "^9.0.2",
8091
"@anthropic-ai/claude-agent-sdk": "0.2.12",
8192
"@anthropic-ai/sdk": "^0.71.0",
8293
"@modelcontextprotocol/sdk": "^1.25.3",
94+
"@posthog/shared": "workspace:*",
95+
"commander": "^14.0.2",
8396
"diff": "^8.0.2",
8497
"dotenv": "^17.2.3",
8598
"tar": "^7.5.0",
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import jwt from "jsonwebtoken";
2+
import { type SetupServerApi, setupServer } from "msw/node";
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
import { createTestRepo, type TestRepo } from "../test/fixtures/api.js";
5+
import { createPostHogHandlers } from "../test/mocks/msw-handlers.js";
6+
import { AgentServer } from "./agent-server.js";
7+
import { type JwtPayload, SANDBOX_CONNECTION_AUDIENCE } from "./jwt.js";
8+
9+
function createTestJwt(
10+
payload: JwtPayload,
11+
privateKey: string,
12+
expiresInSeconds = 3600,
13+
): string {
14+
return jwt.sign(
15+
{ ...payload, aud: SANDBOX_CONNECTION_AUDIENCE },
16+
privateKey,
17+
{
18+
algorithm: "RS256",
19+
expiresIn: expiresInSeconds,
20+
},
21+
);
22+
}
23+
24+
// Test RSA key pair (2048-bit, for testing only)
25+
const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
26+
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDqh94SYMFsvG4C
27+
Co9BSGjtPr2/OxzuNGr41O4+AMkDQRd9pKO49DhTA4VzwnOvrH8y4eI9N8OQne7B
28+
wpdoouSn4DoDAS/b3SUfij/RoFUSyZiTQoWz0H6o2Vuufiz0Hf+BzlZEVnhSQ1ru
29+
vqSf+4l8cWgeMXaFXgdD5kQ8GjvR5uqKxvO2Env1hMJRKeOOEGgCep/0c6SkMUTX
30+
SeC+VjypVg9+8yPxtIpOQ7XKv+7e/PA0ilqehRQh4fo9BAWjUW1+HnbtsjJAjjfv
31+
ngzIjpajuQVyMi7G79v8OvijhLMJjJBh3TdbVIfi+RkVj/H94UUfKWRfJA0eLykA
32+
VvTiFf0nAgMBAAECggEABkLBQWFW2IXBNAm/IEGEF408uH2l/I/mqSTaBUq1EwKq
33+
U17RRg8y77hg2CHBP9fNf3i7NuIltNcaeA6vRwpOK1MXiVv/QJHLO2fP41Mx4jIC
34+
gi/c7NtsfiprQaG5pnykhP0SnXlndd65bzUkpOasmWdXnbK5VL8ZV40uliInJafE
35+
1Eo9qSYCJxHmivU/4AbiBgygOAo1QIiuuUHcx0YGknLrBaMQETuvWJGE3lxVQ30/
36+
EuRyA3r6BwN2T0z47PZBzvCpg/C1KeoYuKSMwMyEXfl+a8NclqdROkVaenmZpvVH
37+
0lAvFDuPrBSDmU4XJbKCEfwfHjRkiWAFaTrKntGQtQKBgQD/ILoK4U9DkJoKTYvY
38+
9lX7dg6wNO8jGLHNufU8tHhU+QnBMH3hBXrAtIKQ1sGs+D5rq/O7o0Balmct9vwb
39+
CQZ1EpPfa83Thsv6Skd7lWK0JF7g2vVk8kT4nY/eqkgZUWgkfdMp+OMg2drYiIE8
40+
u+sRPTCdq4Tv5miRg0OToX2H/QKBgQDrVR2GXm6ZUyFbCy8A0kttXP1YyXqDVq7p
41+
L4kqyUq43hmbjzIRM4YDN3EvgZvVf6eub6L/3HfKvWD/OvEhHovTvHb9jkwZ3FO+
42+
YQllB/ccAWJs/Dw5jLAsX9O+eIe4lfwROib3vYLnDTAmrXD5VL35R5F0MsdRoxk5
43+
lTCq1sYI8wKBgGA9ZjDIgXAJUjJkwkZb1l9/T1clALiKjjf+2AXIRkQ3lXhs5G9H
44+
8+BRt5cPjAvFsTZIrS6xDIufhNiP/NXt96OeGG4FaqVKihOmhYSW+57cwXWs4zjr
45+
Mx1dwnHKZlw2m0R4unlwy60OwUFBbQ8ODER6gqZXl1Qv5G5Px+Qe3Q25AoGAUl+s
46+
wgfz9r9egZvcjBEQTeuq0pVTyP1ipET7YnqrKSK1G/p3sAW09xNFDzfy8DyK2UhC
47+
agUl+VVoym47UTh8AVWK4R4aDUNOHOmifDbZjHf/l96CxjI0yJOSbq2J9FarsOwG
48+
D9nKJE49eIxlayD6jnM6us27bxwEDF/odSRQlXkCgYEAxn9l/5kewWkeEA0Afe1c
49+
Uf+mepHBLw1Pbg5GJYIZPC6e5+wRNvtFjM5J6h5LVhyb7AjKeLBTeohoBKEfUyUO
50+
rl/ql9qDIh5lJFn3uNh7+r7tmG21Zl2pyh+O8GljjZ25mYhdiwl0uqzVZaINe2Wa
51+
vbMnD1ZQKgL8LHgb02cbTsc=
52+
-----END PRIVATE KEY-----`;
53+
54+
const TEST_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
55+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6ofeEmDBbLxuAgqPQUho
56+
7T69vzsc7jRq+NTuPgDJA0EXfaSjuPQ4UwOFc8Jzr6x/MuHiPTfDkJ3uwcKXaKLk
57+
p+A6AwEv290lH4o/0aBVEsmYk0KFs9B+qNlbrn4s9B3/gc5WRFZ4UkNa7r6kn/uJ
58+
fHFoHjF2hV4HQ+ZEPBo70ebqisbzthJ79YTCUSnjjhBoAnqf9HOkpDFE10ngvlY8
59+
qVYPfvMj8bSKTkO1yr/u3vzwNIpanoUUIeH6PQQFo1Ftfh527bIyQI43754MyI6W
60+
o7kFcjIuxu/b/Dr4o4SzCYyQYd03W1SH4vkZFY/x/eFFHylkXyQNHi8pAFb04hX9
61+
JwIDAQAB
62+
-----END PUBLIC KEY-----`;
63+
64+
describe("AgentServer HTTP Mode", () => {
65+
let repo: TestRepo;
66+
let server: AgentServer;
67+
let mswServer: SetupServerApi;
68+
let appendLogCalls: unknown[][];
69+
const port = 3099;
70+
71+
beforeEach(async () => {
72+
repo = await createTestRepo("agent-server-http");
73+
appendLogCalls = [];
74+
mswServer = setupServer(
75+
...createPostHogHandlers({
76+
baseUrl: "http://localhost:8000",
77+
onAppendLog: (entries) => appendLogCalls.push(entries),
78+
}),
79+
);
80+
mswServer.listen({ onUnhandledRequest: "bypass" });
81+
});
82+
83+
afterEach(async () => {
84+
if (server) {
85+
await server.stop();
86+
}
87+
mswServer.close();
88+
await repo.cleanup();
89+
});
90+
91+
const createServer = () => {
92+
server = new AgentServer({
93+
port,
94+
jwtPublicKey: TEST_PUBLIC_KEY,
95+
repositoryPath: repo.path,
96+
apiUrl: "http://localhost:8000",
97+
apiKey: "test-api-key",
98+
projectId: 1,
99+
});
100+
return server;
101+
};
102+
103+
const createToken = (overrides = {}) => {
104+
return createTestJwt(
105+
{
106+
run_id: "test-run-id",
107+
task_id: "test-task-id",
108+
team_id: 1,
109+
user_id: 1,
110+
distinct_id: "test-distinct-id",
111+
...overrides,
112+
},
113+
TEST_PRIVATE_KEY,
114+
);
115+
};
116+
117+
describe("GET /health", () => {
118+
it("returns ok status", async () => {
119+
await createServer().start();
120+
121+
const response = await fetch(`http://localhost:${port}/health`);
122+
const body = await response.json();
123+
124+
expect(response.status).toBe(200);
125+
expect(body).toEqual({ status: "ok", hasSession: false });
126+
});
127+
});
128+
129+
describe("GET /events", () => {
130+
it("returns 401 without authorization header", async () => {
131+
await createServer().start();
132+
133+
const response = await fetch(`http://localhost:${port}/events`);
134+
const body = await response.json();
135+
136+
expect(response.status).toBe(401);
137+
expect(body.error).toBe("Missing authorization header");
138+
});
139+
140+
it("returns 401 with invalid token", async () => {
141+
await createServer().start();
142+
143+
const response = await fetch(`http://localhost:${port}/events`, {
144+
headers: { Authorization: "Bearer invalid-token" },
145+
});
146+
const body = await response.json();
147+
148+
expect(response.status).toBe(401);
149+
expect(body.code).toBe("invalid_signature");
150+
});
151+
152+
it("accepts valid JWT and returns SSE stream", async () => {
153+
await createServer().start();
154+
const token = createToken();
155+
156+
const response = await fetch(`http://localhost:${port}/events`, {
157+
headers: { Authorization: `Bearer ${token}` },
158+
});
159+
160+
expect(response.status).toBe(200);
161+
expect(response.headers.get("content-type")).toBe("text/event-stream");
162+
});
163+
});
164+
165+
describe("POST /command", () => {
166+
it("returns 401 without authorization", async () => {
167+
await createServer().start();
168+
169+
const response = await fetch(`http://localhost:${port}/command`, {
170+
method: "POST",
171+
headers: { "Content-Type": "application/json" },
172+
body: JSON.stringify({
173+
jsonrpc: "2.0",
174+
method: "user_message",
175+
params: { content: "test" },
176+
}),
177+
});
178+
179+
expect(response.status).toBe(401);
180+
});
181+
182+
it("returns 400 when no session exists", async () => {
183+
await createServer().start();
184+
const token = createToken();
185+
186+
const response = await fetch(`http://localhost:${port}/command`, {
187+
method: "POST",
188+
headers: {
189+
Authorization: `Bearer ${token}`,
190+
"Content-Type": "application/json",
191+
},
192+
body: JSON.stringify({
193+
jsonrpc: "2.0",
194+
method: "user_message",
195+
params: { content: "test" },
196+
}),
197+
});
198+
199+
expect(response.status).toBe(400);
200+
const body = await response.json();
201+
expect(body.error).toBe("No active session for this run");
202+
});
203+
});
204+
205+
describe("404 handling", () => {
206+
it("returns 404 for unknown routes", async () => {
207+
await createServer().start();
208+
209+
const response = await fetch(`http://localhost:${port}/unknown`);
210+
const body = await response.json();
211+
212+
expect(response.status).toBe(404);
213+
expect(body.error).toBe("Not found");
214+
});
215+
});
216+
});

0 commit comments

Comments
 (0)