Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

agent-multiplayer.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

agent-multiplayer.test.tsBlame466 lines · 2 contributors
e75eddcClaude1/**
2 * Agent multiplayer v1 — sessions, leases, budgets, branch namespacing.
3 *
4 * Two layers:
5 * - Pure helpers (token shape, namespace normalisation, ref membership)
6 * run unconditionally — no DB required.
7 * - DB-backed flows (create session, acquire/conflict lease, budget
8 * exhaustion, PATCH ref namespace guard) are gated behind `HAS_DB`
9 * so the suite stays green on machines without Postgres.
10 */
11
12import { describe, it, expect, beforeAll, afterAll } from "bun:test";
13import { join } from "path";
14import { rm, mkdir } from "fs/promises";
15import { randomBytes } from "crypto";
16import app from "../app";
17import { clearRateLimitStore } from "../middleware/rate-limit";
18import { initBareRepo, getRepoPath, resolveRef } from "../git/repository";
19import {
20 AGENT_TOKEN_PREFIX,
21 generateAgentToken,
22 hashAgentToken,
23 isAgentToken,
24 normaliseBranchNamespace,
25 refIsInNamespace,
26} from "../lib/agent-multiplayer";
27
28const HAS_DB = Boolean(process.env.DATABASE_URL);
29const TEST_REPOS = join(
30 import.meta.dir,
31 "../../.test-repos-agent-multiplayer-" + Date.now()
32);
33
34beforeAll(async () => {
35 process.env.GIT_REPOS_PATH = TEST_REPOS;
36 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
37 clearRateLimitStore();
38 await rm(TEST_REPOS, { recursive: true, force: true });
39 await mkdir(TEST_REPOS, { recursive: true });
40});
41
42afterAll(async () => {
43 await rm(TEST_REPOS, { recursive: true, force: true });
44});
45
46function jsonHeaders(extra: Record<string, string> = {}): Record<string, string> {
47 return { "Content-Type": "application/json", ...extra };
48}
49
50function bearer(token: string): Record<string, string> {
51 return { Authorization: `Bearer ${token}` };
52}
53
54async function run(cmd: string[], cwd: string) {
55 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
56 await new Response(proc.stdout).text();
779c681ccantynz-alt57 const __code = await proc.exited;
58 // Fail loudly. These fixtures sit under the project dir, so a silently
59 // failed clone leaves cwd inside the REAL repo and the following git
60 // config/add/commit then mutate it. See week3.test.ts.
61 if (__code !== 0) throw new Error(`git fixture command failed (exit ${__code})`);
e75eddcClaude62}
63
64async function seedBareRepoWithCommit(
65 owner: string,
66 name: string,
67 branch: string = "main"
68) {
69 await initBareRepo(owner, name);
70 const bare = getRepoPath(owner, name);
71 const work = join(TEST_REPOS, "_work_" + randomBytes(4).toString("hex"));
72 await mkdir(work, { recursive: true });
73 await run(["git", "clone", bare, work], TEST_REPOS);
74 await run(["git", "config", "user.email", "test@gluecron.com"], work);
75 await run(["git", "config", "user.name", "Test User"], work);
76 await run(["git", "checkout", "-B", branch], work);
77 await Bun.write(join(work, "README.md"), "# hi\n");
78 await run(["git", "add", "-A"], work);
79 await run(["git", "commit", "-m", "seed"], work);
80 await run(["git", "push", "-u", "origin", branch], work);
81 await rm(work, { recursive: true, force: true });
82}
83
84// ---------------------------------------------------------------------------
85// Pure helpers (no DB)
86// ---------------------------------------------------------------------------
87
88describe("agent-multiplayer — pure helpers", () => {
89 it("generates a 32-byte hex token with the agt_ prefix", () => {
90 const t = generateAgentToken();
91 expect(t.startsWith(AGENT_TOKEN_PREFIX)).toBe(true);
92 expect(t.length).toBe(AGENT_TOKEN_PREFIX.length + 64);
93 expect(/^agt_[0-9a-f]{64}$/.test(t)).toBe(true);
94 });
95
96 it("generates unique tokens on repeated calls", () => {
97 const a = generateAgentToken();
98 const b = generateAgentToken();
99 const c = generateAgentToken();
100 expect(a).not.toBe(b);
101 expect(b).not.toBe(c);
102 expect(a).not.toBe(c);
103 });
104
105 it("isAgentToken rejects non-agent and malformed tokens", () => {
106 expect(isAgentToken(generateAgentToken())).toBe(true);
107 expect(isAgentToken("glc_" + "a".repeat(64))).toBe(false);
108 expect(isAgentToken("agt_too-short")).toBe(false);
109 expect(isAgentToken("agt_" + "z".repeat(64))).toBe(false);
110 expect(isAgentToken("")).toBe(false);
111 });
112
113 it("hashAgentToken is a deterministic 64-char hex digest", async () => {
114 const t = generateAgentToken();
115 const h1 = await hashAgentToken(t);
116 const h2 = await hashAgentToken(t);
117 expect(h1).toBe(h2);
118 expect(h1.length).toBe(64);
119 expect(/^[0-9a-f]{64}$/.test(h1)).toBe(true);
120 expect(h1).not.toBe(t);
121 });
122
123 it("normaliseBranchNamespace defaults to agents/<name>/", () => {
124 expect(normaliseBranchNamespace("claude-1")).toBe("agents/claude-1/");
125 expect(normaliseBranchNamespace("claude-1", "")).toBe("agents/claude-1/");
126 expect(normaliseBranchNamespace("claude-1", "agents/foo")).toBe("agents/foo/");
127 expect(normaliseBranchNamespace("claude-1", "agents/foo/")).toBe(
128 "agents/foo/"
129 );
130 expect(normaliseBranchNamespace("claude-1", "refs/heads/agents/bar")).toBe(
131 "agents/bar/"
132 );
133 expect(normaliseBranchNamespace("claude-1", "/agents/zap/")).toBe(
134 "agents/zap/"
135 );
136 });
137
138 it("refIsInNamespace handles short and fully-qualified refs", () => {
139 expect(refIsInNamespace("agents/claude-1/feat", "agents/claude-1/")).toBe(
140 true
141 );
142 expect(
143 refIsInNamespace("refs/heads/agents/claude-1/feat", "agents/claude-1/")
144 ).toBe(true);
145 expect(refIsInNamespace("main", "agents/claude-1/")).toBe(false);
146 expect(
147 refIsInNamespace("agents/other/x", "agents/claude-1/")
148 ).toBe(false);
149 });
150});
151
152// ---------------------------------------------------------------------------
153// HTTP shape — no DB required
154// ---------------------------------------------------------------------------
155
156describe("agent-multiplayer — HTTP auth", () => {
157 it("POST /api/v2/agents/sessions without auth returns 401", async () => {
158 const res = await app.request("/api/v2/agents/sessions", {
159 method: "POST",
160 headers: jsonHeaders(),
161 body: JSON.stringify({ name: "claude-1" }),
162 });
163 expect(res.status).toBe(401);
164 });
165
166 it("POST /api/v2/agents/leases without agent auth returns 401", async () => {
167 const res = await app.request("/api/v2/agents/leases", {
168 method: "POST",
169 headers: jsonHeaders(),
170 body: JSON.stringify({ target_type: "issue", target_id: "42" }),
171 });
172 expect(res.status).toBe(401);
173 });
174
175 it("POST /api/v2/agents/leases with a bogus agt_ token returns 401", async () => {
176 const res = await app.request("/api/v2/agents/leases", {
177 method: "POST",
178 headers: jsonHeaders(bearer("agt_" + "0".repeat(64))),
179 body: JSON.stringify({ target_type: "issue", target_id: "42" }),
180 });
181 expect(res.status).toBe(401);
182 });
183
184 it("GET /api/v2/agents/usage without any auth returns 401", async () => {
185 const res = await app.request("/api/v2/agents/usage");
186 expect(res.status).toBe(401);
187 });
188});
189
190// ---------------------------------------------------------------------------
191// DB-backed lib behaviour
192// ---------------------------------------------------------------------------
193
194describe.skipIf(!HAS_DB)("agent-multiplayer — DB-backed flows", () => {
195 it("createAgentSession + authenticateAgent round-trip", async () => {
196 const { db } = await import("../db");
197 const { users } = await import("../db/schema");
198 const { createAgentSession, authenticateAgent } = await import(
199 "../lib/agent-multiplayer"
200 );
201
202 const stamp = randomBytes(4).toString("hex");
203 const [u] = await db
204 .insert(users)
205 .values({
206 username: `agentowner-${stamp}`,
207 email: `agentowner-${stamp}@test.local`,
208 passwordHash: "x",
209 })
210 .returning();
211 expect(u).toBeDefined();
212 if (!u) return;
213
214 const created = await createAgentSession({
215 ownerUserId: u.id,
216 name: `claude-${stamp}`,
217 budgetCentsPerDay: 1000,
218 });
219 expect(created).not.toBeNull();
220 if (!created) return;
221 expect(created.token.startsWith("agt_")).toBe(true);
222 expect(created.session.branchNamespace).toBe(`agents/claude-${stamp}/`);
223 expect(created.session.budgetCentsPerDay).toBe(1000);
224
225 const looked = await authenticateAgent(created.token);
226 expect(looked).not.toBeNull();
227 expect(looked?.id).toBe(created.session.id);
228
229 const bad = await authenticateAgent("agt_" + "0".repeat(64));
230 expect(bad).toBeNull();
231 });
232
233 it("acquireLease — happy path returns an active lease", async () => {
234 const { db } = await import("../db");
235 const { users } = await import("../db/schema");
236 const { createAgentSession, acquireLease, releaseLease } = await import(
237 "../lib/agent-multiplayer"
238 );
239
240 const stamp = randomBytes(4).toString("hex");
241 const [u] = await db
242 .insert(users)
243 .values({
244 username: `leaseuser-${stamp}`,
245 email: `leaseuser-${stamp}@test.local`,
246 passwordHash: "x",
247 })
248 .returning();
249 if (!u) return;
250
251 const a1 = await createAgentSession({
252 ownerUserId: u.id,
253 name: `agent-a-${stamp}`,
254 });
255 if (!a1) return;
256
257 const lease = await acquireLease(a1.session.id, "issue", `iss-${stamp}`);
258 expect(lease).not.toBeNull();
259 expect(lease?.status).toBe("active");
260 expect(lease?.targetType).toBe("issue");
261
262 // Clean up so the test is idempotent.
263 if (lease) await releaseLease(lease.id);
264 });
265
266 it("acquireLease — conflict: second agent on same target fails", async () => {
267 const { db } = await import("../db");
268 const { users } = await import("../db/schema");
269 const { createAgentSession, acquireLease, releaseLease } = await import(
270 "../lib/agent-multiplayer"
271 );
272
273 const stamp = randomBytes(4).toString("hex");
274 const [u] = await db
275 .insert(users)
276 .values({
277 username: `conflictuser-${stamp}`,
278 email: `conflictuser-${stamp}@test.local`,
279 passwordHash: "x",
280 })
281 .returning();
282 if (!u) return;
283
284 const a1 = await createAgentSession({
285 ownerUserId: u.id,
286 name: `agent-a-${stamp}`,
287 });
288 const a2 = await createAgentSession({
289 ownerUserId: u.id,
290 name: `agent-b-${stamp}`,
291 });
292 if (!a1 || !a2) return;
293
294 const target = `pr-${stamp}`;
295 const first = await acquireLease(a1.session.id, "pr", target);
296 expect(first).not.toBeNull();
297
298 const second = await acquireLease(a2.session.id, "pr", target);
299 expect(second).toBeNull();
300
301 // Release the first; the second agent should now be able to grab it.
302 if (first) {
303 const released = await releaseLease(first.id);
304 expect(released).toBe(true);
305 }
306
307 const third = await acquireLease(a2.session.id, "pr", target);
308 expect(third).not.toBeNull();
309 if (third) await releaseLease(third.id);
310 });
311
312 it("chargeAgent — budget exhaustion blocks the next charge", async () => {
313 const { db } = await import("../db");
314 const { users, agentSessions } = await import("../db/schema");
315 const { eq } = await import("drizzle-orm");
316 const { createAgentSession, chargeAgent, getAgentUsage } = await import(
317 "../lib/agent-multiplayer"
318 );
319
320 const stamp = randomBytes(4).toString("hex");
321 const [u] = await db
322 .insert(users)
323 .values({
324 username: `budgetuser-${stamp}`,
325 email: `budgetuser-${stamp}@test.local`,
326 passwordHash: "x",
327 })
328 .returning();
329 if (!u) return;
330
331 const created = await createAgentSession({
332 ownerUserId: u.id,
333 name: `agent-bg-${stamp}`,
334 budgetCentsPerDay: 100,
335 });
336 if (!created) return;
337 const sid = created.session.id;
338
339 expect(await chargeAgent(sid, 40)).toBe(true);
340 expect(await chargeAgent(sid, 50)).toBe(true);
341 // 90 spent, 10 remaining — a 20-cent charge must fail.
342 expect(await chargeAgent(sid, 20)).toBe(false);
343 // A 10-cent charge fits exactly.
344 expect(await chargeAgent(sid, 10)).toBe(true);
345 // Now full — any further charge is denied.
346 expect(await chargeAgent(sid, 1)).toBe(false);
347
348 const usage = await getAgentUsage(sid);
349 expect(usage.spent).toBe(100);
350 expect(usage.cap).toBe(100);
351 expect(usage.remaining).toBe(0);
352
353 // Clean-up: leave the row in place but drop the spent counter so a
354 // re-run on a stale DB doesn't accumulate.
355 await db
356 .update(agentSessions)
357 .set({ spentCentsToday: 0 })
358 .where(eq(agentSessions.id, sid));
359 });
360
361 it("resetDailyBudgets clears spent_cents_today", async () => {
362 const { db } = await import("../db");
363 const { users, agentSessions } = await import("../db/schema");
364 const { eq } = await import("drizzle-orm");
365 const {
366 createAgentSession,
367 chargeAgent,
368 resetDailyBudgets,
369 getAgentUsage,
370 } = await import("../lib/agent-multiplayer");
371
372 const stamp = randomBytes(4).toString("hex");
373 const [u] = await db
374 .insert(users)
375 .values({
376 username: `resetuser-${stamp}`,
377 email: `resetuser-${stamp}@test.local`,
378 passwordHash: "x",
379 })
380 .returning();
381 if (!u) return;
382
383 const created = await createAgentSession({
384 ownerUserId: u.id,
385 name: `agent-reset-${stamp}`,
386 budgetCentsPerDay: 200,
387 });
388 if (!created) return;
389
390 expect(await chargeAgent(created.session.id, 150)).toBe(true);
391 let usage = await getAgentUsage(created.session.id);
392 expect(usage.spent).toBe(150);
393
394 const count = await resetDailyBudgets();
395 expect(count).toBeGreaterThan(0);
396 usage = await getAgentUsage(created.session.id);
397 expect(usage.spent).toBe(0);
398 expect(usage.remaining).toBe(200);
399
400 await db
401 .delete(agentSessions)
402 .where(eq(agentSessions.id, created.session.id));
403 });
404
405 // -----------------------------------------------------------------------
406 // Branch-namespace enforcement on PATCH /git/refs/heads/:branch
407 // -----------------------------------------------------------------------
408 it("PATCH /git/refs/heads/:branch rejects refs outside the agent's namespace", async () => {
409 const { db } = await import("../db");
410 const { users, repositories } = await import("../db/schema");
411 const { createAgentSession } = await import("../lib/agent-multiplayer");
412
413 const stamp = randomBytes(4).toString("hex");
414 const username = `nsuser-${stamp}`;
415 const reponame = `nsrepo-${stamp}`;
416
417 const [u] = await db
418 .insert(users)
419 .values({
420 username,
421 email: `${username}@test.local`,
422 passwordHash: "x",
423 })
424 .returning();
425 if (!u) return;
426
427 await seedBareRepoWithCommit(username, reponame);
428
429 const [r] = await db
430 .insert(repositories)
431 .values({
432 name: reponame,
433 ownerId: u.id,
434 diskPath: getRepoPath(username, reponame),
435 defaultBranch: "main",
436 })
437 .returning();
438 if (!r) return;
439
440 const agent = await createAgentSession({
441 ownerUserId: u.id,
442 name: `claude-${stamp}`,
443 repositoryId: r.id,
444 });
445 if (!agent) return;
446 // Sanity: the namespace is what we expect.
447 expect(agent.session.branchNamespace).toBe(`agents/claude-${stamp}/`);
448
449 const headSha = await resolveRef(username, reponame, "refs/heads/main");
450 expect(headSha).not.toBeNull();
451 if (!headSha) return;
452
453 // 1. Update of `main` (outside the namespace) → 403.
454 const denied = await app.request(
455 `/api/v2/repos/${username}/${reponame}/git/refs/heads/main`,
456 {
457 method: "PATCH",
458 headers: jsonHeaders(bearer(agent.token)),
459 body: JSON.stringify({ sha: headSha, force: true }),
460 }
461 );
462 expect(denied.status).toBe(403);
463 const deniedBody = (await denied.json()) as any;
464 expect(deniedBody.namespace).toBe(`agents/claude-${stamp}/`);
465 });
466});