feat(agents): grant default permissions to existing identities (#278)

Co-authored-by: jarvis <jarvis@agents.realmroot.dev>
This commit is contained in:
realmroot[bot]
2026-09-06 05:05:16 +00:00
committed by GitHub
parent bb7ba4998b
commit 1d7ddbdbe3
7 changed files with 208 additions and 62 deletions
@@ -99,3 +99,15 @@ Acceptance: create a new Agent through Toolbox in Demo, inspect its permissions
before assignment, then run a repository Task including Issue, PR, CI log, and
review continuation operations. Use a new Session to prove existing grants can
be acquired without controller approval. Keep existing Agent grants unchanged.
## Permissions for existing Agents
`POST /api/agents/{agentId}/permissions` requires `agent:write` and an empty JSON
object. AK resolves the existing identity through the tenant-scoped Enbor project,
then uses the same saved user grant, Token Exchange and GitHub defaults as Agent
creation. It returns 204 after Realmroot confirms the grants. Equivalent grants
are reused and other permissions are retained. It does not create or delete
Agents or identities, and does not modify AK native grants. An unbound identity
or missing user login returns 409; a permission upstream failure returns 502 with
the cause. Repeating the explicit POST completes missing permissions. Ordinary
reads and assignments still do not change existing permissions.
+29
View File
@@ -73,6 +73,35 @@ export function registerAgentRoutes(api: Hono<{ Bindings: Env }>): void {
return c.json(represented, 201);
});
api.post("/api/agents/:agentId/permissions", authorizeScope("agent:write"), async (c) => {
const body = await readJsonBody<Record<string, unknown>>(c);
if (body instanceof Response) return body;
assertResourceWriteFields(body, new Set(), "Agent permissions");
const { client } = await agencyDependencies(c, ["agents:read"]);
const agent = await client.agents.get(c.req.param("agentId")!);
if (!agent.spec.identity?.agentId) {
throw new HTTPException(409, { message: "Agent has no bound Realmroot identity" });
}
const principal = c.get("principal");
const platformResource = new URL("/api", c.env.OIDC_ISSUER).toString();
const permissionToken = await delegatedResourceToken(c.env, {
user: { tenantId: principal.tenantId, subjectId: principal.subjectId },
resource: platformResource,
scopes: ["agents:write"],
});
try {
await grantDefaultAgentPermissions(createAgentPermissionGateway(platformResource, permissionToken), agent.spec.identity.agentId, {
githubResource: c.env.GITHUB_RESOURCE,
});
} catch (cause) {
throw new HTTPException(502, {
message: `Agent permission configuration failed: ${cause instanceof Error ? cause.message : String(cause)}`,
cause,
});
}
return c.body(null, 204);
});
api.get("/api/agents", authorizeScope("agent:read"), async (c) => {
const page = await readExternalPage(c);
if (page instanceof Response) return page;
+1
View File
@@ -17,6 +17,7 @@ export function isPublishedV2Operation(method: string, path: string): boolean {
if (/^\/api\/repositories\/[^/]+$/.test(path)) return method === "GET" || method === "DELETE";
if (path === "/api/github-app/config" || path === "/api/github-app/repositories") return method === "GET";
if (/^\/api\/repository-installations\/[^/]+$/.test(path)) return method === "PUT";
if (/^\/api\/agents\/[^/]+\/permissions$/.test(path)) return method === "POST";
if (path === "/api/agents") return method === "GET" || method === "POST";
if (/^\/api\/agents\/[^/]+$/.test(path)) return method === "GET";
if (path === "/api/machines") return method === "GET" || method === "POST";
+10
View File
@@ -202,6 +202,16 @@ function baseDocument(env: Env) {
responses: { "200": entityResponse("Agent", { $ref: "#/components/schemas/Agent" }), ...projectionReadProblems },
},
},
"/agents/{agentId}/permissions": {
parameters: [{ name: "agentId", in: "path", required: true, schema: { type: "string" } }],
post: {
...operation("createAgentPermissions", "agent:write", "Persistent default GitHub permissions configured", "204"),
description:
"Grant the default GitHub development, Issue and CI scopes to an existing bound Agent using the caller's saved user authorization. Existing equivalent permissions are reused; other grants are retained. No identity is created. Missing identity or user login returns 409. Repeat with an empty object to complete an interrupted grant.",
requestBody: { required: true, ...json({ type: "object", additionalProperties: false }) },
responses: { "204": response("Default GitHub permissions confirmed active"), ...projectionCreateProblems },
},
},
"/machines": {
get: {
...operation("listMachines", "machine:read", "Machines"),
+12 -1
View File
@@ -32,7 +32,7 @@ Feature: Agent projections
And AK does not create an Inbox Trigger because Task assignment directly creates a Session
And replays the compound operation without duplicate resources when its Idempotency-Key is retried
And stores no local Agent entity
And grants the new identity its default AK and GitHub permissions before returning success
And grants the new identity its default GitHub permissions before returning success
And a permission failure identifies the created Agent and a retry resumes without duplicates
@journey:agents/assignment-subject @entrypoint:toolbox @proof:unit
@@ -77,3 +77,14 @@ Feature: Agent projections
And Realmroot resolves the authorization Contexts internally without a preliminary AK query
And missing scopes fail explicitly instead of returning partial permission success
And existing Agents are not updated
@journey:agents/existing-permissions @entrypoint:toolbox @proof:integration
Scenario: Explicitly grant default GitHub permissions to an existing bound Agent
Given an authorized caller has a saved user grant and connected GitHub installations
When the caller posts an empty object to an existing Agent's permissions collection
Then AK resolves the identity from the current tenant's Enbor project
And grants the default persistent GitHub scopes through user Token Exchange
And preserves the existing Agent and identity on success or permission failure
And a repeated request reuses equivalent grants without removing other permissions
And an Agent without an identity is rejected without creating one
And missing user authorization and upstream failures are reported explicitly
@@ -66,7 +66,7 @@ describe("published Resource Server contract", () => {
["/tasks/{taskId}/claims", "post"],
] as const;
expect(Object.values(contract.paths).flatMap((pathItem) => Object.keys(pathItem).filter((key) => key !== "parameters"))).toHaveLength(36);
expect(Object.values(contract.paths).flatMap((pathItem) => Object.keys(pathItem).filter((key) => key !== "parameters"))).toHaveLength(37);
for (const [path, method] of additionalOperations) {
expect(contract.paths[path]?.[method], `${method.toUpperCase()} ${path}`).toBeDefined();
}
@@ -142,7 +142,7 @@ describe("published Resource Server contract", () => {
.sort();
expect(commands).toEqual(["wait"]);
for (const path of ["/agents", "/agents/{agentId}", "/machines", "/machines/{machineId}"]) {
for (const path of ["/agents", "/agents/{agentId}", "/agents/{agentId}/permissions", "/machines", "/machines/{machineId}"]) {
expect(toolbox.paths).toHaveProperty(path);
}
expect(toolbox.paths).not.toHaveProperty("/ama/provision");
@@ -159,6 +159,66 @@ function twoRequestBarrier(): () => Promise<void> {
}
describe("Agent and Machine projection HTTP resources", () => {
it.each(["bound", "unbound", "missing", "upstream-failure", "missing-login"])(
"[spec: agents/existing-permissions] configures existing permissions with explicit outcomes: %s",
async (outcome) => {
const reads: string[] = [];
let grants = 0;
if (outcome === "missing-login") {
await fixture.db.prepare("DELETE FROM realmroot_user_grants").run();
}
const delegated = delegatedAgencyFetch(["agents:read"], async (request) => {
expect(request.method).toBe("GET");
expect(request.headers.get("x-enbor-project-id")).toBe(projectId);
reads.push(new URL(request.url).pathname);
if (outcome === "missing") return Response.json({ detail: "Not found" }, { status: 404 });
return Response.json({
metadata: metadata("existing-agent", "Existing"),
spec: {
identity: outcome === "unbound" ? null : { agentId: "realmroot-concurrent", subject: "existing-subject", runtime: "codex" },
},
status: { phase: "active", schedulable: outcome !== "unbound" },
});
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(String(input), init);
if (request.url.endsWith("/permissions")) {
grants += 1;
if (outcome === "upstream-failure") {
return Response.json({ detail: "Connect GitHub before granting permissions" }, { status: 409 });
}
}
return delegated(request);
}),
);
const response = await browserPost("/agents/existing-agent/permissions", {});
const expected = outcome === "bound" ? 204 : outcome === "missing" ? 404 : outcome === "upstream-failure" ? 502 : 409;
expect(response.status, await response.clone().text()).toBe(expected);
if (outcome === "bound") {
expect(await response.text()).toBe("");
const repeated = await browserPost("/agents/existing-agent/permissions", {});
expect(repeated.status, await repeated.clone().text()).toBe(204);
expect(grants).toBe(2);
} else if (outcome === "upstream-failure") {
expect(await response.text()).toContain("Connect GitHub");
expect(grants).toBe(1);
} else {
expect(grants).toBe(0);
}
expect(reads.every((path) => path === "/api/v1/agents/existing-agent")).toBe(true);
},
);
it("[spec: agents/existing-permissions] rejects caller-controlled identity and permission fields", async () => {
const upstream = vi.fn();
vi.stubGlobal("fetch", upstream);
const response = await browserPost("/agents/existing-agent/permissions", { agentId: "someone-else", scopes: ["contents:write"] });
expect(response.status).toBe(422);
expect(upstream).not.toHaveBeenCalled();
});
it("[spec: agents/authoritative-projection] returns bound and unbound Enbor Agents as safe AK resources", async () => {
vi.stubGlobal(
"fetch",
@@ -750,66 +810,89 @@ describe("Agent and Machine projection HTTP resources", () => {
).resolves.toEqual({ count: 2 });
});
it("[spec: agents/authoritative-projection] uses exact DPoP Agent authority and minimal delegated Agency scope", async () => {
await fixture.db.prepare("DROP TABLE realmroot_user_ama_grants").run();
const url = `${resource}/agents`;
const issuer = env.OIDC_ISSUER;
const issuerKeys = await generateKeyPair("ES256", { extractable: true });
const issuerJwk = await exportJWK(issuerKeys.publicKey);
issuerJwk.kid = "projection-issuer";
const dpopKeys = await generateKeyPair("ES256", { extractable: true });
const dpopJwk = await exportJWK(dpopKeys.publicKey);
const token = await new SignJWT({
scope: "agent:read",
client_id: "realmroot-cli",
cnf: { jkt: await calculateJwkThumbprint(dpopJwk) },
act: { iss: issuer, sub: "agent-projection-subject" },
"urn:realmroot:params:oauth:org": ownerId,
})
.setProtectedHeader({ alg: "ES256", kid: issuerJwk.kid, typ: "at+jwt" })
.setIssuer(issuer)
.setAudience(resource)
.setSubject("controller-exact")
.setIssuedAt()
.setExpirationTime("5m")
.sign(issuerKeys.privateKey);
const proof = await new SignJWT({ htu: url, htm: "GET", ath: createHash("sha256").update(token).digest("base64url") })
.setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk: dpopJwk })
.setJti(randomUUID())
.setIssuedAt()
.sign(dpopKeys.privateKey);
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(String(input), init);
if (request.url === `${issuer}/.well-known/openid-configuration`) {
return Response.json({
issuer,
authorization_endpoint: `${issuer}/oauth2/authorize`,
token_endpoint: `${issuer}/oauth2/token`,
jwks_uri: `${issuer}/jwks`,
});
}
if (request.url === `${issuer}/jwks`) return Response.json({ keys: [issuerJwk] });
if (request.url === `${issuer}/oauth2/token`) {
const body = new URLSearchParams(await request.text());
expect(body.get("subject_token")).toBe(token);
expect(body.get("audience")).toBe(`${env.AGENCY_ORIGIN}/api`);
expect(body.get("scope")).toBe("agents:read");
return Response.json({ access_token: "agent-delegated-enbor-token" });
}
expect(request.headers.get("authorization")).toBe("Bearer agent-delegated-enbor-token");
expect(request.headers.get("x-enbor-project-id")).toBe(projectId);
return Response.json({ data: [], pagination: { nextCursor: null, hasMore: false } });
}),
);
it.each(["GET", "POST"])(
"[spec: agents/authoritative-projection] [spec: agents/existing-permissions] uses exact DPoP Agent authority for %s",
async (method) => {
await fixture.db.prepare("DROP TABLE realmroot_user_ama_grants").run();
const url = method === "GET" ? `${resource}/agents` : `${resource}/agents/existing-agent/permissions`;
if (method === "POST") env.OIDC_ISSUER = `${env.OIDC_ISSUER}/existing-permissions`;
const issuer = env.OIDC_ISSUER;
const issuerKeys = await generateKeyPair("ES256", { extractable: true });
const issuerJwk = await exportJWK(issuerKeys.publicKey);
issuerJwk.kid = "projection-issuer";
const dpopKeys = await generateKeyPair("ES256", { extractable: true });
const dpopJwk = await exportJWK(dpopKeys.publicKey);
const token = await new SignJWT({
scope: "agent:read agent:write",
client_id: "realmroot-cli",
cnf: { jkt: await calculateJwkThumbprint(dpopJwk) },
act: { iss: issuer, sub: "agent-projection-subject" },
"urn:realmroot:params:oauth:org": ownerId,
})
.setProtectedHeader({ alg: "ES256", kid: issuerJwk.kid, typ: "at+jwt" })
.setIssuer(issuer)
.setAudience(resource)
.setSubject(method === "GET" ? "controller-exact" : subjectId)
.setIssuedAt()
.setExpirationTime("5m")
.sign(issuerKeys.privateKey);
const proof = await new SignJWT({ htu: url, htm: method, ath: createHash("sha256").update(token).digest("base64url") })
.setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk: dpopJwk })
.setJti(randomUUID())
.setIssuedAt()
.sign(dpopKeys.privateKey);
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(String(input), init);
if (request.url === `${issuer}/.well-known/openid-configuration`) {
return Response.json({
issuer,
authorization_endpoint: `${issuer}/oauth2/authorize`,
token_endpoint: `${issuer}/oauth2/token`,
jwks_uri: `${issuer}/jwks`,
});
}
if (request.url === `${issuer}/jwks`) return Response.json({ keys: [issuerJwk] });
if (request.url === `${issuer}/oauth2/token`) {
const body = new URLSearchParams(await request.text());
if (body.get("audience") === "https://id.realmroot.dev/api") {
expect(body.get("subject_token")).toBe("ak-browser-access-token");
expect(body.get("scope")).toBe("agents:write");
return Response.json({ access_token: "platform-user-token" });
}
expect(body.get("subject_token")).toBe(token);
expect(body.get("audience")).toBe(`${env.AGENCY_ORIGIN}/api`);
expect(body.get("scope")).toBe("agents:read");
return Response.json({ access_token: "agent-delegated-enbor-token" });
}
if (request.url.endsWith("/permissions")) {
expect(request.headers.get("authorization")).toBe("Bearer platform-user-token");
const body = (await request.json()) as { scopes: string[] };
expect(body.scopes).toEqual(DEFAULT_GITHUB_SCOPES);
return Response.json({
items: body.scopes.map((scope) => ({ agentId: "existing-identity", scope, mode: "persistent", status: "active" })),
});
}
expect(request.headers.get("authorization")).toBe("Bearer agent-delegated-enbor-token");
expect(request.headers.get("x-enbor-project-id")).toBe(projectId);
if (method === "POST")
return Response.json({ metadata: metadata("existing-agent", "Existing"), spec: { identity: { agentId: "existing-identity" } } });
return Response.json({ data: [], pagination: { nextCursor: null, hasMore: false } });
}),
);
const response = await api.fetch(
new Request(url, { headers: { authorization: `DPoP ${token}`, dpop: proof, "API-Version": "2026-08-29" } }),
env,
);
expect(response.status, await response.clone().text()).toBe(200);
});
const response = await api.fetch(
new Request(url, {
method,
headers: { authorization: `DPoP ${token}`, dpop: proof, "API-Version": "2026-08-29", "content-type": "application/json" },
...(method === "POST" ? { body: "{}" } : {}),
}),
env,
);
expect(response.status, await response.clone().text()).toBe(method === "POST" ? 204 : 200);
},
);
it("[spec: machines/archive-environment] archives the authoritative Enbor Environment without a local Machine entity", async () => {
await fixture.db.prepare("DROP TABLE machines").run();