import { accountDb } from "../scoped-db"; import { revokeAccessTokensWhere } from "../access-token-store"; import { randomId } from "../crypto"; import { badRequest, conflict, notFoundError } from "../errors"; import { assertBelowCap } from "../limits"; import { hasPermission, loadRepo, repoPermission } from "../repos"; import { MCP_AUDIENCE, parseTokenPermissions } from "../token-scope"; import type { TokenPermissions } from "../token-scope"; import type { Viewer } from "~/lib/types"; import { mintFineGrainedToken } from "./grants"; import type { MintedToken } from "./grants"; const MAX_AGENTS_PER_USER = 50; const MAX_TOKEN_REPOS = 50; const DAY_MS = 24 * 60 * 60 * 1000; export const MAX_AGENT_TOKEN_DAYS = 366; export const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/; export type AgentSummary = { id: string; name: string; description: string | null; createdAt: number; tokens: number; }; export async function listAgents(userId: string): Promise { const rows = await accountDb(userId).all<{ id: string; name: string; description: string | null; created_at: number; tokens: number; }>( `SELECT a.id, a.name, a.description, a.created_at, (SELECT COUNT(*) FROM mcp_token_agents m JOIN access_tokens t ON t.id = m.token_id WHERE m.agent_id = a.id AND (t.expires_at IS NULL OR t.expires_at > ?2)) AS tokens FROM agents a WHERE a.owner_id = ?1 ORDER BY a.name`, userId, Date.now(), ); return rows.map((row) => ({ id: row.id, name: row.name, description: row.description, createdAt: row.created_at, tokens: row.tokens, })); } export async function createAgent(viewer: Viewer, name: string, description: string | null): Promise { if (!AGENT_NAME.test(name)) throw badRequest("Agent names use letters, digits, spaces, dots, dashes and underscores"); const db = accountDb(viewer.id); const count = await db.first<{ count: number }>("SELECT COUNT(*) AS count FROM agents WHERE owner_id = ?", viewer.id); assertBelowCap(count?.count ?? 0, MAX_AGENTS_PER_USER, "agents"); if (await db.first("SELECT 1 AS ok FROM agents WHERE owner_id = ? AND name = ?", viewer.id, name)) { throw conflict("You already have an agent with that name"); } const id = randomId(); const now = Date.now(); await db.run( "INSERT INTO agents (id, owner_id, name, description, created_at) VALUES (?, ?, ?, ?, ?)", id, viewer.id, name, description, now, ); return { id, name, description, createdAt: now, tokens: 0 }; } /** Revokes every token minted for the agent (with their delegations), then the agent. */ export async function deleteAgent(viewer: Viewer, agentId: string): Promise { const db = accountDb(viewer.id); if (!(await db.first("SELECT 1 AS ok FROM agents WHERE id = ? AND owner_id = ?", agentId, viewer.id))) { throw notFoundError("Agent not found"); } await revokeAccessTokensWhere(viewer.id, "id IN (SELECT token_id FROM mcp_token_agents WHERE agent_id = ?)", agentId); const result = await db.run("DELETE FROM agents WHERE id = ? AND owner_id = ?", agentId, viewer.id); if (result.meta.changes === 0) throw notFoundError("Agent not found"); } export type AgentTokenRequest = { repos: string[]; allRepos: boolean; permissions: TokenPermissions; /** 1..MAX_AGENT_TOKEN_DAYS; agent tokens can never be non-expiring. */ expiresInDays: number; /** Off by default: the token only works on /mcp unless git, LFS and REST are explicitly allowed. */ forgeAccess: boolean; }; /** * Mints a fine-grained token bound to a registered agent. Attribution on commits, pull requests and * audit entries then names the agent from the token itself, never from what the client claims. */ export async function mintAgentToken( viewer: Viewer, agentId: string, request: AgentTokenRequest, /** Set when a token (not a session) mints: the agent token is revoked with it and never outlives it. */ lineage?: { parentTokenId: string; notAfter: number | null }, ): Promise { const agent = await accountDb(viewer.id).first<{ id: string; name: string }>( "SELECT id, name FROM agents WHERE id = ? AND owner_id = ?", agentId, viewer.id, ); if (!agent) throw notFoundError("Agent not found"); if (!request.allRepos && request.repos.length === 0) throw badRequest("Select at least one repository"); if (request.repos.length > MAX_TOKEN_REPOS) throw badRequest(`At most ${MAX_TOKEN_REPOS} repositories`); const repoIds: string[] = []; for (const fullName of request.allRepos ? [] : [...new Set(request.repos)]) { const [owner, name, ...rest] = fullName.split("/"); const repo = owner && name && rest.length === 0 ? await loadRepo(owner, name) : null; if (!repo || !hasPermission(await repoPermission(repo, viewer), "read")) { throw notFoundError(`Repository ${fullName} not found`); } repoIds.push(repo.id); } return mintFineGrainedToken({ userId: viewer.id, name: `Agent: ${agent.name}`, scopes: "agent", grant: { permissions: parseTokenPermissions(JSON.stringify(request.permissions)), allRepos: request.allRepos, repoIds, agent: agent.name, }, // Agent tokens always expire (INT-A34): an unattended credential must not outlive its purpose. ttlMs: Math.min(Math.max(request.expiresInDays, 1), MAX_AGENT_TOKEN_DAYS) * DAY_MS, clientId: null, parentTokenId: lineage?.parentTokenId, notAfter: lineage?.notAfter, kind: "registered", agentId: agent.id, audience: request.forgeAccess ? null : MCP_AUDIENCE, }); }