From 4d75374a27f7e75cf03b2ff5538022d69d5fa295 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 12:39:47 -0700 Subject: [PATCH 1/7] test(access-tools): prove Myra run creates agent then scoped grant via natives (CL-7465, red) --- bun.lock | 3 + packages/access-tools/package.json | 3 + packages/access-tools/src/client.test.ts | 149 ++++- packages/access-tools/src/client.ts | 248 ++++---- packages/access-tools/src/routes.ts | 567 +++++++++++++----- packages/access-tools/src/tool.test.ts | 62 +- .../test/myra-orchestrates.test.ts | 445 ++++++++++++++ packages/access-tools/test/routes.test.ts | 217 ++++--- 8 files changed, 1318 insertions(+), 376 deletions(-) create mode 100644 packages/access-tools/test/myra-orchestrates.test.ts diff --git a/bun.lock b/bun.lock index 1be5a2bde6..7931110a8b 100644 --- a/bun.lock +++ b/bun.lock @@ -218,6 +218,9 @@ "hono": "catalog:", }, "devDependencies": { + "@corbits/agent-directory": "workspace:*", + "@corbits/agent-directory-tools": "workspace:*", + "@corbits/tool-registry-publish": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:", }, diff --git a/packages/access-tools/package.json b/packages/access-tools/package.json index 9443f51ff1..452e144de5 100644 --- a/packages/access-tools/package.json +++ b/packages/access-tools/package.json @@ -26,6 +26,9 @@ "hono": "catalog:" }, "devDependencies": { + "@corbits/agent-directory": "workspace:*", + "@corbits/agent-directory-tools": "workspace:*", + "@corbits/tool-registry-publish": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:" } diff --git a/packages/access-tools/src/client.test.ts b/packages/access-tools/src/client.test.ts index 9c286fadf6..e322025fc9 100644 --- a/packages/access-tools/src/client.test.ts +++ b/packages/access-tools/src/client.test.ts @@ -4,6 +4,8 @@ import { AccessForbiddenError, AccessNotFoundError, grantAccess, + listGrants, + listPrincipals, revokeAccess, type AccessToolClientConfig, } from "./client"; @@ -17,13 +19,128 @@ function testConfig(fetchImpl: typeof fetch): AccessToolClientConfig { }; } -test("a 403 from grantAccess surfaces as AccessForbiddenError with the hub's userMessage", async () => { +function nativePrincipal(id: string) { + return { + id, + kind: "workflow", + refId: `${id}@example.test`, + status: "active", + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + }; +} + +function nativeGrant(id: string) { + return { + id, + principalId: "prin_2", + resource: "workflow-run:*", + action: "read", + effect: "allow", + origin: "invoker", + conditions: null, + expiresAt: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + }; +} + +test("listPrincipals reads the native page envelope and follows nextCursor", async () => { + const seen: string[] = []; + const fetchImpl = (async (url: string | URL | Request) => { + const raw = String(url); + seen.push(raw); + if (raw.includes("cursor=prin_2")) { + return Response.json({ + data: [nativePrincipal("prin_3")], + nextCursor: null, + }); + } + return Response.json({ + data: [nativePrincipal("prin_1"), nativePrincipal("prin_2")], + nextCursor: "prin_2", + }); + }) as unknown as typeof fetch; + + const principals = await listPrincipals(testConfig(fetchImpl)); + expect(principals.map((p) => p.id)).toEqual(["prin_1", "prin_2", "prin_3"]); + expect(principals[0]).toMatchObject({ + kind: "workflow", + refId: "prin_1@example.test", + status: "active", + }); + expect(seen).toHaveLength(2); + expect(seen[1]).toContain("cursor=prin_2"); +}); + +test("listGrants passes filters through and reads the native page envelope", async () => { + let seenUrl = ""; + const fetchImpl = (async (url: string | URL | Request) => { + seenUrl = String(url); + return Response.json({ data: [nativeGrant("grant_1")], nextCursor: null }); + }) as unknown as typeof fetch; + + const grants = await listGrants(testConfig(fetchImpl), { + principalId: "prin_2", + resource: "workflow-run:*", + }); + expect(seenUrl).toContain("/grants?principalId=prin_2&resource=workflow-run%3A*"); + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + id: "grant_1", + principalId: "prin_2", + resource: "workflow-run:*", + action: "read", + effect: "allow", + }); +}); + +test("grantAccess posts one native single-action body per action and parses single GrantResponse objects", async () => { + const posted: unknown[] = []; + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + expect(String(url)).toBe( + "https://hub.example.com/api/workflow-access/grants", + ); + expect(init?.method).toBe("POST"); + const body = JSON.parse(String(init?.body)) as Record; + posted.push(body); + return new Response( + JSON.stringify({ ...nativeGrant(`grant_${posted.length}`), action: body["action"] }), + { status: 201 }, + ); + }) as unknown as typeof fetch; + + const grants = await grantAccess(testConfig(fetchImpl), { + principalId: "prin_2", + resource: "workflow-run:*", + actions: ["read", "write"], + }); + expect(posted).toEqual([ + { + principalId: "prin_2", + resource: "workflow-run:*", + action: "read", + effect: "allow", + origin: "invoker", + }, + { + principalId: "prin_2", + resource: "workflow-run:*", + action: "write", + effect: "allow", + origin: "invoker", + }, + ]); + expect(grants.map((g) => g.action)).toEqual(["read", "write"]); +}); + +test("a 403 with the native error envelope surfaces as AccessForbiddenError with the hub's message", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ error: { code: "forbidden", - userMessage: "You do not have permission to perform this action", + message: "Not a member of this tenant", }, }), { status: 403 }, @@ -36,13 +153,20 @@ test("a 403 from grantAccess surfaces as AccessForbiddenError with the hub's use actions: ["read"], }), ).rejects.toThrow(AccessForbiddenError); + await expect( + grantAccess(testConfig(fetchImpl), { + principalId: "prin_2", + resource: "workflow-run:*", + actions: ["read"], + }), + ).rejects.toThrow("Not a member of this tenant"); }); -test("a 404 from revokeAccess surfaces as AccessNotFoundError", async () => { +test("a 404 with the native error envelope from revokeAccess surfaces as AccessNotFoundError", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ - error: { code: "not_found", userMessage: "No grant here" }, + error: { code: "not_found", message: "Grant not found" }, }), { status: 404 }, )) as unknown as typeof fetch; @@ -51,3 +175,20 @@ test("a 404 from revokeAccess surfaces as AccessNotFoundError", async () => { revokeAccess(testConfig(fetchImpl), "grant_missing"), ).rejects.toThrow(AccessNotFoundError); }); + +test("revokeAccess treats a native 204 with no body as success", async () => { + const fetchImpl = (async () => new Response(null, { status: 204 })) as unknown as typeof fetch; + + await revokeAccess(testConfig(fetchImpl), "grant_1"); +}); + +test("a grant body without principalId is a shape error, never a null-linked grant", async () => { + const { principalId: _link, ...linkless } = nativeGrant("grant_1"); + void _link; + const fetchImpl = (async () => + Response.json({ data: [linkless], nextCursor: null })) as unknown as typeof fetch; + + await expect(listGrants(testConfig(fetchImpl), {})).rejects.toThrow( + "did not match the expected shape", + ); +}); diff --git a/packages/access-tools/src/client.ts b/packages/access-tools/src/client.ts index ac5e043637..172a2ffa6a 100644 --- a/packages/access-tools/src/client.ts +++ b/packages/access-tools/src/client.ts @@ -1,9 +1,18 @@ -// A minimal client for `@corbits/access-tools`' own workflow-run- -// authenticated surface (`./routes.ts`, mounted at -// `/api/workflow-access`): same auth-header shape, same error-handling, +// Client for the workflow-run-authenticated counterpart of the native +// Interchange tenant routes (`@intx/hub-api`: `createPrincipalRoutes`, +// `createGrantRoutes`) — see `./routes.ts`, mounted at +// `/api/workflow-access`. Same auth-header shape, same error-handling, // same arktype-response-parsing pattern as every other tool bundle's // `client.ts` in this codebase (`@corbits/agent-directory-tools`, -// `@corbits/capability-tools`). +// `@corbits/capability-tools`); only the base path differs. +// +// Every request and response speaks the native Interchange contract: +// `{data, nextCursor}` pages for `GET /principals` and `GET /grants`, +// one single-action `POST /grants` body per grant (returning the single +// `GrantResponse` object), `DELETE /grants/:grantId` returning `204`, and +// the canonical `{error: {code, message}}` envelope on failures +// (`AccessForbiddenError` / `AccessNotFoundError` surface the hub's +// `message` verbatim so Myra can act on it). import { type } from "arktype"; export interface AccessToolClientConfig { @@ -42,130 +51,132 @@ function authHeaders(config: AccessToolClientConfig): Record { }; } -/** Pulls `error.userMessage` out of the canonical hub envelope - * (`{error: {code, userMessage, refId}}`), if `body` matches that shape. */ +/** Pulls `error.message` out of the canonical hub envelope + * (`{error: {code, message}}`, per `errorResponse` in + * `@intx/hub-common/errors`), if `body` matches that shape. */ function errorMessageFrom(body: unknown): string | undefined { if (body === null || typeof body !== "object" || !("error" in body)) { return undefined; } const error = (body as { error: unknown }).error; - if ( - error === null || - typeof error !== "object" || - !("userMessage" in error) - ) { + if (error === null || typeof error !== "object" || !("message" in error)) { return undefined; } - const userMessage = (error as { userMessage: unknown }).userMessage; - return typeof userMessage === "string" ? userMessage : undefined; + const message = (error as { message: unknown }).message; + return typeof message === "string" ? message : undefined; } -async function readErrorMessage( - response: Response, - fallback: string, -): Promise { - const body: unknown = await response.json().catch(() => undefined); - return errorMessageFrom(body) ?? fallback; +export class AccessForbiddenError extends Error { + override readonly name = "AccessForbiddenError"; } -/** Thrown when the access route rejects the request as forbidden — - * the caller's own principal has no `principal:*`/`grant:*` grant of - * its own — distinct from a bare transport/HTTP failure, so a caller - * can report honestly that a human must grant it access first. */ -export class AccessForbiddenError extends Error {} - -/** Thrown for a not-found principal/grant id, distinct from a bare - * transport/HTTP failure. */ -export class AccessNotFoundError extends Error {} +export class AccessNotFoundError extends Error { + override readonly name = "AccessNotFoundError"; +} -async function doRequest( - config: AccessToolClientConfig, - path: string, - init: RequestInit, - failureLabel: string, -): Promise { - const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch(`${config.hubAccessUrl}${path}`, { - ...init, - headers: { ...authHeaders(config), ...(init.headers ?? {}) }, - }); +async function throwForStatus( + operation: string, + response: Response, +): Promise { + const body: unknown = await response.json().catch(() => null); + const message = errorMessageFrom(body) ?? response.statusText; if (response.status === 403) { - throw new AccessForbiddenError( - await readErrorMessage(response, `${failureLabel}: forbidden`), - ); + throw new AccessForbiddenError(`${operation} failed: ${message}`); } if (response.status === 404) { - throw new AccessNotFoundError( - await readErrorMessage(response, `${failureLabel}: not found`), - ); + throw new AccessNotFoundError(`${operation} failed: ${message}`); } - if (!response.ok) { - throw new Error( - `${failureLabel}: ${response.status} ${response.statusText}`, - ); - } - return response; + throw new Error(`${operation} failed: ${message}`); } const ListedPrincipalsResponse = type({ - principals: type({ + data: type({ id: "string", - kind: "'user'|'agent'|'workflow'", + kind: "'user' | 'agent' | 'workflow'", refId: "string", - status: "'active'|'suspended'|'invited'|'deactivated'", + status: "'active' | 'suspended' | 'invited' | 'deactivated'", }).array(), + "nextCursor?": "string | null", }); -export async function listPrincipals( - config: AccessToolClientConfig, -): Promise { - const response = await doRequest( - config, - "/principals", - { headers: authHeaders(config) }, - "Listing principals failed", - ); - const body: unknown = await response.json(); - const parsed = ListedPrincipalsResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `List-principals response did not match the expected shape: ${parsed.summary}`, - ); - } - return parsed.principals; -} - const ListedGrantsResponse = type({ - grants: type({ + data: type({ id: "string", - principalId: "string | null", + "principalId?": "string | null", resource: "string", action: "string", - effect: "'allow'|'deny'|'ask'", + effect: "'allow' | 'deny' | 'ask'", }).array(), + "nextCursor?": "string | null", +}); + +const CreatedGrantResponse = type({ + id: "string", + "principalId?": "string | null", + resource: "string", + action: "string", + effect: "'allow' | 'deny' | 'ask'", }); +/** Lists every principal in the run's tenant, following the native + * `nextCursor` pages to the end — Myra needs the full list to find the + * agent she just created before granting it access. */ +export async function listPrincipals( + config: AccessToolClientConfig, +): Promise { + const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); + const principals: ListedPrincipal[] = []; + let cursor: string | null | undefined; + do { + const url = + cursor === undefined || cursor === null + ? `${config.hubAccessUrl}/principals` + : `${config.hubAccessUrl}/principals?cursor=${encodeURIComponent(cursor)}`; + const response = await fetchImpl(url, { + headers: { ...authHeaders(config) }, + }); + if (!response.ok) { + await throwForStatus("Listing principals", response); + } + const body: unknown = await response.json(); + const parsed = ListedPrincipalsResponse(body); + if (parsed instanceof type.errors) { + throw new Error( + `Principals response did not match the expected shape: ${parsed.summary}`, + ); + } + principals.push(...parsed.data); + cursor = parsed.nextCursor ?? null; + } while (cursor !== null); + return principals; +} + export interface ListGrantsFilter { readonly principalId?: string; readonly resource?: string; + readonly action?: string; } +/** Lists grants in the run's tenant, passing the given filters through as + * the native `GET /grants` query params (`principalId`, `resource`, + * `action`). */ export async function listGrants( config: AccessToolClientConfig, filter?: ListGrantsFilter, -): Promise { +): Promise { + const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); const params = new URLSearchParams(); - if (filter?.principalId !== undefined) { + if (filter?.principalId !== undefined) params.set("principalId", filter.principalId); - } if (filter?.resource !== undefined) params.set("resource", filter.resource); - const query = params.toString(); - const response = await doRequest( - config, - `/grants${query.length > 0 ? `?${query}` : ""}`, - { headers: authHeaders(config) }, - "Listing grants failed", - ); + if (filter?.action !== undefined) params.set("action", filter.action); + const query = params.size === 0 ? "" : `?${params.toString()}`; + const response = await fetchImpl(`${config.hubAccessUrl}/grants${query}`, { + headers: { ...authHeaders(config) }, + }); + if (!response.ok) { + await throwForStatus("Listing grants", response); + } const body: unknown = await response.json(); const parsed = ListedGrantsResponse(body); if (parsed instanceof type.errors) { @@ -173,41 +184,58 @@ export async function listGrants( `List-grants response did not match the expected shape: ${parsed.summary}`, ); } - return parsed.grants; + return parsed.data; } +/** Grants access by posting one native single-action `POST /grants` body + * per requested action — `effect: "allow"`, `origin: "invoker"` — and + * parsing each single `GrantResponse` object the hub returns. */ export async function grantAccess( config: AccessToolClientConfig, - input: GrantAccessRequest, -): Promise { - const response = await doRequest( - config, - "/grants", - { + request: GrantAccessRequest, +): Promise { + const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); + const created: ListedGrant[] = []; + for (const action of request.actions) { + const response = await fetchImpl(`${config.hubAccessUrl}/grants`, { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), - }, - "Granting access failed", - ); - const body: unknown = await response.json(); - const parsed = ListedGrantsResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `Grant-access response did not match the expected shape: ${parsed.summary}`, - ); + headers: { ...authHeaders(config), "content-type": "application/json" }, + body: JSON.stringify({ + principalId: request.principalId, + resource: request.resource, + action, + effect: "allow", + origin: "invoker", + }), + }); + if (!response.ok) { + await throwForStatus("Granting access", response); + } + const body: unknown = await response.json(); + const parsed = CreatedGrantResponse(body); + if (parsed instanceof type.errors) { + throw new Error( + `Grant-access response did not match the expected shape: ${parsed.summary}`, + ); + } + created.push(parsed); } - return parsed.grants; + return created; } +/** Revokes the grant with the given id. The native + * `DELETE /grants/:grantId` returns `204` with no body, so success is the + * absence of a throw. */ export async function revokeAccess( config: AccessToolClientConfig, grantId: string, ): Promise { - await doRequest( - config, - `/grants/${encodeURIComponent(grantId)}`, - { method: "DELETE" }, - "Revoking access failed", + const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); + const response = await fetchImpl( + `${config.hubAccessUrl}/grants/${encodeURIComponent(grantId)}`, + { method: "DELETE", headers: { ...authHeaders(config) } }, ); + if (!response.ok) { + await throwForStatus("Revoking access", response); + } } diff --git a/packages/access-tools/src/routes.ts b/packages/access-tools/src/routes.ts index 4022cac8b9..1b0f41410b 100644 --- a/packages/access-tools/src/routes.ts +++ b/packages/access-tools/src/routes.ts @@ -1,44 +1,55 @@ -// Workflow-run-authenticated principal + grant surface: the execution -// half of `@corbits/access-tools`' `list_principals`, `list_grants`, -// `grant_access`, and `revoke_access` tools. A workflow child has no -// browser session, only its sidecar bearer token and its own run -// address, so it authenticates through a `WorkflowRunAuthenticator` -// rather than the tenant-session pipeline `@intx/hub-api`'s own -// `/api/tenants/:tenantId/principals` and `/grants` routes use — the -// same reasoning `@corbits/agent-directory`'s workflow-capability-routes -// and `@corbits/chat`'s workflow-participant-routes give for their own -// bearer-authenticated mirrors. -// -// Mounted OUTSIDE the tenant prefix for that reason, at +// The workflow-run-authenticated counterpart of Interchange's native +// tenant principal/grant routes (`@intx/hub-api`'s `createPrincipalRoutes` +// / `createGrantRoutes`, served at `/api/tenants/:tenantId/principals` and +// `/grants`) — the server half of `@corbits/access-tools`' +// `list_principals`, `list_grants`, `grant_access`, and `revoke_access` +// tools. A workflow child has no browser session, only its sidecar bearer +// token and its own run address, so it authenticates through a +// `WorkflowRunAuthenticator` rather than the tenant-session pipeline — +// the same reasoning `@corbits/agent-directory`'s +// workflow-capability-routes and `@corbits/chat`'s +// workflow-participant-routes give for their own bearer-authenticated +// mirrors. Mounted OUTSIDE the tenant prefix for that reason, at // `/api/workflow-access`. Every write is scoped to the authenticated // run's own tenant/principal alone — identity never rides in a request // body. // -// This is a thin wrapper, not a reimplementation: authorization for -// every route below runs through `@intx/hub-api`'s own -// `createRequireGrant` against the SAME grant store and condition -// registry the tenant-session routes gate on, resolved for the caller's -// real principal row (fetched once here, exactly like -// `@intx/hub-api`'s own `createWorkflowRunDeployAuth` resolves the -// deploy route's bearer mirror). A principal the tenant hasn't granted -// `principal:*`/`grant:*` gets a real 403 from the real grant store, not -// a bundle-local approximation. -import { and, eq } from "drizzle-orm"; +// Same contract as the native routes: `{data, nextCursor}` cursor pages +// for the two `GET` lists (same cursor algorithm, so cursors stay +// interchangeable), the native `CreateGrant` single-action body for +// `POST /grants` returning the single native `GrantResponse` object, +// `204` deletes, and the canonical `{error: {code, message}}` envelope on +// every failure. The ONLY deliberate deltas from native are the run-bearer +// auth above and the delegation ceiling below: a caller may only grant +// (or revoke) authority it already holds itself — otherwise +// `grant:*`/`create` alone would let any principal escalate past its own +// ceiling. That check reuses `@intx/authz`'s own `authorize` against the +// SAME grant store `requireGrant` checks against, per requested pair. +// +// Authorization for every route runs through `@intx/hub-api`'s own +// `createRequireGrant` against that same store and condition registry, +// resolved for the caller's real principal row (fetched once here, +// exactly like `@intx/hub-api`'s own `createWorkflowRunDeployAuth` +// resolves the deploy route's bearer mirror). A principal the tenant +// hasn't granted `principal:*`/`grant:*` gets a real 403 from the real +// grant store, not a bundle-local approximation. +import { and, desc, eq, lt, ne, or, sql } from "drizzle-orm"; import { Hono } from "hono"; import { type } from "arktype"; -import type { DB } from "@intx/db"; -import { grant, principal, tenant } from "@intx/db/schema"; +import { parseGrantRow, type DB } from "@intx/db"; +import { grant, principal, principalRole, role, tenant } from "@intx/db/schema"; import { generateId } from "@intx/hub-common"; import { createRequireGrant, idResource, + resolveWorkflowPrincipalLabels, type RequireGrant, type TenantEnv, } from "@intx/hub-api"; import { authorize } from "@intx/authz"; +import { base64urlDecode, base64urlEncode, CreateGrant } from "@intx/types"; import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; -import { makeErrorEnvelope } from "@corbits/error-sink"; export type WorkflowAccessRunScope = { readonly tenantId: string; @@ -52,58 +63,76 @@ export type WorkflowRunAuthenticator = { ): Promise; }; -export interface ListedPrincipal { - readonly id: string; - readonly kind: "user" | "agent" | "workflow"; - readonly refId: string; - readonly status: "active" | "suspended" | "invited" | "deactivated"; -} +export type CreateWorkflowAccessRoutesDeps = { + db: DB["db"]; + authenticator: WorkflowRunAuthenticator; + grantStore: GrantStore; + conditionRegistry: ConditionRegistry; +}; -export interface ListedGrant { - readonly id: string; - readonly principalId: string | null; - readonly resource: string; - readonly action: string; - readonly effect: "allow" | "deny" | "ask"; +/** Cursor paging identical to the native tenant lists + * (`vendor/intx/hub-api/src/pagination.ts`, reimplemented here because + * that module is not part of `@intx/hub-api`'s published surface): + * keyset on `(createdAt, id)` descending, opaque base64url cursor, default + * limit 50, max 100. Cursors minted here decode on the native routes and + * vice versa. */ +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +const CursorData = type({ + t: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + id: "string", +}); + +function parsePageParams(query: { + cursor: string | undefined; + limit: string | undefined; +}): { limit: number; cursor: { t: string; id: string } | null } { + const raw = query.limit !== undefined ? Number(query.limit) : DEFAULT_LIMIT; + const limit = Number.isNaN(raw) + ? DEFAULT_LIMIT + : Math.min(Math.max(1, raw), MAX_LIMIT); + let cursor: { t: string; id: string } | null = null; + if (query.cursor !== undefined) { + try { + const data = CursorData( + JSON.parse(new TextDecoder().decode(base64urlDecode(query.cursor))), + ); + cursor = data instanceof type.errors ? null : data; + } catch { + cursor = null; + } + } + return { limit, cursor }; } -function formatPrincipal(row: typeof principal.$inferSelect): ListedPrincipal { - return { - id: row.id, - kind: row.kind, - refId: row.refId, - status: row.status, - }; +function encodeCursor(createdAt: Date, id: string): string { + return base64urlEncode( + new TextEncoder().encode( + JSON.stringify({ t: createdAt.toISOString(), id }), + ), + ); } -function formatGrant(row: typeof grant.$inferSelect): ListedGrant { +function pageResponse( + items: T[], + rows: { createdAt: Date; id: string }[], + limit: number, +): { data: T[]; nextCursor: string | null } { + const hasMore = items.length === limit; + const lastRow = hasMore ? rows[rows.length - 1] : undefined; return { - id: row.id, - principalId: row.principalId ?? null, - resource: row.resource, - action: row.action, - effect: row.effect, + data: items, + nextCursor: + lastRow !== undefined ? encodeCursor(lastRow.createdAt, lastRow.id) : null, }; } -const GrantAccessInput = type({ - principalId: "string > 0", - resource: "string > 0", - actions: type("string > 0").array().atLeastLength(1), -}); - -export type CreateWorkflowAccessRoutesDeps = { - db: DB["db"]; - authenticator: WorkflowRunAuthenticator; - grantStore: GrantStore; - conditionRegistry: ConditionRegistry; -}; - // A caller may only grant (or revoke) authority it already holds itself — // otherwise `grant:*`/`create` alone would let any principal escalate past // its own ceiling. Reuses `@intx/authz`'s own `authorize` against the same // grant store `requireGrant` checks against, per requested pair. -async function firstPairOutsideCeiling( +async function pairOutsideCeiling( deps: Pick< CreateWorkflowAccessRoutesDeps, "grantStore" | "conditionRegistry" @@ -111,22 +140,113 @@ async function firstPairOutsideCeiling( callerPrincipalId: string, tenantId: string, resource: string, - actions: readonly string[], -): Promise { - for (const action of actions) { - const result = await authorize( - deps.grantStore, - callerPrincipalId, - tenantId, - resource, - action, - deps.conditionRegistry, - ); - if (result.effect !== "allow") { - return action; + action: string, +): Promise { + const result = await authorize( + deps.grantStore, + callerPrincipalId, + tenantId, + resource, + action, + deps.conditionRegistry, + ); + return result.effect !== "allow"; +} + +function nativeError(code: string, message: string) { + return { error: { code, message } }; +} + +type ResolvedNames = { + roleNames: Map; + principalNames: Map; +}; + +async function resolveGrantNames( + db: DB["db"], + grants: (typeof grant.$inferSelect)[], +): Promise { + const roleIds = [ + ...new Set( + grants.map((g) => g.roleId).filter((id): id is string => id !== null), + ), + ]; + const principalIds = [ + ...new Set( + grants + .map((g) => g.principalId) + .filter((id): id is string => id !== null), + ), + ]; + + const roleNames = new Map(); + if (roleIds.length > 0) { + const roles = await db.query.role.findMany({ + where: (r, { inArray }) => inArray(r.id, roleIds), + }); + for (const r of roles) { + roleNames.set(r.id, r.name); } } - return null; + + const principalNames = new Map(); + if (principalIds.length > 0) { + const principals = await db.query.principal.findMany({ + where: (p, { inArray }) => inArray(p.id, principalIds), + }); + const userRefIds = principals + .filter((p) => p.kind === "user") + .map((p) => p.refId); + const workflowRefIds = principals + .filter((p) => p.kind === "workflow") + .map((p) => p.refId); + + const refToName = new Map(); + if (userRefIds.length > 0) { + const users = await db.query.user.findMany({ + where: (u, { inArray }) => inArray(u.id, userRefIds), + }); + for (const u of users) { + refToName.set(u.id, u.name); + } + } + if (workflowRefIds.length > 0) { + const wfNames = await resolveWorkflowPrincipalLabels(db, workflowRefIds); + for (const [refId, name] of wfNames) { + refToName.set(refId, name); + } + } + for (const p of principals) { + const name = refToName.get(p.refId); + if (name) principalNames.set(p.id, name); + } + } + + return { roleNames, principalNames }; +} + +/** The native `GrantResponse` item shape for one grant row. */ +function formatGrant(row: typeof grant.$inferSelect, names?: ResolvedNames) { + const parsed = parseGrantRow(row); + const ts = (d: Date): string => d.toISOString(); + return { + id: parsed.id, + tenantId: parsed.tenantId, + roleId: parsed.roleId ?? null, + roleName: (parsed.roleId && names?.roleNames.get(parsed.roleId)) ?? null, + principalId: parsed.principalId ?? null, + principalName: + (parsed.principalId && names?.principalNames.get(parsed.principalId)) ?? + null, + resource: parsed.resource, + action: parsed.action, + effect: parsed.effect, + conditions: parsed.conditions, + origin: parsed.origin, + expiresAt: parsed.expiresAt ? ts(parsed.expiresAt) : null, + createdAt: ts(parsed.createdAt), + updatedAt: ts(parsed.updatedAt), + }; } export function createWorkflowAccessRoutes( @@ -147,11 +267,10 @@ export function createWorkflowAccessRoutes( const scope = await deps.authenticator.resolve(token, address); if (scope === null) { return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: - "Missing or unrecognized sidecar bearer token / run address", - }), + nativeError( + "unauthorized", + "Missing or unrecognized sidecar bearer token / run address", + ), 401, ); } @@ -176,11 +295,10 @@ export function createWorkflowAccessRoutes( principalRow.status !== "active" ) { return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: - "Missing or unrecognized sidecar bearer token / run address", - }), + nativeError( + "unauthorized", + "Missing or unrecognized sidecar bearer token / run address", + ), 401, ); } @@ -191,98 +309,233 @@ export function createWorkflowAccessRoutes( app.get("/principals", requireGrant("principal:*", "read"), async (c) => { const tenantCtx = c.get("tenant"); + const kind = c.req.query("kind"); + const status = c.req.query("status"); + const { limit, cursor } = parsePageParams({ + cursor: c.req.query("cursor"), + limit: c.req.query("limit"), + }); + + const conditions = [eq(principal.tenantId, tenantCtx.id)]; + if (kind === "user" || kind === "agent" || kind === "workflow") { + conditions.push(eq(principal.kind, kind)); + } + if ( + status === "active" || + status === "suspended" || + status === "invited" || + status === "deactivated" + ) { + conditions.push(eq(principal.status, status)); + } else { + // Exclude deactivated principals by default, like the native list. + conditions.push(ne(principal.status, "deactivated")); + } + if (cursor) { + conditions.push( + or( + lt(principal.createdAt, new Date(cursor.t)), + and( + eq(principal.createdAt, new Date(cursor.t)), + lt(principal.id, cursor.id), + ), + ) ?? sql`false`, + ); + } + const rows = await deps.db.query.principal.findMany({ - where: eq(principal.tenantId, tenantCtx.id), + where: and(...conditions), + orderBy: [desc(principal.createdAt), desc(principal.id)], + limit, }); - return c.json({ principals: rows.map(formatPrincipal) }); + + const assignments = + rows.length > 0 + ? await deps.db.query.principalRole.findMany({ + where: (pr, { inArray }) => + inArray( + pr.principalId, + rows.map((p) => p.id), + ), + }) + : []; + const assignedRoleIds = [...new Set(assignments.map((a) => a.roleId))]; + const assignedRoles = + assignedRoleIds.length > 0 + ? await deps.db.query.role.findMany({ + where: (r, { inArray }) => inArray(r.id, assignedRoleIds), + }) + : []; + const roleMap = new Map(assignedRoles.map((r) => [r.id, r])); + const rolesByPrincipal = new Map(); + for (const a of assignments) { + const r = roleMap.get(a.roleId); + if (!r) continue; + const list = rolesByPrincipal.get(a.principalId) ?? []; + list.push({ id: r.id, name: r.name }); + rolesByPrincipal.set(a.principalId, list); + } + + const userRefIds = rows.filter((p) => p.kind === "user").map((p) => p.refId); + const workflowRefIds = rows + .filter((p) => p.kind === "workflow") + .map((p) => p.refId); + const identities = new Map(); + if (userRefIds.length > 0) { + const users = await deps.db.query.user.findMany({ + where: (u, { inArray }) => inArray(u.id, userRefIds), + }); + for (const u of users) { + identities.set(u.id, { displayName: u.name, email: u.email }); + } + } + if (workflowRefIds.length > 0) { + const wfNames = await resolveWorkflowPrincipalLabels( + deps.db, + workflowRefIds, + ); + for (const [refId, displayName] of wfNames) { + identities.set(refId, { displayName }); + } + } + + const items = rows.map((p) => { + const identity = identities.get(p.refId); + return { + id: p.id, + tenantId: p.tenantId, + kind: p.kind, + refId: p.refId, + displayName: identity?.displayName ?? p.refId, + ...(identity?.email ? { email: identity.email } : {}), + status: p.status, + roles: rolesByPrincipal.get(p.id) ?? [], + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + }; + }); + + return c.json(pageResponse(items, rows, limit)); }); app.get("/grants", requireGrant("grant:*", "read"), async (c) => { const tenantCtx = c.get("tenant"); const principalId = c.req.query("principalId"); + const roleId = c.req.query("roleId"); const resource = c.req.query("resource"); + const effect = c.req.query("effect"); + const { limit, cursor } = parsePageParams({ + cursor: c.req.query("cursor"), + limit: c.req.query("limit"), + }); + const conditions = [eq(grant.tenantId, tenantCtx.id)]; if (principalId) conditions.push(eq(grant.principalId, principalId)); + if (roleId) conditions.push(eq(grant.roleId, roleId)); if (resource) conditions.push(eq(grant.resource, resource)); + if (effect === "allow" || effect === "deny" || effect === "ask") { + conditions.push(eq(grant.effect, effect)); + } + if (cursor) { + conditions.push( + or( + lt(grant.createdAt, new Date(cursor.t)), + and( + eq(grant.createdAt, new Date(cursor.t)), + lt(grant.id, cursor.id), + ), + ) ?? sql`false`, + ); + } + const rows = await deps.db.query.grant.findMany({ where: and(...conditions), + orderBy: [desc(grant.createdAt), desc(grant.id)], + limit, }); - return c.json({ grants: rows.map(formatGrant) }); + + const names = await resolveGrantNames(deps.db, rows); + return c.json( + pageResponse( + rows.map((g) => formatGrant(g, names)), + rows, + limit, + ), + ); }); app.post("/grants", requireGrant("grant:*", "create"), async (c) => { const tenantCtx = c.get("tenant"); const callerPrincipal = c.get("principal"); - const body = GrantAccessInput(await c.req.json().catch(() => undefined)); + // The native `CreateGrant` body: exactly one target, one action. + const body = CreateGrant(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid grant request: ${body.summary}`, - }), - 400, - ); + return c.json(nativeError("bad_request", body.summary), 400); } - const disallowedAction = await firstPairOutsideCeiling( + const outside = await pairOutsideCeiling( deps, callerPrincipal.id, tenantCtx.id, body.resource, - body.actions, + body.action, ); - if (disallowedAction !== null) { + if (outside) { return c.json( - makeErrorEnvelope({ - code: "forbidden", - userMessage: `Cannot grant "${body.resource}" "${disallowedAction}": exceeds the caller's own authority`, - }), + nativeError( + "forbidden", + `Cannot grant "${body.resource}" "${body.action}": exceeds the caller's own authority`, + ), 403, ); } - const target = await deps.db.query.principal.findFirst({ - where: and( - eq(principal.id, body.principalId), - eq(principal.tenantId, tenantCtx.id), - ), - }); - if (target === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: `No principal "${body.principalId}" in this tenant`, - }), - 404, - ); + if (body.principalId != null) { + const target = await deps.db.query.principal.findFirst({ + where: and( + eq(principal.id, body.principalId), + eq(principal.tenantId, tenantCtx.id), + ), + }); + if (target === undefined) { + return c.json( + nativeError( + "not_found", + `No principal "${body.principalId}" in this tenant`, + ), + 404, + ); + } } const now = new Date(); - const rows = await deps.db + const [row] = await deps.db .insert(grant) - .values( - body.actions.map((action) => ({ - id: generateId("grant"), - tenantId: tenantCtx.id, - roleId: null, - principalId: body.principalId, - resource: body.resource, - action, - effect: "allow" as const, - conditions: null, - // The invoking human's authority delegated this grant: the - // tool declares `approval: "ask"`, so a human already - // approved this exact principal/resource/actions triple - // before this route ever ran. - origin: "invoker" as const, - expiresAt: null, - createdAt: now, - updatedAt: now, - })), - ) + .values({ + id: generateId("grant"), + tenantId: tenantCtx.id, + roleId: body.roleId ?? null, + principalId: body.principalId ?? null, + resource: body.resource, + action: body.action, + effect: body.effect, + conditions: body.conditions ?? null, + // The invoking human's authority delegated this grant: the + // tool declares `approval: "ask"`, so a human already + // approved this exact principal/resource/action triple + // before this route ever ran. + origin: body.origin, + expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, + createdAt: now, + updatedAt: now, + }) .returning(); + if (row === undefined) { + return c.json(nativeError("not_found", "Grant not found"), 404); + } - return c.json({ grants: rows.map(formatGrant) }, 201); + const names = await resolveGrantNames(deps.db, [row]); + return c.json(formatGrant(row, names), 201); }); app.delete( @@ -296,28 +549,22 @@ export function createWorkflowAccessRoutes( where: and(eq(grant.id, grantId), eq(grant.tenantId, tenantCtx.id)), }); if (target === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: `No grant "${grantId}" in this tenant`, - }), - 404, - ); + return c.json(nativeError("not_found", "Grant not found"), 404); } - const disallowedAction = await firstPairOutsideCeiling( + const outside = await pairOutsideCeiling( deps, callerPrincipal.id, tenantCtx.id, target.resource, - [target.action], + target.action, ); - if (disallowedAction !== null) { + if (outside) { return c.json( - makeErrorEnvelope({ - code: "forbidden", - userMessage: `Cannot revoke "${target.resource}" "${target.action}": exceeds the caller's own authority`, - }), + nativeError( + "forbidden", + `Cannot revoke "${target.resource}" "${target.action}": exceeds the caller's own authority`, + ), 403, ); } @@ -327,13 +574,7 @@ export function createWorkflowAccessRoutes( .where(and(eq(grant.id, grantId), eq(grant.tenantId, tenantCtx.id))) .returning(); if (deleted.length === 0) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: `No grant "${grantId}" in this tenant`, - }), - 404, - ); + return c.json(nativeError("not_found", "Grant not found"), 404); } return c.body(null, 204); }, diff --git a/packages/access-tools/src/tool.test.ts b/packages/access-tools/src/tool.test.ts index 5ef6de451d..a13a9df42f 100644 --- a/packages/access-tools/src/tool.test.ts +++ b/packages/access-tools/src/tool.test.ts @@ -38,14 +38,14 @@ test("list_grants rejects invalid input before ever calling fetch", async () => } }); -test("a 403 from grant_access surfaces as a clear tool error", async () => { +test("a 403 with the native error envelope from grant_access surfaces as a clear tool error", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async () => new Response( JSON.stringify({ error: { code: "forbidden", - userMessage: "You do not have permission to perform this action", + message: "Myra can only grant room:read — she herself holds no room:write here", }, }), { status: 403 }, @@ -55,14 +55,14 @@ test("a 403 from grant_access surfaces as a clear tool error", async () => { const result = await bundle.run( callFor(GRANT_ACCESS_TOOL, { principalId: "prin_2", - resource: "workflow-run:*", - actions: ["read"], + resource: "room:*", + actions: ["write"], }), new AbortController().signal, ); expect(result.isError).toBe(true); expect(result.content).toBe( - "You do not have permission to perform this action", + "Granting access failed: Myra can only grant room:read — she herself holds no room:write here", ); } finally { globalThis.fetch = originalFetch; @@ -71,25 +71,48 @@ test("a 403 from grant_access surfaces as a clear tool error", async () => { test("a successful grant_access round-trips into list_grants", async () => { const originalFetch = globalThis.fetch; - const grantRow = { - id: "grant_1", - principalId: "prin_2", - resource: "workflow-run:*", - action: "read", - effect: "allow", - }; - let grantCreated = false; + function nativeRow(id: string, action: string) { + return { + id, + principalId: "prin_2", + resource: "workflow-run:*", + action, + effect: "allow", + origin: "invoker", + conditions: null, + expiresAt: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + }; + } + const posted: unknown[] = []; globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { const path = new URL(String(url)).pathname; if (init?.method === "POST" && path.endsWith("/grants")) { - grantCreated = true; - return new Response(JSON.stringify({ grants: [grantRow] }), { - status: 201, - }); + const body = JSON.parse(String(init?.body)) as { action: string }; + posted.push(body); + // Native `POST /grants` creates exactly one single-action grant and + // returns the single `GrantResponse` object. + return new Response( + JSON.stringify(nativeRow(`grant_${posted.length}`, body.action)), + { status: 201 }, + ); } if (path.endsWith("/grants")) { + // Native `GET /grants` returns the `{data, nextCursor}` page envelope. return new Response( - JSON.stringify({ grants: grantCreated ? [grantRow] : [] }), + JSON.stringify({ + data: + posted.length === 0 + ? [] + : posted.map((body, index) => + nativeRow( + `grant_${index + 1}`, + (body as { action: string }).action, + ), + ), + nextCursor: null, + }), ); } throw new Error(`unexpected request: ${String(url)}`); @@ -101,11 +124,12 @@ test("a successful grant_access round-trips into list_grants", async () => { callFor(GRANT_ACCESS_TOOL, { principalId: "prin_2", resource: "workflow-run:*", - actions: ["read"], + actions: ["read", "write"], }), new AbortController().signal, ); expect(grantResult.isError).toBe(false); + expect(grantResult.content).toContain("2 grants created"); const listResult = await bundle.run( callFor(LIST_GRANTS_TOOL, { principalId: "prin_2" }), diff --git a/packages/access-tools/test/myra-orchestrates.test.ts b/packages/access-tools/test/myra-orchestrates.test.ts new file mode 100644 index 0000000000..dbea539be4 --- /dev/null +++ b/packages/access-tools/test/myra-orchestrates.test.ts @@ -0,0 +1,445 @@ +// Myra orchestrates the bench, end to end (CL-7465): the exact tool +// bundles the reactor invokes — `@corbits/agent-directory-tools`'s +// `create_agent` and `@corbits/access-tools`'s `grant_access` — drive the +// REAL mounted hub routes (`createWorkflowAgentCreateRoutes` and +// `createWorkflowAccessRoutes`) through a stubbed `fetch` dispatcher, with +// a scripted turn list standing in for model inference. Turn 1 creates a +// specialist agent through the native workflow-run create route (same +// materialization the tenant-session route uses); turn 2 mints that +// specialist a scoped grant through the native grant route, which lands a +// real row in Postgres; turn 3 reads it back through `list_grants`. +// +// The deployer seam stays stubbed (a real deploy needs the sidecar probe), +// mirroring `packages/agent-directory/test/workflow-create-routes.test.ts`'s +// own fakes — the route, its auth, its inventory checks, and its core +// input are all real. The grant leg is fully real: route + in-memory +// `@intx/authz` store + drizzle Postgres handle. +// +// DB-gated like `./routes.test.ts`: needs `DATABASE_URL` (see +// `scripts/e2e/db-gate`). +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { ToolCall } from "@intx/types/runtime"; + +import { createDB, schema, type DB } from "@intx/db"; +import { createInMemoryGrantStore } from "@intx/authz"; +import { generateId } from "@intx/hub-common"; +import { + createInMemoryDefinitionSkillsStore, + createWorkflowAgentCreateRoutes, + type CapabilityInventoryProvider, + type CreateWorkflowAgentCreateRoutesDeps, + type WorkflowCapabilityRunScope, + type WorkflowCapabilityRunAuthenticator, +} from "@corbits/agent-directory"; +import { + agentDirectoryTools, + CREATE_AGENT_TOOL, + type WorkflowAgentDirectoryEnv, +} from "@corbits/agent-directory-tools"; +import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; +import { dbGate } from "../../../scripts/e2e/db-gate"; + +import { + createWorkflowAccessRoutes, + type WorkflowRunAuthenticator, +} from "../src/routes"; +import { + accessTools, + GRANT_ACCESS_TOOL, + LIST_GRANTS_TOOL, + type WorkflowAccessEnv, +} from "../src/tool"; + +const databaseUrl = process.env["DATABASE_URL"]; +const describeIfDb = dbGate(databaseUrl, import.meta.path); + +function dbConfigFromUrl(url: string) { + const parsed = new URL(url); + return { + host: parsed.hostname, + port: parsed.port === "" ? 5432 : Number(parsed.port), + user: decodeURIComponent(parsed.username), + password: decodeURIComponent(parsed.password), + database: parsed.pathname.replace(/^\//, ""), + }; +} + +// Mirrors `./routes.test.ts`'s own helper: a system-origin store row for +// the caller, the same shape `createRequireGrant` and the ceiling check +// read in production. +function storeGrant(principalId: string, resource: string, action: string) { + return { + id: generateId("grant"), + resource, + action, + effect: "allow" as const, + origin: "system" as const, + conditions: null, + expiresAt: null, + roleId: null, + principalId, + }; +} + +describeIfDb("Myra orchestrates the bench", () => { + let db: DB; + + const tenantId = generateId("tenant"); + const callerPrincipalId = generateId("principal"); + const specialistPrincipalId = generateId("principal"); + const SIDECAR_TOKEN = "sidecar-token-for-this-run"; + const RUN_ADDRESS = "myra-run@tenant.example.test"; + const SPECIALIST_ADDRESS = "release-notes-writer@tenant.example.test"; + + const HUB_ORIGIN = "https://hub.example.com"; + + beforeAll(async () => { + if (databaseUrl === undefined) return; + db = createDB(dbConfigFromUrl(databaseUrl)); + + await db.db.insert(schema.tenant).values({ + id: tenantId, + name: "Myra Orchestrates Test Tenant", + slug: `myra-orchestrates-${tenantId}`, + domain: `myra-orchestrates-${tenantId}.localhost`, + parentId: null, + config: null, + }); + await db.db.insert(schema.principal).values([ + { + id: callerPrincipalId, + tenantId, + kind: "workflow", + refId: RUN_ADDRESS, + status: "active", + }, + { + id: specialistPrincipalId, + tenantId, + kind: "agent", + refId: SPECIALIST_ADDRESS, + status: "active", + }, + ]); + }); + + afterAll(async () => { + if (databaseUrl === undefined) return; + await db.db.delete(schema.grant).where(eq(schema.grant.tenantId, tenantId)); + await db.db + .delete(schema.principal) + .where(eq(schema.principal.tenantId, tenantId)); + await db.db.delete(schema.tenant).where(eq(schema.tenant.id, tenantId)); + }); + + // The agent-create fakes below mirror + // `packages/agent-directory/test/workflow-create-routes.test.ts` — one + // deliberate difference: `currentVersion` is the string `"1"`, matching + // the real `workflow_definition.current_version` text column (CL-6480), + // which the tool client parses as `"string"`. + type FakeDefinitionRow = { + id: string; + tenantId: string; + name: string; + description: string | null; + status: string; + currentVersion: string; + assetId: string | null; + }; + + const createdRow: FakeDefinitionRow = { + id: "def_new", + tenantId, + name: "Release Notes Writer", + description: null, + status: "deployed", + currentVersion: "1", + assetId: "ast_new", + }; + + function fakeAgentDb(): DB["db"] { + return { + query: { + tenant: { + findFirst: async () => ({ + id: tenantId, + domain: `myra-orchestrates-${tenantId}.localhost`, + parentId: null, + }), + }, + asset: { + findFirst: async () => ({ + id: "ast_corbits_tools", + tenantId, + kind: "package-registry" as const, + name: CORBITS_TOOLS_REGISTRY, + }), + }, + workflowDefinition: { + findFirst: async () => createdRow, + findMany: async () => [createdRow], + }, + }, + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => + Promise.resolve([ + { + tenantId, + creatorPrincipalId: null, + name: createdRow.name, + displayName: createdRow.name, + }, + ]), + }), + }), + }), + insert: () => ({ + values: () => { + const chain: Record = { + onConflictDoNothing: () => chain, + returning: () => Promise.resolve([{ id: createdRow.id }]), + then: (onFulfilled: unknown) => + Promise.resolve([]).then(onFulfilled as never), + }; + return chain; + }, + }), + } as unknown as DB["db"]; + } + + function fakeAssetService( + onPopulate?: (files: Record) => void, + ): CreateWorkflowAgentCreateRoutesDeps["assetService"] { + return { + createAsset: () => + Promise.resolve({ id: "ast_new", tenantId, kind: "workflow" }), + populateAsset: (params: { + tree: { files: Record }; + }) => { + onPopulate?.(params.tree.files); + return Promise.resolve({ commitSha: "deadbeef" }); + }, + readAssetBlob: () => { + throw new Error("not used in this test"); + }, + listAssetBlobs: () => Promise.resolve(["corbits-memory-tools-1.4.0.tgz"]), + } as unknown as CreateWorkflowAgentCreateRoutesDeps["assetService"]; + } + + const fakeCapabilityInventory: CapabilityInventoryProvider = { + resolve: () => + Promise.resolve({ + toolPackages: [{ name: "@corbits/memory-tools" }], + skills: [{ name: "research" }], + models: [{ canonicalName: "anthropic/claude-sonnet" }], + }), + }; + + const fakeSkillIndex = { + resolve: ( + _tenantId: string, + _principalId: string, + names: readonly string[], + ) => + Promise.resolve( + names.map((name) => ({ name, description: `What ${name} does.` })), + ), + }; + + function recordingDeployer() { + const deploys: { + tenantId: string; + principalId: string; + assetId: string; + assetName: string; + commitSha: string; + entry: string; + }[] = []; + return { + deploys, + deploy: (input: { + tenantId: string; + principalId: string; + assetId: string; + assetName: string; + commitSha: string; + entry: string; + }) => { + deploys.push(input); + return Promise.resolve({ + deploymentId: "dep_1", + definitionAssetId: input.assetId, + status: "deployed" as const, + }); + }, + }; + } + + const agentAuthenticator: WorkflowCapabilityRunAuthenticator = { + resolve: (token, address) => + Promise.resolve( + token === SIDECAR_TOKEN && address === RUN_ADDRESS + ? ({ + tenantId, + principalId: callerPrincipalId, + runId: "run_myra", + } satisfies WorkflowCapabilityRunScope) + : null, + ), + }; + + const accessAuthenticator: WorkflowRunAuthenticator = { + async resolve(token, runAddress) { + if (token !== SIDECAR_TOKEN || runAddress !== RUN_ADDRESS) return null; + return { tenantId, principalId: callerPrincipalId }; + }, + }; + + // Stands in for the hub origin: every bundle client builds absolute + // URLs under the mount points above, so the full URL passes straight + // through with method, auth headers, and body untouched — exactly what + // the sidecar sends in production. + function hubFetch(hub: Hono) { + return (async (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (!url.startsWith(HUB_ORIGIN)) { + throw new Error(`myra-orchestrates test: unexpected fetch to ${url}`); + } + return hub.request(url, init); + }) as unknown as typeof fetch; + } + + function callFor(name: string, args: Record): ToolCall { + return { id: `call_${name}`, name, arguments: args }; + } + + test("a Myra run creates a specialist agent, then grants it scoped access", async () => { + const deployer = recordingDeployer(); + let populatedFiles: Record | undefined; + const agentRoutesApp = createWorkflowAgentCreateRoutes({ + db: fakeAgentDb(), + assetService: fakeAssetService((files) => { + populatedFiles = files; + }), + skillIndex: fakeSkillIndex, + skillsStore: createInMemoryDefinitionSkillsStore(), + capabilityInventory: fakeCapabilityInventory, + authenticator: agentAuthenticator, + deployer, + }); + + const accessRoutesApp = createWorkflowAccessRoutes({ + db: db.db, + authenticator: accessAuthenticator, + grantStore: createInMemoryGrantStore([ + storeGrant(callerPrincipalId, "grant:*", "create"), + storeGrant(callerPrincipalId, "grant:*", "read"), + storeGrant(callerPrincipalId, "room:*", "read"), + ]), + conditionRegistry: {}, + }); + + const hub = new Hono(); + // Same mount points as the real composition root + // (`apps/hub/src/index.ts`): the bundles' clients build these exact + // absolute paths, so the stubbed fetch below hands the full URL + // straight to this parent — no prefix surgery, no drift. + hub.route("/api/workflow-agent-directory", agentRoutesApp); + hub.route("/api/workflow-access", accessRoutesApp); + + const directoryEnv = { + hubAgentDirectoryUrl: HUB_ORIGIN, + hubChatUrl: `${HUB_ORIGIN}/api/workflow-chat`, + sidecarToken: SIDECAR_TOKEN, + address: RUN_ADDRESS, + } as unknown as WorkflowAgentDirectoryEnv; + const accessEnv = { + hubAccessUrl: `${HUB_ORIGIN}/api/workflow-access`, + sidecarToken: SIDECAR_TOKEN, + address: RUN_ADDRESS, + } as unknown as WorkflowAccessEnv; + + const originalFetch = globalThis.fetch; + globalThis.fetch = hubFetch(hub); + try { + const directoryBundle = agentDirectoryTools(directoryEnv); + const accessBundle = accessTools(accessEnv); + const signal = new AbortController().signal; + + // Turn 1 (stubbed inference): stand up the specialist. `invite: + // false` keeps this leg on the definitions route — opening the + // specialist's own chat is `@corbits/chat`'s participant surface, + // covered by its own suites, not this flow. + const created = await directoryBundle.run( + callFor(CREATE_AGENT_TOOL, { + name: "Release Notes Writer", + systemPrompt: "You write crisp release notes from commit lists.", + toolPackagePins: ["@corbits/memory-tools"], + invite: false, + }), + signal, + ); + expect(created.isError).toBe(false); + expect(created.content).toContain("Release Notes Writer"); + expect(created.content).toContain("def_new"); + + // The native core path ran: the definition's source tree was + // materialized and the tenant-scoped deploy recorded. + expect(populatedFiles).toBeDefined(); + expect(deployer.deploys).toHaveLength(1); + expect(deployer.deploys[0]?.tenantId).toBe(tenantId); + expect(deployer.deploys[0]?.assetName).toBe("release-notes-writer"); + expect(deployer.deploys[0]?.commitSha).toBe("deadbeef"); + + // Turn 2 (stubbed inference): give the specialist room:read, + // inside Myra's own ceiling — the route persists a native row. + const granted = await accessBundle.run( + callFor(GRANT_ACCESS_TOOL, { + principalId: specialistPrincipalId, + resource: "room:*", + actions: ["read"], + }), + signal, + ); + expect(granted.isError).toBe(false); + expect(granted.content).toContain("1 grant created"); + + // Turn 3 (stubbed inference): read it back through the native + // list surface — proof the grant survived the round trip. + const listed = await accessBundle.run( + callFor(LIST_GRANTS_TOOL, { principalId: specialistPrincipalId }), + signal, + ); + expect(listed.isError).toBe(false); + expect(listed.content).toContain( + `allow room:*:read -> principal ${specialistPrincipalId}`, + ); + } finally { + globalThis.fetch = originalFetch; + } + + // And the row is really in Postgres, not just in the route's + // response envelope. + const rows = await db.db.query.grant.findMany({ + where: eq(schema.grant.tenantId, tenantId), + }); + expect( + rows.some( + (row) => + row.principalId === specialistPrincipalId && + row.resource === "room:*" && + row.action === "read" && + row.effect === "allow", + ), + ).toBe(true); + }); +}); diff --git a/packages/access-tools/test/routes.test.ts b/packages/access-tools/test/routes.test.ts index ee6a8e31c7..ae2198ca84 100644 --- a/packages/access-tools/test/routes.test.ts +++ b/packages/access-tools/test/routes.test.ts @@ -9,6 +9,15 @@ // `createRequireGrant` and the delegation-ceiling check both call — so // this proves the ceiling is enforced against the SAME grants a real // deploy would see, not a bundle-local approximation. +// +// Every assertion speaks the native Interchange contract (`@intx/hub-api`'s +// `createGrantRoutes`/`createPrincipalRoutes` vocabulary): `{data, +// nextCursor}` pages, single-action `POST /grants` bodies returning the +// single `GrantResponse` object, `204` deletes, and `{error: {code, +// message}}` envelopes. The workflow-access surface is the +// workflow-run-authenticated counterpart to `/api/tenants/:tenantId +// /principals` and `/grants` — same shapes, run bearer instead of a human +// session (see the mount comment in `apps/hub/src/index.ts`). import { afterAll, beforeAll, expect, test } from "bun:test"; import { eq } from "drizzle-orm"; @@ -36,6 +45,34 @@ function dbConfigFromUrl(databaseUrl: string) { const databaseUrl = process.env["DATABASE_URL"]; const describeIfDb = dbGate(databaseUrl, import.meta.path); +function storeGrant( + principalId: string, + resource: string, + action: string, +): { + id: string; + resource: string; + action: string; + effect: "allow"; + origin: "system"; + conditions: null; + expiresAt: null; + roleId: null; + principalId: string; +} { + return { + id: generateId("grant"), + resource, + action, + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId, + }; +} + describeIfDb("createWorkflowAccessRoutes", () => { let db: DB; @@ -125,6 +162,27 @@ describeIfDb("createWorkflowAccessRoutes", () => { expect(res.status).toBe(401); }); + test("GET /principals returns the native page envelope", async () => { + const app = mountedApp( + createInMemoryGrantStore([ + storeGrant(callerPrincipalId, "principal:*", "read"), + ]), + ); + const res = await app.request(request("/principals")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { id: string; kind: string; status: string }[]; + nextCursor: string | null; + }; + expect(Array.isArray(body.data)).toBe(true); + expect( + body.data.some( + (p) => p.id === callerPrincipalId && p.kind === "workflow", + ), + ).toBe(true); + expect(body.nextCursor).toBeNull(); + }); + test("403 when the caller lacks grant:*/create", async () => { const app = mountedApp(createInMemoryGrantStore([])); const res = await app.request( @@ -133,7 +191,9 @@ describeIfDb("createWorkflowAccessRoutes", () => { body: { principalId: targetPrincipalId, resource: "room:*", - actions: ["read"], + action: "read", + effect: "allow", + origin: "invoker", }, }), ); @@ -142,28 +202,8 @@ describeIfDb("createWorkflowAccessRoutes", () => { test("403 when the requested pair exceeds the caller's own ceiling", async () => { const grantStore = createInMemoryGrantStore([ - { - id: generateId("grant"), - resource: "grant:*", - action: "create", - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: callerPrincipalId, - }, - { - id: generateId("grant"), - resource: "room:*", - action: "read", - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: callerPrincipalId, - }, + storeGrant(callerPrincipalId, "grant:*", "create"), + storeGrant(callerPrincipalId, "room:*", "read"), ]); const app = mountedApp(grantStore); const res = await app.request( @@ -172,39 +212,22 @@ describeIfDb("createWorkflowAccessRoutes", () => { body: { principalId: targetPrincipalId, resource: "room:*", - actions: ["read", "write"], + action: "write", + effect: "allow", + origin: "invoker", }, }), ); expect(res.status).toBe(403); - const body = (await res.json()) as { error: { userMessage: string } }; - expect(body.error.userMessage).toContain("write"); + const body = (await res.json()) as { error: { message: string } }; + expect(body.error.message).toContain("write"); }); - test("201 within the caller's own ceiling", async () => { + test("201 within the caller's own ceiling returns the single native GrantResponse", async () => { const grantStore = createInMemoryGrantStore([ - { - id: generateId("grant"), - resource: "grant:*", - action: "create", - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: callerPrincipalId, - }, - { - id: generateId("grant"), - resource: "room:*", - action: "read", - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: callerPrincipalId, - }, + storeGrant(callerPrincipalId, "grant:*", "create"), + storeGrant(callerPrincipalId, "grant:*", "read"), + storeGrant(callerPrincipalId, "room:*", "read"), ]); const app = mountedApp(grantStore); const res = await app.request( @@ -213,22 +236,76 @@ describeIfDb("createWorkflowAccessRoutes", () => { body: { principalId: targetPrincipalId, resource: "room:*", - actions: ["read"], + action: "read", + effect: "allow", + origin: "invoker", }, }), ); expect(res.status).toBe(201); const body = (await res.json()) as { - grants: { id: string; resource: string; action: string }[]; + id: string; + principalId: string; + resource: string; + action: string; + effect: string; + origin: string; }; - expect(body.grants).toHaveLength(1); - const createdGrant = body.grants[0]; - if (createdGrant === undefined) throw new Error("expected a created grant"); - expect(createdGrant.resource).toBe("room:*"); + expect(body.principalId).toBe(targetPrincipalId); + expect(body.resource).toBe("room:*"); + expect(body.action).toBe("read"); + expect(body.effect).toBe("allow"); - await db.db - .delete(schema.grant) - .where(eq(schema.grant.id, createdGrant.id)); + const listRes = await app.request( + request(`/grants?principalId=${targetPrincipalId}`), + ); + expect(listRes.status).toBe(200); + const listBody = (await listRes.json()) as { + data: { id: string }[]; + nextCursor: string | null; + }; + expect(listBody.data.some((g) => g.id === body.id)).toBe(true); + expect(listBody.nextCursor).toBeNull(); + + await db.db.delete(schema.grant).where(eq(schema.grant.id, body.id)); + }); + + test("revoke: 204 within the caller's own ceiling", async () => { + const inserted = await db.db + .insert(schema.grant) + .values({ + id: generateId("grant"), + tenantId, + roleId: null, + principalId: targetPrincipalId, + resource: "room:*", + action: "read", + effect: "allow", + conditions: null, + origin: "invoker", + expiresAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + const grantableRow = inserted[0]; + if (grantableRow === undefined) + throw new Error("expected an inserted grant"); + + const grantStore = createInMemoryGrantStore([ + storeGrant(callerPrincipalId, "grant:*", "manage"), + storeGrant(callerPrincipalId, "room:*", "read"), + ]); + const app = mountedApp(grantStore); + const res = await app.request( + request(`/grants/${grantableRow.id}`, { method: "DELETE" }), + ); + expect(res.status).toBe(204); + + const gone = await db.db.query.grant.findFirst({ + where: eq(schema.grant.id, grantableRow.id), + }); + expect(gone).toBeUndefined(); }); test("revoke: 403 outside the caller's own ceiling", async () => { @@ -254,17 +331,7 @@ describeIfDb("createWorkflowAccessRoutes", () => { throw new Error("expected an inserted grant"); const grantStore = createInMemoryGrantStore([ - { - id: generateId("grant"), - resource: "grant:*", - action: "manage", - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: callerPrincipalId, - }, + storeGrant(callerPrincipalId, "grant:*", "manage"), ]); const app = mountedApp(grantStore); const res = await app.request( @@ -284,17 +351,7 @@ describeIfDb("createWorkflowAccessRoutes", () => { test("404 for an unknown grant id", async () => { const grantStore = createInMemoryGrantStore([ - { - id: generateId("grant"), - resource: "grant:*", - action: "manage", - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: callerPrincipalId, - }, + storeGrant(callerPrincipalId, "grant:*", "manage"), ]); const app = mountedApp(grantStore); const res = await app.request( From c694c4414340a463e4157a4f46331e4927af704f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 12:39:54 -0700 Subject: [PATCH 2/7] fix(access-tools): require principalId on native grant bodies (CL-7465, green) --- packages/access-tools/src/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/access-tools/src/client.ts b/packages/access-tools/src/client.ts index 172a2ffa6a..cf70d1095d 100644 --- a/packages/access-tools/src/client.ts +++ b/packages/access-tools/src/client.ts @@ -102,7 +102,7 @@ const ListedPrincipalsResponse = type({ const ListedGrantsResponse = type({ data: type({ id: "string", - "principalId?": "string | null", + principalId: "string | null", resource: "string", action: "string", effect: "'allow' | 'deny' | 'ask'", @@ -112,7 +112,7 @@ const ListedGrantsResponse = type({ const CreatedGrantResponse = type({ id: "string", - "principalId?": "string | null", + principalId: "string | null", resource: "string", action: "string", effect: "'allow' | 'deny' | 'ask'", From 705da52d54737276785cfe70a9179ac1c28d01c2 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 12:40:32 -0700 Subject: [PATCH 3/7] style(access-tools): prettier formatting (CL-7465) --- packages/access-tools/src/client.test.ts | 22 +++++++++++++++++----- packages/access-tools/src/routes.ts | 18 +++++++++++------- packages/access-tools/src/tool.test.ts | 3 ++- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/access-tools/src/client.test.ts b/packages/access-tools/src/client.test.ts index e322025fc9..f9a7b438ad 100644 --- a/packages/access-tools/src/client.test.ts +++ b/packages/access-tools/src/client.test.ts @@ -84,7 +84,9 @@ test("listGrants passes filters through and reads the native page envelope", asy principalId: "prin_2", resource: "workflow-run:*", }); - expect(seenUrl).toContain("/grants?principalId=prin_2&resource=workflow-run%3A*"); + expect(seenUrl).toContain( + "/grants?principalId=prin_2&resource=workflow-run%3A*", + ); expect(grants).toHaveLength(1); expect(grants[0]).toMatchObject({ id: "grant_1", @@ -97,7 +99,10 @@ test("listGrants passes filters through and reads the native page envelope", asy test("grantAccess posts one native single-action body per action and parses single GrantResponse objects", async () => { const posted: unknown[] = []; - const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + const fetchImpl = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { expect(String(url)).toBe( "https://hub.example.com/api/workflow-access/grants", ); @@ -105,7 +110,10 @@ test("grantAccess posts one native single-action body per action and parses sing const body = JSON.parse(String(init?.body)) as Record; posted.push(body); return new Response( - JSON.stringify({ ...nativeGrant(`grant_${posted.length}`), action: body["action"] }), + JSON.stringify({ + ...nativeGrant(`grant_${posted.length}`), + action: body["action"], + }), { status: 201 }, ); }) as unknown as typeof fetch; @@ -177,7 +185,8 @@ test("a 404 with the native error envelope from revokeAccess surfaces as AccessN }); test("revokeAccess treats a native 204 with no body as success", async () => { - const fetchImpl = (async () => new Response(null, { status: 204 })) as unknown as typeof fetch; + const fetchImpl = (async () => + new Response(null, { status: 204 })) as unknown as typeof fetch; await revokeAccess(testConfig(fetchImpl), "grant_1"); }); @@ -186,7 +195,10 @@ test("a grant body without principalId is a shape error, never a null-linked gra const { principalId: _link, ...linkless } = nativeGrant("grant_1"); void _link; const fetchImpl = (async () => - Response.json({ data: [linkless], nextCursor: null })) as unknown as typeof fetch; + Response.json({ + data: [linkless], + nextCursor: null, + })) as unknown as typeof fetch; await expect(listGrants(testConfig(fetchImpl), {})).rejects.toThrow( "did not match the expected shape", diff --git a/packages/access-tools/src/routes.ts b/packages/access-tools/src/routes.ts index 1b0f41410b..a26f9b6f1c 100644 --- a/packages/access-tools/src/routes.ts +++ b/packages/access-tools/src/routes.ts @@ -124,7 +124,9 @@ function pageResponse( return { data: items, nextCursor: - lastRow !== undefined ? encodeCursor(lastRow.createdAt, lastRow.id) : null, + lastRow !== undefined + ? encodeCursor(lastRow.createdAt, lastRow.id) + : null, }; } @@ -376,11 +378,16 @@ export function createWorkflowAccessRoutes( rolesByPrincipal.set(a.principalId, list); } - const userRefIds = rows.filter((p) => p.kind === "user").map((p) => p.refId); + const userRefIds = rows + .filter((p) => p.kind === "user") + .map((p) => p.refId); const workflowRefIds = rows .filter((p) => p.kind === "workflow") .map((p) => p.refId); - const identities = new Map(); + const identities = new Map< + string, + { displayName: string; email?: string } + >(); if (userRefIds.length > 0) { const users = await deps.db.query.user.findMany({ where: (u, { inArray }) => inArray(u.id, userRefIds), @@ -440,10 +447,7 @@ export function createWorkflowAccessRoutes( conditions.push( or( lt(grant.createdAt, new Date(cursor.t)), - and( - eq(grant.createdAt, new Date(cursor.t)), - lt(grant.id, cursor.id), - ), + and(eq(grant.createdAt, new Date(cursor.t)), lt(grant.id, cursor.id)), ) ?? sql`false`, ); } diff --git a/packages/access-tools/src/tool.test.ts b/packages/access-tools/src/tool.test.ts index a13a9df42f..fee8d29bd8 100644 --- a/packages/access-tools/src/tool.test.ts +++ b/packages/access-tools/src/tool.test.ts @@ -45,7 +45,8 @@ test("a 403 with the native error envelope from grant_access surfaces as a clear JSON.stringify({ error: { code: "forbidden", - message: "Myra can only grant room:read — she herself holds no room:write here", + message: + "Myra can only grant room:read — she herself holds no room:write here", }, }), { status: 403 }, From 6013b70c7f1589a744bcd62327159662b0fa6990 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 12:41:20 -0700 Subject: [PATCH 4/7] fix(access-tools): drop unused schema imports (CL-7465) --- packages/access-tools/src/routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/access-tools/src/routes.ts b/packages/access-tools/src/routes.ts index a26f9b6f1c..e75940379f 100644 --- a/packages/access-tools/src/routes.ts +++ b/packages/access-tools/src/routes.ts @@ -38,7 +38,7 @@ import { Hono } from "hono"; import { type } from "arktype"; import { parseGrantRow, type DB } from "@intx/db"; -import { grant, principal, principalRole, role, tenant } from "@intx/db/schema"; +import { grant, principal, tenant } from "@intx/db/schema"; import { generateId } from "@intx/hub-common"; import { createRequireGrant, From 89d9c5a94888e6d278a6103ccfe7282eced724a2 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 12:57:50 -0700 Subject: [PATCH 5/7] fix(access-tools): drop dead action filter; page listGrants fully (CL-7465) --- packages/access-tools/src/client.test.ts | 26 +++++++++++- packages/access-tools/src/client.ts | 54 +++++++++++++----------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/packages/access-tools/src/client.test.ts b/packages/access-tools/src/client.test.ts index f9a7b438ad..8e2059a5c9 100644 --- a/packages/access-tools/src/client.test.ts +++ b/packages/access-tools/src/client.test.ts @@ -97,6 +97,30 @@ test("listGrants passes filters through and reads the native page envelope", asy }); }); +test("listGrants follows nextCursor so a full page plus one all return", async () => { + const all = Array.from({ length: 51 }, (_, i) => + nativeGrant(`grant_${i + 1}`), + ); + const seen: string[] = []; + const fetchImpl = (async (url: string | URL | Request) => { + const raw = String(url); + seen.push(raw); + if (raw.includes("cursor=page_2")) { + return Response.json({ data: all.slice(50), nextCursor: null }); + } + return Response.json({ data: all.slice(0, 50), nextCursor: "page_2" }); + }) as unknown as typeof fetch; + + const grants = await listGrants(testConfig(fetchImpl), { + principalId: "prin_2", + }); + expect(grants).toHaveLength(51); + expect(grants.map((g) => g.id)).toEqual(all.map((g) => g.id)); + expect(seen).toHaveLength(2); + expect(seen[0]).toContain("principalId=prin_2"); + expect(seen[1]).toContain("cursor=page_2"); +}); + test("grantAccess posts one native single-action body per action and parses single GrantResponse objects", async () => { const posted: unknown[] = []; const fetchImpl = (async ( @@ -191,7 +215,7 @@ test("revokeAccess treats a native 204 with no body as success", async () => { await revokeAccess(testConfig(fetchImpl), "grant_1"); }); -test("a grant body without principalId is a shape error, never a null-linked grant", async () => { +test("a grant body missing principalId is a shape error, never a null-linked grant", async () => { const { principalId: _link, ...linkless } = nativeGrant("grant_1"); void _link; const fetchImpl = (async () => diff --git a/packages/access-tools/src/client.ts b/packages/access-tools/src/client.ts index cf70d1095d..db68b41cab 100644 --- a/packages/access-tools/src/client.ts +++ b/packages/access-tools/src/client.ts @@ -154,37 +154,43 @@ export async function listPrincipals( export interface ListGrantsFilter { readonly principalId?: string; readonly resource?: string; - readonly action?: string; } -/** Lists grants in the run's tenant, passing the given filters through as - * the native `GET /grants` query params (`principalId`, `resource`, - * `action`). */ +/** Lists every grant in the run's tenant matching the given filters, + * following the native `nextCursor` pages to the end — the mirror honors + * the same `principalId`/`resource` query params as the native + * `GET /grants`. */ export async function listGrants( config: AccessToolClientConfig, filter?: ListGrantsFilter, ): Promise { const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); - const params = new URLSearchParams(); - if (filter?.principalId !== undefined) - params.set("principalId", filter.principalId); - if (filter?.resource !== undefined) params.set("resource", filter.resource); - if (filter?.action !== undefined) params.set("action", filter.action); - const query = params.size === 0 ? "" : `?${params.toString()}`; - const response = await fetchImpl(`${config.hubAccessUrl}/grants${query}`, { - headers: { ...authHeaders(config) }, - }); - if (!response.ok) { - await throwForStatus("Listing grants", response); - } - const body: unknown = await response.json(); - const parsed = ListedGrantsResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `List-grants response did not match the expected shape: ${parsed.summary}`, - ); - } - return parsed.data; + const grants: ListedGrant[] = []; + let cursor: string | null | undefined; + do { + const params = new URLSearchParams(); + if (filter?.principalId !== undefined) + params.set("principalId", filter.principalId); + if (filter?.resource !== undefined) params.set("resource", filter.resource); + if (cursor !== undefined && cursor !== null) params.set("cursor", cursor); + const query = params.size === 0 ? "" : `?${params.toString()}`; + const response = await fetchImpl(`${config.hubAccessUrl}/grants${query}`, { + headers: { ...authHeaders(config) }, + }); + if (!response.ok) { + await throwForStatus("Listing grants", response); + } + const body: unknown = await response.json(); + const parsed = ListedGrantsResponse(body); + if (parsed instanceof type.errors) { + throw new Error( + `List-grants response did not match the expected shape: ${parsed.summary}`, + ); + } + grants.push(...parsed.data); + cursor = parsed.nextCursor ?? null; + } while (cursor !== null); + return grants; } /** Grants access by posting one native single-action `POST /grants` body From 7c35fb577075ff12d57231afe4c51d9469c11a27 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 09:15:16 -0700 Subject: [PATCH 6/7] =?UTF-8?q?fix(access-tools):=20rebase=20fixups=20?= =?UTF-8?q?=E2=80=94=20stale=20skillsStore=20test=20wiring,=20version=20bu?= =?UTF-8?q?mp,=20report-error=20opt-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing PR #727 onto main (post catalog/skills-native/run-scope/Slack cutovers) needed no code conflicts, only test/CI fixups: - myra-orchestrates.test.ts still referenced the deleted createInMemoryDefinitionSkillsStore/skillsStore dep now that @corbits/agent-directory takes skillIndex only. - packages/access-tools' src/ changed without a version bump; bumped 0.0.2 -> 0.0.3 and updated the workflows/assistant pin. - Added a report-error-ignore comment on the new malformed-cursor catch in routes.ts (bad input, not an incident). bun.lock regenerated against main. --- bun.lock | 3 --- packages/access-tools/package.json | 2 +- packages/access-tools/src/routes.ts | 3 +++ packages/access-tools/test/myra-orchestrates.test.ts | 2 -- workflows/assistant/src/index.ts | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 7931110a8b..1be5a2bde6 100644 --- a/bun.lock +++ b/bun.lock @@ -218,9 +218,6 @@ "hono": "catalog:", }, "devDependencies": { - "@corbits/agent-directory": "workspace:*", - "@corbits/agent-directory-tools": "workspace:*", - "@corbits/tool-registry-publish": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:", }, diff --git a/packages/access-tools/package.json b/packages/access-tools/package.json index 452e144de5..7d858870d2 100644 --- a/packages/access-tools/package.json +++ b/packages/access-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/access-tools", "private": true, "description": "Myra's principal and grant tools: list_principals, list_grants, grant_access, and revoke_access as an @intx/agent tool bundle, plus the workflow-run-authenticated routes they call — a thin wrapper over the tenant's real principal/grant rows and Interchange's own grant-store authorization, never a reimplementation of authz", - "version": "0.0.2", + "version": "0.0.3", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/access-tools/src/routes.ts b/packages/access-tools/src/routes.ts index e75940379f..10a6a16d32 100644 --- a/packages/access-tools/src/routes.ts +++ b/packages/access-tools/src/routes.ts @@ -100,6 +100,9 @@ function parsePageParams(query: { ); cursor = data instanceof type.errors ? null : data; } catch { + // report-error-ignore: a malformed cursor from a caller is bad + // input, not an incident — it falls back to the first page like an + // absent cursor, exactly as the native tenant routes do. cursor = null; } } diff --git a/packages/access-tools/test/myra-orchestrates.test.ts b/packages/access-tools/test/myra-orchestrates.test.ts index dbea539be4..406c930269 100644 --- a/packages/access-tools/test/myra-orchestrates.test.ts +++ b/packages/access-tools/test/myra-orchestrates.test.ts @@ -26,7 +26,6 @@ import { createDB, schema, type DB } from "@intx/db"; import { createInMemoryGrantStore } from "@intx/authz"; import { generateId } from "@intx/hub-common"; import { - createInMemoryDefinitionSkillsStore, createWorkflowAgentCreateRoutes, type CapabilityInventoryProvider, type CreateWorkflowAgentCreateRoutesDeps, @@ -331,7 +330,6 @@ describeIfDb("Myra orchestrates the bench", () => { populatedFiles = files; }), skillIndex: fakeSkillIndex, - skillsStore: createInMemoryDefinitionSkillsStore(), capabilityInventory: fakeCapabilityInventory, authenticator: agentAuthenticator, deployer, diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 69cd7ac138..0eec7dbf19 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -57,7 +57,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/interaction-tools", version: "0.0.8" }, { name: "@corbits/manus-tools", version: "0.0.11" }, { name: "@corbits/workflow-authoring-tools", version: "0.0.5" }, - { name: "@corbits/access-tools", version: "0.0.2" }, + { name: "@corbits/access-tools", version: "0.0.3" }, ]; /** From 5b19f0fc55d2240a518f531d64adbe68a19d504c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 09:18:47 -0700 Subject: [PATCH 7/7] fix(hub): drop unused chat imports after rebase onto main Claude-Session: https://claude.ai/code/session_01RweuMUskU2nvcFhdckc7EG --- bun.lock | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 1be5a2bde6..27365a1dc8 100644 --- a/bun.lock +++ b/bun.lock @@ -204,7 +204,7 @@ }, "packages/access-tools": { "name": "@corbits/access-tools", - "version": "0.0.2", + "version": "0.0.3", "dependencies": { "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", @@ -218,6 +218,9 @@ "hono": "catalog:", }, "devDependencies": { + "@corbits/agent-directory": "workspace:*", + "@corbits/agent-directory-tools": "workspace:*", + "@corbits/tool-registry-publish": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:", }, @@ -428,7 +431,7 @@ }, "packages/catalog-tools": { "name": "@corbits/catalog-tools", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@intx/agent": "workspace:*", "@intx/types": "workspace:*", @@ -3482,8 +3485,12 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], + "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3504,6 +3511,12 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], + "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], + + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + + "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -3552,6 +3565,8 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3598,6 +3613,8 @@ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],