Add agent auth MVP: first-class agent identity with scoped capabilities

Co-Authored-By: madison@stack-auth.com <madison.w.kennedy@gmail.com>
This commit is contained in:
Devin AI 2026-07-07 23:27:39 +00:00
parent 105f03fb94
commit 5e6e1165c3
25 changed files with 2521 additions and 4 deletions

View File

@ -38,6 +38,7 @@
"db:seed": "pnpm run with-env:dev tsx scripts/db-migrations.ts seed",
"db:init": "pnpm run with-env:dev tsx scripts/db-migrations.ts init",
"db:migrate": "pnpm run with-env:dev tsx scripts/db-migrations.ts migrate",
"agent-auth-demo": "pnpm run with-env:dev tsx scripts/agent-auth-demo.ts",
"db:backfill-bulldozer-from-prisma": "pnpm run with-env:dev tsx scripts/db-migrations.ts backfill-bulldozer-from-prisma",
"db:backfill-internal-free-plans": "pnpm run with-env:dev tsx scripts/db-migrations.ts backfill-internal-free-plans",
"db:regen-internal-subscriptions-to-latest": "pnpm run with-env:dev tsx scripts/db-migrations.ts regen-internal-subscriptions-to-latest",

View File

@ -0,0 +1,100 @@
-- CreateEnum
CREATE TYPE "AgentHostStatus" AS ENUM ('ACTIVE', 'PENDING', 'REVOKED', 'REJECTED');
-- CreateEnum
CREATE TYPE "AgentMode" AS ENUM ('DELEGATED', 'AUTONOMOUS');
-- CreateEnum
CREATE TYPE "AgentStatus" AS ENUM ('PENDING', 'ACTIVE', 'EXPIRED', 'REVOKED', 'REJECTED', 'CLAIMED');
-- CreateEnum
CREATE TYPE "AgentCapabilityGrantStatus" AS ENUM ('ACTIVE', 'PENDING', 'DENIED');
-- CreateTable
CREATE TABLE "AgentHost" (
"tenancyId" UUID NOT NULL,
"id" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"projectUserId" UUID,
"name" TEXT NOT NULL,
"publicJwk" JSONB NOT NULL,
"jwkThumbprint" TEXT NOT NULL,
"status" "AgentHostStatus" NOT NULL DEFAULT 'PENDING',
"defaultCapabilities" TEXT[] DEFAULT ARRAY[]::TEXT[],
CONSTRAINT "AgentHost_pkey" PRIMARY KEY ("tenancyId", "id")
);
-- CreateTable
CREATE TABLE "Agent" (
"tenancyId" UUID NOT NULL,
"id" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"hostId" UUID NOT NULL,
"name" TEXT NOT NULL,
"mode" "AgentMode" NOT NULL,
"projectUserId" UUID,
"publicJwk" JSONB NOT NULL,
"jwkThumbprint" TEXT NOT NULL,
"status" "AgentStatus" NOT NULL DEFAULT 'PENDING',
"reason" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"maxLifetimeEndsAt" TIMESTAMP(3) NOT NULL,
"absoluteLifetimeEndsAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Agent_pkey" PRIMARY KEY ("tenancyId", "id")
);
-- CreateTable
CREATE TABLE "AgentCapabilityGrant" (
"tenancyId" UUID NOT NULL,
"id" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"agentId" UUID NOT NULL,
"capability" TEXT NOT NULL,
"status" "AgentCapabilityGrantStatus" NOT NULL DEFAULT 'PENDING',
"constraints" JSONB,
"grantedByProjectUserId" UUID,
"reason" TEXT,
"expiresAt" TIMESTAMP(3),
CONSTRAINT "AgentCapabilityGrant_pkey" PRIMARY KEY ("tenancyId", "id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AgentHost_tenancyId_jwkThumbprint_key" ON "AgentHost"("tenancyId", "jwkThumbprint");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_tenancyId_jwkThumbprint_key" ON "Agent"("tenancyId", "jwkThumbprint");
-- CreateIndex
CREATE INDEX "Agent_tenancyId_hostId_idx" ON "Agent"("tenancyId", "hostId");
-- CreateIndex
CREATE INDEX "Agent_tenancyId_projectUserId_idx" ON "Agent"("tenancyId", "projectUserId");
-- CreateIndex
CREATE INDEX "AgentCapabilityGrant_tenancyId_agentId_idx" ON "AgentCapabilityGrant"("tenancyId", "agentId");
-- CreateIndex
CREATE UNIQUE INDEX "AgentCapabilityGrant_tenancyId_agentId_capability_key" ON "AgentCapabilityGrant"("tenancyId", "agentId", "capability");
-- AddForeignKey
ALTER TABLE "AgentHost" ADD CONSTRAINT "AgentHost_tenancyId_projectUserId_fkey" FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_tenancyId_hostId_fkey" FOREIGN KEY ("tenancyId", "hostId") REFERENCES "AgentHost"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_tenancyId_projectUserId_fkey" FOREIGN KEY ("tenancyId", "projectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentCapabilityGrant" ADD CONSTRAINT "AgentCapabilityGrant_tenancyId_agentId_fkey" FOREIGN KEY ("tenancyId", "agentId") REFERENCES "Agent"("tenancyId", "id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentCapabilityGrant" ADD CONSTRAINT "AgentCapabilityGrant_tenancyId_grantedByProjectUserId_fkey" FOREIGN KEY ("tenancyId", "grantedByProjectUserId") REFERENCES "ProjectUser"("tenancyId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,165 @@
import { randomUUID } from "crypto";
import type { Sql } from "postgres";
import { expect } from "vitest";
export const preMigration = async (sql: Sql) => {
const projectId = `test-${randomUUID()}`;
const tenancyId = randomUUID();
const projectUserId = randomUUID();
await sql`
INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode")
VALUES (${projectId}, NOW(), NOW(), 'Agent Auth Test Project', '', false)
`;
await sql`
INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization")
VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")
`;
await sql`
INSERT INTO "ProjectUser" (
"projectUserId",
"tenancyId",
"mirroredProjectId",
"mirroredBranchId",
"createdAt",
"updatedAt",
"lastActiveAt"
)
VALUES (${projectUserId}::uuid, ${tenancyId}::uuid, ${projectId}, 'main', NOW(), NOW(), NOW())
`;
return { projectId, tenancyId, projectUserId };
};
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
const enumRows = await sql`
SELECT typname
FROM pg_type
WHERE typname IN ('AgentHostStatus', 'AgentMode', 'AgentStatus', 'AgentCapabilityGrantStatus')
ORDER BY typname
`;
expect(enumRows.map((row) => row.typname)).toEqual([
"AgentCapabilityGrantStatus",
"AgentHostStatus",
"AgentMode",
"AgentStatus",
]);
const hostPublicJwk = { kty: "OKP", crv: "Ed25519", x: "host-public-key" };
const agentPublicJwk = { kty: "OKP", crv: "Ed25519", x: "agent-public-key" };
await sql`
INSERT INTO "AgentHost" (
"tenancyId",
"id",
"createdAt",
"updatedAt",
"projectUserId",
"name",
"publicJwk",
"jwkThumbprint",
"status"
)
VALUES (
${ctx.tenancyId}::uuid,
${randomUUID()}::uuid,
NOW(),
NOW(),
${ctx.projectUserId}::uuid,
'Host',
${sql.json(hostPublicJwk)},
'host-thumbprint',
'ACTIVE'::"AgentHostStatus"
)
`;
await sql`
INSERT INTO "Agent" (
"tenancyId",
"id",
"createdAt",
"updatedAt",
"hostId",
"name",
"mode",
"projectUserId",
"publicJwk",
"jwkThumbprint",
"status",
"expiresAt",
"maxLifetimeEndsAt",
"absoluteLifetimeEndsAt"
)
VALUES (
${ctx.tenancyId}::uuid,
${randomUUID()}::uuid,
NOW(),
NOW(),
(SELECT "id" FROM "AgentHost" WHERE "tenancyId" = ${ctx.tenancyId}::uuid AND "jwkThumbprint" = 'host-thumbprint'),
'Agent',
'DELEGATED'::"AgentMode",
${ctx.projectUserId}::uuid,
${sql.json(agentPublicJwk)},
'agent-thumbprint',
'ACTIVE'::"AgentStatus",
NOW() + INTERVAL '30 minutes',
NOW() + INTERVAL '24 hours',
NOW() + INTERVAL '7 days'
)
`;
await sql`
INSERT INTO "AgentCapabilityGrant" (
"tenancyId",
"id",
"createdAt",
"updatedAt",
"agentId",
"capability",
"status",
"constraints",
"grantedByProjectUserId",
"reason"
)
VALUES (
${ctx.tenancyId}::uuid,
${randomUUID()}::uuid,
NOW(),
NOW(),
(SELECT "id" FROM "Agent" WHERE "tenancyId" = ${ctx.tenancyId}::uuid AND "jwkThumbprint" = 'agent-thumbprint'),
'list_users',
'ACTIVE'::"AgentCapabilityGrantStatus",
${sql.json({ limit: { min: 1, max: 10 } })},
${ctx.projectUserId}::uuid,
'Test grant'
)
`;
const countsBeforeDelete = await sql`
SELECT
(SELECT COUNT(*) FROM "AgentHost" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS host_count,
(SELECT COUNT(*) FROM "Agent" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS agent_count,
(SELECT COUNT(*) FROM "AgentCapabilityGrant" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS grant_count
`;
expect(countsBeforeDelete).toHaveLength(1);
expect(Number(countsBeforeDelete[0].host_count)).toBe(1);
expect(Number(countsBeforeDelete[0].agent_count)).toBe(1);
expect(Number(countsBeforeDelete[0].grant_count)).toBe(1);
await sql`
DELETE FROM "ProjectUser"
WHERE "tenancyId" = ${ctx.tenancyId}::uuid
AND "projectUserId" = ${ctx.projectUserId}::uuid
`;
const countsAfterDelete = await sql`
SELECT
(SELECT COUNT(*) FROM "AgentHost" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS host_count,
(SELECT COUNT(*) FROM "Agent" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS agent_count,
(SELECT COUNT(*) FROM "AgentCapabilityGrant" WHERE "tenancyId" = ${ctx.tenancyId}::uuid) AS grant_count
`;
expect(countsAfterDelete).toHaveLength(1);
expect(Number(countsAfterDelete[0].host_count)).toBe(0);
expect(Number(countsAfterDelete[0].agent_count)).toBe(0);
expect(Number(countsAfterDelete[0].grant_count)).toBe(0);
};

View File

@ -334,6 +334,9 @@ model ProjectUser {
teamMembers TeamMember[]
contactChannels ContactChannel[]
authMethods AuthMethod[]
agentHosts AgentHost[] @relation("AgentHostProjectUser")
agents Agent[] @relation("AgentProjectUser")
agentCapabilityGrantsGranted AgentCapabilityGrant[] @relation("AgentCapabilityGrantGrantedByProjectUser")
// some backlinks for the unique constraints on some auth methods
passwordAuthMethod PasswordAuthMethod[]
@ -783,6 +786,118 @@ model ProjectApiKey {
@@id([tenancyId, id])
}
enum AgentHostStatus {
ACTIVE
PENDING
REVOKED
REJECTED
}
enum AgentMode {
DELEGATED
AUTONOMOUS
}
enum AgentStatus {
PENDING
ACTIVE
EXPIRED
REVOKED
REJECTED
CLAIMED
}
enum AgentCapabilityGrantStatus {
ACTIVE
PENDING
DENIED
}
model AgentHost {
tenancyId String @db.Uuid
id String @default(uuid()) @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsedAt DateTime?
projectUserId String? @db.Uuid
projectUser ProjectUser? @relation("AgentHostProjectUser", fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
name String
publicJwk Json
jwkThumbprint String
status AgentHostStatus @default(PENDING)
defaultCapabilities String[] @default([])
agents Agent[]
@@id([tenancyId, id])
@@unique([tenancyId, jwkThumbprint])
}
model Agent {
tenancyId String @db.Uuid
id String @default(uuid()) @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsedAt DateTime?
hostId String @db.Uuid
host AgentHost @relation(fields: [tenancyId, hostId], references: [tenancyId, id], onDelete: Cascade)
name String
mode AgentMode
projectUserId String? @db.Uuid
projectUser ProjectUser? @relation("AgentProjectUser", fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
publicJwk Json
jwkThumbprint String
status AgentStatus @default(PENDING)
reason String?
expiresAt DateTime
maxLifetimeEndsAt DateTime
absoluteLifetimeEndsAt DateTime
capabilityGrants AgentCapabilityGrant[]
@@id([tenancyId, id])
@@unique([tenancyId, jwkThumbprint])
@@index([tenancyId, hostId])
@@index([tenancyId, projectUserId])
}
model AgentCapabilityGrant {
tenancyId String @db.Uuid
id String @default(uuid()) @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agentId String @db.Uuid
agent Agent @relation(fields: [tenancyId, agentId], references: [tenancyId, id], onDelete: Cascade)
capability String
status AgentCapabilityGrantStatus @default(PENDING)
constraints Json?
grantedByProjectUserId String? @db.Uuid
grantedByProjectUser ProjectUser? @relation("AgentCapabilityGrantGrantedByProjectUser", fields: [tenancyId, grantedByProjectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
reason String?
expiresAt DateTime?
@@id([tenancyId, id])
@@unique([tenancyId, agentId, capability])
@@index([tenancyId, agentId])
}
enum EmailTemplateType {
EMAIL_VERIFICATION
PASSWORD_RESET

View File

@ -0,0 +1,237 @@
import * as jose from "jose";
import { HexclaveAdminApp, HexclaveServerApp } from "@hexclave/next";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
import { Client } from "pg";
const hexclavePortPrefix = getEnvVariable("NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX", "81");
const backendBaseUrl = getEnvVariable("NEXT_PUBLIC_HEXCLAVE_API_URL", `http://localhost:${hexclavePortPrefix}02`);
const projectId = getEnvVariable("HEXCLAVE_INTERNAL_PROJECT_ID", "internal");
const publishableClientKey = getEnvVariable("HEXCLAVE_INTERNAL_PROJECT_CLIENT_KEY", getEnvVariable("STACK_INTERNAL_PROJECT_CLIENT_KEY", ""));
const secretServerKey = getEnvVariable("HEXCLAVE_INTERNAL_PROJECT_SECRET_SERVER_KEY", getEnvVariable("STACK_INTERNAL_PROJECT_SERVER_KEY", ""));
const superSecretAdminKey = getEnvVariable("HEXCLAVE_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY");
const registerAudience = new URL("/api/latest/agent-auth/agents/register", backendBaseUrl).toString();
const executeAudience = new URL("/api/latest/agent-auth/capabilities/execute", backendBaseUrl).toString();
async function generateKeyPair() {
const { publicKey, privateKey } = await jose.generateKeyPair("EdDSA", { crv: "Ed25519" });
const jwk = await jose.exportJWK(publicKey);
const thumbprint = await jose.calculateJwkThumbprint(jwk);
return {
privateKey,
publicJwk: {
...jwk,
alg: "EdDSA",
crv: "Ed25519",
kid: thumbprint,
use: "sig",
},
thumbprint,
};
}
async function signJwt(options: {
privateKey: Awaited<ReturnType<typeof generateKeyPair>>["privateKey"],
thumbprint: string,
typ: "host+jwt" | "agent+jwt",
audience: string,
expiresInSeconds: number,
}) {
return await new jose.SignJWT({})
.setProtectedHeader({ alg: "EdDSA", typ: options.typ, kid: options.thumbprint })
.setIssuer(options.thumbprint)
.setAudience(options.audience)
.setIssuedAt()
.setExpirationTime(`${options.expiresInSeconds}s`)
.sign(options.privateKey);
}
async function main() {
console.log("Agent Auth demo starting...");
console.log(`Backend: ${backendBaseUrl}`);
console.log(`Project: ${projectId}`);
const adminApp = new HexclaveAdminApp({
baseUrl: backendBaseUrl,
projectId,
publishableClientKey,
secretServerKey,
superSecretAdminKey,
tokenStore: "memory",
redirectMethod: "none",
});
const serverApp = new HexclaveServerApp({
baseUrl: backendBaseUrl,
projectId,
publishableClientKey,
secretServerKey,
tokenStore: "memory",
redirectMethod: "none",
});
const project = await adminApp.getProject();
console.log("Enabling Agent Auth for the project...");
await project.updateConfig({
apps: {
installed: {
"agent-auth": {
enabled: true,
},
},
},
});
const users = await serverApp.listUsers({
includeRestricted: true,
orderBy: "signedUpAt",
limit: 1,
});
const linkedUser = users.at(0);
if (linkedUser == null) {
throw new Error("No existing users found in the internal project");
}
console.log(`Linked real user: ${linkedUser.id} (${linkedUser.primaryEmail ?? "no primary email"})`);
const hostKeys = await generateKeyPair();
const agentKeys = await generateKeyPair();
const hostJwt = await signJwt({
privateKey: hostKeys.privateKey,
thumbprint: hostKeys.thumbprint,
typ: "host+jwt",
audience: registerAudience,
expiresInSeconds: 300,
});
console.log("Registering the host + agent...");
const registerResponse = await fetch(registerAudience, {
method: "POST",
headers: {
"content-type": "application/json",
"x-stack-access-type": "server",
"x-stack-project-id": projectId,
"x-stack-publishable-client-key": publishableClientKey,
"x-stack-secret-server-key": secretServerKey,
authorization: `Bearer ${hostJwt}`,
},
body: JSON.stringify({
host_public_jwk: hostKeys.publicJwk,
agent_public_jwk: agentKeys.publicJwk,
host_name: "demo-host",
agent_name: "demo-agent",
mode: "delegated",
user_id: linkedUser.id,
requested_capabilities: [
{
name: "list_users",
constraints: {
limit: {
max: 3,
},
},
},
{
name: "get_project_info",
},
],
}),
});
if (!registerResponse.ok) {
throw new Error(`Register failed: ${registerResponse.status} ${await registerResponse.text()}`);
}
const registerBody = await registerResponse.json() as {
agent_id: string,
host_id: string,
agent_thumbprint: string,
host_thumbprint: string,
granted_capabilities: Array<{ name: string, status: string }>,
};
console.log(`Registered agent ${registerBody.agent_id} under host ${registerBody.host_id}`);
console.log(`Host thumbprint: ${registerBody.host_thumbprint}`);
console.log(`Agent thumbprint: ${registerBody.agent_thumbprint}`);
console.log(`Granted capabilities: ${registerBody.granted_capabilities.map((grant) => `${grant.name}:${grant.status}`).join(", ")}`);
const agentJwt = await signJwt({
privateKey: agentKeys.privateKey,
thumbprint: agentKeys.thumbprint,
typ: "agent+jwt",
audience: executeAudience,
expiresInSeconds: 60,
});
const execute = async (capability: string, input: Record<string, unknown>) => {
const response = await fetch(executeAudience, {
method: "POST",
headers: {
"content-type": "application/json",
"x-stack-access-type": "server",
"x-stack-project-id": projectId,
"x-stack-publishable-client-key": publishableClientKey,
"x-stack-secret-server-key": secretServerKey,
authorization: `Bearer ${agentJwt}`,
},
body: JSON.stringify({ capability, input }),
});
if (!response.ok) {
throw new Error(`Capability ${capability} failed: ${response.status} ${await response.text()}`);
}
return await response.json() as { result: unknown };
};
const listUsersResult = await execute("list_users", { limit: 2 });
console.log("Capability list_users result:");
console.log(JSON.stringify(listUsersResult.result, null, 2));
const projectInfoResult = await execute("get_project_info", {});
console.log("Capability get_project_info result:");
console.log(JSON.stringify(projectInfoResult.result, null, 2));
const agentsResponse = await fetch(new URL("/api/latest/agent-auth/agents", backendBaseUrl).toString(), {
headers: {
"x-stack-access-type": "admin",
"x-stack-project-id": projectId,
"x-stack-publishable-client-key": publishableClientKey,
"x-stack-secret-server-key": secretServerKey,
"x-stack-super-secret-admin-key": superSecretAdminKey,
},
});
if (!agentsResponse.ok) {
throw new Error(`GET agents failed: ${agentsResponse.status} ${await agentsResponse.text()}`);
}
const agentsBody = await agentsResponse.json() as { agents: Array<{ id: string, name: string, status: string, host: { name: string } }> };
console.log("Registered agents:");
console.log(JSON.stringify(agentsBody.agents.filter((agent) => agent.id === registerBody.agent_id), null, 2));
const auditConnectionString = getEnvVariable(
"HEXCLAVE_DATABASE_CONNECTION_STRING",
getEnvVariable("STACK_DATABASE_CONNECTION_STRING", ""),
);
if (auditConnectionString === "") {
throw new Error("A local database connection string is required for audit lookup");
}
const auditClient = new Client({ connectionString: auditConnectionString });
await auditClient.connect();
const audit = await auditClient.query<{
systemEventTypeIds: string[],
data: { actor?: { type?: string, agentId?: string, hostId?: string, userId?: string } } | null,
}>(
`
SELECT "systemEventTypeIds", data
FROM "Event"
WHERE "systemEventTypeIds" && ARRAY[$1, $2]
ORDER BY "createdAt" DESC
LIMIT 10
`,
["$agent-registered", "$agent-capability-executed"],
);
await auditClient.end();
console.log("Audit events showing the agent actor envelope:");
console.log(JSON.stringify(audit.rows, null, 2));
console.log("Agent Auth demo complete.");
}
// eslint-disable-next-line no-restricted-syntax -- standalone script handles its own fatal error path
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -0,0 +1,54 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { jsonSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { AGENT_AUTH_AGENTS_PATH, AGENT_AUTH_CAPABILITY_EXECUTE_PATH, AGENT_AUTH_DISCOVERY_PATH, AGENT_AUTH_MODES, AGENT_AUTH_PROTOCOL_VERSION, AGENT_AUTH_REGISTER_PATH } from "@/lib/agent-auth/constants";
import { AGENT_CAPABILITIES } from "@/lib/agent-auth/capabilities";
import { getAgentAuthTenancy, getHeader } from "@/lib/agent-auth/requests";
function buildDiscoveryUrl(baseUrl: string, path: string) {
return new URL(path, new URL(baseUrl).origin).toString();
}
export const GET = createSmartRouteHandler({
request: yupObject({
auth: yupObject({
type: yupString(),
user: jsonSchema,
project: jsonSchema,
}).nullable(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: jsonSchema.defined(),
}),
handler: async (_req, fullReq) => {
const projectId = getHeader(fullReq, "x-stack-project-id") ?? getHeader(fullReq, "x-hexclave-project-id");
if (!projectId) {
throw new StatusError(StatusError.BadRequest, "Project id is required");
}
await getAgentAuthTenancy(projectId);
return {
statusCode: 200,
bodyType: "json",
body: {
version: AGENT_AUTH_PROTOCOL_VERSION,
provider: new URL(fullReq.url).origin,
issuer: new URL(fullReq.url).origin,
algorithms: ["Ed25519"],
modes: [...AGENT_AUTH_MODES],
endpoints: {
discovery: buildDiscoveryUrl(fullReq.url, AGENT_AUTH_DISCOVERY_PATH),
register: buildDiscoveryUrl(fullReq.url, AGENT_AUTH_REGISTER_PATH),
agents: buildDiscoveryUrl(fullReq.url, AGENT_AUTH_AGENTS_PATH),
execute: buildDiscoveryUrl(fullReq.url, AGENT_AUTH_CAPABILITY_EXECUTE_PATH),
},
capabilities: Object.values(AGENT_CAPABILITIES).map((capability) => ({
name: capability.name,
description: capability.description,
})),
},
};
},
});

View File

@ -0,0 +1,52 @@
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { serializeAgent } from "@/lib/agent-auth/serialization";
import { getAgentAuthTenancy, getHeader } from "@/lib/agent-auth/requests";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { jsonSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
export const GET = createSmartRouteHandler({
request: yupObject({
auth: yupObject({
type: yupString().oneOf(["server", "admin"]).defined(),
}).defined(),
params: yupObject({
agentId: yupString().uuid().defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: jsonSchema.defined(),
}),
handler: async (req, fullReq) => {
const projectId = getHeader(fullReq, "x-stack-project-id") ?? getHeader(fullReq, "x-hexclave-project-id");
if (!projectId) throw new StatusError(StatusError.BadRequest, "Project id is required");
const tenancy = await getAgentAuthTenancy(projectId);
const prisma = await getPrismaClientForTenancy(tenancy);
const agent = await prisma.agent.findUnique({
where: {
tenancyId_id: {
tenancyId: tenancy.id,
id: req.params.agentId,
},
},
include: {
host: true,
projectUser: true,
capabilityGrants: true,
},
});
if (!agent) {
throw new StatusError(StatusError.NotFound, "Agent not found");
}
return {
statusCode: 200 as const,
bodyType: "json" as const,
body: {
agent: serializeAgent(agent),
},
};
},
});

View File

@ -0,0 +1,177 @@
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { getAgentCapability, normalizeGrantConstraints } from "@/lib/agent-auth/capabilities";
import { AGENT_AUTH_ABSOLUTE_LIFETIME_MILLIS, AGENT_AUTH_MAX_LIFETIME_MILLIS, AGENT_AUTH_MODES, AGENT_AUTH_SESSION_TTL_MILLIS } from "@/lib/agent-auth/constants";
import { getAgentAuthAudience, getAgentAuthTenancy, getBearerToken, getHeader } from "@/lib/agent-auth/requests";
import { getAgentAuthJwkThumbprint, normalizeAgentAuthPublicJwk, verifyHostJwt } from "@/lib/agent-auth/jwt";
import { logEvent, SystemEventTypes } from "@/lib/events";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { jsonSchema, yupArray, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
export const POST = createSmartRouteHandler({
request: yupObject({
auth: yupObject({
type: yupString().oneOf(["server", "admin"]).defined(),
}).defined(),
body: yupObject({
host_public_jwk: jsonSchema.defined(),
agent_public_jwk: jsonSchema.defined(),
host_name: yupString().defined(),
agent_name: yupString().defined(),
mode: yupString().oneOf([...AGENT_AUTH_MODES]).defined(),
user_id: yupString().uuid().defined(),
requested_capabilities: yupArray(
yupObject({
name: yupString().defined(),
constraints: jsonSchema.optional(),
}),
).defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([201]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: jsonSchema.defined(),
}),
handler: async (req, fullReq) => {
const projectId = getHeader(fullReq, "x-stack-project-id") ?? getHeader(fullReq, "x-hexclave-project-id");
if (!projectId) throw new StatusError(StatusError.BadRequest, "Project id is required");
const tenancy = await getAgentAuthTenancy(projectId);
const prisma = await getPrismaClientForTenancy(tenancy);
const hostJwt = getBearerToken(fullReq);
if (!hostJwt) throw new StatusError(StatusError.Unauthorized, "invalid_host_token");
const hostPublicJwk = normalizeAgentAuthPublicJwk(req.body.host_public_jwk);
const agentPublicJwk = normalizeAgentAuthPublicJwk(req.body.agent_public_jwk);
const hostAudience = getAgentAuthAudience(fullReq.url, "/api/latest/agent-auth/agents/register");
const verifiedHost = await verifyHostJwt({
jwt: hostJwt,
publicJwk: hostPublicJwk,
audience: hostAudience,
});
if (verifiedHost.payload.iss !== verifiedHost.thumbprint) {
throw new StatusError(StatusError.Unauthorized, "invalid_host_token");
}
const existingUser = await prisma.projectUser.findUnique({
where: {
tenancyId_projectUserId: {
tenancyId: tenancy.id,
projectUserId: req.body.user_id,
},
},
});
if (!existingUser) {
throw new StatusError(StatusError.BadRequest, "user_not_found");
}
const requestedCapabilities = req.body.requested_capabilities.map((capability) => {
const definition = getAgentCapability(capability.name);
const constraints = normalizeGrantConstraints(capability.name, capability.constraints);
return {
name: definition.name,
constraints,
};
});
const now = new Date();
const sessionExpiresAt = new Date(now.getTime() + AGENT_AUTH_SESSION_TTL_MILLIS);
const maxLifetimeEndsAt = new Date(now.getTime() + AGENT_AUTH_MAX_LIFETIME_MILLIS);
const absoluteLifetimeEndsAt = new Date(now.getTime() + AGENT_AUTH_ABSOLUTE_LIFETIME_MILLIS);
const hostThumbprint = verifiedHost.thumbprint;
const host = await prisma.agentHost.upsert({
where: {
tenancyId_jwkThumbprint: {
tenancyId: tenancy.id,
jwkThumbprint: hostThumbprint,
},
},
update: {
name: req.body.host_name,
publicJwk: JSON.parse(JSON.stringify(hostPublicJwk)),
status: "ACTIVE",
projectUserId: existingUser.projectUserId,
lastUsedAt: now,
},
create: {
tenancyId: tenancy.id,
name: req.body.host_name,
publicJwk: JSON.parse(JSON.stringify(hostPublicJwk)),
jwkThumbprint: hostThumbprint,
status: "ACTIVE",
defaultCapabilities: requestedCapabilities.map((capability) => capability.name),
projectUserId: existingUser.projectUserId,
lastUsedAt: now,
},
});
const agentThumbprint = await getAgentAuthJwkThumbprint(agentPublicJwk);
const agent = await prisma.agent.create({
data: {
tenancyId: tenancy.id,
hostId: host.id,
name: req.body.agent_name,
mode: "DELEGATED",
projectUserId: existingUser.projectUserId,
publicJwk: JSON.parse(JSON.stringify(agentPublicJwk)),
jwkThumbprint: agentThumbprint,
status: "ACTIVE",
expiresAt: sessionExpiresAt,
maxLifetimeEndsAt,
absoluteLifetimeEndsAt,
lastUsedAt: now,
},
});
if (requestedCapabilities.length > 0) {
await prisma.agentCapabilityGrant.createMany({
data: requestedCapabilities.map((capability) => ({
tenancyId: tenancy.id,
agentId: agent.id,
capability: capability.name,
status: "ACTIVE",
constraints: capability.constraints == null ? undefined : JSON.parse(JSON.stringify(capability.constraints)),
grantedByProjectUserId: existingUser.projectUserId,
expiresAt: sessionExpiresAt,
})),
});
}
await logEvent([SystemEventTypes.AgentRegistered], {
projectId: tenancy.project.id,
branchId: tenancy.branchId,
actor: {
type: "agent",
agentId: agent.id,
hostId: host.id,
userId: existingUser.projectUserId,
},
hostId: host.id,
agentId: agent.id,
requestedCapabilities,
mode: req.body.mode,
agentName: req.body.agent_name,
hostName: req.body.host_name,
}, {
billingTeamId: null,
});
return {
statusCode: 201 as const,
bodyType: "json" as const,
body: {
host_id: host.id,
agent_id: agent.id,
host_thumbprint: host.jwkThumbprint,
agent_thumbprint: agent.jwkThumbprint,
granted_capabilities: requestedCapabilities.map((capability) => ({
name: capability.name,
status: "active",
constraints: capability.constraints,
})),
},
};
},
});

View File

@ -0,0 +1,42 @@
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { serializeAgent } from "@/lib/agent-auth/serialization";
import { getAgentAuthTenancy, getHeader } from "@/lib/agent-auth/requests";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { jsonSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
export const GET = createSmartRouteHandler({
request: yupObject({
auth: yupObject({
type: yupString().oneOf(["server", "admin"]).defined(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: jsonSchema.defined(),
}),
handler: async (_req, fullReq) => {
const projectId = getHeader(fullReq, "x-stack-project-id") ?? getHeader(fullReq, "x-hexclave-project-id");
if (!projectId) throw new StatusError(StatusError.BadRequest, "Project id is required");
const tenancy = await getAgentAuthTenancy(projectId);
const prisma = await getPrismaClientForTenancy(tenancy);
const agents = await prisma.agent.findMany({
where: { tenancyId: tenancy.id },
include: {
host: true,
projectUser: true,
capabilityGrants: true,
},
orderBy: { createdAt: "desc" },
});
return {
statusCode: 200 as const,
bodyType: "json" as const,
body: {
agents: agents.map(serializeAgent),
},
};
},
});

View File

@ -0,0 +1,167 @@
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { AGENT_AUTH_SESSION_TTL_MILLIS } from "@/lib/agent-auth/constants";
import { executeAgentCapability, getAgentCapability, normalizeGrantConstraints } from "@/lib/agent-auth/capabilities";
import { getAgentAuthAudience, getAgentAuthTenancy, getBearerToken, getHeader } from "@/lib/agent-auth/requests";
import { normalizeAgentAuthPublicJwk, verifyAgentJwt } from "@/lib/agent-auth/jwt";
import { logEvent, SystemEventTypes } from "@/lib/events";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { jsonSchema, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import * as jose from "jose";
function resolveAgentStatusError(status: string): never {
if (status === "REVOKED" || status === "REJECTED") {
throw new StatusError(StatusError.Forbidden, "agent_not_active");
}
throw new StatusError(StatusError.Forbidden, "agent_expired");
}
export const POST = createSmartRouteHandler({
request: yupObject({
auth: yupObject({}).nullable().optional(),
body: yupObject({
capability: yupString().defined(),
input: jsonSchema.optional(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: jsonSchema.defined(),
}),
handler: async (req, fullReq) => {
const projectId = getHeader(fullReq, "x-stack-project-id") ?? getHeader(fullReq, "x-hexclave-project-id");
if (!projectId) throw new StatusError(StatusError.BadRequest, "Project id is required");
const tenancy = await getAgentAuthTenancy(projectId);
const prisma = await getPrismaClientForTenancy(tenancy);
const agentJwt = getBearerToken(fullReq);
if (!agentJwt) {
throw new StatusError(StatusError.Unauthorized, "invalid_agent_token");
}
const decodedHeader = jose.decodeProtectedHeader(agentJwt);
if (decodedHeader.typ !== "agent+jwt") {
throw new StatusError(StatusError.Unauthorized, "invalid_agent_token");
}
const decodedPayload = jose.decodeJwt(agentJwt);
const principalThumbprint = typeof decodedPayload.iss === "string" ? decodedPayload.iss : null;
if (!principalThumbprint) {
throw new StatusError(StatusError.Unauthorized, "invalid_agent_token");
}
const agent = await prisma.agent.findUnique({
where: {
tenancyId_jwkThumbprint: {
tenancyId: tenancy.id,
jwkThumbprint: principalThumbprint,
},
},
include: {
host: true,
projectUser: true,
capabilityGrants: true,
},
});
if (!agent) {
throw new StatusError(StatusError.Unauthorized, "agent_expired");
}
const agentTokenAudience = getAgentAuthAudience(fullReq.url, "/api/latest/agent-auth/capabilities/execute");
const verified = await verifyAgentJwt({
jwt: agentJwt,
publicJwk: normalizeAgentAuthPublicJwk(agent.publicJwk),
audience: agentTokenAudience,
});
if (verified.payload.iss !== agent.jwkThumbprint) {
throw new StatusError(StatusError.Unauthorized, "invalid_agent_token");
}
const now = new Date();
if (agent.status !== "ACTIVE") {
resolveAgentStatusError(agent.status);
}
if (agent.expiresAt.getTime() <= now.getTime() || agent.maxLifetimeEndsAt.getTime() <= now.getTime() || agent.absoluteLifetimeEndsAt.getTime() <= now.getTime()) {
await prisma.agent.update({
where: {
tenancyId_id: {
tenancyId: tenancy.id,
id: agent.id,
},
},
data: { status: "EXPIRED" },
});
throw new StatusError(StatusError.Forbidden, "agent_expired");
}
const grant = agent.capabilityGrants.find((candidate) => candidate.capability === req.body.capability && candidate.status === "ACTIVE");
if (!grant) {
throw new StatusError(StatusError.Forbidden, "capability_not_granted");
}
if (grant.expiresAt != null && grant.expiresAt.getTime() <= now.getTime()) {
throw new StatusError(StatusError.Forbidden, "capability_not_granted");
}
if (agent.mode === "DELEGATED" && !agent.projectUser) {
throw new StatusError(StatusError.Forbidden, "agent_not_active");
}
const linkedUserId = agent.projectUserId ?? (() => {
throw new StatusError(StatusError.Forbidden, "agent_not_active");
})();
const capability = getAgentCapability(req.body.capability);
const normalizedInput = req.body.input ?? {};
const normalizedConstraints = normalizeGrantConstraints(req.body.capability, grant.constraints);
const result = await executeAgentCapability({
tenancy,
capabilityName: req.body.capability,
input: normalizedInput,
constraints: normalizedConstraints,
});
const nextExpiresAt = new Date(Math.min(
now.getTime() + AGENT_AUTH_SESSION_TTL_MILLIS,
agent.maxLifetimeEndsAt.getTime(),
agent.absoluteLifetimeEndsAt.getTime(),
));
await prisma.agent.update({
where: {
tenancyId_id: {
tenancyId: tenancy.id,
id: agent.id,
},
},
data: {
lastUsedAt: now,
expiresAt: nextExpiresAt,
},
});
await logEvent([SystemEventTypes.AgentCapabilityExecuted], {
projectId: tenancy.project.id,
branchId: tenancy.branchId,
actor: {
type: "agent",
agentId: agent.id,
hostId: agent.host.id,
userId: linkedUserId,
},
agentId: agent.id,
hostId: agent.host.id,
capability: capability.name,
input: normalizedInput,
decision: "allowed",
constraints: grant.constraints,
}, {
billingTeamId: null,
});
return {
statusCode: 200,
bodyType: "json",
body: {
capability: capability.name,
result,
},
};
},
});

View File

@ -0,0 +1,235 @@
import { getPrismaClientForTenancy } from "@/prisma-client";
import { AGENT_AUTH_DEFAULT_LIST_USERS_LIMIT } from "./constants";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { yupNumber, yupObject } from "@hexclave/shared/dist/schema-fields";
import type { Tenancy } from "@/lib/tenancies";
import * as yup from "yup";
export type AgentCapabilityConstraint = {
min?: number,
max?: number,
in?: unknown[],
not_in?: unknown[],
};
export type AgentCapabilityGrantConstraints = Record<string, AgentCapabilityConstraint | undefined>;
export type AgentCapabilityDefinition<TInput extends Record<string, unknown>, TResult> = {
name: string,
description: string,
inputSchema: yup.Schema<TInput>,
handler: (options: { tenancy: Tenancy, prisma: Awaited<ReturnType<typeof getPrismaClientForTenancy>>, input: TInput }) => Promise<TResult>,
constrainInput: ((input: TInput, constraints: AgentCapabilityGrantConstraints | null | undefined) => TInput) | undefined,
};
function throwConstraintViolation(message: string): never {
throw new StatusError(StatusError.BadRequest, message);
}
function assertKnownConstraintKeys(constraint: Record<string, unknown>) {
for (const key of Object.keys(constraint)) {
if (!["min", "max", "in", "not_in"].includes(key)) {
throwConstraintViolation("constraint_violated");
}
}
}
function normalizeConstraintValue(value: unknown): AgentCapabilityConstraint {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
throwConstraintViolation("constraint_violated");
}
assertKnownConstraintKeys(value as Record<string, unknown>);
const constraint = value as Record<string, unknown>;
const result: AgentCapabilityConstraint = {};
if (constraint.min != null) {
if (typeof constraint.min !== "number") throwConstraintViolation("constraint_violated");
result.min = constraint.min;
}
if (constraint.max != null) {
if (typeof constraint.max !== "number") throwConstraintViolation("constraint_violated");
result.max = constraint.max;
}
if (constraint.in != null) {
if (!Array.isArray(constraint.in)) throwConstraintViolation("constraint_violated");
result.in = constraint.in;
}
if (constraint.not_in != null) {
if (!Array.isArray(constraint.not_in)) throwConstraintViolation("constraint_violated");
result.not_in = constraint.not_in;
}
return result;
}
export function normalizeGrantConstraints(capability: string, constraints: unknown): AgentCapabilityGrantConstraints | null {
if (constraints == null) return null;
if (capability === "get_project_info") {
if (typeof constraints === "object" && !Array.isArray(constraints) && Object.keys(constraints).length > 0) {
throwConstraintViolation("constraint_violated");
}
return {};
}
if (typeof constraints !== "object" || Array.isArray(constraints)) {
throwConstraintViolation("constraint_violated");
}
const result: AgentCapabilityGrantConstraints = {};
for (const [field, value] of Object.entries(constraints as Record<string, unknown>)) {
result[field] = normalizeConstraintValue(value);
}
return result;
}
function clampNumber(value: number, min?: number, max?: number): number {
let result = value;
if (min != null) result = Math.max(result, min);
if (max != null) result = Math.min(result, max);
return result;
}
function assertConstraintSatisfied(field: string, value: unknown, constraint: AgentCapabilityConstraint) {
if (constraint.min != null && (typeof value !== "number" || value < constraint.min)) {
throwConstraintViolation("constraint_violated");
}
if (constraint.max != null && (typeof value !== "number" || value > constraint.max)) {
throwConstraintViolation("constraint_violated");
}
if (constraint.in != null && !constraint.in.some((candidate) => Object.is(candidate, value))) {
throwConstraintViolation("constraint_violated");
}
if (constraint.not_in != null && constraint.not_in.some((candidate) => Object.is(candidate, value))) {
throwConstraintViolation("constraint_violated");
}
if (field !== "limit" && (constraint.min != null || constraint.max != null)) {
throwConstraintViolation("constraint_violated");
}
}
function applyConstraintsForListUsers(input: { limit?: number }, constraints: AgentCapabilityGrantConstraints | null | undefined) {
if (constraints == null || Object.keys(constraints).length === 0) {
return {
limit: input.limit ?? AGENT_AUTH_DEFAULT_LIST_USERS_LIMIT,
};
}
for (const field of Object.keys(constraints)) {
if (field !== "limit") {
throwConstraintViolation("constraint_violated");
}
}
const limitConstraint = constraints.limit;
if (limitConstraint == null) return input;
const resolvedLimit = clampNumber(input.limit ?? AGENT_AUTH_DEFAULT_LIST_USERS_LIMIT, limitConstraint.min, limitConstraint.max);
assertConstraintSatisfied("limit", resolvedLimit, limitConstraint);
return { limit: resolvedLimit };
}
export function validateAgentCapabilityInput<TInput extends Record<string, unknown>>(
capability: AgentCapabilityDefinition<TInput, unknown>,
input: unknown,
): TInput {
try {
return capability.inputSchema.validateSync(input, {
abortEarly: false,
strict: true,
});
} catch (error) {
if (error instanceof yup.ValidationError) {
throw new StatusError(StatusError.BadRequest, "invalid_capability_input");
}
throw error;
}
}
export const AGENT_CAPABILITIES = {
list_users: {
name: "list_users",
description: "List real ProjectUsers in the current tenancy.",
inputSchema: yupObject({
limit: yupNumber().integer().positive().optional(),
}),
constrainInput: applyConstraintsForListUsers,
handler: async ({ tenancy, prisma, input }: {
tenancy: Tenancy,
prisma: Awaited<ReturnType<typeof getPrismaClientForTenancy>>,
input: { limit?: number },
}) => {
const users = await prisma.projectUser.findMany({
where: { tenancyId: tenancy.id },
orderBy: [
{ signedUpAt: "desc" },
{ projectUserId: "asc" },
],
take: input.limit ?? AGENT_AUTH_DEFAULT_LIST_USERS_LIMIT,
});
const primaryEmailChannels = await prisma.contactChannel.findMany({
where: {
tenancyId: tenancy.id,
projectUserId: {
in: users.map((user) => user.projectUserId),
},
type: "EMAIL",
isPrimary: "TRUE",
},
select: {
projectUserId: true,
value: true,
},
});
const primaryEmailByUserId = new Map(primaryEmailChannels.map((channel) => [channel.projectUserId, channel.value]));
return {
users: users.map((user) => ({
id: user.projectUserId,
display_name: user.displayName ?? null,
primary_email: primaryEmailByUserId.get(user.projectUserId) ?? null,
signed_up_at: user.signedUpAt.toISOString(),
})),
};
},
},
get_project_info: {
name: "get_project_info",
description: "Read the current project's identifier, display name, and user count.",
inputSchema: yupObject({}),
constrainInput: undefined,
handler: async ({ tenancy, prisma }) => {
const userCount = await prisma.projectUser.count({
where: { tenancyId: tenancy.id },
});
return {
project_id: tenancy.project.id,
display_name: tenancy.project.display_name,
user_count: userCount,
};
},
},
} as const satisfies Record<string, AgentCapabilityDefinition<Record<string, unknown>, unknown>>;
export type AgentCapabilityName = keyof typeof AGENT_CAPABILITIES;
export function getAgentCapability(name: string) {
const capability = Object.entries(AGENT_CAPABILITIES).find(([capabilityName]) => capabilityName === name)?.[1];
if (capability == null) {
throwConstraintViolation("capability_not_supported");
}
return capability;
}
export async function executeAgentCapability(options: {
tenancy: Tenancy,
capabilityName: string,
input: unknown,
constraints: AgentCapabilityGrantConstraints | null | undefined,
}) {
const capability = getAgentCapability(options.capabilityName);
const prisma = await getPrismaClientForTenancy(options.tenancy);
const validatedInput = validateAgentCapabilityInput(capability, options.input ?? {});
const constrainedInput = capability.constrainInput
? capability.constrainInput(validatedInput, options.constraints)
: validatedInput;
return await capability.handler({
tenancy: options.tenancy,
prisma,
input: constrainedInput,
});
}

View File

@ -0,0 +1,17 @@
export const AGENT_AUTH_APP_ID = "agent-auth" as const;
export const AGENT_AUTH_PROTOCOL_VERSION = "1.0-draft" as const;
export const AGENT_AUTH_JWS_ALG = "EdDSA" as const;
export const AGENT_AUTH_JWK_CRV = "Ed25519" as const;
export const AGENT_AUTH_HOST_JWT_TYP = "host+jwt" as const;
export const AGENT_AUTH_AGENT_JWT_TYP = "agent+jwt" as const;
export const AGENT_AUTH_MODES = ["delegated"] as const;
export const AGENT_AUTH_CAPABILITY_EXECUTE_PATH = "/api/latest/agent-auth/capabilities/execute" as const;
export const AGENT_AUTH_REGISTER_PATH = "/api/latest/agent-auth/agents/register" as const;
export const AGENT_AUTH_AGENTS_PATH = "/api/latest/agent-auth/agents" as const;
export const AGENT_AUTH_DISCOVERY_PATH = "/.well-known/agent-configuration" as const;
export const AGENT_AUTH_SESSION_TTL_MILLIS = 30 * 60 * 1000;
export const AGENT_AUTH_MAX_LIFETIME_MILLIS = 24 * 60 * 60 * 1000;
export const AGENT_AUTH_ABSOLUTE_LIFETIME_MILLIS = 7 * 24 * 60 * 60 * 1000;
export const AGENT_AUTH_MAX_AGENT_JWT_SECONDS = 60;
export const AGENT_AUTH_DEFAULT_LIST_USERS_LIMIT = 25;

View File

@ -0,0 +1,192 @@
import * as jose from "jose";
import { HexclaveAssertionError, StatusError } from "@hexclave/shared/dist/utils/errors";
import {
AGENT_AUTH_AGENT_JWT_TYP,
AGENT_AUTH_HOST_JWT_TYP,
AGENT_AUTH_JWS_ALG,
AGENT_AUTH_JWK_CRV,
AGENT_AUTH_MAX_AGENT_JWT_SECONDS,
} from "./constants";
export type AgentAuthPublicJwk = jose.JWK & {
alg: typeof AGENT_AUTH_JWS_ALG,
crv: typeof AGENT_AUTH_JWK_CRV,
kid: string,
use?: "sig",
};
export type AgentAuthPrivateJwk = jose.JWK & {
alg: typeof AGENT_AUTH_JWS_ALG,
crv: typeof AGENT_AUTH_JWK_CRV,
kid: string,
use?: "sig",
d: string,
};
export type AgentAuthKeyPair = {
publicJwk: AgentAuthPublicJwk,
privateJwk: AgentAuthPrivateJwk,
thumbprint: string,
publicKey: CryptoKey,
privateKey: CryptoKey,
};
export type VerifiedAgentAuthJwt = {
thumbprint: string,
payload: jose.JWTPayload,
};
function getJwtError(message: string): StatusError {
return new StatusError(StatusError.Unauthorized, message);
}
function ensureEd25519PublicJwk(publicJwk: jose.JWK): AgentAuthPublicJwk {
if (publicJwk.kty !== "OKP" || publicJwk.crv !== AGENT_AUTH_JWK_CRV || typeof publicJwk.x !== "string") {
throw new HexclaveAssertionError("Expected an Ed25519 OKP public JWK");
}
return {
...publicJwk,
alg: AGENT_AUTH_JWS_ALG,
crv: AGENT_AUTH_JWK_CRV,
kid: typeof publicJwk.kid === "string" && publicJwk.kid.length > 0 ? publicJwk.kid : "",
use: "sig",
};
}
export function normalizeAgentAuthPublicJwk(value: unknown): AgentAuthPublicJwk {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
throw new HexclaveAssertionError("Expected an Ed25519 OKP public JWK");
}
return ensureEd25519PublicJwk(value as jose.JWK);
}
export async function getAgentAuthJwkThumbprint(publicJwk: AgentAuthPublicJwk): Promise<string> {
return await jose.calculateJwkThumbprint(publicJwk);
}
export async function generateAgentAuthKeyPair(): Promise<AgentAuthKeyPair> {
const { publicKey, privateKey } = await jose.generateKeyPair(AGENT_AUTH_JWS_ALG, { crv: AGENT_AUTH_JWK_CRV });
const publicJwk = ensureEd25519PublicJwk(await jose.exportJWK(publicKey));
const thumbprint = await jose.calculateJwkThumbprint(publicJwk);
const publicJwkWithKid = { ...publicJwk, kid: thumbprint };
const privateJwk = {
...await jose.exportJWK(privateKey),
alg: AGENT_AUTH_JWS_ALG,
crv: AGENT_AUTH_JWK_CRV,
kid: thumbprint,
use: "sig" as const,
} as AgentAuthPrivateJwk;
return {
publicJwk: publicJwkWithKid,
privateJwk,
thumbprint,
publicKey,
privateKey,
};
}
export async function signHostJwt(options: {
privateKey: CryptoKey,
publicJwk: AgentAuthPublicJwk,
audience: string,
expiresInSeconds?: number,
additionalClaims?: Record<string, unknown>,
}): Promise<string> {
const thumbprint = await jose.calculateJwkThumbprint(options.publicJwk);
const kid = options.publicJwk.kid || thumbprint;
return await new jose.SignJWT(options.additionalClaims ?? {})
.setProtectedHeader({ alg: AGENT_AUTH_JWS_ALG, typ: AGENT_AUTH_HOST_JWT_TYP, kid })
.setIssuer(thumbprint)
.setAudience(options.audience)
.setIssuedAt()
.setExpirationTime(`${options.expiresInSeconds ?? 5 * 60}s`)
.sign(options.privateKey);
}
export async function signAgentJwt(options: {
privateKey: CryptoKey,
publicJwk: AgentAuthPublicJwk,
audience: string,
expiresInSeconds?: number,
additionalClaims?: Record<string, unknown>,
}): Promise<string> {
const thumbprint = await jose.calculateJwkThumbprint(options.publicJwk);
const kid = options.publicJwk.kid || thumbprint;
return await new jose.SignJWT(options.additionalClaims ?? {})
.setProtectedHeader({ alg: AGENT_AUTH_JWS_ALG, typ: AGENT_AUTH_AGENT_JWT_TYP, kid })
.setIssuer(thumbprint)
.setAudience(options.audience)
.setIssuedAt()
.setExpirationTime(`${options.expiresInSeconds ?? AGENT_AUTH_MAX_AGENT_JWT_SECONDS}s`)
.sign(options.privateKey);
}
async function verifyJwtAgainstPublicJwk(options: {
jwt: string,
publicJwk: AgentAuthPublicJwk,
expectedTyp: typeof AGENT_AUTH_HOST_JWT_TYP | typeof AGENT_AUTH_AGENT_JWT_TYP,
audience: string,
maxLifetimeSeconds?: number,
}): Promise<VerifiedAgentAuthJwt> {
const header = jose.decodeProtectedHeader(options.jwt);
if (header.alg !== AGENT_AUTH_JWS_ALG) {
throw getJwtError("invalid_agent_token");
}
if (header.typ !== options.expectedTyp) {
throw getJwtError("invalid_agent_token");
}
const thumbprint = await jose.calculateJwkThumbprint(options.publicJwk);
const importedKey = await jose.importJWK(options.publicJwk, AGENT_AUTH_JWS_ALG);
const { payload } = await jose.jwtVerify(options.jwt, importedKey, {
audience: options.audience,
issuer: thumbprint,
algorithms: [AGENT_AUTH_JWS_ALG],
});
if (payload.aud !== options.audience) {
throw getJwtError("invalid_agent_token");
}
if (payload.iss !== thumbprint) {
throw getJwtError("invalid_agent_token");
}
if (options.maxLifetimeSeconds != null) {
if (typeof payload.iat !== "number" || typeof payload.exp !== "number" || payload.exp - payload.iat > options.maxLifetimeSeconds) {
throw getJwtError("invalid_agent_token");
}
}
return {
thumbprint,
payload,
};
}
export async function verifyHostJwt(options: {
jwt: string,
publicJwk: AgentAuthPublicJwk,
audience: string,
}): Promise<VerifiedAgentAuthJwt> {
return await verifyJwtAgainstPublicJwk({
jwt: options.jwt,
publicJwk: options.publicJwk,
expectedTyp: AGENT_AUTH_HOST_JWT_TYP,
audience: options.audience,
});
}
export async function verifyAgentJwt(options: {
jwt: string,
publicJwk: AgentAuthPublicJwk,
audience: string,
}): Promise<VerifiedAgentAuthJwt> {
return await verifyJwtAgainstPublicJwk({
jwt: options.jwt,
publicJwk: options.publicJwk,
expectedTyp: AGENT_AUTH_AGENT_JWT_TYP,
audience: options.audience,
maxLifetimeSeconds: AGENT_AUTH_MAX_AGENT_JWT_SECONDS,
});
}

View File

@ -0,0 +1,33 @@
import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { AGENT_AUTH_APP_ID } from "./constants";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import type { SmartRequest } from "@/route-handlers/smart-request";
export function getHeader(req: SmartRequest, name: string): string | null {
const lowerName = name.toLowerCase();
return req.headers[lowerName]?.[0] ?? null;
}
export function getBearerToken(req: SmartRequest): string | null {
const authorization = getHeader(req, "authorization");
if (!authorization) return null;
const [scheme, ...rest] = authorization.trim().split(/\s+/);
const token = rest.join(" ");
if (scheme.toLowerCase() !== "bearer" || token.length === 0) return null;
return token;
}
export function getAgentAuthAudience(reqUrl: string, path: string): string {
return new URL(path, new URL(reqUrl).origin).toString();
}
export async function getAgentAuthTenancy(projectId: string) {
const tenancy = await getSoleTenancyFromProjectBranch(projectId, "main", true);
if (!tenancy) {
throw new StatusError(StatusError.NotFound, "Agent auth project not found");
}
if (!tenancy.config.apps.installed[AGENT_AUTH_APP_ID]?.enabled) {
throw new StatusError(StatusError.NotFound, "Not found");
}
return tenancy;
}

View File

@ -0,0 +1,78 @@
export type SerializedAgentCapabilityGrant = {
capability: string,
status: string,
constraints: unknown,
expires_at: string | null,
};
export type SerializedAgent = {
id: string,
name: string,
mode: string,
status: string,
agent_thumbprint: string,
linked_user_id: string | null,
host: {
id: string,
name: string,
thumbprint: string,
linked_user_id: string | null,
},
linked_user: { id: string, display_name: string | null } | null,
capabilities: SerializedAgentCapabilityGrant[],
expires_at: string,
max_lifetime_ends_at: string,
absolute_lifetime_ends_at: string,
created_at: string,
updated_at: string,
last_used_at: string | null,
};
export function serializeAgent(agent: {
id: string,
name: string,
mode: string,
status: string,
projectUserId: string | null,
jwkThumbprint: string,
expiresAt: Date,
maxLifetimeEndsAt: Date,
absoluteLifetimeEndsAt: Date,
createdAt: Date,
updatedAt: Date,
lastUsedAt: Date | null,
host: { id: string, name: string, jwkThumbprint: string, projectUserId: string | null },
projectUser: { projectUserId: string, displayName: string | null } | null,
capabilityGrants: Array<{ capability: string, status: string, constraints: unknown, expiresAt: Date | null }>,
}): SerializedAgent {
return {
id: agent.id,
name: agent.name,
mode: agent.mode === "DELEGATED" ? "delegated" : "autonomous",
status: agent.status.toLowerCase(),
agent_thumbprint: agent.jwkThumbprint,
linked_user_id: agent.projectUserId,
host: {
id: agent.host.id,
name: agent.host.name,
thumbprint: agent.host.jwkThumbprint,
linked_user_id: agent.host.projectUserId,
},
linked_user: agent.projectUser == null ? null : {
id: agent.projectUser.projectUserId,
display_name: agent.projectUser.displayName,
},
capabilities: agent.capabilityGrants.map((grant) => ({
capability: grant.capability,
status: grant.status.toLowerCase(),
constraints: grant.constraints,
expires_at: grant.expiresAt?.toISOString() ?? null,
})),
expires_at: agent.expiresAt.toISOString(),
max_lifetime_ends_at: agent.maxLifetimeEndsAt.toISOString(),
absolute_lifetime_ends_at: agent.absoluteLifetimeEndsAt.toISOString(),
created_at: agent.createdAt.toISOString(),
updated_at: agent.updatedAt.toISOString(),
last_used_at: agent.lastUsedAt?.toISOString() ?? null,
};
}

View File

@ -169,6 +169,48 @@ const SignUpRuleTriggerEventType = {
inherits: [],
} as const satisfies SystemEventTypeBase;
const AgentRegisteredEventType = {
id: "$agent-registered",
dataSchema: yupObject({
projectId: yupString().defined(),
branchId: yupString().defined(),
actor: yupObject({
type: yupString().oneOf(["agent"]).defined(),
agentId: yupString().uuid().defined(),
hostId: yupString().uuid().defined(),
userId: yupString().uuid().defined(),
}).defined(),
hostId: yupString().uuid().defined(),
agentId: yupString().uuid().defined(),
mode: yupString().oneOf(["delegated", "autonomous"]).defined(),
agentName: yupString().defined(),
hostName: yupString().defined(),
requestedCapabilities: yupMixed().defined(),
}),
inherits: [],
} as const satisfies SystemEventTypeBase;
const AgentCapabilityExecutedEventType = {
id: "$agent-capability-executed",
dataSchema: yupObject({
projectId: yupString().defined(),
branchId: yupString().defined(),
actor: yupObject({
type: yupString().oneOf(["agent"]).defined(),
agentId: yupString().uuid().defined(),
hostId: yupString().uuid().defined(),
userId: yupString().uuid().defined(),
}).defined(),
agentId: yupString().uuid().defined(),
hostId: yupString().uuid().defined(),
capability: yupString().defined(),
input: yupMixed().defined(),
decision: yupString().oneOf(["allowed", "denied"]).defined(),
constraints: yupMixed().nullable().defined(),
}),
inherits: [],
} as const satisfies SystemEventTypeBase;
export const SystemEventTypes = stripEventTypeSuffixFromKeys({
ProjectEventType,
ProjectActivityEventType,
@ -178,6 +220,8 @@ export const SystemEventTypes = stripEventTypeSuffixFromKeys({
ApiRequestEventType,
LegacyApiEventType,
SignUpRuleTriggerEventType,
AgentRegisteredEventType,
AgentCapabilityExecutedEventType,
} as const);
const systemEventTypesById = new Map(Object.values(SystemEventTypes).map(eventType => [eventType.id, eventType]));

View File

@ -0,0 +1,198 @@
"use client";
import { DesignAlert, DesignCard } from "@/components/design-components";
import { Switch, Typography } from "@/components/ui";
import { useUpdateConfig } from "@/components/config-update";
import { hexclaveAppInternalsSymbol } from "@hexclave/next";
import { GearSix, Users } from "@phosphor-icons/react";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { useEffect, useState } from "react";
import { AppEnabledGuard } from "../app-enabled-guard";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
type AgentGrant = {
capability: string,
status: string,
constraints: unknown,
expires_at: string | null,
};
type AgentRow = {
id: string,
name: string,
mode: "delegated" | "autonomous",
status: string,
agent_thumbprint: string,
linked_user_id: string | null,
host: {
id: string,
name: string,
thumbprint: string,
linked_user_id: string | null,
},
linked_user: {
id: string,
display_name: string | null,
} | null,
capabilities: AgentGrant[],
expires_at: string,
max_lifetime_ends_at: string,
absolute_lifetime_ends_at: string,
created_at: string,
updated_at: string,
last_used_at: string | null,
};
export default function PageClient() {
const adminApp = useAdminApp();
const project = adminApp.useProject();
const config = project.useConfig();
const updateConfig = useUpdateConfig();
const enabled = config.apps.installed["agent-auth"]?.enabled ?? false;
const [localEnabled, setLocalEnabled] = useState<boolean | undefined>(undefined);
const [agents, setAgents] = useState<AgentRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const appEnabled = localEnabled ?? enabled;
useEffect(() => {
if (!appEnabled) {
setAgents([]);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
async function loadAgents() {
setLoading(true);
setError(null);
try {
const response = await adminApp[hexclaveAppInternalsSymbol].sendRequest(
"/api/latest/agent-auth/agents",
{},
"admin",
);
if (!response.ok) {
throw new Error(`Failed to load agents: ${response.status}`);
}
const body = await response.json() as { agents: AgentRow[] };
if (!cancelled) {
setAgents(body.agents);
}
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : "Failed to load agents");
setAgents(null);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadAgents().catch((loadError) => {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : "Failed to load agents");
setAgents(null);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [adminApp, appEnabled]);
const handleToggle = (checked: boolean) => {
runAsynchronouslyWithAlert(async () => {
await updateConfig({
adminApp,
configUpdate: {
"apps.installed.agent-auth.enabled": checked,
},
pushable: true,
});
setLocalEnabled(undefined);
});
};
return (
<AppEnabledGuard appId="agent-auth">
<PageLayout
title="Agent Auth"
description="Review real agent identities, hosts, and capability grants."
>
<DesignAlert
variant="info"
title="Agent identities"
description="This page reads the live backend Agents API for the current project."
/>
<DesignCard title="App State" subtitle="Enable or disable Agent Auth for this project" icon={GearSix}>
<div className="flex items-center justify-between gap-3">
<Typography className="text-sm font-medium">
Agent Auth enabled
</Typography>
<Switch checked={appEnabled} onCheckedChange={handleToggle} />
</div>
</DesignCard>
<DesignCard title="Agents" subtitle="Live agents registered in this project" icon={Users}>
{loading ? (
<Typography className="text-sm text-muted-foreground">Loading agents</Typography>
) : error ? (
<Typography className="text-sm text-destructive">{error}</Typography>
) : agents == null || agents.length === 0 ? (
<Typography className="text-sm text-muted-foreground">No agents registered yet.</Typography>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b">
<tr className="text-muted-foreground">
<th className="py-2 pr-4">Name</th>
<th className="py-2 pr-4">Agent</th>
<th className="py-2 pr-4">Host</th>
<th className="py-2 pr-4">Linked user</th>
<th className="py-2 pr-4">Mode</th>
<th className="py-2 pr-4">Status</th>
<th className="py-2 pr-4">Capabilities</th>
<th className="py-2 pr-4">Last used</th>
</tr>
</thead>
<tbody>
{agents.map((agent) => (
<tr key={agent.id} className="border-b last:border-b-0 align-top">
<td className="py-3 pr-4 font-medium">{agent.name}</td>
<td className="py-3 pr-4 font-mono text-xs">{agent.id.slice(0, 8)}</td>
<td className="py-3 pr-4">
<div className="font-medium">{agent.host.name}</div>
<div className="font-mono text-xs text-muted-foreground">{agent.host.id.slice(0, 8)}</div>
</td>
<td className="py-3 pr-4">
{agent.linked_user?.display_name ?? agent.linked_user_id ?? "—"}
</td>
<td className="py-3 pr-4">{agent.mode}</td>
<td className="py-3 pr-4">{agent.status}</td>
<td className="py-3 pr-4">
{agent.capabilities.map((capability) => (
<div key={`${agent.id}-${capability.capability}`} className="mb-1">
<span className="font-medium">{capability.capability}</span>
<span className="ml-2 text-xs text-muted-foreground">{capability.status}</span>
</div>
))}
</td>
<td className="py-3 pr-4">{agent.last_used_at ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</DesignCard>
</PageLayout>
</AppEnabledGuard>
);
}

View File

@ -0,0 +1,5 @@
import PageClient from "./page-client";
export default function Page() {
return <PageClient />;
}

View File

@ -120,6 +120,21 @@ export const ALL_APPS_FRONTEND = {
</>
),
},
"agent-auth": {
icon: FingerprintSimpleIcon,
href: "agent-auth-app",
navigationItems: [
{ displayName: "Agents", href: "." },
],
screenshots: [],
storeDescription: (
<>
<p>Agent Auth gives AI agents their own identity, key material, and scoped capabilities.</p>
<p>Use it to register hosts and agents, inspect granted capabilities, and review agent-attributed audit events.</p>
<p>It is designed for live, real tenancy data rather than synthetic demos.</p>
</>
),
},
"fraud-protection": {
icon: ShieldCheckIcon,
href: "sign-up-rules",

View File

@ -0,0 +1,191 @@
import * as jose from "jose";
import { Client } from "pg";
import { describe } from "vitest";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
import { wait } from "@hexclave/shared/dist/utils/promises";
import { STACK_BACKEND_BASE_URL, it } from "../helpers";
import { Auth, Project, niceBackendFetch, withInternalProject } from "./backend-helpers";
const AGENT_AUTH_REGISTER_AUDIENCE = new URL("/api/latest/agent-auth/agents/register", STACK_BACKEND_BASE_URL).toString();
const AGENT_AUTH_EXECUTE_AUDIENCE = new URL("/api/latest/agent-auth/capabilities/execute", STACK_BACKEND_BASE_URL).toString();
async function generateEd25519KeyPair() {
const { publicKey, privateKey } = await jose.generateKeyPair("EdDSA", { crv: "Ed25519" });
const publicJwk = await jose.exportJWK(publicKey);
const thumbprint = await jose.calculateJwkThumbprint(publicJwk);
return {
publicKey,
privateKey,
thumbprint,
publicJwk: {
...publicJwk,
alg: "EdDSA",
crv: "Ed25519",
kid: thumbprint,
use: "sig",
},
};
}
async function signJwt(options: {
privateKey: jose.KeyLike,
thumbprint: string,
typ: "host+jwt" | "agent+jwt",
audience: string,
expiresInSeconds: number,
claims?: Record<string, unknown>,
}) {
return await new jose.SignJWT(options.claims ?? {})
.setProtectedHeader({ alg: "EdDSA", typ: options.typ, kid: options.thumbprint })
.setIssuer(options.thumbprint)
.setAudience(options.audience)
.setIssuedAt()
.setExpirationTime(`${options.expiresInSeconds}s`)
.sign(options.privateKey);
}
describe("agent auth", () => {
it("registers, executes, and audits a real agent", async ({ expect }) => {
await withInternalProject(async () => {
await Project.updateConfig({
apps: {
installed: {
"agent-auth": {
enabled: true,
},
},
},
});
const { userId } = await Auth.fastSignUp();
const hostKeys = await generateEd25519KeyPair();
const agentKeys = await generateEd25519KeyPair();
const hostJwt = await signJwt({
privateKey: hostKeys.privateKey,
thumbprint: hostKeys.thumbprint,
typ: "host+jwt",
audience: AGENT_AUTH_REGISTER_AUDIENCE,
expiresInSeconds: 300,
});
const registerResponse = await niceBackendFetch("/api/latest/agent-auth/agents/register", {
accessType: "server",
method: "POST",
headers: {
authorization: `Bearer ${hostJwt}`,
},
body: {
host_public_jwk: hostKeys.publicJwk,
agent_public_jwk: agentKeys.publicJwk,
host_name: "e2e-host",
agent_name: "e2e-agent",
mode: "delegated",
user_id: userId,
requested_capabilities: [
{
name: "list_users",
constraints: {
limit: {
max: 2,
},
},
},
{
name: "get_project_info",
constraints: {},
},
],
},
});
expect(registerResponse.status).toBe(201);
const agentJwt = await signJwt({
privateKey: agentKeys.privateKey,
thumbprint: agentKeys.thumbprint,
typ: "agent+jwt",
audience: AGENT_AUTH_EXECUTE_AUDIENCE,
expiresInSeconds: 60,
});
const listUsersResponse = await niceBackendFetch("/api/latest/agent-auth/capabilities/execute", {
accessType: "server",
method: "POST",
headers: {
authorization: `Bearer ${agentJwt}`,
},
body: {
capability: "list_users",
input: {
limit: 1,
},
},
});
expect(listUsersResponse.status).toBe(200);
expect(listUsersResponse.body.result.users).toHaveLength(1);
const projectInfoResponse = await niceBackendFetch("/api/latest/agent-auth/capabilities/execute", {
accessType: "server",
method: "POST",
headers: {
authorization: `Bearer ${agentJwt}`,
},
body: {
capability: "get_project_info",
input: {},
},
});
expect(projectInfoResponse.status).toBe(200);
expect(projectInfoResponse.body.result.project_id).toBe("internal");
const agentsResponse = await niceBackendFetch("/api/latest/agent-auth/agents", {
accessType: "admin",
method: "GET",
});
expect(agentsResponse.status).toBe(200);
expect(agentsResponse.body.agents.some((agent: { name: string }) => agent.name === "e2e-agent")).toBe(true);
const connectionString = getEnvVariable(
"HEXCLAVE_DATABASE_CONNECTION_STRING",
getEnvVariable("STACK_DATABASE_CONNECTION_STRING", ""),
);
if (connectionString === "") {
throw new Error("A local database connection string is required for audit verification");
}
const auditDb = new Client({ connectionString });
await auditDb.connect();
let auditEvents: Array<{
systemEventTypeIds: string[],
data: { actor?: { type?: string } } | null,
}> = [];
for (let attempt = 0; attempt < 20; attempt += 1) {
const auditEventsResult = await auditDb.query<{
systemEventTypeIds: string[],
data: { actor?: { type?: string } } | null,
}>(
`
SELECT "systemEventTypeIds", data
FROM "Event"
WHERE "systemEventTypeIds" && ARRAY[$1, $2]
ORDER BY "createdAt" DESC
LIMIT 10
`,
["$agent-registered", "$agent-capability-executed"],
);
auditEvents = auditEventsResult.rows;
if (auditEvents.some((row) => {
const data = row.data ?? {};
return row.systemEventTypeIds.includes("$agent-capability-executed") && data.actor?.type === "agent";
})) {
break;
}
await wait(250);
}
await auditDb.end();
expect(auditEvents.some((row) => {
const data = row.data ?? {};
return row.systemEventTypeIds.includes("$agent-capability-executed") && data.actor?.type === "agent";
})).toBe(true);
});
});
});

View File

@ -4110,6 +4110,179 @@
}
}
},
"/agent-auth/agents": {
"get": {
"summary": "GET /agent-auth/agents",
"description": "No documentation available for this endpoint.",
"parameters": [],
"tags": [
"Others"
],
"x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/agent-auth/agents/register": {
"post": {
"summary": "POST /agent-auth/agents/register",
"description": "No documentation available for this endpoint.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"host_public_jwk": {
"type": "object"
},
"agent_public_jwk": {
"type": "object"
},
"host_name": {
"type": "string"
},
"agent_name": {
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"delegated"
]
},
"user_id": {
"type": "string"
},
"requested_capabilities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"constraints": {
"type": "object"
}
},
"required": [
"name"
]
}
}
},
"required": [
"host_name",
"agent_name",
"mode",
"user_id",
"requested_capabilities"
],
"example": {}
}
}
}
},
"tags": [
"Others"
],
"x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents/register",
"responses": {
"201": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/agent-auth/agents/{agentId}": {
"get": {
"summary": "GET /agent-auth/agents/{agentId}",
"description": "No documentation available for this endpoint.",
"parameters": [
{
"name": "agentId",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"tags": [
"Others"
],
"x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents/{agentId}",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/agent-auth/capabilities/execute": {
"post": {
"summary": "POST /agent-auth/capabilities/execute",
"description": "No documentation available for this endpoint.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"capability": {
"type": "string"
},
"input": {
"type": "object"
}
},
"required": [
"capability"
],
"example": {}
}
}
}
},
"tags": [
"Others"
],
"x-full-url": "https://api.hexclave.com/api/v1/agent-auth/capabilities/execute",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/auth/mfa/sign-in": {
"post": {
"summary": "MFA sign in",

View File

@ -2977,6 +2977,49 @@
}
}
},
"/agent-auth/capabilities/execute": {
"post": {
"summary": "POST /agent-auth/capabilities/execute",
"description": "No documentation available for this endpoint.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"capability": {
"type": "string"
},
"input": {
"type": "object"
}
},
"required": [
"capability"
],
"example": {}
}
}
}
},
"tags": [
"Others"
],
"x-full-url": "https://api.hexclave.com/api/v1/agent-auth/capabilities/execute",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/auth/mfa/sign-in": {
"post": {
"summary": "MFA sign in",

View File

@ -4070,6 +4070,179 @@
}
}
},
"/agent-auth/agents": {
"get": {
"summary": "GET /agent-auth/agents",
"description": "No documentation available for this endpoint.",
"parameters": [],
"tags": [
"Others"
],
"x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/agent-auth/agents/register": {
"post": {
"summary": "POST /agent-auth/agents/register",
"description": "No documentation available for this endpoint.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"host_public_jwk": {
"type": "object"
},
"agent_public_jwk": {
"type": "object"
},
"host_name": {
"type": "string"
},
"agent_name": {
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"delegated"
]
},
"user_id": {
"type": "string"
},
"requested_capabilities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"constraints": {
"type": "object"
}
},
"required": [
"name"
]
}
}
},
"required": [
"host_name",
"agent_name",
"mode",
"user_id",
"requested_capabilities"
],
"example": {}
}
}
}
},
"tags": [
"Others"
],
"x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents/register",
"responses": {
"201": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/agent-auth/agents/{agentId}": {
"get": {
"summary": "GET /agent-auth/agents/{agentId}",
"description": "No documentation available for this endpoint.",
"parameters": [
{
"name": "agentId",
"in": "path",
"schema": {
"type": "string"
},
"required": true
}
],
"tags": [
"Others"
],
"x-full-url": "https://api.hexclave.com/api/v1/agent-auth/agents/{agentId}",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/agent-auth/capabilities/execute": {
"post": {
"summary": "POST /agent-auth/capabilities/execute",
"description": "No documentation available for this endpoint.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"capability": {
"type": "string"
},
"input": {
"type": "object"
}
},
"required": [
"capability"
],
"example": {}
}
}
}
},
"tags": [
"Others"
],
"x-full-url": "https://api.hexclave.com/api/v1/agent-auth/capabilities/execute",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
}
}
}
},
"/auth/mfa/sign-in": {
"post": {
"summary": "MFA sign in",

View File

@ -245,9 +245,7 @@ const docsJson = {
}
},
"seo": {
"metatags": {
"robots": "noindex"
}
"indexing": "all"
},
"settings": {
"customScripts": [
@ -255,7 +253,12 @@ const docsJson = {
"/code-language-labels.js"
]
},
"redirects": []
"redirects": [
{
"source": "/guides/going-further/backend-integration",
"destination": "/guides/going-further/local-vs-cloud-dashboard"
}
]
} as const;
export default docsJson;

View File

@ -57,6 +57,13 @@ export const ALL_APPS = {
tags: ["auth", "security"],
stage: "stable",
},
"agent-auth": {
displayName: "Agent Auth",
subtitle: "Register, authorize, and audit AI agent identities",
tags: ["auth", "security", "developers"],
stage: "alpha",
parentAppId: "authentication",
},
"fraud-protection": {
displayName: "Fraud Protection",
subtitle: "Protect your project from fraud and abuse",