mirror of
https://github.com/saltbo/agent-kanban.git
synced 2026-09-19 09:01:47 +08:00
feat(agents): grant default permissions to existing identities (#278)
Co-authored-by: jarvis <jarvis@agents.realmroot.dev>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user