From 02591d6af27151025d64d552ae8320c9b975cec4 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Tue, 8 Sep 2026 22:45:31 +0000 Subject: [PATCH 1/5] feat(agents): add Agent Auth blueprints, tokens, instances and sessions Implements the 13 Agent Auth endpoints (blueprint CRUD, all four token grants plus validation, instance and session listing/lookup/deletion/ revocation), the seven agent.* lifecycle events, and an agentBlueprints seed key. Agent Registration is intentionally left unimplemented. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 84 +++ SUPPORTED.md | 57 +- scripts/gen-supported-lib.ts | 17 +- src/core/id.ts | 3 + src/core/jwt.ts | 16 +- src/index.ts | 1 + src/workos/agent-sessions.ts | 147 ++++ src/workos/config-validator.ts | 181 +++++ src/workos/entities.ts | 52 ++ src/workos/helpers.ts | 80 +++ src/workos/index.ts | 99 ++- src/workos/routes/agents.spec.ts | 876 +++++++++++++++++++++++ src/workos/routes/agents.ts | 750 +++++++++++++++++++ src/workos/seed-agent-blueprints.spec.ts | 163 +++++ src/workos/store.ts | 19 + 15 files changed, 2506 insertions(+), 39 deletions(-) create mode 100644 src/workos/agent-sessions.ts create mode 100644 src/workos/routes/agents.spec.ts create mode 100644 src/workos/routes/agents.ts create mode 100644 src/workos/seed-agent-blueprints.spec.ts diff --git a/README.md b/README.md index 55ac9a7..9ca58fe 100644 --- a/README.md +++ b/README.md @@ -642,6 +642,90 @@ collection-level `flag.created` / `flag.updated` / `flag.deleted` events run wit context and always report the placeholder. Flags are not environment-scoped, so every flag event reports `environment_test`. Deleting a user or organization removes its flag targets. +### Agent Auth + +Agent blueprints, the tokens minted from them, and the resulting instances and sessions are all +implemented (`/agents/blueprints`, `/agents/instances`, `/agents/sessions`). Blueprints can be +created over the API or seeded; instances and sessions only ever come into being by minting. +`permissions` and `invocable_by.role_slugs` name seeded `permissions` and `roles` by slug, and +`invocable_by.organizations` names `organizations` by name, the same join feature-flag targets use. + +```yaml +permissions: + - slug: crm:read + name: Read CRM + - slug: email:send + name: Send email + +roles: + - slug: manager + name: Manager + permissions: [crm:read, email:send] + - slug: member + name: Member + permissions: [crm:read] + +organizations: + - name: Acme Corp + memberships: + - email: alice@acme.com + role: manager + +agentBlueprints: + - name: Prospecting Agent + description: Finds and qualifies sales prospects. + permissions: [crm:read, email:send] + invocable_by: + role_slugs: [manager] + organizations: [Acme Corp] + session_settings: + max_age_seconds: 3600 + access_token_ttl_seconds: 300 + refresh_token_ttl_seconds: 3600 +``` + +`POST /agents/blueprints/{id}/tokens` accepts the four grant types production does: + +- **`user_delegated`** takes a user access token minted by the emulator (any `authenticate` or + `/oauth2/token` grant). The token only identifies the user and organization: the session behind + its `sid` must still be live, the user must be an active member of the organization, the + organization and the member's role must be allowed by `invocable_by`, and the login must be + younger than `max_age_seconds`. The granted permissions are the blueprint's `permissions` + intersected with what the member's role currently grants — recomputed at every mint and refresh, + so a role change lands in the next token. +- **`autonomous`** takes an `organization_id` and grants the whole blueprint ceiling. +- **`agent_delegated`** exchanges an agent access token for a new session on the same instance. + Chains are self-only (a token from another blueprint is `invalid_agent_access_token`), at most 32 + deep, and anchored at the root: no hop may outlive the root session's `created_at + +max_age_seconds`. +- **`refresh`** rotates the refresh token. Each is single-use, and a refresh never extends the + session past its chain root's max-age window. + +Access tokens are RS256 JWTs signed with the emulator key and `typ: at+jwt`, so the same JWKS a +backend already uses for user tokens validates them. Claims follow production: `sub` is the agent +instance id, `sub_profile: ai_agent`, `sid` is the session id, plus `org_id`, `permissions`, +`intent: { text }` when supplied, `act: { sub: , sub_profile: user }` for delegated +sessions, and `auth_time` from the delegating login. `aud` is the `workos-emulate` placeholder, +since nothing at the API-key-authenticated token endpoint names a client. +`POST .../tokens/validate` checks the signature, the session (revoked, expired, or torn down), and +for delegated chains that the backing user session is still live. + +Revoking a session (`POST /agents/sessions/{id}/revoke`, or revoking or logging out of the user +session it was delegated from) cascades to every session chained from it. Deleting an instance +revokes its live sessions first, and deleting a blueprint tears down its instances. Session +`status` is derived at read time from `revoked_at` and `expires_at`. The seven `agent.*` events +fire through the same webhook and `/events` plumbing as everything else. + +Errors use production's stable codes: `invalid_request` (400) for a malformed body; +`permission_not_found`, `role_not_found`, `organization_not_found` (422) and `name_already_in_use` +(409) on blueprint create and update; and at mint time `invalid_user_access_token`, +`invalid_agent_access_token`, `invalid_refresh_token`, `session_revoked`, `session_expired`, +`user_session_ended`, `max_age_exceeded`, `chain_depth_exceeded` (400) and +`user_not_member_of_organization`, `organization_not_invocable`, `role_not_invocable` (403). + +Agent Registration (`/agents/registrations`, claim attempts, credential validation) is not +implemented. + ## Widgets `POST /widgets/token` mints the session token the `@workos-inc/widgets` components authenticate diff --git a/SUPPORTED.md b/SUPPORTED.md index e776527..ccf7c00 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,7 +2,7 @@ # Supported Features -The emulator implements **166 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**66.4%**). +The emulator implements **179 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**71.6%**). Endpoint coverage says whether a route exists, not whether a feature is usable; for example, Directory Sync implements every endpoint the spec defines for it and is @@ -17,33 +17,34 @@ answers "can I actually emulate this?". ✅ full · ⚠️ partial · ❌ none · — not applicable -| Feature | Read | Write | Set up | Notes | -| ------------------------ | -------- | -------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Organizations | ⚠️ 5/6 | ⚠️ 6/10 | ✅ seed `organizations` | IT contact endpoints are not implemented. | -| User Management | ⚠️ 8/11 | ⚠️ 7/13 | ✅ seed `users` | Email-change confirm/send and waitlist endpoints are not implemented. | -| Authentication | ⚠️ 3/4 | ⚠️ 4/5 | ⚠️ API only | All grant types are hand-written rather than generated from the spec. Refresh tokens always rotate, which is stricter than production. | -| Organization Memberships | ✅ 3/3 | ✅ 5/5 | ✅ seed `memberships` | Seeded via `memberships` nested under an organization. | -| Groups | ✅ 3/3 | ✅ 5/5 | ✅ seed `groups` | Seeded via `groups` nested under an organization. Members reference a seeded membership by email. | -| Invitations | ✅ 3/3 | ✅ 4/4 | ✅ seed `invitations` | | -| SSO | ⚠️ 5/8 | ⚠️ 4/11 | ✅ seed `connections` | Seeded connections carry `profiles`, which drive the SSO login flow. | -| Directory Sync | ✅ 6/6 | ✅ 1/1 | ❌ none | Read-only. Every spec endpoint is implemented and all `dsync.*` events are wired, but nothing can create a directory: there is no POST route and no seed key. Node callers can insert directly via `getWorkOSStore(emulator.store)`, which does emit the events. `dsync.group.user_added` / `user_removed` are never emitted — there is no group membership mutation surface. | -| Multi-Factor Auth | ✅ 2/2 | ✅ 5/5 | ⚠️ API only | TOTP codes are accepted without verifying the shared secret. | -| FGA / Authorization | ⚠️ 15/19 | ⚠️ 18/26 | ✅ seed `roles`, `permissions` | Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented. Resource types are not modeled: any `resource_type_slug` is accepted on roles and permissions, and permission scopes are not checked against the role scope. | -| Audit Logs | ⚠️ 3/4 | ⚠️ 3/4 | ⚠️ API only | Events are stored and queryable. Export generation is not implemented. | -| Vault | ✅ 5/5 | ⚠️ 3/6 | ⚠️ API only | Object CRUD is implemented; data-key encryption endpoints are not. | -| Feature Flags | ✅ 4/4 | ✅ 4/4 | ✅ seed `featureFlags` | Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key. | -| API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. | -| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 4/12 | ✅ seed `connectedAccounts` | Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`. | -| Applications | ⚠️ 4/5 | ⚠️ 4/8 | ✅ seed `connectApplications` | | -| JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. | -| Webhooks | ✅ 1/1 | ⚠️ 2/3 | ✅ seed `webhookEndpoints` | Delivery is fire-and-forget with a 5s timeout and no retries. Endpoints registered in a seed file do not receive events from that same seed file. | -| Events | ✅ 1/1 | — | ✅ automatic | Emitted as a side effect of every other operation. All are queryable at `GET /events`, including those with no registered webhook endpoint. | -| AuthKit Configuration | ❌ 0/2 | ⚠️ 2/3 | ⚠️ API only | Redirect URIs are accepted but not enforced against authorize requests. | -| Admin Portal | — | ✅ 1/1 | ⚠️ API only | Generates a portal link; the portal itself is not served. | -| Widgets | — | ✅ 1/1 | ⚠️ API only | Mints widget tokens and serves the private `/_widgets/ApiKeys/*` routes the org-scope `` widget calls; that surface is outside the public spec, so it is not counted here. Other widgets and `scope="user"` API keys are not implemented. | -| Radar | — | ⚠️ 1/4 | ⚠️ API only | Attempt listing only; no risk signals are computed. | -| Agents | ❌ 0/7 | ❌ 0/9 | ❌ none | Not implemented. | -| Platform Teams | ❌ 0/1 | ❌ 0/1 | ❌ none | Not implemented. | +| Feature | Read | Write | Set up | Notes | +| ------------------------ | -------- | -------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Organizations | ⚠️ 5/6 | ⚠️ 6/10 | ✅ seed `organizations` | IT contact endpoints are not implemented. | +| User Management | ⚠️ 8/11 | ⚠️ 7/13 | ✅ seed `users` | Email-change confirm/send and waitlist endpoints are not implemented. | +| Authentication | ⚠️ 3/4 | ⚠️ 4/5 | ⚠️ API only | All grant types are hand-written rather than generated from the spec. Refresh tokens always rotate, which is stricter than production. | +| Organization Memberships | ✅ 3/3 | ✅ 5/5 | ✅ seed `memberships` | Seeded via `memberships` nested under an organization. | +| Groups | ✅ 3/3 | ✅ 5/5 | ✅ seed `groups` | Seeded via `groups` nested under an organization. Members reference a seeded membership by email. | +| Invitations | ✅ 3/3 | ✅ 4/4 | ✅ seed `invitations` | | +| SSO | ⚠️ 5/8 | ⚠️ 4/11 | ✅ seed `connections` | Seeded connections carry `profiles`, which drive the SSO login flow. | +| Directory Sync | ✅ 6/6 | ✅ 1/1 | ❌ none | Read-only. Every spec endpoint is implemented and all `dsync.*` events are wired, but nothing can create a directory: there is no POST route and no seed key. Node callers can insert directly via `getWorkOSStore(emulator.store)`, which does emit the events. `dsync.group.user_added` / `user_removed` are never emitted — there is no group membership mutation surface. | +| Multi-Factor Auth | ✅ 2/2 | ✅ 5/5 | ⚠️ API only | TOTP codes are accepted without verifying the shared secret. | +| FGA / Authorization | ⚠️ 15/19 | ⚠️ 18/26 | ✅ seed `roles`, `permissions` | Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented. Resource types are not modeled: any `resource_type_slug` is accepted on roles and permissions, and permission scopes are not checked against the role scope. | +| Audit Logs | ⚠️ 3/4 | ⚠️ 3/4 | ⚠️ API only | Events are stored and queryable. Export generation is not implemented. | +| Vault | ✅ 5/5 | ⚠️ 3/6 | ⚠️ API only | Object CRUD is implemented; data-key encryption endpoints are not. | +| Feature Flags | ✅ 4/4 | ✅ 4/4 | ✅ seed `featureFlags` | Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key. | +| API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. | +| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 4/12 | ✅ seed `connectedAccounts` | Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`. | +| Applications | ⚠️ 4/5 | ⚠️ 4/8 | ✅ seed `connectApplications` | | +| JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. | +| Webhooks | ✅ 1/1 | ⚠️ 2/3 | ✅ seed `webhookEndpoints` | Delivery is fire-and-forget with a 5s timeout and no retries. Endpoints registered in a seed file do not receive events from that same seed file. | +| Events | ✅ 1/1 | — | ✅ automatic | Emitted as a side effect of every other operation. All are queryable at `GET /events`, including those with no registered webhook endpoint. | +| AuthKit Configuration | ❌ 0/2 | ⚠️ 2/3 | ⚠️ API only | Redirect URIs are accepted but not enforced against authorize requests. | +| Admin Portal | — | ✅ 1/1 | ⚠️ API only | Generates a portal link; the portal itself is not served. | +| Widgets | — | ✅ 1/1 | ⚠️ API only | Mints widget tokens and serves the private `/_widgets/ApiKeys/*` routes the org-scope `` widget calls; that surface is outside the public spec, so it is not counted here. Other widgets and `scope="user"` API keys are not implemented. | +| Radar | — | ⚠️ 1/4 | ⚠️ API only | Attempt listing only; no risk signals are computed. | +| Agent Auth | ✅ 6/6 | ✅ 7/7 | ✅ seed `agentBlueprints` | Blueprint CRUD, all four token grants (`user_delegated`, `autonomous`, `agent_delegated`, `refresh`), token validation, and instance and session listing, lookup, deletion and revocation. Agent access tokens are signed with the emulator key (`typ: at+jwt`, `sub_profile: ai_agent`) so JWKS validation works; delegated permissions are recomputed from the member’s current role at every mint and refresh. Agent tokens carry the `workos-emulate` placeholder audience, since nothing at the API-key-authenticated token endpoint names a client. | +| Agent Registration | ❌ 0/1 | ❌ 0/2 | ❌ none | Not implemented. | +| Platform Teams | ❌ 0/1 | ❌ 0/1 | ❌ none | Not implemented. | ## How this file is generated diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index 5d704c2..6a8a2ab 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -248,14 +248,15 @@ export const FEATURES: FeatureDef[] = [ notes: 'Attempt listing only; no risk signals are computed.', }, { - name: 'Agents', - tags: [ - 'agents.blueprints', - 'agents.blueprints.tokens', - 'agents.instances', - 'agents.registrations', - 'agents.sessions', - ], + name: 'Agent Auth', + tags: ['agents.blueprints', 'agents.blueprints.tokens', 'agents.instances', 'agents.sessions'], + seedKeys: ['agentBlueprints'], + notes: + 'Blueprint CRUD, all four token grants (`user_delegated`, `autonomous`, `agent_delegated`, `refresh`), token validation, and instance and session listing, lookup, deletion and revocation. Agent access tokens are signed with the emulator key (`typ: at+jwt`, `sub_profile: ai_agent`) so JWKS validation works; delegated permissions are recomputed from the member’s current role at every mint and refresh. Agent tokens carry the `workos-emulate` placeholder audience, since nothing at the API-key-authenticated token endpoint names a client.', + }, + { + name: 'Agent Registration', + tags: ['agents.registrations'], notes: 'Not implemented.', }, { diff --git a/src/core/id.ts b/src/core/id.ts index 0afb913..490cab4 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -89,4 +89,7 @@ export const ID_PREFIXES = { data_integration_auth: 'di_auth', radar_attempt: 'radar_attempt', webhook_endpoint: 'we', + agent_blueprint: 'agent_blueprint', + agent_instance: 'agent', + agent_instance_session: 'agent_session', } as const; diff --git a/src/core/jwt.ts b/src/core/jwt.ts index 655891e..da2ed06 100644 --- a/src/core/jwt.ts +++ b/src/core/jwt.ts @@ -39,7 +39,14 @@ export interface JWTClaims { * The nested `sub` carries the impersonator's email, the identifier the session-tokens * reference documents (and the only one production surfaces for impersonators). */ - act?: { sub: string }; + act?: { sub: string; sub_profile?: string }; + /** + * Marks the kind of subject `sub` names. Agent access tokens carry `ai_agent` so a + * consumer can tell an agent instance from a user without inspecting the id prefix. + */ + sub_profile?: string; + /** Free-text purpose an agent access token was minted for. */ + intent?: { text: string }; /** Entitlement slugs of the organization the session is scoped to; omitted when empty. */ entitlements?: string[]; /** Slugs of feature flags resolving true for the session's user/org context; omitted when empty. */ @@ -71,6 +78,11 @@ interface SignOptions { * AuthKit discovery document describes. */ issuerClientId?: string; + /** + * JOSE header `typ`. Defaults to `JWT`; agent access tokens use RFC 9068's `at+jwt`, as + * production does. + */ + typ?: string; } export interface SigningKeyOptions { @@ -206,7 +218,7 @@ export class JWTManager { exp: now + expiresIn, }; - const header = { alg: 'RS256', typ: 'JWT', kid: this.kid }; + const header = { alg: 'RS256', typ: options?.typ ?? 'JWT', kid: this.kid }; const headerB64 = base64url(JSON.stringify(header)); const payloadB64 = base64url(JSON.stringify(fullPayload)); const signingInput = `${headerB64}.${payloadB64}`; diff --git a/src/index.ts b/src/index.ts index 01b9f9a..40f84f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ export interface EmulatorSeedConfig { connectApplications?: WorkOSSeedConfig['connectApplications']; jwtTemplate?: WorkOSSeedConfig['jwtTemplate']; featureFlags?: WorkOSSeedConfig['featureFlags']; + agentBlueprints?: WorkOSSeedConfig['agentBlueprints']; errorHooks?: ErrorHookSeedConfig[]; } diff --git a/src/workos/agent-sessions.ts b/src/workos/agent-sessions.ts new file mode 100644 index 0000000..2650986 --- /dev/null +++ b/src/workos/agent-sessions.ts @@ -0,0 +1,147 @@ +import type { WorkOSStore } from './store.js'; +import type { + WorkOSAgentBlueprint, + WorkOSAgentInstance, + WorkOSAgentInstanceSession, + WorkOSSession, +} from './entities.js'; +import { getRolePermissions, resolvePrimaryRole } from './role-helpers.js'; + +/** + * Longest `agent_delegated` chain production will mint; every hop persists a session and + * the root walk is O(depth), so the cap only closes an unbounded-growth lever. + */ +export const MAX_AGENT_CHAIN_DEPTH = 32; + +export const AGENT_SUBJECT_PROFILE = 'ai_agent'; +export const USER_SUBJECT_PROFILE = 'user'; + +export const AGENT_SESSION_SETTING_LIMITS = { + max_age_seconds: 31_536_000, + access_token_ttl_seconds: 3_600, + refresh_token_ttl_seconds: 5_184_000, +} as const; + +export const DEFAULT_AGENT_SESSION_SETTINGS = { + max_age_seconds: 3600, + access_token_ttl_seconds: 300, + refresh_token_ttl_seconds: 3600, +} as const; + +/** + * A user session still able to back delegation: present, active, and unexpired. Revoked + * sessions are deleted by the sessions routes, so absence reads as ended. + */ +export function isUserSessionLive(session: WorkOSSession | undefined, now = Date.now()): session is WorkOSSession { + return !!session && session.status === 'active' && new Date(session.expires_at).getTime() > now; +} + +export function isOrganizationInvocable(blueprint: WorkOSAgentBlueprint, organizationId: string): boolean { + const ids = blueprint.invocable_by.organization_ids; + return ids.length === 0 || ids.includes(organizationId); +} + +/** + * Permission slugs a membership's primary role grants, resolved the same way the + * authorization endpoints and user access tokens do so the three never disagree. + */ +export function membershipPermissionSlugs(ws: WorkOSStore, organizationId: string, roleSlug: string): string[] { + const role = resolvePrimaryRole(ws, organizationId, roleSlug); + return role ? getRolePermissions(ws, role.id).map((p) => p.slug) : []; +} + +export function isRoleInvocable(blueprint: WorkOSAgentBlueprint, roleSlug: string): boolean { + const slugs = blueprint.invocable_by.role_slugs; + return slugs.length === 0 || slugs.includes(roleSlug); +} + +/** Blueprint ceiling narrowed to what the delegating member's role grants; order follows the ceiling. */ +export function intersectPermissions(blueprint: WorkOSAgentBlueprint, granted: string[]): string[] { + const held = new Set(granted); + return blueprint.permissions.filter((slug) => held.has(slug)); +} + +export interface ChainRoot { + root: WorkOSAgentInstanceSession; + /** Hops between the presented session and its root; 0 for an unchained session. */ + depth: number; + ancestorRevoked: boolean; +} + +/** + * Walk `parent_session_id` provenance to the chain root. Every hop is on the same instance, + * so a missing parent means the chain was torn down and the walk stops where it is. + */ +export function findChainRoot(ws: WorkOSStore, session: WorkOSAgentInstanceSession): ChainRoot { + let current = session; + let depth = 0; + let ancestorRevoked = false; + while (current.parent_session_id !== null && depth < MAX_AGENT_CHAIN_DEPTH) { + const parent = ws.agentInstanceSessions.get(current.parent_session_id); + if (!parent) break; + if (parent.revoked_at !== null) ancestorRevoked = true; + current = parent; + depth += 1; + } + return { root: current, depth, ancestorRevoked }; +} + +/** + * Revoke a session and every descendant chained from it. Only rows whose `revoked_at` + * flips from null are touched, so the update hook emits one `agent.instance.session.revoked` + * per session that was actually live. Returns how many sessions were revoked. + */ +export function revokeAgentSessionTree( + ws: WorkOSStore, + sessionId: string, + revokedAt = new Date().toISOString(), +): number { + let count = 0; + const pending = [sessionId]; + const seen = new Set(); + while (pending.length > 0) { + const id = pending.pop()!; + if (seen.has(id)) continue; + seen.add(id); + const session = ws.agentInstanceSessions.get(id); + if (!session) continue; + if (session.revoked_at === null) { + ws.agentInstanceSessions.update(id, { revoked_at: revokedAt }); + count += 1; + } + for (const child of ws.agentInstanceSessions.findBy('parent_session_id', id)) pending.push(child.id); + } + return count; +} + +/** + * Tear down an instance the way production does: live sessions are revoked (so their + * revocation events fire) before every session row and the instance itself are removed. + */ +export function deleteAgentInstance(ws: WorkOSStore, instance: WorkOSAgentInstance): void { + const sessions = ws.agentInstanceSessions.findBy('agent_instance_id', instance.id); + for (const session of sessions) { + if (session.revoked_at === null && new Date(session.expires_at).getTime() > Date.now()) { + ws.agentInstanceSessions.update(session.id, { revoked_at: new Date().toISOString() }); + } + } + for (const session of sessions) ws.agentInstanceSessions.delete(session.id); + ws.agentInstances.delete(instance.id); +} + +export function deleteAgentBlueprint(ws: WorkOSStore, blueprint: WorkOSAgentBlueprint): void { + for (const instance of ws.agentInstances.findBy('agent_blueprint_id', blueprint.id)) { + deleteAgentInstance(ws, instance); + } + ws.agentBlueprints.delete(blueprint.id); +} + +/** + * Agent sessions delegated from a user session die with it. Called when a user session is + * revoked; chained sessions record only their parent, so revoking each root cascades. + */ +export function revokeAgentSessionsForUserSession(ws: WorkOSStore, userSessionId: string): void { + for (const root of ws.agentInstanceSessions.findBy('user_session_id', userSessionId)) { + revokeAgentSessionTree(ws, root.id); + } +} diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index d663d86..02c6772 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -4,6 +4,7 @@ import type { WorkOSSeedConfig } from './index.js'; import { validateJwtTemplateContent } from './jwt-template.js'; import { isValidResourceTypeSlug } from './constants.js'; +import { AGENT_SESSION_SETTING_LIMITS } from './agent-sessions.js'; import { normalizeEmail, type NormalizedEmail } from './helpers.js'; /** @@ -1011,6 +1012,186 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe } } + // Validate agent blueprints with the same checks the create route applies, so a seeded + // blueprint production would have rejected fails the boot instead of minting oddly. + if (config.agentBlueprints) { + if (!Array.isArray(config.agentBlueprints)) { + errors.push({ + path: 'agentBlueprints', + message: 'agentBlueprints must be an array', + value: config.agentBlueprints, + }); + } else { + const orgNames = new Set( + Array.isArray(config.organizations) + ? config.organizations.map((o) => o.name).filter((n): n is string => typeof n === 'string') + : [], + ); + const permissionSlugs = new Set( + Array.isArray(config.permissions) + ? config.permissions.map((p) => p.slug).filter((s): s is string => typeof s === 'string') + : [], + ); + const roleSlugs = new Set( + Array.isArray(config.roles) + ? config.roles.map((r) => r.slug).filter((s): s is string => typeof s === 'string') + : [], + ); + const seenNames = new Set(); + const seenIds = new Set(); + + config.agentBlueprints.forEach((blueprint, index) => { + const at = (field: string) => `agentBlueprints[${index}].${field}`; + + if (blueprint === null || typeof blueprint !== 'object' || Array.isArray(blueprint)) { + errors.push({ + path: `agentBlueprints[${index}]`, + message: 'each agent blueprint must be an object', + value: blueprint, + }); + return; + } + + if (blueprint.id !== undefined) { + if (typeof blueprint.id !== 'string' || !PINNED_ID_PATTERN.test(blueprint.id)) { + errors.push({ + path: at('id'), + message: 'id must be a string of letters, numbers, hyphens or underscores if provided', + value: blueprint.id, + }); + } else if (seenIds.has(blueprint.id)) { + errors.push({ path: at('id'), message: 'id must be unique across agentBlueprints', value: blueprint.id }); + } else { + seenIds.add(blueprint.id); + } + } + + if (typeof blueprint.name !== 'string' || blueprint.name.length === 0 || blueprint.name.length > 255) { + errors.push({ + path: at('name'), + message: 'name is required and must be a string of 1 to 255 characters', + value: blueprint.name, + }); + } else if (seenNames.has(blueprint.name)) { + errors.push({ + path: at('name'), + message: 'name must be unique across agentBlueprints', + value: blueprint.name, + }); + } else { + seenNames.add(blueprint.name); + } + + if ( + blueprint.description !== undefined && + blueprint.description !== null && + (typeof blueprint.description !== 'string' || blueprint.description.length > 1000) + ) { + errors.push({ + path: at('description'), + message: 'description must be a string of at most 1000 characters, or null, if provided', + value: blueprint.description, + }); + } + + if (blueprint.permissions !== undefined) { + if (!Array.isArray(blueprint.permissions)) { + errors.push({ + path: at('permissions'), + message: 'permissions must be an array of permission slugs if provided', + value: blueprint.permissions, + }); + } else { + blueprint.permissions.forEach((slug, i) => { + if (typeof slug !== 'string' || !permissionSlugs.has(slug)) { + errors.push({ + path: at(`permissions[${i}]`), + message: 'permissions references a slug not defined in `permissions`', + value: slug, + }); + } + }); + } + } + + const invocableBy = blueprint.invocable_by; + if (invocableBy !== undefined) { + if (typeof invocableBy !== 'object' || invocableBy === null || Array.isArray(invocableBy)) { + errors.push({ + path: at('invocable_by'), + message: 'invocable_by must be an object if provided', + value: invocableBy, + }); + } else { + if (invocableBy.role_slugs !== undefined) { + if (!Array.isArray(invocableBy.role_slugs)) { + errors.push({ + path: at('invocable_by.role_slugs'), + message: 'invocable_by.role_slugs must be an array of role slugs if provided', + value: invocableBy.role_slugs, + }); + } else { + invocableBy.role_slugs.forEach((slug, i) => { + if (typeof slug !== 'string' || !roleSlugs.has(slug)) { + errors.push({ + path: at(`invocable_by.role_slugs[${i}]`), + message: 'invocable_by.role_slugs references a slug not defined in `roles`', + value: slug, + }); + } + }); + } + } + if (invocableBy.organizations !== undefined) { + if (!Array.isArray(invocableBy.organizations)) { + errors.push({ + path: at('invocable_by.organizations'), + message: 'invocable_by.organizations must be an array of organization names if provided', + value: invocableBy.organizations, + }); + } else { + invocableBy.organizations.forEach((name, i) => { + if (typeof name !== 'string' || !orgNames.has(name)) { + errors.push({ + path: at(`invocable_by.organizations[${i}]`), + message: 'invocable_by.organizations references a name not defined in `organizations`', + value: name, + }); + } + }); + } + } + } + } + + const settings = blueprint.session_settings; + if (settings !== undefined) { + if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) { + errors.push({ + path: at('session_settings'), + message: 'session_settings must be an object if provided', + value: settings, + }); + } else { + for (const key of Object.keys( + AGENT_SESSION_SETTING_LIMITS, + ) as (keyof typeof AGENT_SESSION_SETTING_LIMITS)[]) { + const value = settings[key]; + const max = AGENT_SESSION_SETTING_LIMITS[key]; + if (value !== undefined && (!Number.isInteger(value) || value <= 0 || value > max)) { + errors.push({ + path: at(`session_settings.${key}`), + message: `session_settings.${key} must be a positive integer of at most ${max} if provided`, + value, + }); + } + } + } + } + }); + } + } + // Validating the template here means `--validate-config` catches a broken one, rather // than leaving it to fail at the first sign-in. if (config.jwtTemplate !== undefined) { diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 9a42169..ce78249 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -566,3 +566,55 @@ export interface WorkOSWebhookEndpoint extends Entity { events: string[]; description: string | null; } + +export interface WorkOSAgentBlueprintInvocableBy { + /** Membership role slugs allowed to start delegated sessions; empty means any member. */ + role_slugs: string[]; + /** Organizations allowed to invoke the blueprint; empty means every organization. */ + organization_ids: string[]; +} + +export interface WorkOSAgentBlueprintSessionSettings { + /** Hard ceiling on how long any session chain rooted in a blueprint may live, in seconds. */ + max_age_seconds: number; + access_token_ttl_seconds: number; + refresh_token_ttl_seconds: number; +} + +export interface WorkOSAgentBlueprint extends Entity { + object: 'agent_blueprint'; + name: string; + description: string | null; + /** Permission slugs that cap what any instance of this blueprint may be granted. */ + permissions: string[]; + invocable_by: WorkOSAgentBlueprintInvocableBy; + session_settings: WorkOSAgentBlueprintSessionSettings; +} + +/** + * One instance exists per (blueprint, organization) for autonomous sessions and per + * (blueprint, organization, membership) for user-delegated ones; minting reuses it. + */ +export interface WorkOSAgentInstance extends Entity { + object: 'agent_instance'; + agent_blueprint_id: string; + organization_id: string; + organization_membership_id: string | null; + type: 'autonomous' | 'delegated'; +} + +export interface WorkOSAgentInstanceSession extends Entity { + object: 'agent_instance_session'; + agent_instance_id: string; + expires_at: string; + revoked_at: string | null; + /** Current refresh token; replaced on every refresh so a presented token is single-use. */ + refresh_token: string; + /** Session this one was chained from via an `agent_delegated` grant; null for chain roots. */ + parent_session_id: string | null; + /** Backing user session of a delegated chain root; only the root records it. */ + user_session_id: string | null; + /** Permission slugs granted at the last mint, reported on the session-created event. */ + permissions: string[]; + intent: string | null; +} diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 88807a2..6861335 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -21,6 +21,9 @@ import { import type { WorkOSStore } from './store.js'; import type { EventBus } from './event-bus.js'; import type { + WorkOSAgentBlueprint, + WorkOSAgentInstance, + WorkOSAgentInstanceSession, WorkOSOrganization, WorkOSOrganizationDomain, WorkOSOrganizationMembership, @@ -1226,3 +1229,80 @@ export function formatWebhookEndpoint( updated_at: ep.updated_at, }; } + +export function formatAgentBlueprint(b: WorkOSAgentBlueprint): Record { + return { + object: 'agent_blueprint', + id: b.id, + name: b.name, + description: b.description, + permissions: b.permissions, + invocable_by: { + role_slugs: b.invocable_by.role_slugs, + organization_ids: b.invocable_by.organization_ids, + }, + session_settings: { + max_age_seconds: b.session_settings.max_age_seconds, + access_token_ttl_seconds: b.session_settings.access_token_ttl_seconds, + refresh_token_ttl_seconds: b.session_settings.refresh_token_ttl_seconds, + }, + created_at: b.created_at, + updated_at: b.updated_at, + }; +} + +export function formatAgentInstance(i: WorkOSAgentInstance): Record { + return { + object: 'agent_instance', + id: i.id, + agent_blueprint_id: i.agent_blueprint_id, + organization_id: i.organization_id, + organization_membership_id: i.organization_membership_id, + type: i.type, + created_at: i.created_at, + updated_at: i.updated_at, + }; +} + +/** Status is derived at read time, as in production: no clock ever flips a stored row to `expired`. */ +export function agentSessionStatus(s: WorkOSAgentInstanceSession, now = Date.now()): 'active' | 'revoked' | 'expired' { + if (s.revoked_at !== null) return 'revoked'; + if (new Date(s.expires_at).getTime() <= now) return 'expired'; + return 'active'; +} + +export function formatAgentInstanceSession(s: WorkOSAgentInstanceSession): Record { + return { + object: 'agent_instance_session', + id: s.id, + agent_instance_id: s.agent_instance_id, + status: agentSessionStatus(s), + expires_at: s.expires_at, + revoked_at: s.revoked_at, + created_at: s.created_at, + updated_at: s.updated_at, + }; +} + +/** + * Session webhook payloads differ from the REST object: they carry the owning instance's + * `organization_id` and no `status`, and the created event adds the granted + * `permission_slugs`. The refresh token never leaves the store either way. + */ +export function formatAgentInstanceSessionEvent( + s: WorkOSAgentInstanceSession, + organizationId: string, + opts?: { permissionSlugs: string[] }, +): Record { + return { + object: 'agent_instance_session', + id: s.id, + agent_instance_id: s.agent_instance_id, + organization_id: organizationId, + expires_at: s.expires_at, + revoked_at: s.revoked_at, + created_at: s.created_at, + updated_at: s.updated_at, + ...(opts ? { permission_slugs: opts.permissionSlugs } : {}), + }; +} diff --git a/src/workos/index.ts b/src/workos/index.ts index 9dfe713..83b718b 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -37,6 +37,7 @@ import { oauthRoutes } from './routes/oauth.js'; import { directoryRoutes } from './routes/directories.js'; import { auditLogRoutes } from './routes/audit-logs.js'; import { featureFlagRoutes } from './routes/feature-flags.js'; +import { agentRoutes } from './routes/agents.js'; import { dataIntegrationRoutes } from './routes/data-integrations.js'; import { webhookEndpointRoutes } from './routes/webhook-endpoints.js'; import { eventRoutes } from './routes/events.js'; @@ -45,6 +46,7 @@ import { STORE_KEYS, EVENTS, DEFAULT_RESOURCE_TYPE_SLUG } from './constants.js'; import { validateSeedConfig, formatValidationErrors } from './config-validator.js'; import { validateJwtTemplateContent } from './jwt-template.js'; import { environmentIdFor, flagEventContext } from './flag-context.js'; +import { DEFAULT_AGENT_SESSION_SETTINGS, revokeAgentSessionsForUserSession } from './agent-sessions.js'; import { generateVerificationToken, hashPassword, @@ -68,6 +70,9 @@ import { formatApiKeyRecord, formatFeatureFlag, formatFeatureFlagEvent, + formatAgentBlueprint, + formatAgentInstance, + formatAgentInstanceSessionEvent, generateClientId, findUserByEmail, formatConnectedAccountEvent, @@ -342,6 +347,31 @@ export interface WorkOSSeedFeatureFlag { }; } +export interface WorkOSSeedAgentBlueprint { + /** Pinned blueprint id (e.g. `agent_blueprint_01ABC…`). Generated if omitted. */ + id?: string; + /** Required and unique within the environment, as production enforces on create. */ + name: string; + description?: string | null; + /** Slugs of permissions defined in `permissions`; the ceiling on what a minted session may hold. */ + permissions?: string[]; + invocable_by?: { + /** Slugs of roles defined in `roles`. Empty or omitted lets any member mint a delegated session. */ + role_slugs?: string[]; + /** + * Names of organizations defined in `organizations`, joined by name for the same reason + * feature-flag targets are. Empty or omitted lets every organization invoke the blueprint. + */ + organizations?: string[]; + }; + /** Defaults match production: 3600 / 300 / 3600 seconds. */ + session_settings?: { + max_age_seconds?: number; + access_token_ttl_seconds?: number; + refresh_token_ttl_seconds?: number; + }; +} + export interface WorkOSSeedJwtTemplate { /** * Template string rendering to a JSON object of claims, e.g. @@ -379,6 +409,11 @@ export interface WorkOSSeedConfig { * made in the dashboard — so seeding is the only way to get one into the emulator. */ featureFlags?: WorkOSSeedFeatureFlag[]; + /** + * Agent blueprints, so a test suite can mint agent tokens without a create call. Instances + * and sessions are never seeded: they only come into being by minting. + */ + agentBlueprints?: WorkOSSeedAgentBlueprint[]; } export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSeedConfig): void { @@ -826,6 +861,33 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee } } + // After permissions, roles and organizations, which every reference here resolves against. + if (config.agentBlueprints) { + for (const blueprintConfig of config.agentBlueprints) { + const organizationIds = (blueprintConfig.invocable_by?.organizations ?? []).map((name) => { + const org = ws.organizations.findOneBy('name', name); + if (!org) { + throw new Error( + `workos seed config: agentBlueprints[${JSON.stringify(blueprintConfig.name)}].invocable_by.organizations not found: ${JSON.stringify(name)}`, + ); + } + return org.id; + }); + ws.agentBlueprints.insert({ + object: 'agent_blueprint', + id: blueprintConfig.id, + name: blueprintConfig.name, + description: blueprintConfig.description ?? null, + permissions: [...new Set(blueprintConfig.permissions ?? [])], + invocable_by: { + role_slugs: [...new Set(blueprintConfig.invocable_by?.role_slugs ?? [])], + organization_ids: [...new Set(organizationIds)], + }, + session_settings: { ...DEFAULT_AGENT_SESSION_SETTINGS, ...blueprintConfig.session_settings }, + }); + } + } + if (config.jwtTemplate) { const problems = validateJwtTemplateContent(config.jwtTemplate.content); if (problems.length > 0) { @@ -879,6 +941,7 @@ export const workosPlugin: ServicePlugin = { directoryRoutes(ctx); auditLogRoutes(ctx); featureFlagRoutes(ctx); + agentRoutes(ctx); dataIntegrationRoutes(ctx); webhookEndpointRoutes(ctx); eventRoutes(ctx); @@ -975,7 +1038,10 @@ export const workosPlugin: ServicePlugin = { }); ws.sessions.setHooks({ onInsert: (s) => eventBus.emit({ event: EVENTS.sessionCreated, data: formatSession(s) }), - onDelete: (s) => eventBus.emit({ event: EVENTS.sessionRevoked, data: formatSession(s) }), + onDelete: (s) => { + eventBus.emit({ event: EVENTS.sessionRevoked, data: formatSession(s) }); + revokeAgentSessionsForUserSession(ws, s.id); + }, }); ws.invitations.setHooks({ onInsert: (i) => eventBus.emit({ event: EVENTS.invitationCreated, data: formatInvitation(i) }), @@ -1061,6 +1127,37 @@ export const workosPlugin: ServicePlugin = { onUpdate: flagEvent(EVENTS.flagUpdated), onDelete: flagEvent(EVENTS.flagDeleted), }); + ws.agentBlueprints.setHooks({ + onInsert: (b) => eventBus.emit({ event: EVENTS.agentBlueprintCreated, data: formatAgentBlueprint(b) }), + onUpdate: (b) => eventBus.emit({ event: EVENTS.agentBlueprintUpdated, data: formatAgentBlueprint(b) }), + onDelete: (b) => eventBus.emit({ event: EVENTS.agentBlueprintDeleted, data: formatAgentBlueprint(b) }), + }); + ws.agentInstances.setHooks({ + onInsert: (i) => eventBus.emit({ event: EVENTS.agentInstanceCreated, data: formatAgentInstance(i) }), + onDelete: (i) => eventBus.emit({ event: EVENTS.agentInstanceDeleted, data: formatAgentInstance(i) }), + }); + // Session payloads need the owning instance's organization_id. Refresh rotation goes + // through updateSilent, so the only update that reaches this hook is a revocation; the + // spec has no session.deleted event, and teardown revokes live sessions before deleting. + ws.agentInstanceSessions.setHooks({ + onInsert: (s) => { + const instance = ws.agentInstances.get(s.agent_instance_id); + if (!instance) return; + eventBus.emit({ + event: EVENTS.agentInstanceSessionCreated, + data: formatAgentInstanceSessionEvent(s, instance.organization_id, { permissionSlugs: s.permissions }), + }); + }, + onUpdate: (s, prev) => { + if (s.revoked_at === null || prev.revoked_at !== null) return; + const instance = ws.agentInstances.get(s.agent_instance_id); + if (!instance) return; + eventBus.emit({ + event: EVENTS.agentInstanceSessionRevoked, + data: formatAgentInstanceSessionEvent(s, instance.organization_id), + }); + }, + }); ws.webhookEndpoints.setHooks({ onInsert: () => eventBus.rebuildIndex(), onUpdate: () => eventBus.rebuildIndex(), diff --git a/src/workos/routes/agents.spec.ts b/src/workos/routes/agents.spec.ts new file mode 100644 index 0000000..fc7ca07 --- /dev/null +++ b/src/workos/routes/agents.spec.ts @@ -0,0 +1,876 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { createServer, type ApiKeyMap, type Store } from '../../core/index.js'; +import { workosPlugin } from '../index.js'; +import { getWorkOSStore } from '../store.js'; +import { hashPassword } from '../helpers.js'; + +const apiKeys: ApiKeyMap = { sk_test_agents: { environment: 'test' } }; +const headers = { Authorization: 'Bearer sk_test_agents', 'Content-Type': 'application/json' }; + +const BLUEPRINT_KEYS = [ + 'object', + 'id', + 'name', + 'description', + 'permissions', + 'invocable_by', + 'session_settings', + 'created_at', + 'updated_at', +].sort(); +const INSTANCE_KEYS = [ + 'object', + 'id', + 'agent_blueprint_id', + 'organization_id', + 'organization_membership_id', + 'type', + 'created_at', + 'updated_at', +].sort(); +const SESSION_KEYS = [ + 'object', + 'id', + 'agent_instance_id', + 'status', + 'expires_at', + 'revoked_at', + 'created_at', + 'updated_at', +].sort(); +const MINT_KEYS = [ + 'access_token', + 'token_type', + 'expires_in', + 'refresh_token', + 'agent_instance_id', + 'new_instance', + 'agent_instance_session_id', + 'permissions', +].sort(); + +function createTestApp() { + return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); +} + +function decodeJwt(token: string): { header: Record; payload: Record } { + const [h, p] = token.split('.'); + const decode = (s: string) => JSON.parse(Buffer.from(s, 'base64url').toString('utf8')); + return { header: decode(h!), payload: decode(p!) }; +} + +describe('Agent Auth routes', () => { + let app: ReturnType['app']; + let store: Store; + const ws = () => getWorkOSStore(store); + + beforeEach(() => { + const server = createTestApp(); + app = server.app; + store = server.store; + }); + + const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); + const json = (res: Response) => res.json() as Promise; + const post = (path: string, body: unknown) => req(path, { method: 'POST', body: JSON.stringify(body) }); + const events = (name: string) => + ws() + .events.all() + .filter((e) => e.event === name); + + function seedOrg(name = 'Acme Corp') { + return ws().organizations.insert({ + object: 'organization', + name, + allow_profiles_outside_organization: false, + external_id: null, + metadata: {}, + entitlements: [], + stripe_customer_id: null, + }); + } + + function seedPermission(slug: string) { + return ws().permissions.insert({ object: 'permission', slug, name: slug, description: null }); + } + + function seedRole(slug: string, permissionSlugs: string[]) { + const role = ws().roles.insert({ + object: 'role', + slug, + name: slug, + description: null, + type: 'EnvironmentRole', + organization_id: null, + is_default_role: false, + priority: 0, + }); + for (const p of permissionSlugs) { + const permission = ws().permissions.findOneBy('slug', p) ?? seedPermission(p); + ws().rolePermissions.insert({ role_id: role.id, permission_id: permission.id }); + } + return role; + } + + function seedUser(email = 'alice@acme.com', password = 'secret') { + return ws().users.insert({ + object: 'user', + email, + name: null, + first_name: null, + last_name: null, + email_verified: true, + profile_picture_url: null, + last_sign_in_at: null, + external_id: null, + metadata: {}, + locale: null, + password_hash: hashPassword(password), + impersonator: null, + oauth_provider: null, + }); + } + + function seedMembership(organizationId: string, userId: string, roleSlug: string) { + return ws().organizationMemberships.insert({ + object: 'organization_membership', + organization_id: organizationId, + user_id: userId, + role: { slug: roleSlug }, + status: 'active', + external_id: null, + metadata: {}, + }); + } + + async function loginAs(email = 'alice@acme.com', password = 'secret') { + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email, password }), + }); + expect(res.status).toBe(200); + return json(res); + } + + /** Org with a manager (crm:read, email:send) and a member (crm:read), plus a blueprint over both slugs. */ + async function seedWorld(blueprintOverrides: Record = {}) { + const org = seedOrg(); + seedRole('manager', ['crm:read', 'email:send']); + seedRole('member', ['crm:read']); + const alice = seedUser(); + const membership = seedMembership(org.id, alice.id, 'manager'); + const res = await post('/agents/blueprints', { + name: 'Prospecting Agent', + permissions: ['crm:read', 'email:send'], + ...blueprintOverrides, + }); + expect(res.status).toBe(201); + const blueprint = await json(res); + return { org, alice, membership, blueprint }; + } + + const mint = (blueprintId: string, body: unknown) => post(`/agents/blueprints/${blueprintId}/tokens`, body); + + async function mintOk(blueprintId: string, body: unknown) { + const res = await mint(blueprintId, body); + expect(res.status).toBe(200); + return json(res); + } + + async function expectError(res: Response, status: number, code: string) { + expect(res.status).toBe(status); + expect((await json(res)).code).toBe(code); + } + + describe('blueprints', () => { + it('creates a blueprint with defaults and the documented shape', async () => { + seedPermission('crm:read'); + const res = await post('/agents/blueprints', { name: 'Reader', permissions: ['crm:read'] }); + expect(res.status).toBe(201); + const body = await json(res); + expect(Object.keys(body).sort()).toEqual(BLUEPRINT_KEYS); + expect(body.id).toStartWith('agent_blueprint_'); + expect(body).toMatchObject({ + object: 'agent_blueprint', + name: 'Reader', + description: null, + permissions: ['crm:read'], + invocable_by: { role_slugs: [], organization_ids: [] }, + session_settings: { max_age_seconds: 3600, access_token_ttl_seconds: 300, refresh_token_ttl_seconds: 3600 }, + }); + expect(events('agent.blueprint.created')).toHaveLength(1); + expect(Object.keys(events('agent.blueprint.created')[0]!.data).sort()).toEqual(BLUEPRINT_KEYS); + }); + + it('rejects a malformed body with field errors', async () => { + const res = await post('/agents/blueprints', { + description: '', + permissions: 'crm:read', + session_settings: { access_token_ttl_seconds: 3601, max_age_seconds: 0 }, + }); + expect(res.status).toBe(400); + const body = await json(res); + expect(body.code).toBe('invalid_request'); + expect(body.errors.map((e: { field: string }) => e.field).sort()).toEqual([ + 'description', + 'name', + 'permissions', + 'session_settings.access_token_ttl_seconds', + 'session_settings.max_age_seconds', + ]); + }); + + it('rejects unknown permissions, roles and organizations with 422 codes', async () => { + await expectError( + await post('/agents/blueprints', { name: 'A', permissions: ['nope'] }), + 422, + 'permission_not_found', + ); + await expectError( + await post('/agents/blueprints', { name: 'A', invocable_by: { role_slugs: ['nope'] } }), + 422, + 'role_not_found', + ); + await expectError( + await post('/agents/blueprints', { name: 'A', invocable_by: { organization_ids: ['org_nope'] } }), + 422, + 'organization_not_found', + ); + }); + + it('rejects a duplicate name with 409 on create and update', async () => { + await post('/agents/blueprints', { name: 'One' }); + const two = await json(await post('/agents/blueprints', { name: 'Two' })); + await expectError(await post('/agents/blueprints', { name: 'One' }), 409, 'name_already_in_use'); + await expectError( + await req(`/agents/blueprints/${two.id}`, { method: 'PATCH', body: JSON.stringify({ name: 'One' }) }), + 409, + 'name_already_in_use', + ); + // Renaming to its own name is not a conflict. + const same = await req(`/agents/blueprints/${two.id}`, { + method: 'PATCH', + body: JSON.stringify({ name: 'Two' }), + }); + expect(same.status).toBe(200); + }); + + it('gets, lists with cursor pagination, and 404s a missing blueprint', async () => { + for (const name of ['A', 'B', 'C']) await post('/agents/blueprints', { name }); + const page1 = await json(await req('/agents/blueprints?limit=2')); + expect(page1.object).toBe('list'); + expect(page1.data).toHaveLength(2); + expect(page1.list_metadata.after).toBeTruthy(); + const page2 = await json(await req(`/agents/blueprints?limit=2&after=${page1.list_metadata.after}`)); + expect(page2.data).toHaveLength(1); + expect(page2.list_metadata.after).toBeNull(); + const ids = new Set([...page1.data, ...page2.data].map((b: { id: string }) => b.id)); + expect(ids.size).toBe(3); + + const one = await req(`/agents/blueprints/${page1.data[0].id}`); + expect(one.status).toBe(200); + expect((await json(one)).id).toBe(page1.data[0].id); + expect((await req('/agents/blueprints/agent_blueprint_missing')).status).toBe(404); + }); + + it('patches fields individually, leaving the rest intact', async () => { + const { org, blueprint } = await seedWorld(); + const res = await req(`/agents/blueprints/${blueprint.id}`, { + method: 'PATCH', + body: JSON.stringify({ + description: 'Finds prospects', + invocable_by: { organization_ids: [org.id] }, + session_settings: { access_token_ttl_seconds: 60 }, + }), + }); + expect(res.status).toBe(200); + const body = await json(res); + expect(body).toMatchObject({ + name: 'Prospecting Agent', + description: 'Finds prospects', + permissions: ['crm:read', 'email:send'], + invocable_by: { role_slugs: [], organization_ids: [org.id] }, + session_settings: { max_age_seconds: 3600, access_token_ttl_seconds: 60, refresh_token_ttl_seconds: 3600 }, + }); + expect(events('agent.blueprint.updated')).toHaveLength(1); + }); + + it('deletes a blueprint and tears down its instances and sessions', async () => { + const { org, blueprint } = await seedWorld(); + const minted = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const res = await req(`/agents/blueprints/${blueprint.id}`, { method: 'DELETE' }); + expect(res.status).toBe(204); + expect((await req(`/agents/blueprints/${blueprint.id}`)).status).toBe(404); + expect((await req(`/agents/instances/${minted.agent_instance_id}`)).status).toBe(404); + expect((await req(`/agents/sessions/${minted.agent_instance_session_id}`)).status).toBe(404); + expect(events('agent.blueprint.deleted')).toHaveLength(1); + expect(events('agent.instance.deleted')).toHaveLength(1); + expect(events('agent.instance.session.revoked')).toHaveLength(1); + expect(events('agent.blueprint.deleted')[0]!.data).toMatchObject({ id: blueprint.id, name: 'Prospecting Agent' }); + }); + }); + + describe('autonomous tokens', () => { + it('mints a token carrying the blueprint ceiling and the documented claims', async () => { + const { org, blueprint } = await seedWorld(); + const body = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id, intent: 'Find leads' }); + expect(Object.keys(body).sort()).toEqual(MINT_KEYS); + expect(body).toMatchObject({ + token_type: 'Bearer', + expires_in: 300, + new_instance: true, + permissions: ['crm:read', 'email:send'], + }); + expect(body.agent_instance_id).toStartWith('agent_'); + expect(body.agent_instance_session_id).toStartWith('agent_session_'); + + const { header, payload } = decodeJwt(body.access_token); + expect(header.typ).toBe('at+jwt'); + expect(header.alg).toBe('RS256'); + expect(payload).toMatchObject({ + sub: body.agent_instance_id, + sub_profile: 'ai_agent', + sid: body.agent_instance_session_id, + org_id: org.id, + permissions: ['crm:read', 'email:send'], + intent: { text: 'Find leads' }, + aud: 'workos-emulate', + }); + expect(payload.act).toBeUndefined(); + expect(payload.auth_time).toBeUndefined(); + expect(payload.exp - payload.iat).toBe(300); + + const instance = await json(await req(`/agents/instances/${body.agent_instance_id}`)); + expect(Object.keys(instance).sort()).toEqual(INSTANCE_KEYS); + expect(instance).toMatchObject({ + object: 'agent_instance', + agent_blueprint_id: blueprint.id, + organization_id: org.id, + organization_membership_id: null, + type: 'autonomous', + }); + expect(events('agent.instance.created')).toHaveLength(1); + const created = events('agent.instance.session.created'); + expect(created).toHaveLength(1); + expect(created[0]!.data).toMatchObject({ + object: 'agent_instance_session', + id: body.agent_instance_session_id, + organization_id: org.id, + revoked_at: null, + permission_slugs: ['crm:read', 'email:send'], + }); + expect(created[0]!.data).not.toHaveProperty('refresh_token'); + }); + + it('reuses the instance for the same blueprint and organization', async () => { + const { org, blueprint } = await seedWorld(); + const first = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const second = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + expect(second.agent_instance_id).toBe(first.agent_instance_id); + expect(second.new_instance).toBe(false); + expect(second.agent_instance_session_id).not.toBe(first.agent_instance_session_id); + expect(events('agent.instance.created')).toHaveLength(1); + }); + + it('requires organization_id, an existing organization, and an invocable one', async () => { + const { org, blueprint } = await seedWorld(); + const other = seedOrg('Other Inc'); + await expectError(await mint(blueprint.id, { type: 'autonomous' }), 400, 'invalid_request'); + expect((await mint(blueprint.id, { type: 'autonomous', organization_id: 'org_missing' })).status).toBe(404); + await req(`/agents/blueprints/${blueprint.id}`, { + method: 'PATCH', + body: JSON.stringify({ invocable_by: { organization_ids: [org.id] } }), + }); + await expectError( + await mint(blueprint.id, { type: 'autonomous', organization_id: other.id }), + 403, + 'organization_not_invocable', + ); + expect((await mint(blueprint.id, { type: 'autonomous', organization_id: org.id })).status).toBe(200); + }); + + it('rejects an unknown grant type and a missing blueprint', async () => { + const { blueprint } = await seedWorld(); + await expectError(await mint(blueprint.id, { type: 'client_credentials' }), 400, 'invalid_request'); + expect((await mint('agent_blueprint_missing', { type: 'autonomous', organization_id: 'x' })).status).toBe(404); + }); + + it('caps the access token TTL at the session lifetime', async () => { + const { org, blueprint } = await seedWorld({ + session_settings: { access_token_ttl_seconds: 300, refresh_token_ttl_seconds: 120 }, + }); + const body = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + expect(body.expires_in).toBe(120); + }); + }); + + describe('user-delegated tokens', () => { + it('mints a token scoped to the member and the intersection of permissions', async () => { + const { org, alice, membership, blueprint } = await seedWorld({ permissions: ['crm:read'] }); + const login = await loginAs(); + const body = await mintOk(blueprint.id, { + type: 'user_delegated', + user_access_token: login.access_token, + intent: 'Draft outreach', + }); + expect(body.permissions).toEqual(['crm:read']); + expect(body.new_instance).toBe(true); + + const { payload } = decodeJwt(body.access_token); + const userClaims = decodeJwt(login.access_token).payload; + expect(payload).toMatchObject({ + sub: body.agent_instance_id, + sub_profile: 'ai_agent', + org_id: org.id, + permissions: ['crm:read'], + act: { sub: alice.id, sub_profile: 'user' }, + }); + expect(payload.auth_time).toBe( + Math.floor(new Date(ws().sessions.get(userClaims.sid)!.created_at).getTime() / 1000), + ); + + const instance = await json(await req(`/agents/instances/${body.agent_instance_id}`)); + expect(instance).toMatchObject({ type: 'delegated', organization_membership_id: membership.id }); + }); + + it('narrows to what the role currently grants, not what the blueprint allows', async () => { + const { org, blueprint } = await seedWorld(); + const bob = seedUser('bob@acme.com'); + seedMembership(org.id, bob.id, 'member'); + const login = await loginAs('bob@acme.com'); + const body = await mintOk(blueprint.id, { type: 'user_delegated', user_access_token: login.access_token }); + expect(body.permissions).toEqual(['crm:read']); + }); + + it('rejects garbage, foreign and agent tokens as invalid_user_access_token', async () => { + const { org, blueprint } = await seedWorld(); + await expectError(await mint(blueprint.id, { type: 'user_delegated' }), 400, 'invalid_request'); + await expectError( + await mint(blueprint.id, { type: 'user_delegated', user_access_token: 'not.a.jwt' }), + 400, + 'invalid_user_access_token', + ); + const agent = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + await expectError( + await mint(blueprint.id, { type: 'user_delegated', user_access_token: agent.access_token }), + 400, + 'invalid_user_access_token', + ); + }); + + it('rejects a token whose user session has been revoked', async () => { + const { blueprint } = await seedWorld(); + const login = await loginAs(); + const { sid } = decodeJwt(login.access_token).payload; + await post('/user_management/sessions/revoke', { session_id: sid }); + await expectError( + await mint(blueprint.id, { type: 'user_delegated', user_access_token: login.access_token }), + 400, + 'invalid_user_access_token', + ); + }); + + it('enforces membership, organization invocability and role invocability in that order', async () => { + const { membership, blueprint } = await seedWorld(); + const login = await loginAs(); + const grant = { type: 'user_delegated', user_access_token: login.access_token }; + + await req(`/agents/blueprints/${blueprint.id}`, { + method: 'PATCH', + body: JSON.stringify({ invocable_by: { role_slugs: ['member'] } }), + }); + await expectError(await mint(blueprint.id, grant), 403, 'role_not_invocable'); + + const other = seedOrg('Other Inc'); + await req(`/agents/blueprints/${blueprint.id}`, { + method: 'PATCH', + body: JSON.stringify({ invocable_by: { role_slugs: [], organization_ids: [other.id] } }), + }); + await expectError(await mint(blueprint.id, grant), 403, 'organization_not_invocable'); + + ws().organizationMemberships.update(membership.id, { status: 'inactive' }); + await expectError(await mint(blueprint.id, grant), 403, 'user_not_member_of_organization'); + }); + + it('rejects a login older than max_age_seconds', async () => { + const { blueprint } = await seedWorld({ session_settings: { max_age_seconds: 60 } }); + const login = await loginAs(); + const { sid } = decodeJwt(login.access_token).payload; + ws().sessions.updateSilent(sid, { created_at: new Date(Date.now() - 120_000).toISOString() }); + await expectError( + await mint(blueprint.id, { type: 'user_delegated', user_access_token: login.access_token }), + 400, + 'max_age_exceeded', + ); + }); + + it('revokes delegated agent sessions when the user session ends', async () => { + const { blueprint } = await seedWorld(); + const login = await loginAs(); + const agent = await mintOk(blueprint.id, { type: 'user_delegated', user_access_token: login.access_token }); + const child = await mintOk(blueprint.id, { type: 'agent_delegated', agent_access_token: agent.access_token }); + + const { sid } = decodeJwt(login.access_token).payload; + await post('/user_management/sessions/revoke', { session_id: sid }); + + for (const id of [agent.agent_instance_session_id, child.agent_instance_session_id]) { + expect((await json(await req(`/agents/sessions/${id}`))).status).toBe('revoked'); + } + expect(events('agent.instance.session.revoked')).toHaveLength(2); + await expectError( + await post(`/agents/blueprints/${blueprint.id}/tokens/validate`, { agent_access_token: agent.access_token }), + 400, + 'session_revoked', + ); + }); + }); + + describe('agent-delegated tokens', () => { + it('chains a new session on the same instance and keeps the delegating user in act', async () => { + const { alice, blueprint } = await seedWorld(); + const login = await loginAs(); + const root = await mintOk(blueprint.id, { type: 'user_delegated', user_access_token: login.access_token }); + const child = await mintOk(blueprint.id, { + type: 'agent_delegated', + agent_access_token: root.access_token, + intent: 'Sub-task', + }); + expect(child.agent_instance_id).toBe(root.agent_instance_id); + expect(child.new_instance).toBe(false); + expect(child.agent_instance_session_id).not.toBe(root.agent_instance_session_id); + const { payload } = decodeJwt(child.access_token); + expect(payload.act).toEqual({ sub: alice.id, sub_profile: 'user' }); + expect(payload.intent).toEqual({ text: 'Sub-task' }); + expect(typeof payload.auth_time).toBe('number'); + }); + + it('rejects tokens from another blueprint, revoked sessions and non-agent tokens', async () => { + const { org, blueprint } = await seedWorld(); + const otherRes = await post('/agents/blueprints', { name: 'Other Agent', permissions: ['crm:read'] }); + const other = await json(otherRes); + const foreign = await mintOk(other.id, { type: 'autonomous', organization_id: org.id }); + await expectError( + await mint(blueprint.id, { type: 'agent_delegated', agent_access_token: foreign.access_token }), + 400, + 'invalid_agent_access_token', + ); + + const login = await loginAs(); + await expectError( + await mint(blueprint.id, { type: 'agent_delegated', agent_access_token: login.access_token }), + 400, + 'invalid_agent_access_token', + ); + + const own = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + await post(`/agents/sessions/${own.agent_instance_session_id}/revoke`, {}); + await expectError( + await mint(blueprint.id, { type: 'agent_delegated', agent_access_token: own.access_token }), + 400, + 'invalid_agent_access_token', + ); + }); + + it('caps the chain depth at 32', async () => { + const { org, blueprint } = await seedWorld(); + let token = (await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id })).access_token; + for (let hop = 1; hop <= 32; hop++) { + const res = await mint(blueprint.id, { type: 'agent_delegated', agent_access_token: token }); + expect(res.status).toBe(200); + token = (await json(res)).access_token; + } + await expectError( + await mint(blueprint.id, { type: 'agent_delegated', agent_access_token: token }), + 400, + 'chain_depth_exceeded', + ); + }); + + it('anchors every hop to the root session max-age window', async () => { + const { org, blueprint } = await seedWorld({ + session_settings: { max_age_seconds: 600, refresh_token_ttl_seconds: 3600 }, + }); + const root = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const rootRow = ws().agentInstanceSessions.get(root.agent_instance_session_id)!; + const windowEnd = new Date(rootRow.created_at).getTime() + 600_000; + + const child = await mintOk(blueprint.id, { type: 'agent_delegated', agent_access_token: root.access_token }); + const childRow = ws().agentInstanceSessions.get(child.agent_instance_session_id)!; + expect(new Date(childRow.expires_at).getTime()).toBe(windowEnd); + + ws().agentInstanceSessions.updateSilent(root.agent_instance_session_id, { + created_at: new Date(Date.now() - 601_000).toISOString(), + }); + await expectError( + await mint(blueprint.id, { type: 'agent_delegated', agent_access_token: root.access_token }), + 400, + 'max_age_exceeded', + ); + }); + }); + + describe('refresh tokens', () => { + it('rotates the refresh token and rejects a replay', async () => { + const { org, blueprint } = await seedWorld(); + const first = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const second = await mintOk(blueprint.id, { type: 'refresh', refresh_token: first.refresh_token }); + expect(second.agent_instance_session_id).toBe(first.agent_instance_session_id); + expect(second.refresh_token).not.toBe(first.refresh_token); + expect(second.new_instance).toBe(false); + await expectError( + await mint(blueprint.id, { type: 'refresh', refresh_token: first.refresh_token }), + 400, + 'invalid_refresh_token', + ); + const third = await mintOk(blueprint.id, { type: 'refresh', refresh_token: second.refresh_token }); + expect(third.agent_instance_session_id).toBe(first.agent_instance_session_id); + // Rotation is not a new session and not a revocation. + expect(events('agent.instance.session.created')).toHaveLength(1); + expect(events('agent.instance.session.revoked')).toHaveLength(0); + }); + + it('rejects a refresh token presented to another blueprint', async () => { + const { org, blueprint } = await seedWorld(); + const other = await json(await post('/agents/blueprints', { name: 'Other Agent' })); + const minted = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + await expectError( + await mint(other.id, { type: 'refresh', refresh_token: minted.refresh_token }), + 400, + 'invalid_refresh_token', + ); + }); + + it('reports revoked and expired sessions with their own codes', async () => { + const { org, blueprint } = await seedWorld(); + const revoked = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + await post(`/agents/sessions/${revoked.agent_instance_session_id}/revoke`, {}); + await expectError( + await mint(blueprint.id, { type: 'refresh', refresh_token: revoked.refresh_token }), + 400, + 'session_revoked', + ); + + const expired = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + ws().agentInstanceSessions.updateSilent(expired.agent_instance_session_id, { + expires_at: new Date(Date.now() - 1000).toISOString(), + }); + await expectError( + await mint(blueprint.id, { type: 'refresh', refresh_token: expired.refresh_token }), + 400, + 'session_expired', + ); + }); + + it('recomputes delegated permissions from the current role on refresh', async () => { + const { membership, blueprint } = await seedWorld(); + const login = await loginAs(); + const first = await mintOk(blueprint.id, { type: 'user_delegated', user_access_token: login.access_token }); + expect(first.permissions).toEqual(['crm:read', 'email:send']); + + ws().organizationMemberships.update(membership.id, { role: { slug: 'member' } }); + const refreshed = await mintOk(blueprint.id, { type: 'refresh', refresh_token: first.refresh_token }); + expect(refreshed.permissions).toEqual(['crm:read']); + expect(decodeJwt(refreshed.access_token).payload.permissions).toEqual(['crm:read']); + }); + + it('fails with user_session_ended once the delegating user session is gone', async () => { + const { blueprint } = await seedWorld(); + const login = await loginAs(); + const minted = await mintOk(blueprint.id, { type: 'user_delegated', user_access_token: login.access_token }); + const { sid } = decodeJwt(login.access_token).payload; + // Expire the user session in place; deleting it would cascade a revocation instead. + ws().sessions.updateSilent(sid, { expires_at: new Date(Date.now() - 1000).toISOString() }); + await expectError( + await mint(blueprint.id, { type: 'refresh', refresh_token: minted.refresh_token }), + 400, + 'user_session_ended', + ); + await expectError( + await post(`/agents/blueprints/${blueprint.id}/tokens/validate`, { agent_access_token: minted.access_token }), + 400, + 'user_session_ended', + ); + }); + + it('never extends a session past the root max-age window', async () => { + const { org, blueprint } = await seedWorld({ + session_settings: { max_age_seconds: 600, refresh_token_ttl_seconds: 3600 }, + }); + const minted = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const row = ws().agentInstanceSessions.get(minted.agent_instance_session_id)!; + const windowEnd = new Date(row.created_at).getTime() + 600_000; + const refreshed = await mintOk(blueprint.id, { type: 'refresh', refresh_token: minted.refresh_token }); + const rotated = ws().agentInstanceSessions.get(minted.agent_instance_session_id)!; + expect(new Date(rotated.expires_at).getTime()).toBe(windowEnd); + expect(refreshed.expires_in).toBe(300); + }); + }); + + describe('token validation', () => { + it('validates a live token and reports its session', async () => { + const { org, alice, blueprint } = await seedWorld(); + const login = await loginAs(); + const minted = await mintOk(blueprint.id, { + type: 'user_delegated', + user_access_token: login.access_token, + intent: 'Qualify', + }); + const res = await post(`/agents/blueprints/${blueprint.id}/tokens/validate`, { + agent_access_token: minted.access_token, + }); + expect(res.status).toBe(200); + const body = await json(res); + const row = ws().agentInstanceSessions.get(minted.agent_instance_session_id)!; + expect(body).toEqual({ + valid: true, + agent_instance_id: minted.agent_instance_id, + agent_instance_session_id: minted.agent_instance_session_id, + organization_id: org.id, + permissions: ['crm:read', 'email:send'], + intent: 'Qualify', + acting_user_id: alice.id, + session_expires_at: row.expires_at, + }); + }); + + it('reports null intent and acting user for an autonomous token', async () => { + const { org, blueprint } = await seedWorld(); + const minted = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const body = await json( + await post(`/agents/blueprints/${blueprint.id}/tokens/validate`, { agent_access_token: minted.access_token }), + ); + expect(body).toMatchObject({ valid: true, intent: null, acting_user_id: null, organization_id: org.id }); + }); + + it('rejects malformed, foreign-blueprint, revoked and expired tokens', async () => { + const { org, blueprint } = await seedWorld(); + const validate = (id: string, token: unknown) => + post(`/agents/blueprints/${id}/tokens/validate`, { agent_access_token: token }); + await expectError(await validate(blueprint.id, undefined), 400, 'invalid_request'); + await expectError(await validate(blueprint.id, 'nope'), 400, 'invalid_agent_access_token'); + + const other = await json(await post('/agents/blueprints', { name: 'Other Agent' })); + const minted = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + await expectError(await validate(other.id, minted.access_token), 400, 'invalid_agent_access_token'); + + ws().agentInstanceSessions.updateSilent(minted.agent_instance_session_id, { + expires_at: new Date(Date.now() - 1000).toISOString(), + }); + await expectError(await validate(blueprint.id, minted.access_token), 400, 'session_expired'); + + const second = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + await post(`/agents/sessions/${second.agent_instance_session_id}/revoke`, {}); + await expectError(await validate(blueprint.id, second.access_token), 400, 'session_revoked'); + }); + }); + + describe('instances and sessions', () => { + it('lists instances filtered by organization and blueprint', async () => { + const { org, blueprint } = await seedWorld(); + const other = seedOrg('Other Inc'); + const second = await json(await post('/agents/blueprints', { name: 'Other Agent' })); + await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + await mintOk(blueprint.id, { type: 'autonomous', organization_id: other.id }); + await mintOk(second.id, { type: 'autonomous', organization_id: org.id }); + + expect((await json(await req('/agents/instances'))).data).toHaveLength(3); + const byOrg = await json(await req(`/agents/instances?organization_id=${org.id}`)); + expect(byOrg.data).toHaveLength(2); + const byBoth = await json( + await req(`/agents/instances?organization_id=${org.id}&agent_blueprint_id=${blueprint.id}`), + ); + expect(byBoth.data).toHaveLength(1); + expect(Object.keys(byBoth.data[0]).sort()).toEqual(INSTANCE_KEYS); + expect((await req('/agents/instances/agent_missing')).status).toBe(404); + }); + + it('deletes an instance, revoking then removing its sessions', async () => { + const { org, blueprint } = await seedWorld(); + const minted = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const res = await req(`/agents/instances/${minted.agent_instance_id}`, { method: 'DELETE' }); + expect(res.status).toBe(204); + expect((await req(`/agents/instances/${minted.agent_instance_id}`)).status).toBe(404); + expect((await req(`/agents/sessions/${minted.agent_instance_session_id}`)).status).toBe(404); + expect(events('agent.instance.deleted')).toHaveLength(1); + expect(events('agent.instance.session.revoked')).toHaveLength(1); + expect((await req(`/agents/instances/${minted.agent_instance_id}`, { method: 'DELETE' })).status).toBe(404); + }); + + it('lists sessions filtered by instance and blueprint with derived status', async () => { + const { org, blueprint } = await seedWorld(); + const second = await json(await post('/agents/blueprints', { name: 'Other Agent' })); + const a = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const b = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + await mintOk(second.id, { type: 'autonomous', organization_id: org.id }); + ws().agentInstanceSessions.updateSilent(b.agent_instance_session_id, { + expires_at: new Date(Date.now() - 1000).toISOString(), + }); + + expect((await json(await req('/agents/sessions'))).data).toHaveLength(3); + const byBlueprint = await json(await req(`/agents/sessions?agent_blueprint_id=${blueprint.id}`)); + expect(byBlueprint.data).toHaveLength(2); + const byInstance = await json(await req(`/agents/sessions?agent_instance_id=${a.agent_instance_id}`)); + expect(byInstance.data).toHaveLength(2); + expect(Object.keys(byInstance.data[0]).sort()).toEqual(SESSION_KEYS); + const statuses = Object.fromEntries(byInstance.data.map((s: { id: string; status: string }) => [s.id, s.status])); + expect(statuses[a.agent_instance_session_id]).toBe('active'); + expect(statuses[b.agent_instance_session_id]).toBe('expired'); + expect((await req('/agents/sessions/agent_session_missing')).status).toBe(404); + }); + + it('revokes a session and everything chained from it, idempotently', async () => { + const { org, blueprint } = await seedWorld(); + const root = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const child = await mintOk(blueprint.id, { type: 'agent_delegated', agent_access_token: root.access_token }); + const grandchild = await mintOk(blueprint.id, { + type: 'agent_delegated', + agent_access_token: child.access_token, + }); + const sibling = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + + const res = await post(`/agents/sessions/${child.agent_instance_session_id}/revoke`, {}); + expect(res.status).toBe(200); + const body = await json(res); + expect(body).toMatchObject({ id: child.agent_instance_session_id, status: 'revoked' }); + expect(body.revoked_at).toBeTruthy(); + + const status = async (id: string) => (await json(await req(`/agents/sessions/${id}`))).status; + expect(await status(root.agent_instance_session_id)).toBe('active'); + expect(await status(grandchild.agent_instance_session_id)).toBe('revoked'); + expect(await status(sibling.agent_instance_session_id)).toBe('active'); + + const revokedEvents = events('agent.instance.session.revoked'); + expect(revokedEvents).toHaveLength(2); + expect(Object.keys(revokedEvents[0]!.data).sort()).toEqual( + [ + 'object', + 'id', + 'agent_instance_id', + 'organization_id', + 'expires_at', + 'revoked_at', + 'created_at', + 'updated_at', + ].sort(), + ); + + const again = await json(await post(`/agents/sessions/${child.agent_instance_session_id}/revoke`, {})); + expect(again.revoked_at).toBe(body.revoked_at); + expect(events('agent.instance.session.revoked')).toHaveLength(2); + + // A revoked ancestor poisons the chain below it for delegation and refresh. + await expectError( + await mint(blueprint.id, { type: 'agent_delegated', agent_access_token: grandchild.access_token }), + 400, + 'invalid_agent_access_token', + ); + await expectError( + await mint(blueprint.id, { type: 'refresh', refresh_token: grandchild.refresh_token }), + 400, + 'session_revoked', + ); + expect((await post('/agents/sessions/agent_session_missing/revoke', {})).status).toBe(404); + }); + }); +}); diff --git a/src/workos/routes/agents.ts b/src/workos/routes/agents.ts new file mode 100644 index 0000000..8147f66 --- /dev/null +++ b/src/workos/routes/agents.ts @@ -0,0 +1,750 @@ +import { + type JWTPayload, + type RouteContext, + WorkOSApiError, + generateUlid, + notFound, + parseJsonBody, + parseListParams, +} from '../../core/index.js'; +import { getWorkOSStore, type WorkOSStore } from '../store.js'; +import type { + WorkOSAgentBlueprint, + WorkOSAgentBlueprintInvocableBy, + WorkOSAgentBlueprintSessionSettings, + WorkOSAgentInstance, + WorkOSAgentInstanceSession, + WorkOSOrganizationMembership, +} from '../entities.js'; +import { + formatAgentBlueprint, + formatAgentInstance, + formatAgentInstanceSession, + formatListResponse, +} from '../helpers.js'; +import { + AGENT_SESSION_SETTING_LIMITS, + AGENT_SUBJECT_PROFILE, + DEFAULT_AGENT_SESSION_SETTINGS, + MAX_AGENT_CHAIN_DEPTH, + USER_SUBJECT_PROFILE, + deleteAgentBlueprint, + deleteAgentInstance, + findChainRoot, + intersectPermissions, + isOrganizationInvocable, + isRoleInvocable, + isUserSessionLive, + membershipPermissionSlugs, + revokeAgentSessionTree, +} from '../agent-sessions.js'; + +type FieldError = { field: string; code: string; message?: string }; + +function invalidRequest(message: string, errors?: FieldError[]): WorkOSApiError { + return new WorkOSApiError(400, message, 'invalid_request', errors); +} + +/** Mint-time failures carry stable codes; production reports authorization ones as 403. */ +function tokenError(status: 400 | 403, code: string, message: string): WorkOSApiError { + return new WorkOSApiError(status, message, code); +} + +const isNonEmptyString = (v: unknown): v is string => typeof v === 'string' && v.length > 0; +const isStringList = (v: unknown): v is string[] => Array.isArray(v) && v.every(isNonEmptyString); +const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); + +/** + * Shape-check a blueprint body. Every field is optional here so create and update share + * the code; create supplies `name` separately. Reports all problems at once, the way a + * schema validator would, rather than the first one hit. + */ +function validateBlueprintBody( + body: Record, + errors: FieldError[], +): { + name?: string; + description?: string | null; + permissions?: string[]; + invocable_by?: Partial; + session_settings?: Partial; +} { + const out: ReturnType = {}; + + if (body.name !== undefined) { + if (!isNonEmptyString(body.name) || body.name.length > 255) { + errors.push({ field: 'name', code: 'invalid', message: 'name must be a string of 1 to 255 characters' }); + } else { + out.name = body.name; + } + } + + if (body.description !== undefined) { + if (body.description === null) { + out.description = null; + } else if (!isNonEmptyString(body.description) || body.description.length > 1000) { + errors.push({ + field: 'description', + code: 'invalid', + message: 'description must be a string of 1 to 1000 characters, or null', + }); + } else { + out.description = body.description; + } + } + + if (body.permissions !== undefined) { + if (!isStringList(body.permissions) || body.permissions.length > 1000) { + errors.push({ + field: 'permissions', + code: 'invalid', + message: 'permissions must be an array of at most 1000 permission slugs', + }); + } else { + out.permissions = [...new Set(body.permissions)]; + } + } + + if (body.invocable_by !== undefined) { + if (!isRecord(body.invocable_by)) { + errors.push({ field: 'invocable_by', code: 'invalid', message: 'invocable_by must be an object' }); + } else { + const invocable: Partial = {}; + const lists = [ + ['role_slugs', 100], + ['organization_ids', 1000], + ] as const; + for (const [key, max] of lists) { + const value = body.invocable_by[key]; + if (value === undefined) continue; + if (!isStringList(value) || value.length > max) { + errors.push({ + field: `invocable_by.${key}`, + code: 'invalid', + message: `invocable_by.${key} must be an array of at most ${max} strings`, + }); + } else { + invocable[key] = [...new Set(value)]; + } + } + out.invocable_by = invocable; + } + } + + if (body.session_settings !== undefined) { + if (!isRecord(body.session_settings)) { + errors.push({ field: 'session_settings', code: 'invalid', message: 'session_settings must be an object' }); + } else { + const settings: Partial = {}; + for (const key of Object.keys(AGENT_SESSION_SETTING_LIMITS) as (keyof typeof AGENT_SESSION_SETTING_LIMITS)[]) { + const value = body.session_settings[key]; + if (value === undefined) continue; + const max = AGENT_SESSION_SETTING_LIMITS[key]; + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0 || value > max) { + errors.push({ + field: `session_settings.${key}`, + code: 'invalid', + message: `session_settings.${key} must be a positive integer of at most ${max}`, + }); + } else { + settings[key] = value; + } + } + out.session_settings = settings; + } + } + + return out; +} + +/** + * Every slug and id a blueprint names must exist, checked with production's 422 codes so a + * seed typo surfaces at create time instead of as an inexplicably empty permission set at + * mint time. Role slugs resolve against any role (environment or organization) with that + * slug, since `invocable_by.role_slugs` is matched by slug when a member mints. + */ +function assertBlueprintReferences( + ws: WorkOSStore, + refs: { permissions?: string[]; role_slugs?: string[]; organization_ids?: string[] }, +): void { + for (const slug of refs.permissions ?? []) { + if (ws.permissions.findBy('slug', slug).length === 0) { + throw new WorkOSApiError(422, `Permission not found: ${slug}`, 'permission_not_found'); + } + } + for (const slug of refs.role_slugs ?? []) { + if (ws.roles.findBy('slug', slug).length === 0) { + throw new WorkOSApiError(422, `Role not found: ${slug}`, 'role_not_found'); + } + } + for (const id of refs.organization_ids ?? []) { + if (!ws.organizations.get(id)) { + throw new WorkOSApiError(422, `Organization not found: ${id}`, 'organization_not_found'); + } + } +} + +function assertNameAvailable(ws: WorkOSStore, name: string, exceptId?: string): void { + if (ws.agentBlueprints.findBy('name', name).some((b) => b.id !== exceptId)) { + throw new WorkOSApiError(409, `An agent blueprint named "${name}" already exists.`, 'name_already_in_use'); + } +} + +function requireBlueprint(ws: WorkOSStore, id: string): WorkOSAgentBlueprint { + const blueprint = ws.agentBlueprints.get(id); + if (!blueprint) throw notFound('Agent blueprint'); + return blueprint; +} + +function optionalIntent(body: Record): string | undefined { + if (body.intent === undefined) return undefined; + if (!isNonEmptyString(body.intent) || body.intent.length > 255) { + throw invalidRequest('intent must be a string of 1 to 255 characters', [ + { field: 'intent', code: 'invalid', message: 'intent must be a string of 1 to 255 characters' }, + ]); + } + return body.intent; +} + +function requireBodyString(body: Record, field: string): string { + const value = body[field]; + if (!isNonEmptyString(value)) { + throw invalidRequest(`${field} is required`, [{ field, code: 'required', message: `${field} is required` }]); + } + return value; +} + +/** Instances are keyed by what they act as, so a repeat mint reuses the row and reports `new_instance: false`. */ +function resolveInstance( + ws: WorkOSStore, + blueprint: WorkOSAgentBlueprint, + organizationId: string, + membership: WorkOSOrganizationMembership | null, +): { instance: WorkOSAgentInstance; created: boolean } { + const existing = ws.agentInstances + .findBy('agent_blueprint_id', blueprint.id) + .find( + (i) => + i.organization_id === organizationId && + i.organization_membership_id === (membership?.id ?? null) && + i.type === (membership ? 'delegated' : 'autonomous'), + ); + if (existing) return { instance: existing, created: false }; + const instance = ws.agentInstances.insert({ + object: 'agent_instance', + agent_blueprint_id: blueprint.id, + organization_id: organizationId, + organization_membership_id: membership?.id ?? null, + type: membership ? 'delegated' : 'autonomous', + }); + return { instance, created: true }; +} + +interface SessionAuthority { + permissions: string[]; + act: { sub: string; sub_profile: string } | undefined; +} + +/** + * What an instance may do is derived from what it is, at every mint and refresh: an + * autonomous instance holds the whole blueprint ceiling; a delegated one holds the ceiling + * narrowed to its member's current role, and names the member in `act`. Authority the + * member lost since the last mint does not survive into the next token. + */ +function resolveSessionAuthority( + ws: WorkOSStore, + blueprint: WorkOSAgentBlueprint, + instance: WorkOSAgentInstance, +): SessionAuthority { + if (instance.organization_membership_id === null) { + return { permissions: [...blueprint.permissions], act: undefined }; + } + const membership = ws.organizationMemberships.get(instance.organization_membership_id); + if (!membership || membership.status !== 'active') { + throw tokenError(403, 'user_not_member_of_organization', 'The user is not a member of the organization.'); + } + if (!isRoleInvocable(blueprint, membership.role.slug)) { + throw tokenError( + 403, + 'role_not_invocable', + 'The user does not hold a role allowed to invoke this agent blueprint.', + ); + } + const granted = membershipPermissionSlugs(ws, membership.organization_id, membership.role.slug); + return { + permissions: intersectPermissions(blueprint, granted), + act: { sub: membership.user_id, sub_profile: USER_SUBJECT_PROFILE }, + }; +} + +function assertOrganizationInvocable(ws: WorkOSStore, blueprint: WorkOSAgentBlueprint, organizationId: string): void { + if (!ws.organizations.get(organizationId)) throw notFound('Organization'); + if (!isOrganizationInvocable(blueprint, organizationId)) { + throw tokenError( + 403, + 'organization_not_invocable', + 'The organization is not allowed to invoke this agent blueprint.', + ); + } +} + +/** `auth_time` for a delegated chain: the backing user session's sign-in, so refreshes keep the original value. */ +function userSessionAuthTime(ws: WorkOSStore, userSessionId: string): number { + const session = ws.sessions.get(userSessionId); + if (!isUserSessionLive(session)) { + throw tokenError(400, 'user_session_ended', 'The delegating user session has ended.'); + } + return Math.floor(new Date(session.created_at).getTime() / 1000); +} + +export function agentRoutes(ctx: RouteContext): void { + const { app, store, jwt } = ctx; + const ws = getWorkOSStore(store); + + // Production mints `aud: environment.clientId`. Nothing at the API-key-authenticated + // token endpoint names a client, so the same placeholder the other unbound tokens use. + const audience = 'workos-emulate'; + + interface MintInput { + instance: WorkOSAgentInstance; + /** Session to mint the access token for; when refreshing, the row after rotation. */ + session: WorkOSAgentInstanceSession; + authority: SessionAuthority; + intent: string | undefined; + authTime: number | undefined; + accessTokenTtlSeconds: number; + newInstance: boolean; + } + + function mintResponse(input: MintInput): Record { + const { instance, session, authority, intent, authTime, accessTokenTtlSeconds } = input; + const accessToken = jwt.sign( + { + sub: instance.id, + sub_profile: AGENT_SUBJECT_PROFILE, + sid: session.id, + jti: generateUlid(), + org_id: instance.organization_id, + permissions: authority.permissions, + intent: intent !== undefined ? { text: intent } : undefined, + act: authority.act, + auth_time: authTime, + aud: audience, + }, + { expiresIn: accessTokenTtlSeconds, typ: 'at+jwt' }, + ); + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: accessTokenTtlSeconds, + refresh_token: session.refresh_token, + agent_instance_id: instance.id, + new_instance: input.newInstance, + agent_instance_session_id: session.id, + permissions: authority.permissions, + }; + } + + function createSession( + instance: WorkOSAgentInstance, + settings: WorkOSAgentBlueprintSessionSettings, + authority: SessionAuthority, + intent: string | undefined, + provenance: { parentSessionId?: string; userSessionId?: string; notAfterMs?: number }, + ): WorkOSAgentInstanceSession { + const expiresAtMs = Math.min( + Date.now() + settings.refresh_token_ttl_seconds * 1000, + ...(provenance.notAfterMs !== undefined ? [provenance.notAfterMs] : []), + ); + return ws.agentInstanceSessions.insert({ + object: 'agent_instance_session', + agent_instance_id: instance.id, + expires_at: new Date(expiresAtMs).toISOString(), + revoked_at: null, + refresh_token: generateUlid(), + parent_session_id: provenance.parentSessionId ?? null, + user_session_id: provenance.userSessionId ?? null, + permissions: authority.permissions, + intent: intent ?? null, + }); + } + + /** An access token never advertises validity past its session's own expiry. */ + const capAccessTokenTtl = (settings: WorkOSAgentBlueprintSessionSettings, session: WorkOSAgentInstanceSession) => + Math.min( + settings.access_token_ttl_seconds, + settings.refresh_token_ttl_seconds, + Math.floor((new Date(session.expires_at).getTime() - Date.now()) / 1000), + ); + + /** + * Decode an agent access token to its live session. Signature, expiry, the `ai_agent` + * profile, and the session's existence all fail the same way so a caller learns nothing + * about sessions it does not hold a token for. + */ + function resolveAgentToken(token: string): { + payload: JWTPayload; + session: WorkOSAgentInstanceSession; + instance: WorkOSAgentInstance; + } { + const invalid = tokenError(400, 'invalid_agent_access_token', 'The provided agent access token is invalid.'); + let payload: JWTPayload; + try { + payload = jwt.verify(token); + } catch { + throw invalid; + } + if (payload.sub_profile !== AGENT_SUBJECT_PROFILE || typeof payload.sid !== 'string') throw invalid; + const session = ws.agentInstanceSessions.get(payload.sid); + if (!session || session.agent_instance_id !== payload.sub) throw invalid; + const instance = ws.agentInstances.get(session.agent_instance_id); + if (!instance) throw invalid; + return { payload, session, instance }; + } + + function assertSessionLive(session: WorkOSAgentInstanceSession): void { + if (session.revoked_at !== null) { + throw tokenError(400, 'session_revoked', 'The session backing this token has been revoked.'); + } + if (new Date(session.expires_at).getTime() <= Date.now()) { + throw tokenError(400, 'session_expired', 'The session backing this token has expired.'); + } + } + + // ---- Blueprints ---- + + app.post('/agents/blueprints', async (c) => { + const body = await parseJsonBody(c); + const errors: FieldError[] = []; + if (body.name === undefined) errors.push({ field: 'name', code: 'required', message: 'name is required' }); + const parsed = validateBlueprintBody(body, errors); + if (errors.length > 0 || parsed.name === undefined) throw invalidRequest('Invalid request body', errors); + + const permissions = parsed.permissions ?? []; + const invocable_by: WorkOSAgentBlueprintInvocableBy = { + role_slugs: parsed.invocable_by?.role_slugs ?? [], + organization_ids: parsed.invocable_by?.organization_ids ?? [], + }; + assertBlueprintReferences(ws, { permissions, ...invocable_by }); + assertNameAvailable(ws, parsed.name); + + const blueprint = ws.agentBlueprints.insert({ + object: 'agent_blueprint', + name: parsed.name, + description: parsed.description ?? null, + permissions, + invocable_by, + session_settings: { ...DEFAULT_AGENT_SESSION_SETTINGS, ...parsed.session_settings }, + }); + return c.json(formatAgentBlueprint(blueprint), 201); + }); + + app.get('/agents/blueprints', (c) => { + const params = parseListParams(new URL(c.req.url)); + return c.json(formatListResponse(ws.agentBlueprints.list(params), formatAgentBlueprint)); + }); + + app.get('/agents/blueprints/:id', (c) => c.json(formatAgentBlueprint(requireBlueprint(ws, c.req.param('id'))))); + + app.patch('/agents/blueprints/:id', async (c) => { + const blueprint = requireBlueprint(ws, c.req.param('id')); + const body = await parseJsonBody(c); + const errors: FieldError[] = []; + const parsed = validateBlueprintBody(body, errors); + if (errors.length > 0) throw invalidRequest('Invalid request body', errors); + + const invocable_by: WorkOSAgentBlueprintInvocableBy = { + role_slugs: parsed.invocable_by?.role_slugs ?? blueprint.invocable_by.role_slugs, + organization_ids: parsed.invocable_by?.organization_ids ?? blueprint.invocable_by.organization_ids, + }; + assertBlueprintReferences(ws, { + permissions: parsed.permissions, + role_slugs: parsed.invocable_by?.role_slugs, + organization_ids: parsed.invocable_by?.organization_ids, + }); + if (parsed.name !== undefined) assertNameAvailable(ws, parsed.name, blueprint.id); + + const updated = ws.agentBlueprints.update(blueprint.id, { + ...(parsed.name !== undefined ? { name: parsed.name } : {}), + ...(parsed.description !== undefined ? { description: parsed.description } : {}), + ...(parsed.permissions !== undefined ? { permissions: parsed.permissions } : {}), + invocable_by, + session_settings: { ...blueprint.session_settings, ...parsed.session_settings }, + })!; + return c.json(formatAgentBlueprint(updated)); + }); + + app.delete('/agents/blueprints/:id', (c) => { + deleteAgentBlueprint(ws, requireBlueprint(ws, c.req.param('id'))); + return c.body(null, 204); + }); + + // ---- Tokens ---- + + app.post('/agents/blueprints/:id/tokens', async (c) => { + const blueprint = requireBlueprint(ws, c.req.param('id')); + const body = await parseJsonBody(c); + const intent = optionalIntent(body); + const settings = blueprint.session_settings; + + switch (body.type) { + case 'user_delegated': { + const userAccessToken = requireBodyString(body, 'user_access_token'); + const invalid = tokenError(400, 'invalid_user_access_token', 'The provided user access token is invalid.'); + let payload: JWTPayload; + try { + payload = jwt.verify(userAccessToken); + } catch { + throw invalid; + } + // The presented token authenticates the user and names the organization; nothing + // else on it is trusted. Authority comes from the live session and membership below. + if ( + payload.sub_profile !== undefined || + typeof payload.sub !== 'string' || + typeof payload.org_id !== 'string' || + typeof payload.sid !== 'string' || + !ws.users.get(payload.sub) + ) { + throw invalid; + } + const userSession = ws.sessions.get(payload.sid); + if (!userSession || userSession.user_id !== payload.sub) throw invalid; + if (!isUserSessionLive(userSession)) { + throw tokenError(400, 'user_session_ended', 'The delegating user session has ended.'); + } + const organizationId = payload.org_id; + if (!ws.organizations.get(organizationId)) throw notFound('Organization'); + + // Membership before invocability, so a non-member cannot probe which organizations + // a blueprint is invocable from. + const membership = ws.organizationMemberships + .findBy('organization_id', organizationId) + .find((m) => m.user_id === payload.sub && m.status === 'active'); + if (!membership) { + throw tokenError(403, 'user_not_member_of_organization', 'The user is not a member of the organization.'); + } + assertOrganizationInvocable(ws, blueprint, organizationId); + + // max_age gates the mint only: a login older than the blueprint allows may not start + // a delegated session. Once minted, the session lives by its own TTLs. + const authTime = Math.floor(new Date(userSession.created_at).getTime() / 1000); + if (Date.now() >= (authTime + settings.max_age_seconds) * 1000) { + throw tokenError( + 400, + 'max_age_exceeded', + "The delegating credential's authentication is older than the blueprint allows.", + ); + } + + const { instance, created } = resolveInstance(ws, blueprint, organizationId, membership); + const authority = resolveSessionAuthority(ws, blueprint, instance); + const session = createSession(instance, settings, authority, intent, { userSessionId: userSession.id }); + return c.json( + mintResponse({ + instance, + session, + authority, + intent, + authTime, + accessTokenTtlSeconds: capAccessTokenTtl(settings, session), + newInstance: created, + }), + ); + } + + case 'autonomous': { + const organizationId = requireBodyString(body, 'organization_id'); + assertOrganizationInvocable(ws, blueprint, organizationId); + const { instance, created } = resolveInstance(ws, blueprint, organizationId, null); + const authority = resolveSessionAuthority(ws, blueprint, instance); + const session = createSession(instance, settings, authority, intent, {}); + return c.json( + mintResponse({ + instance, + session, + authority, + intent, + authTime: undefined, + accessTokenTtlSeconds: capAccessTokenTtl(settings, session), + newInstance: created, + }), + ); + } + + case 'agent_delegated': { + const agentAccessToken = requireBodyString(body, 'agent_access_token'); + const invalid = tokenError(400, 'invalid_agent_access_token', 'The provided agent access token is invalid.'); + const { session: presenting, instance } = resolveAgentToken(agentAccessToken); + // Self-chaining only: an agent cannot delegate to another blueprint or instance. + if (instance.agent_blueprint_id !== blueprint.id) throw invalid; + if (presenting.revoked_at !== null || new Date(presenting.expires_at).getTime() <= Date.now()) throw invalid; + assertOrganizationInvocable(ws, blueprint, instance.organization_id); + + // Chains are anchored at their root: no hop may outlive root.created_at + max_age, + // however many chains or refreshes happen in between. + const { root, depth, ancestorRevoked } = findChainRoot(ws, presenting); + if (ancestorRevoked) throw invalid; + if (depth + 1 > MAX_AGENT_CHAIN_DEPTH) { + throw tokenError(400, 'chain_depth_exceeded', 'The agent delegation chain is too deep.'); + } + const windowEndsAtMs = new Date(root.created_at).getTime() + settings.max_age_seconds * 1000; + if (windowEndsAtMs - Date.now() < 1000) { + throw tokenError( + 400, + 'max_age_exceeded', + "The delegating credential's authentication is older than the blueprint allows.", + ); + } + const authTime = root.user_session_id !== null ? userSessionAuthTime(ws, root.user_session_id) : undefined; + const authority = resolveSessionAuthority(ws, blueprint, instance); + const session = createSession(instance, settings, authority, intent, { + parentSessionId: presenting.id, + notAfterMs: windowEndsAtMs, + }); + return c.json( + mintResponse({ + instance, + session, + authority, + intent, + authTime, + accessTokenTtlSeconds: capAccessTokenTtl(settings, session), + newInstance: false, + }), + ); + } + + case 'refresh': { + const refreshToken = requireBodyString(body, 'refresh_token'); + const session = ws.agentInstanceSessions.findBy('refresh_token', refreshToken)[0]; + const instance = session ? ws.agentInstances.get(session.agent_instance_id) : undefined; + if (!session || !instance || instance.agent_blueprint_id !== blueprint.id) { + throw tokenError(400, 'invalid_refresh_token', 'The provided refresh token is invalid.'); + } + assertSessionLive(session); + const { root, ancestorRevoked } = findChainRoot(ws, session); + if (ancestorRevoked) { + throw tokenError(400, 'session_revoked', 'The session backing this token has been revoked.'); + } + const authTime = root.user_session_id !== null ? userSessionAuthTime(ws, root.user_session_id) : undefined; + assertOrganizationInvocable(ws, blueprint, instance.organization_id); + const authority = resolveSessionAuthority(ws, blueprint, instance); + + // A refresh never extends a session past its chain root's max_age window. + const now = Date.now(); + const rotatedExpiresAtMs = Math.min( + now + settings.refresh_token_ttl_seconds * 1000, + new Date(root.created_at).getTime() + settings.max_age_seconds * 1000, + ); + if (rotatedExpiresAtMs - now < 1000) { + throw tokenError(400, 'session_expired', 'The session backing this token has expired.'); + } + // Rotation is what makes the presented token single-use: the row's refresh_token + // is replaced, so a replay no longer resolves to any session. + const rotated = ws.agentInstanceSessions.updateSilent(session.id, { + refresh_token: generateUlid(), + expires_at: new Date(rotatedExpiresAtMs).toISOString(), + permissions: authority.permissions, + intent: intent ?? session.intent, + })!; + return c.json( + mintResponse({ + instance, + session: rotated, + authority, + intent: intent ?? rotated.intent ?? undefined, + authTime, + accessTokenTtlSeconds: capAccessTokenTtl(settings, rotated), + newInstance: false, + }), + ); + } + + default: + throw invalidRequest('type must be one of user_delegated, autonomous, agent_delegated, refresh', [ + { field: 'type', code: 'invalid' }, + ]); + } + }); + + app.post('/agents/blueprints/:id/tokens/validate', async (c) => { + const blueprint = requireBlueprint(ws, c.req.param('id')); + const body = await parseJsonBody(c); + const token = requireBodyString(body, 'agent_access_token'); + const { payload, session, instance } = resolveAgentToken(token); + if (instance.agent_blueprint_id !== blueprint.id) { + throw tokenError(400, 'invalid_agent_access_token', 'The provided agent access token is invalid.'); + } + assertSessionLive(session); + const { root } = findChainRoot(ws, session); + if (root.user_session_id !== null && !isUserSessionLive(ws.sessions.get(root.user_session_id))) { + throw tokenError(400, 'user_session_ended', 'The delegating user session has ended.'); + } + const intent = payload.intent; + return c.json({ + valid: true, + agent_instance_id: instance.id, + agent_instance_session_id: session.id, + organization_id: instance.organization_id, + permissions: Array.isArray(payload.permissions) ? payload.permissions : [], + intent: intent && typeof intent.text === 'string' ? intent.text : null, + acting_user_id: payload.act?.sub ?? null, + session_expires_at: session.expires_at, + }); + }); + + // ---- Instances ---- + + app.get('/agents/instances', (c) => { + const url = new URL(c.req.url); + const params = parseListParams(url); + const organizationId = url.searchParams.get('organization_id'); + const blueprintId = url.searchParams.get('agent_blueprint_id'); + const filter = (i: WorkOSAgentInstance) => + (!organizationId || i.organization_id === organizationId) && + (!blueprintId || i.agent_blueprint_id === blueprintId); + return c.json(formatListResponse(ws.agentInstances.list({ ...params, filter }), formatAgentInstance)); + }); + + app.get('/agents/instances/:id', (c) => { + const instance = ws.agentInstances.get(c.req.param('id')); + if (!instance) throw notFound('Agent instance'); + return c.json(formatAgentInstance(instance)); + }); + + app.delete('/agents/instances/:id', (c) => { + const instance = ws.agentInstances.get(c.req.param('id')); + if (!instance) throw notFound('Agent instance'); + deleteAgentInstance(ws, instance); + return c.body(null, 204); + }); + + // ---- Sessions ---- + + app.get('/agents/sessions', (c) => { + const url = new URL(c.req.url); + const params = parseListParams(url); + const instanceId = url.searchParams.get('agent_instance_id'); + const blueprintId = url.searchParams.get('agent_blueprint_id'); + const filter = (s: WorkOSAgentInstanceSession) => + (!instanceId || s.agent_instance_id === instanceId) && + (!blueprintId || ws.agentInstances.get(s.agent_instance_id)?.agent_blueprint_id === blueprintId); + return c.json(formatListResponse(ws.agentInstanceSessions.list({ ...params, filter }), formatAgentInstanceSession)); + }); + + app.get('/agents/sessions/:id', (c) => { + const session = ws.agentInstanceSessions.get(c.req.param('id')); + if (!session) throw notFound('Agent instance session'); + return c.json(formatAgentInstanceSession(session)); + }); + + // Revocation cascades to every session chained from this one and is idempotent: an + // already-revoked session answers 200 with its existing revoked_at. + app.post('/agents/sessions/:id/revoke', (c) => { + const session = ws.agentInstanceSessions.get(c.req.param('id')); + if (!session) throw notFound('Agent instance session'); + revokeAgentSessionTree(ws, session.id); + return c.json(formatAgentInstanceSession(ws.agentInstanceSessions.get(session.id)!)); + }); +} diff --git a/src/workos/seed-agent-blueprints.spec.ts b/src/workos/seed-agent-blueprints.spec.ts new file mode 100644 index 0000000..4e82a69 --- /dev/null +++ b/src/workos/seed-agent-blueprints.spec.ts @@ -0,0 +1,163 @@ +/** + * Seeding agent blueprints. Blueprints have a create route too, but a seed is how a test + * environment boots with one already in place; instances and sessions are never seeded and + * only come from minting. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createEmulator, type Emulator } from '../index.js'; +import { validateSeedConfig } from './config-validator.js'; + +describe('Seeding agent blueprints', () => { + let emulator: Emulator | undefined; + + afterEach(async () => { + await emulator?.close(); + emulator = undefined; + }); + + const seed = { + users: [{ email: 'alice@acme.com', password: 'test123', email_verified: true }], + permissions: [ + { slug: 'crm:read', name: 'Read CRM' }, + { slug: 'email:send', name: 'Send email' }, + ], + roles: [ + { slug: 'manager', name: 'Manager', permissions: ['crm:read', 'email:send'] }, + { slug: 'member', name: 'Member', permissions: ['crm:read'] }, + ], + organizations: [ + { name: 'Acme Corp', memberships: [{ email: 'alice@acme.com', role: 'manager' }] }, + { name: 'Other Inc' }, + ], + agentBlueprints: [ + { + id: 'agent_blueprint_01PINNED', + name: 'Prospecting Agent', + description: 'Finds prospects', + permissions: ['crm:read', 'email:send'], + invocable_by: { role_slugs: ['manager'], organizations: ['Acme Corp'] }, + session_settings: { access_token_ttl_seconds: 60 }, + }, + { name: 'Minimal Agent' }, + ], + }; + + const api = async (path: string, init?: RequestInit) => { + const res = await fetch(`${emulator!.url}${path}`, { + ...init, + headers: { Authorization: `Bearer ${emulator!.apiKey}`, 'Content-Type': 'application/json', ...init?.headers }, + }); + return { status: res.status, body: (await res.json()) as any }; + }; + + it('seeds blueprints with defaults, a pinned id, and organizations resolved by name', async () => { + emulator = await createEmulator({ port: 0, seed }); + + const orgs = await api('/organizations'); + const acme = orgs.body.data.find((o: { name: string }) => o.name === 'Acme Corp')!; + + const list = await api('/agents/blueprints'); + expect(list.status).toBe(200); + expect(list.body.data).toHaveLength(2); + + const pinned = await api('/agents/blueprints/agent_blueprint_01PINNED'); + expect(pinned.status).toBe(200); + expect(pinned.body).toMatchObject({ + name: 'Prospecting Agent', + description: 'Finds prospects', + permissions: ['crm:read', 'email:send'], + invocable_by: { role_slugs: ['manager'], organization_ids: [acme.id] }, + session_settings: { max_age_seconds: 3600, access_token_ttl_seconds: 60, refresh_token_ttl_seconds: 3600 }, + }); + + const minimal = list.body.data.find((b: { name: string }) => b.name === 'Minimal Agent'); + expect(minimal).toMatchObject({ + description: null, + permissions: [], + invocable_by: { role_slugs: [], organization_ids: [] }, + session_settings: { max_age_seconds: 3600, access_token_ttl_seconds: 300, refresh_token_ttl_seconds: 3600 }, + }); + expect(minimal.id).toStartWith('agent_blueprint_'); + }); + + it('mints from a seeded blueprint with the seeded member', async () => { + emulator = await createEmulator({ port: 0, seed }); + + const login = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'alice@acme.com', password: 'test123' }), + }); + expect(login.status).toBe(200); + const { access_token } = (await login.json()) as { access_token: string }; + + const minted = await api('/agents/blueprints/agent_blueprint_01PINNED/tokens', { + method: 'POST', + body: JSON.stringify({ type: 'user_delegated', user_access_token: access_token }), + }); + expect(minted.status).toBe(200); + expect(minted.body.permissions).toEqual(['crm:read', 'email:send']); + expect(minted.body.expires_in).toBe(60); + }); + + it('accepts the sample config', () => { + expect(validateSeedConfig(seed)).toEqual({ valid: true, errors: [] }); + }); + + it('rejects unknown permission, role and organization references', () => { + const { valid, errors } = validateSeedConfig({ + ...seed, + agentBlueprints: [ + { + name: 'Broken', + permissions: ['nope'], + invocable_by: { role_slugs: ['nope'], organizations: ['Nope Inc'] }, + }, + ], + }); + expect(valid).toBe(false); + expect(errors.map((e) => e.path).sort()).toEqual([ + 'agentBlueprints[0].invocable_by.organizations[0]', + 'agentBlueprints[0].invocable_by.role_slugs[0]', + 'agentBlueprints[0].permissions[0]', + ]); + }); + + it('rejects duplicate names and ids, and out-of-range session settings', () => { + const { valid, errors } = validateSeedConfig({ + agentBlueprints: [ + { id: 'agent_blueprint_dup', name: 'Same' }, + { id: 'agent_blueprint_dup', name: 'Same', session_settings: { access_token_ttl_seconds: 3601 } }, + { name: 'Bad Settings', session_settings: { max_age_seconds: 0, refresh_token_ttl_seconds: 1.5 } }, + ], + }); + expect(valid).toBe(false); + expect(errors.map((e) => e.path).sort()).toEqual([ + 'agentBlueprints[1].id', + 'agentBlueprints[1].name', + 'agentBlueprints[1].session_settings.access_token_ttl_seconds', + 'agentBlueprints[2].session_settings.max_age_seconds', + 'agentBlueprints[2].session_settings.refresh_token_ttl_seconds', + ]); + }); + + it('reports a non-array sub-field rather than throwing', () => { + const run = () => + validateSeedConfig({ + agentBlueprints: [ + { + name: 'a', + permissions: 'crm:read' as unknown as string[], + invocable_by: { role_slugs: 'manager' as unknown as string[] }, + }, + ], + }); + expect(run).not.toThrow(); + const { valid, errors } = run(); + expect(valid).toBe(false); + expect(errors.map((e) => e.path).sort()).toEqual([ + 'agentBlueprints[0].invocable_by.role_slugs', + 'agentBlueprints[0].permissions', + ]); + }); +}); diff --git a/src/workos/store.ts b/src/workos/store.ts index fee7e9a..8648589 100644 --- a/src/workos/store.ts +++ b/src/workos/store.ts @@ -47,6 +47,9 @@ import type { WorkOSVaultObject, WorkOSEvent, WorkOSWebhookEndpoint, + WorkOSAgentBlueprint, + WorkOSAgentInstance, + WorkOSAgentInstanceSession, } from './entities.js'; export interface WorkOSStore { @@ -96,6 +99,9 @@ export interface WorkOSStore { vaultObjects: Collection; events: Collection; webhookEndpoints: Collection; + agentBlueprints: Collection; + agentInstances: Collection; + agentInstanceSessions: Collection; } export function getWorkOSStore(store: Store): WorkOSStore { @@ -252,6 +258,19 @@ export function getWorkOSStore(store: Store): WorkOSStore { ID_PREFIXES.webhook_endpoint, ['endpoint_url'], ), + agentBlueprints: store.collection('workos.agent_blueprints', ID_PREFIXES.agent_blueprint, [ + 'name', + ]), + agentInstances: store.collection('workos.agent_instances', ID_PREFIXES.agent_instance, [ + 'agent_blueprint_id', + 'organization_id', + 'organization_membership_id', + ]), + agentInstanceSessions: store.collection( + 'workos.agent_instance_sessions', + ID_PREFIXES.agent_instance_session, + ['agent_instance_id', 'refresh_token', 'parent_session_id', 'user_session_id'], + ), }; store.setData(STORE_KEYS.workosStore, ws); From 861ebd775f26d4e68c9115bcaa5386a958acc6eb Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Tue, 8 Sep 2026 22:51:16 +0000 Subject: [PATCH 2/5] Match seed blueprint validation to the create route limits Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/workos/config-validator.ts | 18 ++++++++++-------- src/workos/seed-agent-blueprints.spec.ts | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 02c6772..6f78abe 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -1085,20 +1085,22 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe if ( blueprint.description !== undefined && blueprint.description !== null && - (typeof blueprint.description !== 'string' || blueprint.description.length > 1000) + (typeof blueprint.description !== 'string' || + blueprint.description.length === 0 || + blueprint.description.length > 1000) ) { errors.push({ path: at('description'), - message: 'description must be a string of at most 1000 characters, or null, if provided', + message: 'description must be a string of 1 to 1000 characters, or null, if provided', value: blueprint.description, }); } if (blueprint.permissions !== undefined) { - if (!Array.isArray(blueprint.permissions)) { + if (!Array.isArray(blueprint.permissions) || blueprint.permissions.length > 1000) { errors.push({ path: at('permissions'), - message: 'permissions must be an array of permission slugs if provided', + message: 'permissions must be an array of at most 1000 permission slugs if provided', value: blueprint.permissions, }); } else { @@ -1124,10 +1126,10 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe }); } else { if (invocableBy.role_slugs !== undefined) { - if (!Array.isArray(invocableBy.role_slugs)) { + if (!Array.isArray(invocableBy.role_slugs) || invocableBy.role_slugs.length > 100) { errors.push({ path: at('invocable_by.role_slugs'), - message: 'invocable_by.role_slugs must be an array of role slugs if provided', + message: 'invocable_by.role_slugs must be an array of at most 100 role slugs if provided', value: invocableBy.role_slugs, }); } else { @@ -1143,10 +1145,10 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe } } if (invocableBy.organizations !== undefined) { - if (!Array.isArray(invocableBy.organizations)) { + if (!Array.isArray(invocableBy.organizations) || invocableBy.organizations.length > 1000) { errors.push({ path: at('invocable_by.organizations'), - message: 'invocable_by.organizations must be an array of organization names if provided', + message: 'invocable_by.organizations must be an array of at most 1000 organization names if provided', value: invocableBy.organizations, }); } else { diff --git a/src/workos/seed-agent-blueprints.spec.ts b/src/workos/seed-agent-blueprints.spec.ts index 4e82a69..4265643 100644 --- a/src/workos/seed-agent-blueprints.spec.ts +++ b/src/workos/seed-agent-blueprints.spec.ts @@ -141,6 +141,30 @@ describe('Seeding agent blueprints', () => { ]); }); + it('applies the create route limits: non-empty description and list maxima', () => { + const { valid, errors } = validateSeedConfig({ + ...seed, + agentBlueprints: [ + { + name: 'Oversized', + description: '', + permissions: Array.from({ length: 1001 }, () => 'crm:read'), + invocable_by: { + role_slugs: Array.from({ length: 101 }, () => 'manager'), + organizations: Array.from({ length: 1001 }, () => 'Acme Corp'), + }, + }, + ], + }); + expect(valid).toBe(false); + expect(errors.map((e) => e.path).sort()).toEqual([ + 'agentBlueprints[0].description', + 'agentBlueprints[0].invocable_by.organizations', + 'agentBlueprints[0].invocable_by.role_slugs', + 'agentBlueprints[0].permissions', + ]); + }); + it('reports a non-array sub-field rather than throwing', () => { const run = () => validateSeedConfig({ From 04db6932f017d197a82454250d8ca9fa818524e3 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Tue, 8 Sep 2026 23:01:13 +0000 Subject: [PATCH 3/5] Cascade agent teardown from organizations, memberships and permissions Match production's AgentInstancesDeleter: deleting an organization or a membership deletes the agent instances that reference it (firing their deleted and session-revoked events), deactivating a membership revokes its delegated sessions, and deleting a permission drops the slug from every blueprint so no later mint can grant it. A chained session whose parent row is missing now reads as revoked instead of posing as a root. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 8 +- src/workos/agent-sessions.ts | 49 +++++++- src/workos/routes/agents.spec.ts | 109 ++++++++++++++++++ src/workos/routes/agents.ts | 5 +- .../routes/authorization-permissions.ts | 4 +- src/workos/routes/memberships.ts | 3 + src/workos/routes/organizations.ts | 4 + 7 files changed, 175 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9ca58fe..cbd90b2 100644 --- a/README.md +++ b/README.md @@ -712,8 +712,12 @@ for delegated chains that the backing user session is still live. Revoking a session (`POST /agents/sessions/{id}/revoke`, or revoking or logging out of the user session it was delegated from) cascades to every session chained from it. Deleting an instance -revokes its live sessions first, and deleting a blueprint tears down its instances. Session -`status` is derived at read time from `revoked_at` and `expires_at`. The seven `agent.*` events +revokes its live sessions first, and deleting a blueprint tears down its instances. The resources +agents hang off cascade the same way: deleting an organization tears down every instance in it, +deleting a membership tears down the instances delegated from it, deactivating a membership +revokes their sessions (the instance survives for a reactivation), and deleting a permission +removes it from every blueprint ceiling that named it. Session `status` is derived at read time +from `revoked_at` and `expires_at`. The seven `agent.*` events fire through the same webhook and `/events` plumbing as everything else. Errors use production's stable codes: `invalid_request` (400) for a malformed body; diff --git a/src/workos/agent-sessions.ts b/src/workos/agent-sessions.ts index 2650986..d9b3bf2 100644 --- a/src/workos/agent-sessions.ts +++ b/src/workos/agent-sessions.ts @@ -69,8 +69,9 @@ export interface ChainRoot { } /** - * Walk `parent_session_id` provenance to the chain root. Every hop is on the same instance, - * so a missing parent means the chain was torn down and the walk stops where it is. + * Walk `parent_session_id` provenance to the chain root. Every hop is on the same instance and + * an instance's sessions are only ever deleted together, so a missing parent is corrupt + * provenance; it is reported as revoked rather than letting the orphan pose as a root. */ export function findChainRoot(ws: WorkOSStore, session: WorkOSAgentInstanceSession): ChainRoot { let current = session; @@ -78,7 +79,10 @@ export function findChainRoot(ws: WorkOSStore, session: WorkOSAgentInstanceSessi let ancestorRevoked = false; while (current.parent_session_id !== null && depth < MAX_AGENT_CHAIN_DEPTH) { const parent = ws.agentInstanceSessions.get(current.parent_session_id); - if (!parent) break; + if (!parent) { + ancestorRevoked = true; + break; + } if (parent.revoked_at !== null) ancestorRevoked = true; current = parent; depth += 1; @@ -136,6 +140,45 @@ export function deleteAgentBlueprint(ws: WorkOSStore, blueprint: WorkOSAgentBlue ws.agentBlueprints.delete(blueprint.id); } +/** Every instance in the organization, autonomous or delegated, goes when the organization does. */ +export function deleteAgentInstancesForOrganization(ws: WorkOSStore, organizationId: string): void { + for (const instance of ws.agentInstances.findBy('organization_id', organizationId)) { + deleteAgentInstance(ws, instance); + } +} + +/** Delegated instances hang off their membership; deleting the membership deletes them. */ +export function deleteAgentInstancesForMembership(ws: WorkOSStore, membershipId: string): void { + for (const instance of ws.agentInstances.findBy('organization_membership_id', membershipId)) { + deleteAgentInstance(ws, instance); + } +} + +/** + * Deactivating a membership ends the member's ability to act, so every session on an + * instance delegated from it is revoked; the instance itself survives for a reactivation. + */ +export function revokeAgentSessionsForMembership(ws: WorkOSStore, membershipId: string): void { + for (const instance of ws.agentInstances.findBy('organization_membership_id', membershipId)) { + for (const session of ws.agentInstanceSessions.findBy('agent_instance_id', instance.id)) { + if (session.revoked_at === null) revokeAgentSessionTree(ws, session.id); + } + } +} + +/** + * A deleted permission leaves every blueprint ceiling that named it, so no later mint can + * grant a slug that no longer exists. Sessions already minted keep their recorded grant. + */ +export function removePermissionFromAgentBlueprints(ws: WorkOSStore, slug: string): void { + for (const blueprint of ws.agentBlueprints.all()) { + if (!blueprint.permissions.includes(slug)) continue; + ws.agentBlueprints.update(blueprint.id, { + permissions: blueprint.permissions.filter((p) => p !== slug), + }); + } +} + /** * Agent sessions delegated from a user session die with it. Called when a user session is * revoked; chained sessions record only their parent, so revoking each root cascades. diff --git a/src/workos/routes/agents.spec.ts b/src/workos/routes/agents.spec.ts index fc7ca07..b924fc6 100644 --- a/src/workos/routes/agents.spec.ts +++ b/src/workos/routes/agents.spec.ts @@ -873,4 +873,113 @@ describe('Agent Auth routes', () => { expect((await post('/agents/sessions/agent_session_missing/revoke', {})).status).toBe(404); }); }); + + describe('cascades from the resources agents depend on', () => { + const validate = (blueprintId: string, token: string) => + post(`/agents/blueprints/${blueprintId}/tokens/validate`, { agent_access_token: token }); + + it('deleting an organization tears down its autonomous and delegated agents', async () => { + const { org, blueprint } = await seedWorld(); + const other = seedOrg('Other Inc'); + const login = await loginAs(); + const autonomous = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const delegated = await mintOk(blueprint.id, { + type: 'user_delegated', + user_access_token: login.access_token, + }); + const survivor = await mintOk(blueprint.id, { type: 'autonomous', organization_id: other.id }); + + expect((await req(`/organizations/${org.id}`, { method: 'DELETE' })).status).toBe(204); + + for (const minted of [autonomous, delegated]) { + expect((await req(`/agents/instances/${minted.agent_instance_id}`)).status).toBe(404); + expect((await req(`/agents/sessions/${minted.agent_instance_session_id}`)).status).toBe(404); + await expectError(await validate(blueprint.id, minted.access_token), 400, 'invalid_agent_access_token'); + await expectError( + await mint(blueprint.id, { type: 'refresh', refresh_token: minted.refresh_token }), + 400, + 'invalid_refresh_token', + ); + } + expect((await json(await req('/agents/instances'))).data.map((i: { id: string }) => i.id)).toEqual([ + survivor.agent_instance_id, + ]); + expect((await json(await validate(blueprint.id, survivor.access_token))).valid).toBe(true); + expect(events('agent.instance.deleted')).toHaveLength(2); + expect(events('agent.instance.session.revoked')).toHaveLength(2); + }); + + it('deleting a membership deletes the instances delegated from it', async () => { + const { org, membership, blueprint } = await seedWorld(); + const login = await loginAs(); + const delegated = await mintOk(blueprint.id, { + type: 'user_delegated', + user_access_token: login.access_token, + }); + const autonomous = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + + const res = await req(`/user_management/organization_memberships/${membership.id}`, { method: 'DELETE' }); + expect(res.status).toBe(204); + + expect((await req(`/agents/instances/${delegated.agent_instance_id}`)).status).toBe(404); + await expectError(await validate(blueprint.id, delegated.access_token), 400, 'invalid_agent_access_token'); + expect((await json(await validate(blueprint.id, autonomous.access_token))).valid).toBe(true); + expect(events('agent.instance.deleted')).toHaveLength(1); + expect(events('agent.instance.session.revoked')).toHaveLength(1); + }); + + it('deactivating a membership revokes its delegated sessions but keeps the instance', async () => { + const { membership, blueprint } = await seedWorld(); + const login = await loginAs(); + const root = await mintOk(blueprint.id, { type: 'user_delegated', user_access_token: login.access_token }); + const child = await mintOk(blueprint.id, { type: 'agent_delegated', agent_access_token: root.access_token }); + + const res = await req(`/user_management/organization_memberships/${membership.id}/deactivate`, { + method: 'PUT', + }); + expect(res.status).toBe(200); + + for (const minted of [root, child]) { + await expectError(await validate(blueprint.id, minted.access_token), 400, 'session_revoked'); + expect((await json(await req(`/agents/sessions/${minted.agent_instance_session_id}`))).status).toBe('revoked'); + } + expect((await req(`/agents/instances/${root.agent_instance_id}`)).status).toBe(200); + expect(events('agent.instance.session.revoked')).toHaveLength(2); + }); + + it('deleting a permission removes it from every blueprint ceiling', async () => { + const { org, blueprint } = await seedWorld(); + const untouched = await json(await post('/agents/blueprints', { name: 'Reader', permissions: ['crm:read'] })); + + expect((await req('/authorization/permissions/email:send', { method: 'DELETE' })).status).toBe(204); + + expect((await json(await req(`/agents/blueprints/${blueprint.id}`))).permissions).toEqual(['crm:read']); + const minted = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + expect(minted.permissions).toEqual(['crm:read']); + expect(decodeJwt(minted.access_token).payload.permissions).toEqual(['crm:read']); + const updated = events('agent.blueprint.updated'); + expect(updated.map((e) => e.data.id)).toEqual([blueprint.id]); + expect(updated[0]!.data.permissions).toEqual(['crm:read']); + expect((await json(await req(`/agents/blueprints/${untouched.id}`))).permissions).toEqual(['crm:read']); + }); + + it('treats a chained session whose parent is gone as revoked provenance', async () => { + const { org, blueprint } = await seedWorld(); + const root = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const child = await mintOk(blueprint.id, { type: 'agent_delegated', agent_access_token: root.access_token }); + ws().agentInstanceSessions.delete(root.agent_instance_session_id); + + await expectError( + await mint(blueprint.id, { type: 'agent_delegated', agent_access_token: child.access_token }), + 400, + 'invalid_agent_access_token', + ); + await expectError( + await mint(blueprint.id, { type: 'refresh', refresh_token: child.refresh_token }), + 400, + 'session_revoked', + ); + await expectError(await validate(blueprint.id, child.access_token), 400, 'session_revoked'); + }); + }); }); diff --git a/src/workos/routes/agents.ts b/src/workos/routes/agents.ts index 8147f66..61d60e8 100644 --- a/src/workos/routes/agents.ts +++ b/src/workos/routes/agents.ts @@ -677,7 +677,10 @@ export function agentRoutes(ctx: RouteContext): void { throw tokenError(400, 'invalid_agent_access_token', 'The provided agent access token is invalid.'); } assertSessionLive(session); - const { root } = findChainRoot(ws, session); + const { root, ancestorRevoked } = findChainRoot(ws, session); + if (ancestorRevoked) { + throw tokenError(400, 'session_revoked', 'The session backing this token has been revoked.'); + } if (root.user_session_id !== null && !isUserSessionLive(ws.sessions.get(root.user_session_id))) { throw tokenError(400, 'user_session_ended', 'The delegating user session has ended.'); } diff --git a/src/workos/routes/authorization-permissions.ts b/src/workos/routes/authorization-permissions.ts index d2fdced..fc9bdb7 100644 --- a/src/workos/routes/authorization-permissions.ts +++ b/src/workos/routes/authorization-permissions.ts @@ -9,6 +9,7 @@ import { import { getWorkOSStore } from '../store.js'; import { formatPermission, formatListResponse } from '../helpers.js'; import { DEFAULT_RESOURCE_TYPE_SLUG, isValidResourceTypeSlug } from '../constants.js'; +import { removePermissionFromAgentBlueprints } from '../agent-sessions.js'; export function authorizationPermissionRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -86,8 +87,9 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { const permission = ws.permissions.findOneBy('slug', slug); if (!permission) throw notFound('Permission'); - // Cascade: remove from all role-permission joins + // Cascade: remove from all role-permission joins and every agent blueprint ceiling ws.rolePermissions.deleteBy('permission_id', permission.id); + removePermissionFromAgentBlueprints(ws, permission.slug); ws.permissions.delete(permission.id); return c.body(null, 204); diff --git a/src/workos/routes/memberships.ts b/src/workos/routes/memberships.ts index eccf6bf..7dcb5ab 100644 --- a/src/workos/routes/memberships.ts +++ b/src/workos/routes/memberships.ts @@ -8,6 +8,7 @@ import { } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatMembership, formatListResponse } from '../helpers.js'; +import { deleteAgentInstancesForMembership, revokeAgentSessionsForMembership } from '../agent-sessions.js'; export function membershipRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -105,6 +106,7 @@ export function membershipRoutes(ctx: RouteContext): void { app.delete('/user_management/organization_memberships/:id', (c) => { const m = ws.organizationMemberships.get(c.req.param('id')); if (!m) throw notFound('Organization Membership'); + deleteAgentInstancesForMembership(ws, m.id); ws.organizationMemberships.delete(m.id); return c.body(null, 204); }); @@ -118,6 +120,7 @@ export function membershipRoutes(ctx: RouteContext): void { const updated = ws.organizationMemberships.update(m.id, { status: 'inactive', }); + revokeAgentSessionsForMembership(ws, m.id); return c.json(formatMembership(updated!, ws)); }); diff --git a/src/workos/routes/organizations.ts b/src/workos/routes/organizations.ts index 1bf5d5c..8ee0074 100644 --- a/src/workos/routes/organizations.ts +++ b/src/workos/routes/organizations.ts @@ -8,6 +8,7 @@ import { revokeApiKeysForOwner, } from '../helpers.js'; import type { WorkOSOrganizationDomain } from '../entities.js'; +import { deleteAgentInstancesForOrganization } from '../agent-sessions.js'; export function organizationRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -169,6 +170,9 @@ export function organizationRoutes(ctx: RouteContext): void { if (!org) throw notFound('Organization'); ws.organizationDomains.deleteBy('organization_id', org.id); + // Before the memberships: delegated instances reference them, and tearing the instances + // down first is what fires their deleted and session-revoked events. + deleteAgentInstancesForOrganization(ws, org.id); ws.organizationMemberships.deleteBy('organization_id', org.id); // Same as the user cascade: an organization target with no organization behind it is // unreachable through the target routes. From 3973335c08a2e02cb0ffc095234f75076d3151f5 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Wed, 9 Sep 2026 15:37:38 +0000 Subject: [PATCH 4/5] Match production on user tokens, user deletion, revoke and create - Accept user access tokens that carry an explicit `sub_profile: 'user'` for the user_delegated grant; other subject profiles are still rejected. - Delete the instances delegated from a user's memberships when the user is deleted, the way membership and organization deletion already do. - Leave already-expired sessions untouched in revoke cascades so they stay `expired` with a null `revoked_at`, while still walking their descendants. - Reject `description: null` and partial `session_settings` on blueprint create; both remain valid on PATCH. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 12 ++-- src/workos/agent-sessions.ts | 21 ++++-- src/workos/routes/agents.spec.ts | 117 +++++++++++++++++++++++++++++-- src/workos/routes/agents.ts | 38 +++++++--- src/workos/routes/users.ts | 2 + 5 files changed, 163 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index cbd90b2..0ccaeb8 100644 --- a/README.md +++ b/README.md @@ -714,11 +714,13 @@ Revoking a session (`POST /agents/sessions/{id}/revoke`, or revoking or logging session it was delegated from) cascades to every session chained from it. Deleting an instance revokes its live sessions first, and deleting a blueprint tears down its instances. The resources agents hang off cascade the same way: deleting an organization tears down every instance in it, -deleting a membership tears down the instances delegated from it, deactivating a membership -revokes their sessions (the instance survives for a reactivation), and deleting a permission -removes it from every blueprint ceiling that named it. Session `status` is derived at read time -from `revoked_at` and `expires_at`. The seven `agent.*` events -fire through the same webhook and `/events` plumbing as everything else. +deleting a membership or its user tears down the instances delegated from it, deactivating a +membership revokes their sessions (the instance survives for a reactivation), and deleting a +permission removes it from every blueprint ceiling that named it. Session `status` is derived at +read time from `revoked_at` and `expires_at`; revoking touches only live sessions, so an +already-expired one stays `expired` with a null `revoked_at` while its live descendants are +still revoked. The seven `agent.*` events fire through the same webhook and `/events` plumbing +as everything else. Errors use production's stable codes: `invalid_request` (400) for a malformed body; `permission_not_found`, `role_not_found`, `organization_not_found` (422) and `name_already_in_use` diff --git a/src/workos/agent-sessions.ts b/src/workos/agent-sessions.ts index d9b3bf2..4d27224 100644 --- a/src/workos/agent-sessions.ts +++ b/src/workos/agent-sessions.ts @@ -90,16 +90,22 @@ export function findChainRoot(ws: WorkOSStore, session: WorkOSAgentInstanceSessi return { root: current, depth, ancestorRevoked }; } +function isSessionLive(session: WorkOSAgentInstanceSession, nowMs: number): boolean { + return session.revoked_at === null && new Date(session.expires_at).getTime() > nowMs; +} + /** - * Revoke a session and every descendant chained from it. Only rows whose `revoked_at` - * flips from null are touched, so the update hook emits one `agent.instance.session.revoked` - * per session that was actually live. Returns how many sessions were revoked. + * Revoke a session and every descendant chained from it. Only live rows are touched, so the + * update hook emits one `agent.instance.session.revoked` per session that was actually live; + * already-revoked rows keep their `revoked_at` and already-expired rows stay `expired`, but + * both still have their children walked. Returns how many sessions were revoked. */ export function revokeAgentSessionTree( ws: WorkOSStore, sessionId: string, revokedAt = new Date().toISOString(), ): number { + const nowMs = new Date(revokedAt).getTime(); let count = 0; const pending = [sessionId]; const seen = new Set(); @@ -109,7 +115,7 @@ export function revokeAgentSessionTree( seen.add(id); const session = ws.agentInstanceSessions.get(id); if (!session) continue; - if (session.revoked_at === null) { + if (isSessionLive(session, nowMs)) { ws.agentInstanceSessions.update(id, { revoked_at: revokedAt }); count += 1; } @@ -124,9 +130,10 @@ export function revokeAgentSessionTree( */ export function deleteAgentInstance(ws: WorkOSStore, instance: WorkOSAgentInstance): void { const sessions = ws.agentInstanceSessions.findBy('agent_instance_id', instance.id); + const revokedAt = new Date().toISOString(); for (const session of sessions) { - if (session.revoked_at === null && new Date(session.expires_at).getTime() > Date.now()) { - ws.agentInstanceSessions.update(session.id, { revoked_at: new Date().toISOString() }); + if (isSessionLive(session, new Date(revokedAt).getTime())) { + ws.agentInstanceSessions.update(session.id, { revoked_at: revokedAt }); } } for (const session of sessions) ws.agentInstanceSessions.delete(session.id); @@ -161,7 +168,7 @@ export function deleteAgentInstancesForMembership(ws: WorkOSStore, membershipId: export function revokeAgentSessionsForMembership(ws: WorkOSStore, membershipId: string): void { for (const instance of ws.agentInstances.findBy('organization_membership_id', membershipId)) { for (const session of ws.agentInstanceSessions.findBy('agent_instance_id', instance.id)) { - if (session.revoked_at === null) revokeAgentSessionTree(ws, session.id); + revokeAgentSessionTree(ws, session.id); } } } diff --git a/src/workos/routes/agents.spec.ts b/src/workos/routes/agents.spec.ts index b924fc6..2308a07 100644 --- a/src/workos/routes/agents.spec.ts +++ b/src/workos/routes/agents.spec.ts @@ -61,12 +61,14 @@ function decodeJwt(token: string): { header: Record; payload: Recor describe('Agent Auth routes', () => { let app: ReturnType['app']; + let jwt: ReturnType['jwt']; let store: Store; const ws = () => getWorkOSStore(store); beforeEach(() => { const server = createTestApp(); app = server.app; + jwt = server.jwt; store = server.store; }); @@ -207,7 +209,7 @@ describe('Agent Auth routes', () => { const res = await post('/agents/blueprints', { description: '', permissions: 'crm:read', - session_settings: { access_token_ttl_seconds: 3601, max_age_seconds: 0 }, + session_settings: { access_token_ttl_seconds: 3601, max_age_seconds: 0, refresh_token_ttl_seconds: 60 }, }); expect(res.status).toBe(400); const body = await json(res); @@ -221,6 +223,41 @@ describe('Agent Auth routes', () => { ]); }); + it('rejects on create what only update may send: a null description and partial settings', async () => { + const nullDescription = await post('/agents/blueprints', { name: 'Example A', description: null }); + expect(nullDescription.status).toBe(400); + expect((await json(nullDescription)).errors.map((e: { field: string }) => e.field)).toEqual(['description']); + + const partial = await post('/agents/blueprints', { + name: 'Example B', + session_settings: { access_token_ttl_seconds: 60 }, + }); + expect(partial.status).toBe(400); + expect((await json(partial)).errors.map((e: { field: string }) => e.field).sort()).toEqual([ + 'session_settings.max_age_seconds', + 'session_settings.refresh_token_ttl_seconds', + ]); + expect((await json(await req('/agents/blueprints'))).data).toHaveLength(0); + + const complete = await post('/agents/blueprints', { + name: 'Example C', + description: 'Full settings', + session_settings: { max_age_seconds: 600, access_token_ttl_seconds: 60, refresh_token_ttl_seconds: 600 }, + }); + expect(complete.status).toBe(201); + + const { id } = await json(complete); + const patched = await req(`/agents/blueprints/${id}`, { + method: 'PATCH', + body: JSON.stringify({ description: null, session_settings: { access_token_ttl_seconds: 30 } }), + }); + expect(patched.status).toBe(200); + expect(await json(patched)).toMatchObject({ + description: null, + session_settings: { max_age_seconds: 600, access_token_ttl_seconds: 30, refresh_token_ttl_seconds: 600 }, + }); + }); + it('rejects unknown permissions, roles and organizations with 422 codes', async () => { await expectError( await post('/agents/blueprints', { name: 'A', permissions: ['nope'] }), @@ -398,7 +435,7 @@ describe('Agent Auth routes', () => { it('caps the access token TTL at the session lifetime', async () => { const { org, blueprint } = await seedWorld({ - session_settings: { access_token_ttl_seconds: 300, refresh_token_ttl_seconds: 120 }, + session_settings: { max_age_seconds: 3600, access_token_ttl_seconds: 300, refresh_token_ttl_seconds: 120 }, }); const body = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); expect(body.expires_in).toBe(120); @@ -443,6 +480,24 @@ describe('Agent Auth routes', () => { expect(body.permissions).toEqual(['crm:read']); }); + it('accepts a user token that names its subject profile explicitly', async () => { + const { alice, blueprint } = await seedWorld({ permissions: ['crm:read'] }); + const login = await loginAs(); + const { sub, sid, org_id, aud } = decodeJwt(login.access_token).payload; + const explicit = jwt.sign({ sub, sid, org_id, aud, sub_profile: 'user' }); + + const body = await mintOk(blueprint.id, { type: 'user_delegated', user_access_token: explicit }); + expect(body.permissions).toEqual(['crm:read']); + expect(decodeJwt(body.access_token).payload.act).toEqual({ sub: alice.id, sub_profile: 'user' }); + + const other = jwt.sign({ sub, sid, org_id, aud, sub_profile: 'widget' }); + await expectError( + await mint(blueprint.id, { type: 'user_delegated', user_access_token: other }), + 400, + 'invalid_user_access_token', + ); + }); + it('rejects garbage, foreign and agent tokens as invalid_user_access_token', async () => { const { org, blueprint } = await seedWorld(); await expectError(await mint(blueprint.id, { type: 'user_delegated' }), 400, 'invalid_request'); @@ -494,7 +549,9 @@ describe('Agent Auth routes', () => { }); it('rejects a login older than max_age_seconds', async () => { - const { blueprint } = await seedWorld({ session_settings: { max_age_seconds: 60 } }); + const { blueprint } = await seedWorld({ + session_settings: { max_age_seconds: 60, access_token_ttl_seconds: 300, refresh_token_ttl_seconds: 3600 }, + }); const login = await loginAs(); const { sid } = decodeJwt(login.access_token).payload; ws().sessions.updateSilent(sid, { created_at: new Date(Date.now() - 120_000).toISOString() }); @@ -589,7 +646,7 @@ describe('Agent Auth routes', () => { it('anchors every hop to the root session max-age window', async () => { const { org, blueprint } = await seedWorld({ - session_settings: { max_age_seconds: 600, refresh_token_ttl_seconds: 3600 }, + session_settings: { max_age_seconds: 600, access_token_ttl_seconds: 300, refresh_token_ttl_seconds: 3600 }, }); const root = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); const rootRow = ws().agentInstanceSessions.get(root.agent_instance_session_id)!; @@ -695,7 +752,7 @@ describe('Agent Auth routes', () => { it('never extends a session past the root max-age window', async () => { const { org, blueprint } = await seedWorld({ - session_settings: { max_age_seconds: 600, refresh_token_ttl_seconds: 3600 }, + session_settings: { max_age_seconds: 600, access_token_ttl_seconds: 300, refresh_token_ttl_seconds: 3600 }, }); const minted = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); const row = ws().agentInstanceSessions.get(minted.agent_instance_session_id)!; @@ -872,6 +929,34 @@ describe('Agent Auth routes', () => { ); expect((await post('/agents/sessions/agent_session_missing/revoke', {})).status).toBe(404); }); + + it('leaves an already-expired session expired while still revoking its live descendants', async () => { + const { org, blueprint } = await seedWorld(); + const root = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + const child = await mintOk(blueprint.id, { type: 'agent_delegated', agent_access_token: root.access_token }); + const grandchild = await mintOk(blueprint.id, { + type: 'agent_delegated', + agent_access_token: child.access_token, + }); + const expired = new Date(Date.now() - 1000).toISOString(); + ws().agentInstanceSessions.updateSilent(root.agent_instance_session_id, { expires_at: expired }); + ws().agentInstanceSessions.updateSilent(child.agent_instance_session_id, { expires_at: expired }); + + const res = await post(`/agents/sessions/${root.agent_instance_session_id}/revoke`, {}); + expect(res.status).toBe(200); + expect(await json(res)).toMatchObject({ + id: root.agent_instance_session_id, + status: 'expired', + revoked_at: null, + }); + + const session = async (id: string) => json(await req(`/agents/sessions/${id}`)); + expect(await session(child.agent_instance_session_id)).toMatchObject({ status: 'expired', revoked_at: null }); + expect(await session(grandchild.agent_instance_session_id)).toMatchObject({ status: 'revoked' }); + expect(events('agent.instance.session.revoked').map((e) => e.data.id)).toEqual([ + grandchild.agent_instance_session_id, + ]); + }); }); describe('cascades from the resources agents depend on', () => { @@ -928,6 +1013,28 @@ describe('Agent Auth routes', () => { expect(events('agent.instance.session.revoked')).toHaveLength(1); }); + it('deleting a user deletes the instances delegated from its memberships', async () => { + const { org, alice, blueprint } = await seedWorld(); + const login = await loginAs(); + const delegated = await mintOk(blueprint.id, { + type: 'user_delegated', + user_access_token: login.access_token, + }); + const child = await mintOk(blueprint.id, { type: 'agent_delegated', agent_access_token: delegated.access_token }); + const autonomous = await mintOk(blueprint.id, { type: 'autonomous', organization_id: org.id }); + + expect((await req(`/user_management/users/${alice.id}`, { method: 'DELETE' })).status).toBe(204); + + expect((await req(`/agents/instances/${delegated.agent_instance_id}`)).status).toBe(404); + for (const minted of [delegated, child]) { + expect((await req(`/agents/sessions/${minted.agent_instance_session_id}`)).status).toBe(404); + await expectError(await validate(blueprint.id, minted.access_token), 400, 'invalid_agent_access_token'); + } + expect((await json(await validate(blueprint.id, autonomous.access_token))).valid).toBe(true); + expect(events('agent.instance.deleted').map((e) => e.data.id)).toEqual([delegated.agent_instance_id]); + expect(events('agent.instance.session.revoked')).toHaveLength(2); + }); + it('deactivating a membership revokes its delegated sessions but keeps the instance', async () => { const { membership, blueprint } = await seedWorld(); const login = await loginAs(); diff --git a/src/workos/routes/agents.ts b/src/workos/routes/agents.ts index 61d60e8..6f39f0e 100644 --- a/src/workos/routes/agents.ts +++ b/src/workos/routes/agents.ts @@ -55,12 +55,16 @@ const isStringList = (v: unknown): v is string[] => Array.isArray(v) && v.every( const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); /** - * Shape-check a blueprint body. Every field is optional here so create and update share - * the code; create supplies `name` separately. Reports all problems at once, the way a - * schema validator would, rather than the first one hit. + * Shape-check a blueprint body. The limits are shared; the two modes differ the way + * production's create and update schemas do. Create fills omitted fields with defaults and + * so takes no `null` description and only a complete `session_settings`; update lets + * `description: null` clear the field and merges a partial `session_settings` into the + * existing one. Reports all problems at once, the way a schema validator would, rather than + * the first one hit. */ function validateBlueprintBody( body: Record, + mode: 'create' | 'update', errors: FieldError[], ): { name?: string; @@ -80,13 +84,16 @@ function validateBlueprintBody( } if (body.description !== undefined) { - if (body.description === null) { + if (body.description === null && mode === 'update') { out.description = null; } else if (!isNonEmptyString(body.description) || body.description.length > 1000) { errors.push({ field: 'description', code: 'invalid', - message: 'description must be a string of 1 to 1000 characters, or null', + message: + mode === 'update' + ? 'description must be a string of 1 to 1000 characters, or null' + : 'description must be a string of 1 to 1000 characters', }); } else { out.description = body.description; @@ -138,7 +145,16 @@ function validateBlueprintBody( const settings: Partial = {}; for (const key of Object.keys(AGENT_SESSION_SETTING_LIMITS) as (keyof typeof AGENT_SESSION_SETTING_LIMITS)[]) { const value = body.session_settings[key]; - if (value === undefined) continue; + if (value === undefined) { + if (mode === 'create') { + errors.push({ + field: `session_settings.${key}`, + code: 'required', + message: `session_settings.${key} is required when session_settings is provided`, + }); + } + continue; + } const max = AGENT_SESSION_SETTING_LIMITS[key]; if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0 || value > max) { errors.push({ @@ -417,7 +433,7 @@ export function agentRoutes(ctx: RouteContext): void { const body = await parseJsonBody(c); const errors: FieldError[] = []; if (body.name === undefined) errors.push({ field: 'name', code: 'required', message: 'name is required' }); - const parsed = validateBlueprintBody(body, errors); + const parsed = validateBlueprintBody(body, 'create', errors); if (errors.length > 0 || parsed.name === undefined) throw invalidRequest('Invalid request body', errors); const permissions = parsed.permissions ?? []; @@ -450,7 +466,7 @@ export function agentRoutes(ctx: RouteContext): void { const blueprint = requireBlueprint(ws, c.req.param('id')); const body = await parseJsonBody(c); const errors: FieldError[] = []; - const parsed = validateBlueprintBody(body, errors); + const parsed = validateBlueprintBody(body, 'update', errors); if (errors.length > 0) throw invalidRequest('Invalid request body', errors); const invocable_by: WorkOSAgentBlueprintInvocableBy = { @@ -499,8 +515,9 @@ export function agentRoutes(ctx: RouteContext): void { } // The presented token authenticates the user and names the organization; nothing // else on it is trusted. Authority comes from the live session and membership below. + // User tokens carry `sub_profile: 'user'` or omit it; any other family is rejected. if ( - payload.sub_profile !== undefined || + (payload.sub_profile !== undefined && payload.sub_profile !== USER_SUBJECT_PROFILE) || typeof payload.sub !== 'string' || typeof payload.org_id !== 'string' || typeof payload.sid !== 'string' || @@ -743,7 +760,8 @@ export function agentRoutes(ctx: RouteContext): void { }); // Revocation cascades to every session chained from this one and is idempotent: an - // already-revoked session answers 200 with its existing revoked_at. + // already-revoked session answers 200 with its existing revoked_at, and an already-expired + // one stays `expired` with a null revoked_at. app.post('/agents/sessions/:id/revoke', (c) => { const session = ws.agentInstanceSessions.get(c.req.param('id')); if (!session) throw notFound('Agent instance session'); diff --git a/src/workos/routes/users.ts b/src/workos/routes/users.ts index 3622cab..2fa4914 100644 --- a/src/workos/routes/users.ts +++ b/src/workos/routes/users.ts @@ -17,6 +17,7 @@ import { requireEmailField, revokeApiKeysForOwner, } from '../helpers.js'; +import { deleteAgentInstancesForMembership } from '../agent-sessions.js'; export function userRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -136,6 +137,7 @@ export function userRoutes(ctx: RouteContext): void { ws.sessions.delete(s.id); } for (const m of ws.organizationMemberships.findBy('user_id', user.id)) { + deleteAgentInstancesForMembership(ws, m.id); ws.organizationMemberships.delete(m.id); } for (const f of ws.authFactors.findBy('user_id', user.id)) { From c8e0dcdfd4cc7c79344f5e9d92b668beb7e5a90f Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Wed, 9 Sep 2026 15:46:40 +0000 Subject: [PATCH 5/5] Validate seeded blueprints like the create route The agentBlueprints seed key now rejects a null description and requires all three session_settings values when the object is present, matching POST /agents/blueprints. Omitting session_settings still applies production's defaults. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 3 +++ src/workos/config-validator.ts | 13 +++++++--- src/workos/index.ts | 16 +++++++----- src/workos/seed-agent-blueprints.spec.ts | 31 ++++++++++++++++++++---- 4 files changed, 48 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0ccaeb8..394d0f0 100644 --- a/README.md +++ b/README.md @@ -649,6 +649,9 @@ implemented (`/agents/blueprints`, `/agents/instances`, `/agents/sessions`). Blu created over the API or seeded; instances and sessions only ever come into being by minting. `permissions` and `invocable_by.role_slugs` name seeded `permissions` and `roles` by slug, and `invocable_by.organizations` names `organizations` by name, the same join feature-flag targets use. +A seeded blueprint is validated the way `POST /agents/blueprints` validates a body: `description` +is a non-empty string or omitted, and `session_settings` is either omitted (production's 3600 / +300 / 3600 second defaults) or given with all three values. ```yaml permissions: diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 6f78abe..5e55c02 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -1084,14 +1084,13 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe if ( blueprint.description !== undefined && - blueprint.description !== null && (typeof blueprint.description !== 'string' || blueprint.description.length === 0 || blueprint.description.length > 1000) ) { errors.push({ path: at('description'), - message: 'description must be a string of 1 to 1000 characters, or null, if provided', + message: 'description must be a string of 1 to 1000 characters if provided', value: blueprint.description, }); } @@ -1180,10 +1179,16 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe ) as (keyof typeof AGENT_SESSION_SETTING_LIMITS)[]) { const value = settings[key]; const max = AGENT_SESSION_SETTING_LIMITS[key]; - if (value !== undefined && (!Number.isInteger(value) || value <= 0 || value > max)) { + if (value === undefined) { errors.push({ path: at(`session_settings.${key}`), - message: `session_settings.${key} must be a positive integer of at most ${max} if provided`, + message: `session_settings.${key} is required when session_settings is provided`, + value, + }); + } else if (!Number.isInteger(value) || value <= 0 || value > max) { + errors.push({ + path: at(`session_settings.${key}`), + message: `session_settings.${key} must be a positive integer of at most ${max}`, value, }); } diff --git a/src/workos/index.ts b/src/workos/index.ts index 83b718b..3288a21 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -352,7 +352,8 @@ export interface WorkOSSeedAgentBlueprint { id?: string; /** Required and unique within the environment, as production enforces on create. */ name: string; - description?: string | null; + /** 1 to 1000 characters. Omit for no description, as production's create endpoint requires. */ + description?: string; /** Slugs of permissions defined in `permissions`; the ceiling on what a minted session may hold. */ permissions?: string[]; invocable_by?: { @@ -364,11 +365,14 @@ export interface WorkOSSeedAgentBlueprint { */ organizations?: string[]; }; - /** Defaults match production: 3600 / 300 / 3600 seconds. */ + /** + * All three are required when the object is given, as on production's create endpoint. + * Omitting the object uses production's defaults: 3600 / 300 / 3600 seconds. + */ session_settings?: { - max_age_seconds?: number; - access_token_ttl_seconds?: number; - refresh_token_ttl_seconds?: number; + max_age_seconds: number; + access_token_ttl_seconds: number; + refresh_token_ttl_seconds: number; }; } @@ -883,7 +887,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee role_slugs: [...new Set(blueprintConfig.invocable_by?.role_slugs ?? [])], organization_ids: [...new Set(organizationIds)], }, - session_settings: { ...DEFAULT_AGENT_SESSION_SETTINGS, ...blueprintConfig.session_settings }, + session_settings: blueprintConfig.session_settings ?? { ...DEFAULT_AGENT_SESSION_SETTINGS }, }); } } diff --git a/src/workos/seed-agent-blueprints.spec.ts b/src/workos/seed-agent-blueprints.spec.ts index 4265643..372ec79 100644 --- a/src/workos/seed-agent-blueprints.spec.ts +++ b/src/workos/seed-agent-blueprints.spec.ts @@ -6,6 +6,7 @@ import { describe, it, expect, afterEach } from 'bun:test'; import { createEmulator, type Emulator } from '../index.js'; import { validateSeedConfig } from './config-validator.js'; +import type { WorkOSSeedAgentBlueprint } from './index.js'; describe('Seeding agent blueprints', () => { let emulator: Emulator | undefined; @@ -36,7 +37,7 @@ describe('Seeding agent blueprints', () => { description: 'Finds prospects', permissions: ['crm:read', 'email:send'], invocable_by: { role_slugs: ['manager'], organizations: ['Acme Corp'] }, - session_settings: { access_token_ttl_seconds: 60 }, + session_settings: { max_age_seconds: 3600, access_token_ttl_seconds: 60, refresh_token_ttl_seconds: 3600 }, }, { name: 'Minimal Agent' }, ], @@ -123,12 +124,22 @@ describe('Seeding agent blueprints', () => { ]); }); - it('rejects duplicate names and ids, and out-of-range session settings', () => { + it('rejects duplicate names and ids, and out-of-range or missing session settings', () => { const { valid, errors } = validateSeedConfig({ agentBlueprints: [ { id: 'agent_blueprint_dup', name: 'Same' }, - { id: 'agent_blueprint_dup', name: 'Same', session_settings: { access_token_ttl_seconds: 3601 } }, - { name: 'Bad Settings', session_settings: { max_age_seconds: 0, refresh_token_ttl_seconds: 1.5 } }, + { + id: 'agent_blueprint_dup', + name: 'Same', + session_settings: { max_age_seconds: 3600, access_token_ttl_seconds: 3601, refresh_token_ttl_seconds: 3600 }, + }, + { + name: 'Bad Settings', + session_settings: { + max_age_seconds: 0, + refresh_token_ttl_seconds: 1.5, + } as WorkOSSeedAgentBlueprint['session_settings'], + }, ], }); expect(valid).toBe(false); @@ -136,12 +147,22 @@ describe('Seeding agent blueprints', () => { 'agentBlueprints[1].id', 'agentBlueprints[1].name', 'agentBlueprints[1].session_settings.access_token_ttl_seconds', + 'agentBlueprints[2].session_settings.access_token_ttl_seconds', 'agentBlueprints[2].session_settings.max_age_seconds', 'agentBlueprints[2].session_settings.refresh_token_ttl_seconds', ]); + expect(errors.find((e) => e.path === 'agentBlueprints[2].session_settings.access_token_ttl_seconds')?.message).toBe( + 'session_settings.access_token_ttl_seconds is required when session_settings is provided', + ); }); - it('applies the create route limits: non-empty description and list maxima', () => { + it('applies the create route limits: non-null, non-empty description and list maxima', () => { + const nulled = validateSeedConfig({ + agentBlueprints: [{ name: 'Nulled', description: null as unknown as string }], + }); + expect(nulled.valid).toBe(false); + expect(nulled.errors.map((e) => e.path)).toEqual(['agentBlueprints[0].description']); + const { valid, errors } = validateSeedConfig({ ...seed, agentBlueprints: [