Commite75eddcunknown_key
feat(agents): multiplayer foundation — sessions + leases + budgets + branch namespacing
10 files changed+1831−10e75eddca989ae6b41b32afc6152665f47b15fdf5
10 changed files+1831−10
Addeddrizzle/0058_agent_multiplayer.sql+72−0View fileUnifiedSplit
@@ -0,0 +1,72 @@
1-- Agent multiplayer v1 — per-agent namespacing + leases + budget caps.
2--
3-- Premise: when 10-100 AI agents push to the same repo, they must not step
4-- on each other. Each agent gets a stable `agent_sessions` row with a
5-- token, a branch-namespace prefix the git plumbing must enforce, and a
6-- daily budget. Coordination on shared resources (issues, PRs, file
7-- paths, branches) flows through `agent_leases` — a soft mutex with a
8-- TTL. The autopilot resets `spent_cents_today` at midnight UTC and
9-- expires stale leases.
10
11CREATE TABLE IF NOT EXISTS agent_sessions (
12 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
13 -- Human-facing handle: "claude-1", "holden-mercer", "release-bot". Not
14 -- globally unique — uniqueness is enforced per owner_user_id via the
15 -- partial index below so multiple humans can each have a "claude-1".
16 name text NOT NULL,
17 -- The human who manages this agent and pays for its actions.
18 owner_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
19 -- Optional repo scope. NULL means the agent is global to the owner.
20 repository_id uuid REFERENCES repositories(id) ON DELETE CASCADE,
21 -- SHA-256 of the plaintext "agt_..." token. Never store plaintext.
22 token_hash text NOT NULL UNIQUE,
23 -- Branch prefix the git plumbing must enforce on every ref update,
24 -- e.g. "agents/claude-1/". Should end with "/" so simple startsWith
25 -- checks are unambiguous, but the helper normalises it.
26 branch_namespace text NOT NULL,
27 -- Daily spend cap in cents. Default $5.00 — enough for routine PR
28 -- work, low enough that a runaway agent gets caught fast.
29 budget_cents_per_day integer NOT NULL DEFAULT 500,
30 spent_cents_today integer NOT NULL DEFAULT 0,
31 last_active_at timestamptz,
32 created_at timestamptz NOT NULL DEFAULT now()
33);
34
35-- An owner can't have two agents with the same name.
36CREATE UNIQUE INDEX IF NOT EXISTS agent_sessions_owner_name
37 ON agent_sessions (owner_user_id, name);
38
39CREATE INDEX IF NOT EXISTS agent_sessions_owner
40 ON agent_sessions (owner_user_id);
41
42CREATE INDEX IF NOT EXISTS agent_sessions_repo
43 ON agent_sessions (repository_id)
44 WHERE repository_id IS NOT NULL;
45
46CREATE TABLE IF NOT EXISTS agent_leases (
47 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
48 agent_session_id uuid NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
49 -- What kind of thing the lease covers — keeps the (type, id) space
50 -- naturally partitioned. Vocabulary is open-ended; the lib enforces
51 -- the known set.
52 target_type text NOT NULL, -- 'issue' | 'pr' | 'file_path' | 'branch'
53 target_id text NOT NULL,
54 acquired_at timestamptz NOT NULL DEFAULT now(),
55 expires_at timestamptz NOT NULL,
56 status text NOT NULL DEFAULT 'active', -- 'active' | 'released' | 'expired'
57 created_at timestamptz NOT NULL DEFAULT now()
58);
59
60-- Per-target lookup — answers "who holds the lease on issue 42?" in O(1).
61-- Partial unique on the active row prevents two agents holding the same
62-- target simultaneously. Released/expired rows don't conflict.
63CREATE UNIQUE INDEX IF NOT EXISTS agent_leases_active_target
64 ON agent_leases (target_type, target_id)
65 WHERE status = 'active';
66
67CREATE INDEX IF NOT EXISTS agent_leases_agent
68 ON agent_leases (agent_session_id, status);
69
70CREATE INDEX IF NOT EXISTS agent_leases_expires
71 ON agent_leases (expires_at)
72 WHERE status = 'active';
Addedsrc/__tests__/agent-multiplayer.test.ts+462−0View fileUnifiedSplit
@@ -0,0 +1,462 @@
1/**
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});
Modifiedsrc/app.tsx+9−0View fileUnifiedSplit
@@ -23,6 +23,8 @@ import emailVerificationRoutes from "./routes/email-verification";
2323import magicLinkRoutes from "./routes/magic-link";
2424import settingsRoutes from "./routes/settings";
2525import settings2faRoutes from "./routes/settings-2fa";
26import settingsAgentsRoutes from "./routes/settings-agents";
27import agentsRoutes from "./routes/agents";
2628import issueRoutes from "./routes/issues";
2729import repoSettings from "./routes/repo-settings";
2830import collaboratorRoutes from "./routes/collaborators";
@@ -325,6 +327,10 @@ app.route("/", demoRoutes);
325327// REST API v2 (basePath /api/v2)
326328app.route("/", apiV2Routes);
327329
330// Agent multiplayer v1 — /api/v2/agents/* (sessions, leases, usage).
331// Mounted alongside apiV2Routes (its own basePath, no path conflict).
332app.route("/", agentsRoutes);
333
328334// Inbound API hooks (GateTest callback + backup PAT-authed /api/v1/gate-runs)
329335app.route("/", hookRoutes);
330336app.route("/api/events", eventsRoutes);
@@ -362,6 +368,9 @@ app.route("/", settingsRoutes);
362368// 2FA / TOTP settings (Block B4)
363369app.route("/", settings2faRoutes);
364370
371// Agent multiplayer — /settings/agents management UI
372app.route("/", settingsAgentsRoutes);
373
365374// WebAuthn / passkey routes (Block B5)
366375app.route("/", passkeyRoutes);
367376
Modifiedsrc/db/schema.ts+66−8View fileUnifiedSplit
@@ -2968,14 +2968,8 @@ export type NewSyntheticCheckRow = typeof syntheticChecks.$inferInsert;
29682968
29692969// ---------------------------------------------------------------------------
29702970// 0057 — Continuous semantic index (per-push embeddings).
2971//
2972// One row per (repository_id, file_path) — the unique index in the
2973// migration enforces this. `indexChangedFiles()` upserts on every push,
2974// `searchSemantic()` ranks by cosine distance using pgvector's `<=>`.
2975//
2976// Distinct from `code_chunks` (which slices large files into chunks for
2977// the full-repo reindex flow). This table favours fast incremental
2978// updates over chunk granularity — one whole-file embedding per path.
2971// One row per (repository_id, file_path). `indexChangedFiles()` upserts on
2972// every push, `searchSemantic()` ranks by cosine distance via pgvector.
29792973// ---------------------------------------------------------------------------
29802974export const codeEmbeddings = pgTable(
29812975 "code_embeddings",
@@ -3006,3 +3000,67 @@ export const codeEmbeddings = pgTable(
30063000export type CodeEmbedding = typeof codeEmbeddings.$inferSelect;
30073001export type NewCodeEmbedding = typeof codeEmbeddings.$inferInsert;
30083002
3003// ---------------------------------------------------------------------------
3004// 0058 — Agent multiplayer v1: per-agent namespacing + leases + budget caps.
3005// See src/lib/agent-multiplayer.ts for helpers and 0058_agent_multiplayer.sql
3006// for the canonical column docs. UNIQUE partial index on (target_type,
3007// target_id) WHERE status='active' is enforced in SQL (drizzle exposes it
3008// as a regular index here).
3009// ---------------------------------------------------------------------------
3010export const agentSessions = pgTable(
3011 "agent_sessions",
3012 {
3013 id: uuid("id").primaryKey().defaultRandom(),
3014 name: text("name").notNull(),
3015 ownerUserId: uuid("owner_user_id")
3016 .notNull()
3017 .references(() => users.id, { onDelete: "cascade" }),
3018 repositoryId: uuid("repository_id").references(() => repositories.id, {
3019 onDelete: "cascade",
3020 }),
3021 tokenHash: text("token_hash").notNull().unique(),
3022 branchNamespace: text("branch_namespace").notNull(),
3023 budgetCentsPerDay: integer("budget_cents_per_day").default(500).notNull(),
3024 spentCentsToday: integer("spent_cents_today").default(0).notNull(),
3025 lastActiveAt: timestamp("last_active_at", { withTimezone: true }),
3026 createdAt: timestamp("created_at", { withTimezone: true })
3027 .defaultNow()
3028 .notNull(),
3029 },
3030 (table) => [
3031 uniqueIndex("agent_sessions_owner_name").on(table.ownerUserId, table.name),
3032 index("agent_sessions_owner").on(table.ownerUserId),
3033 index("agent_sessions_repo").on(table.repositoryId),
3034 ]
3035);
3036
3037export const agentLeases = pgTable(
3038 "agent_leases",
3039 {
3040 id: uuid("id").primaryKey().defaultRandom(),
3041 agentSessionId: uuid("agent_session_id")
3042 .notNull()
3043 .references(() => agentSessions.id, { onDelete: "cascade" }),
3044 targetType: text("target_type").notNull(),
3045 targetId: text("target_id").notNull(),
3046 acquiredAt: timestamp("acquired_at", { withTimezone: true })
3047 .defaultNow()
3048 .notNull(),
3049 expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
3050 status: text("status").default("active").notNull(),
3051 createdAt: timestamp("created_at", { withTimezone: true })
3052 .defaultNow()
3053 .notNull(),
3054 },
3055 (table) => [
3056 index("agent_leases_active_target").on(table.targetType, table.targetId),
3057 index("agent_leases_agent").on(table.agentSessionId, table.status),
3058 index("agent_leases_expires").on(table.expiresAt),
3059 ]
3060);
3061
3062export type AgentSession = typeof agentSessions.$inferSelect;
3063export type NewAgentSession = typeof agentSessions.$inferInsert;
3064export type AgentLease = typeof agentLeases.$inferSelect;
3065export type NewAgentLease = typeof agentLeases.$inferInsert;
3066
Addedsrc/lib/agent-multiplayer.ts+408−0View fileUnifiedSplit
@@ -0,0 +1,408 @@
1/**
2 * Agent multiplayer v1 — sessions, leases, budgets.
3 *
4 * Goal: when 10-100 AI agents push to the same repo, they don't step on
5 * each other. Each agent gets a stable session with:
6 * - a token (`agt_<hex>`) used as Bearer credentials,
7 * - a `branch_namespace` prefix the git plumbing enforces on every
8 * ref update,
9 * - a daily spend budget (cents) that callers consult via
10 * `chargeAgent()` before billing an action.
11 *
12 * Coordination on shared targets (issues, PRs, file paths, branches)
13 * flows through `agent_leases` — a soft mutex with a TTL. The active
14 * lease is enforced by a partial UNIQUE index on
15 * (target_type, target_id) WHERE status='active'; conflicting INSERTs
16 * raise a 23505 we translate to `null`.
17 *
18 * All helpers swallow DB errors and return null/false, matching the
19 * graceful-degradation pattern used elsewhere on this codebase. Pure
20 * format helpers (token generation, namespace normalisation) work
21 * without a DB so unit tests can exercise them in isolation.
22 */
23
24import { and, eq, lt, sql } from "drizzle-orm";
25import { db } from "../db";
26import { agentSessions, agentLeases } from "../db/schema";
27import type { AgentSession, AgentLease } from "../db/schema";
28
29/** Plaintext token prefix — distinct from PAT (`glc_`) and OAuth (`glct_`). */
30export const AGENT_TOKEN_PREFIX = "agt_";
31
32/** Default lease duration when callers don't supply one. */
33export const DEFAULT_LEASE_DURATION_MS = 5 * 60 * 1000;
34
35/** Recognised target_type values. Keep in sync with the migration comment. */
36export const LEASE_TARGET_TYPES = [
37 "issue",
38 "pr",
39 "file_path",
40 "branch",
41] as const;
42export type LeaseTargetType = (typeof LEASE_TARGET_TYPES)[number];
43
44// ---------------------------------------------------------------------------
45// Pure helpers (no DB)
46// ---------------------------------------------------------------------------
47
48/** 32-byte hex token, `agt_`-prefixed. Mirrors the PAT generator shape. */
49export function generateAgentToken(): string {
50 const bytes = crypto.getRandomValues(new Uint8Array(32));
51 return (
52 AGENT_TOKEN_PREFIX +
53 Array.from(bytes)
54 .map((b) => b.toString(16).padStart(2, "0"))
55 .join("")
56 );
57}
58
59/** SHA-256 hex of the plaintext token — what we persist. */
60export async function hashAgentToken(token: string): Promise<string> {
61 const data = new TextEncoder().encode(token);
62 const digest = await crypto.subtle.digest("SHA-256", data);
63 return Array.from(new Uint8Array(digest))
64 .map((b) => b.toString(16).padStart(2, "0"))
65 .join("");
66}
67
68/**
69 * Detect the agent token shape. Used by middleware to short-circuit
70 * before hitting the DB on regular PAT/OAuth tokens.
71 */
72export function isAgentToken(token: string): boolean {
73 return /^agt_[0-9a-f]{64}$/.test(token);
74}
75
76/**
77 * Normalise a caller-supplied branch namespace to a canonical
78 * `agents/<name>/` form. We keep it lower-case, strip leading
79 * `refs/heads/`, and force a trailing slash so `startsWith()` checks
80 * elsewhere are unambiguous.
81 */
82export function normaliseBranchNamespace(name: string, raw?: string): string {
83 let ns = (raw ?? `agents/${name}`).trim();
84 if (ns.startsWith("refs/heads/")) ns = ns.slice("refs/heads/".length);
85 ns = ns.replace(/^\/+|\/+$/g, ""); // strip surrounding slashes
86 if (!ns) ns = `agents/${name}`;
87 return ns + "/";
88}
89
90/**
91 * Check whether a fully-qualified ref (e.g. `refs/heads/agents/claude-1/foo`)
92 * sits inside the agent's allowed namespace. The caller is expected to pass
93 * a fully-qualified ref OR a short branch name — both work.
94 */
95export function refIsInNamespace(ref: string, namespace: string): boolean {
96 const short = ref.startsWith("refs/heads/")
97 ? ref.slice("refs/heads/".length)
98 : ref;
99 return short.startsWith(namespace);
100}
101
102// ---------------------------------------------------------------------------
103// Session lifecycle
104// ---------------------------------------------------------------------------
105
106export interface CreateAgentSessionInput {
107 ownerUserId: string;
108 name: string;
109 repositoryId?: string | null;
110 branchNamespace?: string;
111 budgetCentsPerDay?: number;
112}
113
114export interface CreateAgentSessionResult {
115 session: AgentSession;
116 /** Plaintext token — returned exactly once, never persisted. */
117 token: string;
118}
119
120/**
121 * Mint a new agent session. Returns the plaintext token alongside the
122 * stored row so the caller can hand it to the agent. The token is only
123 * recoverable here; we persist only its SHA-256 hash.
124 */
125export async function createAgentSession(
126 input: CreateAgentSessionInput
127): Promise<CreateAgentSessionResult | null> {
128 const name = input.name.trim();
129 if (!name) return null;
130
131 const token = generateAgentToken();
132 const tokenHash = await hashAgentToken(token);
133 const branchNamespace = normaliseBranchNamespace(name, input.branchNamespace);
134 const budget =
135 typeof input.budgetCentsPerDay === "number" && input.budgetCentsPerDay >= 0
136 ? Math.floor(input.budgetCentsPerDay)
137 : 500;
138
139 try {
140 const [row] = await db
141 .insert(agentSessions)
142 .values({
143 name,
144 ownerUserId: input.ownerUserId,
145 repositoryId: input.repositoryId ?? null,
146 tokenHash,
147 branchNamespace,
148 budgetCentsPerDay: budget,
149 spentCentsToday: 0,
150 })
151 .returning();
152 if (!row) return null;
153 return { session: row, token };
154 } catch {
155 return null;
156 }
157}
158
159/**
160 * Look up a session by plaintext token. Returns null if the token is
161 * malformed, unknown, or the DB call throws. Side-effect: bumps
162 * `last_active_at` on a hit (fire-and-forget).
163 */
164export async function authenticateAgent(
165 token: string
166): Promise<AgentSession | null> {
167 if (!isAgentToken(token)) return null;
168 try {
169 const tokenHash = await hashAgentToken(token);
170 const [row] = await db
171 .select()
172 .from(agentSessions)
173 .where(eq(agentSessions.tokenHash, tokenHash))
174 .limit(1);
175 if (!row) return null;
176 // Fire-and-forget — never block the request on the activity bump.
177 db.update(agentSessions)
178 .set({ lastActiveAt: new Date() })
179 .where(eq(agentSessions.id, row.id))
180 .catch(() => undefined);
181 return row;
182 } catch {
183 return null;
184 }
185}
186
187export async function listAgentSessionsForOwner(
188 ownerUserId: string
189): Promise<AgentSession[]> {
190 try {
191 return await db
192 .select()
193 .from(agentSessions)
194 .where(eq(agentSessions.ownerUserId, ownerUserId));
195 } catch {
196 return [];
197 }
198}
199
200export async function revokeAgentSession(
201 sessionId: string,
202 ownerUserId: string
203): Promise<boolean> {
204 try {
205 const result = await db
206 .delete(agentSessions)
207 .where(
208 and(
209 eq(agentSessions.id, sessionId),
210 eq(agentSessions.ownerUserId, ownerUserId)
211 )
212 )
213 .returning({ id: agentSessions.id });
214 return result.length > 0;
215 } catch {
216 return false;
217 }
218}
219
220// ---------------------------------------------------------------------------
221// Leases
222// ---------------------------------------------------------------------------
223
224/**
225 * Try to grab an exclusive lease on a target. Returns the lease row on
226 * success, null when another agent already holds an active lease. The
227 * uniqueness invariant is enforced by a partial UNIQUE index, so this
228 * is race-safe even under concurrent INSERTs — the loser hits a 23505
229 * we translate to null.
230 *
231 * Before INSERT we sweep any expired-but-still-active rows for this
232 * target, flipping their status to 'expired' so a stale holder doesn't
233 * block a fresh request.
234 */
235export async function acquireLease(
236 agentSessionId: string,
237 targetType: string,
238 targetId: string,
239 durationMs: number = DEFAULT_LEASE_DURATION_MS
240): Promise<AgentLease | null> {
241 if (!agentSessionId || !targetType || !targetId) return null;
242 const now = new Date();
243 const expiresAt = new Date(now.getTime() + Math.max(1, durationMs));
244
245 try {
246 // Expire stale leases on this target so the unique index doesn't
247 // wrongly block a new acquire.
248 await db
249 .update(agentLeases)
250 .set({ status: "expired" })
251 .where(
252 and(
253 eq(agentLeases.targetType, targetType),
254 eq(agentLeases.targetId, targetId),
255 eq(agentLeases.status, "active"),
256 lt(agentLeases.expiresAt, now)
257 )
258 );
259
260 const [row] = await db
261 .insert(agentLeases)
262 .values({
263 agentSessionId,
264 targetType,
265 targetId,
266 acquiredAt: now,
267 expiresAt,
268 status: "active",
269 })
270 .returning();
271 return row ?? null;
272 } catch {
273 // 23505 unique violation (another active lease) or any other DB error
274 // — treat as "couldn't acquire". The caller should fall back.
275 return null;
276 }
277}
278
279export async function releaseLease(leaseId: string): Promise<boolean> {
280 if (!leaseId) return false;
281 try {
282 const result = await db
283 .update(agentLeases)
284 .set({ status: "released" })
285 .where(
286 and(eq(agentLeases.id, leaseId), eq(agentLeases.status, "active"))
287 )
288 .returning({ id: agentLeases.id });
289 return result.length > 0;
290 } catch {
291 return false;
292 }
293}
294
295export async function listLeasesForAgent(
296 agentSessionId: string
297): Promise<AgentLease[]> {
298 try {
299 return await db
300 .select()
301 .from(agentLeases)
302 .where(eq(agentLeases.agentSessionId, agentSessionId));
303 } catch {
304 return [];
305 }
306}
307
308/**
309 * Mark every active lease whose `expires_at` has passed as 'expired'.
310 * Safe to call from the autopilot ticker.
311 */
312export async function expireStaleLeases(now: Date = new Date()): Promise<number> {
313 try {
314 const result = await db
315 .update(agentLeases)
316 .set({ status: "expired" })
317 .where(
318 and(eq(agentLeases.status, "active"), lt(agentLeases.expiresAt, now))
319 )
320 .returning({ id: agentLeases.id });
321 return result.length;
322 } catch {
323 return 0;
324 }
325}
326
327// ---------------------------------------------------------------------------
328// Budgets
329// ---------------------------------------------------------------------------
330
331/**
332 * Atomically attempt to charge `cents` against an agent's daily budget.
333 * Returns true when the charge was recorded, false when the agent is
334 * over budget (caller should refuse the request).
335 *
336 * We use a single UPDATE with a WHERE clause that includes the budget
337 * predicate, so we never race against a parallel charger: either the
338 * row update succeeds (we landed under cap) or it doesn't (over).
339 */
340export async function chargeAgent(
341 agentSessionId: string,
342 cents: number
343): Promise<boolean> {
344 if (!agentSessionId) return false;
345 const amount = Math.max(0, Math.floor(cents));
346 if (amount === 0) return true; // free actions still pass.
347
348 try {
349 const result = await db
350 .update(agentSessions)
351 .set({
352 spentCentsToday: sql`${agentSessions.spentCentsToday} + ${amount}`,
353 })
354 .where(
355 and(
356 eq(agentSessions.id, agentSessionId),
357 // spent + amount must still fit under cap.
358 sql`${agentSessions.spentCentsToday} + ${amount} <= ${agentSessions.budgetCentsPerDay}`
359 )
360 )
361 .returning({ id: agentSessions.id });
362 return result.length > 0;
363 } catch {
364 return false;
365 }
366}
367
368/** Convenience getter — returns 0 spent / 0 cap when the session is missing. */
369export async function getAgentUsage(agentSessionId: string): Promise<{
370 spent: number;
371 cap: number;
372 remaining: number;
373}> {
374 try {
375 const [row] = await db
376 .select({
377 spent: agentSessions.spentCentsToday,
378 cap: agentSessions.budgetCentsPerDay,
379 })
380 .from(agentSessions)
381 .where(eq(agentSessions.id, agentSessionId))
382 .limit(1);
383 if (!row) return { spent: 0, cap: 0, remaining: 0 };
384 return {
385 spent: row.spent,
386 cap: row.cap,
387 remaining: Math.max(0, row.cap - row.spent),
388 };
389 } catch {
390 return { spent: 0, cap: 0, remaining: 0 };
391 }
392}
393
394/**
395 * Reset every agent's `spent_cents_today` to 0. Designed to run at the
396 * UTC day boundary from the autopilot.
397 */
398export async function resetDailyBudgets(): Promise<number> {
399 try {
400 const result = await db
401 .update(agentSessions)
402 .set({ spentCentsToday: 0 })
403 .returning({ id: agentSessions.id });
404 return result.length;
405 } catch {
406 return 0;
407 }
408}
Addedsrc/middleware/agent-auth.ts+104−0View fileUnifiedSplit
@@ -0,0 +1,104 @@
1/**
2 * Agent multiplayer — Bearer-token auth middleware.
3 *
4 * Sits in front of the v2 API. When the caller presents
5 * `Authorization: Bearer agt_<hex>`, we resolve the row from
6 * `agent_sessions` and stash it on the request context as
7 * `c.set("agent", session)`. Downstream handlers can then:
8 * - charge the session's budget,
9 * - enforce the branch namespace on ref updates,
10 * - rate-limit by agent rather than by user.
11 *
12 * If the header isn't an `agt_` token (or it doesn't validate), we
13 * call next() unchanged so the regular `apiAuth` middleware can pick
14 * up the PAT/OAuth/session flow.
15 *
16 * The middleware never rejects on a bad agent token — it just falls
17 * through and lets the canonical auth middleware decide. This keeps
18 * the matrix of "what the caller can do" centralised in `apiAuth`.
19 */
20
21import { createMiddleware } from "hono/factory";
22import {
23 AGENT_TOKEN_PREFIX,
24 authenticateAgent,
25 isAgentToken,
26 refIsInNamespace,
27} from "../lib/agent-multiplayer";
28import type { AgentSession } from "../db/schema";
29
30export type AgentAuthEnv = {
31 Variables: {
32 agent?: AgentSession | null;
33 };
34};
35
36/**
37 * Detect and resolve an agent Bearer token. Always calls next();
38 * downstream auth handles the non-agent paths.
39 */
40export const agentAuth = createMiddleware<AgentAuthEnv>(async (c, next) => {
41 const authHeader = c.req.header("Authorization");
42 if (!authHeader?.startsWith("Bearer ")) {
43 return next();
44 }
45 const token = authHeader.slice(7).trim();
46 if (!token.startsWith(AGENT_TOKEN_PREFIX) || !isAgentToken(token)) {
47 return next();
48 }
49
50 const session = await authenticateAgent(token);
51 if (session) {
52 c.set("agent", session);
53 }
54 return next();
55});
56
57/**
58 * Guard: require an authenticated agent on this route. Returns 401
59 * when no agent is on the context.
60 */
61export const requireAgentAuth = createMiddleware<AgentAuthEnv>(
62 async (c, next) => {
63 const agent = c.get("agent");
64 if (!agent) {
65 return c.json(
66 {
67 error: "Agent authentication required",
68 hint: "Use Authorization: Bearer agt_<token>",
69 },
70 401
71 );
72 }
73 return next();
74 }
75);
76
77/**
78 * Branch-namespace guard for PATCH /repos/:owner/:repo/git/refs/heads/:branch.
79 * When the caller authenticated as an agent, the target branch must sit
80 * under the agent's `branch_namespace` prefix. Non-agent callers
81 * (sessions, PATs) are passed through unchanged.
82 *
83 * This is mounted on the v2 git-refs path; it never blocks regular
84 * humans.
85 */
86export const enforceAgentBranchNamespace = createMiddleware<AgentAuthEnv>(
87 async (c, next) => {
88 const agent = c.get("agent");
89 if (!agent) return next();
90 const branch = c.req.param("branch");
91 if (!branch) return next();
92 if (!refIsInNamespace(branch, agent.branchNamespace)) {
93 return c.json(
94 {
95 error: "Branch is outside the agent's namespace",
96 namespace: agent.branchNamespace,
97 branch,
98 },
99 403
100 );
101 }
102 return next();
103 }
104);
Modifiedsrc/middleware/api-auth.ts+12−0View fileUnifiedSplit
@@ -29,6 +29,18 @@ export const apiAuth = createMiddleware<ApiAuthEnv>(async (c, next) => {
2929 const authHeader = c.req.header("Authorization");
3030 if (authHeader?.startsWith("Bearer ")) {
3131 const token = authHeader.slice(7);
32
33 // Agent-multiplayer v1: `agentAuth` middleware (when mounted earlier
34 // in the chain) handles `agt_` tokens. Skip them here so we don't
35 // wrongly 401 a valid agent token. authMethod stays "none" because
36 // the agent context lives at c.get("agent"), not c.get("user").
37 if (token.startsWith("agt_")) {
38 c.set("user", null);
39 c.set("authMethod", "none");
40 c.set("tokenScopes", []);
41 return next();
42 }
43
3244 try {
3345 const hasher = new Bun.CryptoHasher("sha256");
3446 hasher.update(token);
Addedsrc/routes/agents.ts+304−0View fileUnifiedSplit
@@ -0,0 +1,304 @@
1/**
2 * Agent multiplayer API — `/api/v2/agents/*`.
3 *
4 * Endpoints:
5 * - POST /api/v2/agents/sessions create new agent (regular user auth)
6 * - GET /api/v2/agents/sessions list the authed user's agents
7 * - DELETE /api/v2/agents/sessions/:id revoke
8 * - POST /api/v2/agents/leases acquire a lease (agent auth)
9 * - DELETE /api/v2/agents/leases/:id release
10 * - GET /api/v2/agents/usage per-agent budget/spend
11 *
12 * Auth model: the session-mgmt endpoints (`/sessions`) require a
13 * regular Gluecron user (session cookie OR PAT). The lease + usage
14 * endpoints require an agent Bearer token. We mount `agentAuth`
15 * before `apiAuth` so the request context picks up either flavour.
16 */
17
18import { Hono } from "hono";
19import { eq } from "drizzle-orm";
20import { db } from "../db";
21import { agentLeases, agentSessions } from "../db/schema";
22import { apiAuth, requireApiAuth } from "../middleware/api-auth";
23import type { ApiAuthEnv } from "../middleware/api-auth";
24import { agentAuth, requireAgentAuth } from "../middleware/agent-auth";
25import type { AgentAuthEnv } from "../middleware/agent-auth";
26import {
27 createAgentSession,
28 revokeAgentSession,
29 listAgentSessionsForOwner,
30 acquireLease,
31 releaseLease,
32 getAgentUsage,
33 LEASE_TARGET_TYPES,
34 DEFAULT_LEASE_DURATION_MS,
35} from "../lib/agent-multiplayer";
36
37// Combined env so handlers can read both `user` and `agent`.
38type Env = ApiAuthEnv & AgentAuthEnv;
39
40const agents = new Hono<Env>().basePath("/api/v2/agents");
41
42// Order matters: agentAuth attempts to resolve an agent Bearer token
43// first; if absent, apiAuth resolves a regular user/PAT/session.
44agents.use("*", agentAuth);
45agents.use("*", apiAuth);
46
47// ---------------------------------------------------------------------------
48// Sessions
49// ---------------------------------------------------------------------------
50
51agents.post("/sessions", requireApiAuth, async (c) => {
52 const user = c.get("user")!;
53 let body: {
54 name?: unknown;
55 repository_id?: unknown;
56 repositoryId?: unknown;
57 branch_namespace?: unknown;
58 branchNamespace?: unknown;
59 budget_cents_per_day?: unknown;
60 budgetCentsPerDay?: unknown;
61 } = {};
62 try {
63 body = await c.req.json();
64 } catch {
65 body = {};
66 }
67
68 const name = typeof body.name === "string" ? body.name.trim() : "";
69 if (!name || name.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(name)) {
70 return c.json(
71 {
72 error:
73 "name is required (1-64 chars; letters, digits, '-' and '_' only)",
74 },
75 400
76 );
77 }
78
79 const repositoryId =
80 typeof body.repository_id === "string"
81 ? body.repository_id
82 : typeof body.repositoryId === "string"
83 ? body.repositoryId
84 : null;
85
86 const rawNs =
87 typeof body.branch_namespace === "string"
88 ? body.branch_namespace
89 : typeof body.branchNamespace === "string"
90 ? body.branchNamespace
91 : undefined;
92
93 const rawBudget =
94 typeof body.budget_cents_per_day === "number"
95 ? body.budget_cents_per_day
96 : typeof body.budgetCentsPerDay === "number"
97 ? body.budgetCentsPerDay
98 : undefined;
99
100 const created = await createAgentSession({
101 ownerUserId: user.id,
102 name,
103 repositoryId,
104 branchNamespace: rawNs,
105 budgetCentsPerDay: rawBudget,
106 });
107 if (!created) {
108 return c.json(
109 {
110 error:
111 "Failed to create agent session — name may already be in use for this user",
112 },
113 409
114 );
115 }
116
117 return c.json(
118 {
119 token: created.token,
120 session: {
121 id: created.session.id,
122 name: created.session.name,
123 branch_namespace: created.session.branchNamespace,
124 repository_id: created.session.repositoryId,
125 budget_cents_per_day: created.session.budgetCentsPerDay,
126 spent_cents_today: created.session.spentCentsToday,
127 created_at: created.session.createdAt,
128 },
129 },
130 201
131 );
132});
133
134agents.get("/sessions", requireApiAuth, async (c) => {
135 const user = c.get("user")!;
136 const rows = await listAgentSessionsForOwner(user.id);
137 return c.json({
138 sessions: rows.map((r) => ({
139 id: r.id,
140 name: r.name,
141 branch_namespace: r.branchNamespace,
142 repository_id: r.repositoryId,
143 budget_cents_per_day: r.budgetCentsPerDay,
144 spent_cents_today: r.spentCentsToday,
145 last_active_at: r.lastActiveAt,
146 created_at: r.createdAt,
147 })),
148 });
149});
150
151agents.delete("/sessions/:id", requireApiAuth, async (c) => {
152 const user = c.get("user")!;
153 const id = c.req.param("id");
154 const ok = await revokeAgentSession(id, user.id);
155 if (!ok) return c.json({ error: "Not found" }, 404);
156 return c.json({ ok: true });
157});
158
159// ---------------------------------------------------------------------------
160// Leases
161// ---------------------------------------------------------------------------
162
163agents.post("/leases", requireAgentAuth, async (c) => {
164 const agent = c.get("agent")!;
165 let body: {
166 target_type?: unknown;
167 targetType?: unknown;
168 target_id?: unknown;
169 targetId?: unknown;
170 duration_ms?: unknown;
171 durationMs?: unknown;
172 } = {};
173 try {
174 body = await c.req.json();
175 } catch {
176 body = {};
177 }
178
179 const targetType =
180 typeof body.target_type === "string"
181 ? body.target_type
182 : typeof body.targetType === "string"
183 ? body.targetType
184 : "";
185 const targetId =
186 typeof body.target_id === "string"
187 ? body.target_id
188 : typeof body.targetId === "string"
189 ? body.targetId
190 : "";
191
192 if (!targetType || !targetId) {
193 return c.json({ error: "target_type and target_id are required" }, 400);
194 }
195 if (!(LEASE_TARGET_TYPES as readonly string[]).includes(targetType)) {
196 return c.json(
197 {
198 error: `target_type must be one of ${LEASE_TARGET_TYPES.join(", ")}`,
199 },
200 400
201 );
202 }
203
204 const rawDur =
205 typeof body.duration_ms === "number"
206 ? body.duration_ms
207 : typeof body.durationMs === "number"
208 ? body.durationMs
209 : DEFAULT_LEASE_DURATION_MS;
210 const durationMs = Math.max(
211 1000,
212 Math.min(60 * 60 * 1000, Math.floor(rawDur))
213 );
214
215 const lease = await acquireLease(agent.id, targetType, targetId, durationMs);
216 if (!lease) {
217 return c.json(
218 {
219 error: "Lease unavailable — another agent holds it",
220 target_type: targetType,
221 target_id: targetId,
222 },
223 409
224 );
225 }
226
227 return c.json(
228 {
229 lease: {
230 id: lease.id,
231 target_type: lease.targetType,
232 target_id: lease.targetId,
233 acquired_at: lease.acquiredAt,
234 expires_at: lease.expiresAt,
235 status: lease.status,
236 },
237 },
238 201
239 );
240});
241
242agents.delete("/leases/:id", requireAgentAuth, async (c) => {
243 const agent = c.get("agent")!;
244 const id = c.req.param("id");
245 // Only let the holder release the lease.
246 try {
247 const [row] = await db
248 .select()
249 .from(agentLeases)
250 .where(eq(agentLeases.id, id))
251 .limit(1);
252 if (!row || row.agentSessionId !== agent.id) {
253 return c.json({ error: "Not found" }, 404);
254 }
255 } catch {
256 return c.json({ error: "Not found" }, 404);
257 }
258 const ok = await releaseLease(id);
259 if (!ok) return c.json({ error: "Lease was not active" }, 409);
260 return c.json({ ok: true });
261});
262
263// ---------------------------------------------------------------------------
264// Usage
265// ---------------------------------------------------------------------------
266
267agents.get("/usage", async (c) => {
268 // Two modes:
269 // - Agent-authed call: return that agent's usage only.
270 // - User-authed call: return all of the user's agents.
271 const agent = c.get("agent");
272 if (agent) {
273 const usage = await getAgentUsage(agent.id);
274 return c.json({
275 agent_id: agent.id,
276 name: agent.name,
277 branch_namespace: agent.branchNamespace,
278 spent_cents_today: usage.spent,
279 budget_cents_per_day: usage.cap,
280 remaining_cents: usage.remaining,
281 });
282 }
283
284 const user = c.get("user");
285 if (!user) {
286 return c.json({ error: "Authentication required" }, 401);
287 }
288 const rows = await db
289 .select()
290 .from(agentSessions)
291 .where(eq(agentSessions.ownerUserId, user.id));
292 return c.json({
293 sessions: rows.map((r) => ({
294 id: r.id,
295 name: r.name,
296 branch_namespace: r.branchNamespace,
297 spent_cents_today: r.spentCentsToday,
298 budget_cents_per_day: r.budgetCentsPerDay,
299 remaining_cents: Math.max(0, r.budgetCentsPerDay - r.spentCentsToday),
300 })),
301 });
302});
303
304export default agents;
Modifiedsrc/routes/api-v2.ts+18−2View fileUnifiedSplit
@@ -55,6 +55,11 @@ import {
5555import { config } from "../lib/config";
5656import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
5757import type { ApiAuthEnv } from "../middleware/api-auth";
58import {
59 agentAuth,
60 enforceAgentBranchNamespace,
61} from "../middleware/agent-auth";
62import type { AgentAuthEnv } from "../middleware/agent-auth";
5863import { apiRateLimit, searchRateLimit } from "../middleware/rate-limit";
5964import { postCommitStatusHandler } from "./commit-statuses";
6065import { apiTokens } from "../db/schema";
@@ -64,11 +69,22 @@ import {
6469 computeLifetimeAiSavingsForUser,
6570} from "../lib/ai-hours-saved";
6671
67const apiv2 = new Hono<ApiAuthEnv>().basePath("/api/v2");
72const apiv2 = new Hono<ApiAuthEnv & AgentAuthEnv>().basePath("/api/v2");
6873
69// Apply auth and rate limiting to all v2 routes
74// Apply auth and rate limiting to all v2 routes.
75//
76// Agent-multiplayer v1: `agentAuth` runs BEFORE `apiAuth` so that an
77// `agt_` Bearer token is detected and stashed at `c.get("agent")`
78// without consuming the token in apiAuth (which only recognises PAT
79// + session). Non-agent tokens fall straight through. The
80// branch-namespace guard runs on PATCH /git/refs/heads/* only.
7081apiv2.use("*", apiRateLimit);
82apiv2.use("*", agentAuth);
7183apiv2.use("*", apiAuth);
84apiv2.use(
85 "/repos/:owner/:repo/git/refs/heads/:branch",
86 enforceAgentBranchNamespace
87);
7288
7389// ─── Helper ─────────────────────────────────────────────────────────────────
7490
Addedsrc/routes/settings-agents.tsx+376−0View fileUnifiedSplit
@@ -0,0 +1,376 @@
1/**
2 * /settings/agents — manage AI-agent sessions, see budgets + recent leases.
3 *
4 * Scoped CSS prefixed with `.sa-` so it cannot bleed into other surfaces.
5 * The Layout shell is reused (additive, not modified). All mutations are
6 * driven via plain HTML forms — no JS required.
7 */
8
9import { Hono } from "hono";
10import { desc, eq } from "drizzle-orm";
11import { raw } from "hono/html";
12import { db } from "../db";
13import { agentLeases, agentSessions } from "../db/schema";
14import { Layout } from "../views/layout";
15import type { AuthEnv } from "../middleware/auth";
16import { requireAuth } from "../middleware/auth";
17import {
18 createAgentSession,
19 revokeAgentSession,
20} from "../lib/agent-multiplayer";
21
22const settingsAgents = new Hono<AuthEnv>();
23
24settingsAgents.use("/settings/agents*", requireAuth);
25
26const styles = `
27 .sa-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
28 .sa-hero {
29 border: 1px solid var(--border);
30 border-radius: 14px;
31 padding: var(--space-5) var(--space-6);
32 background: var(--bg-elevated);
33 margin-bottom: var(--space-5);
34 }
35 .sa-hero h1 {
36 font-size: clamp(24px, 3.5vw, 32px);
37 margin: 0 0 var(--space-2);
38 letter-spacing: -0.02em;
39 }
40 .sa-hero p { color: var(--text-muted); margin: 0; line-height: 1.5; }
41 .sa-section { margin: var(--space-6) 0; }
42 .sa-section h2 {
43 font-size: 17px;
44 margin: 0 0 var(--space-3);
45 letter-spacing: -0.01em;
46 }
47 .sa-card {
48 border: 1px solid var(--border);
49 border-radius: 12px;
50 padding: var(--space-4);
51 background: var(--bg-elevated);
52 margin-bottom: var(--space-3);
53 }
54 .sa-card-row {
55 display: flex;
56 align-items: center;
57 justify-content: space-between;
58 gap: var(--space-3);
59 flex-wrap: wrap;
60 }
61 .sa-name { font-weight: 600; font-size: 15px; }
62 .sa-meta { color: var(--text-muted); font-size: 13px; margin-top: 4px; }
63 .sa-namespace {
64 display: inline-block;
65 font-family: var(--font-mono, monospace);
66 font-size: 12px;
67 color: var(--accent);
68 background: rgba(140, 109, 255, 0.08);
69 padding: 2px 8px;
70 border-radius: 6px;
71 }
72 .sa-budget { margin-top: var(--space-3); }
73 .sa-budget-label {
74 display: flex; justify-content: space-between;
75 font-size: 12px; color: var(--text-muted); margin-bottom: 4px;
76 }
77 .sa-budget-bar {
78 width: 100%; height: 6px; background: var(--border);
79 border-radius: 3px; overflow: hidden;
80 }
81 .sa-budget-fill {
82 height: 100%;
83 background: linear-gradient(90deg, #36c5d6, #8c6dff);
84 transition: width 0.3s;
85 }
86 .sa-budget-fill.over { background: #ef4444; }
87 .sa-form { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
88 .sa-form label {
89 display: flex; flex-direction: column;
90 font-size: 13px; color: var(--text-muted); gap: 4px;
91 }
92 .sa-form input {
93 padding: 8px 10px; border: 1px solid var(--border);
94 border-radius: 8px; background: var(--bg);
95 color: var(--text); font: inherit;
96 }
97 .sa-form-actions {
98 grid-column: 1 / -1; display: flex;
99 justify-content: flex-end; gap: var(--space-2);
100 }
101 .sa-btn {
102 padding: 8px 16px; border-radius: 8px;
103 border: 1px solid var(--border);
104 background: var(--bg); color: var(--text);
105 cursor: pointer; font: inherit;
106 }
107 .sa-btn-primary {
108 background: var(--accent); color: white; border-color: var(--accent);
109 }
110 .sa-btn-danger {
111 background: transparent; color: #ef4444; border-color: #ef4444;
112 }
113 .sa-token-banner {
114 border: 1px solid #f59e0b;
115 background: rgba(245, 158, 11, 0.06);
116 padding: var(--space-4); border-radius: 10px;
117 margin-bottom: var(--space-4);
118 }
119 .sa-token-banner code {
120 display: block; margin-top: 8px; padding: 8px 10px;
121 background: var(--bg); border-radius: 6px;
122 word-break: break-all; font-size: 12px;
123 }
124 .sa-empty {
125 color: var(--text-muted); font-size: 14px;
126 padding: var(--space-4); text-align: center;
127 border: 1px dashed var(--border); border-radius: 12px;
128 }
129 .sa-lease {
130 display: flex; justify-content: space-between;
131 padding: 8px 0; border-bottom: 1px solid var(--border);
132 font-size: 13px;
133 }
134 .sa-lease:last-child { border-bottom: none; }
135 .sa-lease-status {
136 font-size: 11px; text-transform: uppercase;
137 letter-spacing: 0.04em; padding: 2px 6px;
138 border-radius: 4px; background: var(--border);
139 }
140 .sa-lease-status.active { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
141 .sa-lease-status.released { color: var(--text-muted); }
142 .sa-lease-status.expired { color: var(--text-muted); }
143`;
144
145function escapeHtml(s: string): string {
146 return s
147 .replace(/&/g, "&")
148 .replace(/</g, "<")
149 .replace(/>/g, ">")
150 .replace(/"/g, """)
151 .replace(/'/g, "'");
152}
153
154function formatDate(d: Date | string | null | undefined): string {
155 if (!d) return "—";
156 const dt = typeof d === "string" ? new Date(d) : d;
157 return dt.toISOString().replace("T", " ").slice(0, 19);
158}
159
160type SessionRow = typeof agentSessions.$inferSelect;
161type LeaseRow = typeof agentLeases.$inferSelect;
162
163settingsAgents.get("/settings/agents", async (c) => {
164 const user = c.get("user")!;
165 const justCreatedToken = c.req.query("token");
166 const justCreatedName = c.req.query("name");
167
168 let sessions: SessionRow[] = [];
169 let leases: LeaseRow[] = [];
170
171 try {
172 sessions = await db
173 .select()
174 .from(agentSessions)
175 .where(eq(agentSessions.ownerUserId, user.id))
176 .orderBy(desc(agentSessions.createdAt));
177
178 if (sessions.length > 0) {
179 const all: LeaseRow[] = [];
180 for (const s of sessions) {
181 const rows = await db
182 .select()
183 .from(agentLeases)
184 .where(eq(agentLeases.agentSessionId, s.id))
185 .orderBy(desc(agentLeases.acquiredAt))
186 .limit(10);
187 all.push(...rows);
188 }
189 all.sort(
190 (a, b) =>
191 new Date(b.acquiredAt).getTime() - new Date(a.acquiredAt).getTime()
192 );
193 leases = all.slice(0, 25);
194 }
195 } catch {
196 sessions = [];
197 leases = [];
198 }
199
200 const csrfToken = (c as any).get("csrfToken") || "";
201 const csrfInput = csrfToken
202 ? `<input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}" />`
203 : "";
204
205 const tokenBanner = justCreatedToken
206 ? `<div class="sa-token-banner">
207 <strong>New agent created${justCreatedName ? `: ${escapeHtml(justCreatedName)}` : ""}.</strong>
208 Copy this token now — it will not be shown again.
209 <code>${escapeHtml(justCreatedToken)}</code>
210 </div>`
211 : "";
212
213 const sessionsHtml = sessions.length
214 ? sessions
215 .map((s) => {
216 const pct =
217 s.budgetCentsPerDay > 0
218 ? Math.min(
219 100,
220 Math.round((s.spentCentsToday / s.budgetCentsPerDay) * 100)
221 )
222 : 0;
223 const over = s.spentCentsToday >= s.budgetCentsPerDay;
224 return `
225 <div class="sa-card">
226 <div class="sa-card-row">
227 <div>
228 <div class="sa-name">${escapeHtml(s.name)}</div>
229 <div class="sa-meta">
230 <span class="sa-namespace">refs/heads/${escapeHtml(s.branchNamespace)}*</span>
231 · created ${formatDate(s.createdAt)}
232 · last active ${formatDate(s.lastActiveAt)}
233 </div>
234 </div>
235 <form method="post" action="/settings/agents/${escapeHtml(s.id)}/revoke" onsubmit="return confirm('Revoke this agent? Its token will stop working immediately.');">
236 ${csrfInput}
237 <button type="submit" class="sa-btn sa-btn-danger">Revoke</button>
238 </form>
239 </div>
240 <div class="sa-budget">
241 <div class="sa-budget-label">
242 <span>Today: $${(s.spentCentsToday / 100).toFixed(2)} / $${(s.budgetCentsPerDay / 100).toFixed(2)}</span>
243 <span>${pct}%</span>
244 </div>
245 <div class="sa-budget-bar">
246 <div class="sa-budget-fill ${over ? "over" : ""}" style="width: ${pct}%"></div>
247 </div>
248 </div>
249 </div>
250 `;
251 })
252 .join("")
253 : `<div class="sa-empty">No agents yet. Create one below to give an AI agent its own scoped credentials.</div>`;
254
255 const sessionsById = new Map(sessions.map((s) => [s.id, s.name]));
256 const leasesHtml = leases.length
257 ? leases
258 .map(
259 (l) => `
260 <div class="sa-lease">
261 <div>
262 <strong>${escapeHtml(sessionsById.get(l.agentSessionId) ?? "unknown")}</strong>
263 ·
264 <span style="font-family: var(--font-mono, monospace)">${escapeHtml(l.targetType)}:${escapeHtml(l.targetId)}</span>
265 </div>
266 <div>
267 <span class="sa-lease-status ${escapeHtml(l.status)}">${escapeHtml(l.status)}</span>
268 ·
269 <span style="color: var(--text-muted)">${formatDate(l.acquiredAt)}</span>
270 </div>
271 </div>
272 `
273 )
274 .join("")
275 : `<div class="sa-empty">No leases yet. Agents acquire leases via POST /api/v2/agents/leases.</div>`;
276
277 const body = `
278 <style>${styles}</style>
279 <div class="sa-wrap">
280 <div class="sa-hero">
281 <h1>Agent sessions</h1>
282 <p>
283 Give each AI agent its own credentials, branch namespace, and daily
284 spend cap. Agents can only push to branches under their namespace,
285 and can coordinate on shared issues / PRs / file paths through the
286 lease API.
287 </p>
288 </div>
289
290 ${tokenBanner}
291
292 <div class="sa-section">
293 <h2>Your agents</h2>
294 ${sessionsHtml}
295 </div>
296
297 <div class="sa-section">
298 <h2>Create a new agent</h2>
299 <form method="post" action="/settings/agents" class="sa-form sa-card">
300 ${csrfInput}
301 <label>
302 Name
303 <input type="text" name="name" required maxlength="64" pattern="[A-Za-z0-9_\\-]+" placeholder="claude-1" />
304 </label>
305 <label>
306 Daily budget (cents)
307 <input type="number" name="budget_cents_per_day" min="0" step="50" value="500" />
308 </label>
309 <label style="grid-column: 1 / -1">
310 Branch namespace (optional)
311 <input type="text" name="branch_namespace" placeholder="agents/claude-1" />
312 </label>
313 <div class="sa-form-actions">
314 <button type="submit" class="sa-btn sa-btn-primary">Create agent</button>
315 </div>
316 </form>
317 </div>
318
319 <div class="sa-section">
320 <h2>Recent leases</h2>
321 <div class="sa-card">${leasesHtml}</div>
322 </div>
323 </div>
324 `;
325
326 return c.html(
327 <Layout title="Agent sessions" user={user}>
328 {raw(body)}
329 </Layout>
330 );
331});
332
333settingsAgents.post("/settings/agents", async (c) => {
334 const user = c.get("user")!;
335 const form = await c.req.parseBody();
336 const name = String(form.name ?? "").trim();
337 const rawBudget = form.budget_cents_per_day;
338 const budget =
339 typeof rawBudget === "string" && rawBudget !== ""
340 ? Number.parseInt(rawBudget, 10)
341 : undefined;
342 const rawNs = form.branch_namespace;
343 const branchNamespace =
344 typeof rawNs === "string" && rawNs.trim() !== ""
345 ? rawNs.trim()
346 : undefined;
347
348 if (!name || !/^[A-Za-z0-9_-]+$/.test(name) || name.length > 64) {
349 return c.redirect("/settings/agents?error=name");
350 }
351
352 const created = await createAgentSession({
353 ownerUserId: user.id,
354 name,
355 branchNamespace,
356 budgetCentsPerDay:
357 typeof budget === "number" && Number.isFinite(budget) ? budget : undefined,
358 });
359 if (!created) {
360 return c.redirect("/settings/agents?error=duplicate");
361 }
362 const params = new URLSearchParams({
363 token: created.token,
364 name: created.session.name,
365 });
366 return c.redirect(`/settings/agents?${params.toString()}`);
367});
368
369settingsAgents.post("/settings/agents/:id/revoke", async (c) => {
370 const user = c.get("user")!;
371 const id = c.req.param("id");
372 await revokeAgentSession(id, user.id);
373 return c.redirect("/settings/agents");
374});
375
376export default settingsAgents;
0377