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