From ee0f9db2347e1779444178991123dd2c5b1d47b2 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Wed, 4 Nov 2026 00:02:22 -0600 Subject: [PATCH 01/12] feat(shared,backend,react,vue,clerk-js): expose agent task sessions on auth objects Sessions created from an agent task carry an agt claim. Parse it into an agent property ({ sub, type: 'agent', task_id }, the same shape as Session.agent) on backend auth objects and useAuth(). A present agt claim always yields a non-null agent, even when malformed, so a truthiness check fails closed. The stub session built from the JWT in clerk-js now carries the same value. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-task-session-claim.md | 9 +++ .../src/tokens/__tests__/authObjects.test.ts | 41 ++++++++++++ packages/backend/src/tokens/authObjects.ts | 17 ++++- .../src/core/__tests__/jwt-client.test.ts | 30 +++++++++ packages/clerk-js/src/core/jwt-client.ts | 3 +- .../src/hooks/__tests__/useAuth.test.tsx | 19 ++++++ packages/react/src/hooks/useAuthBase.tsx | 4 ++ .../src/__tests__/authorization.spec.ts | 36 +++++++++- .../shared/src/__tests__/deriveState.spec.ts | 27 ++++++++ .../src/__tests__/jwtPayloadParser.spec.ts | 66 +++++++++++++++++++ packages/shared/src/authorization.ts | 8 +++ packages/shared/src/deriveState.ts | 4 ++ packages/shared/src/jwtPayloadParser.ts | 10 +++ packages/shared/src/types/authObject.ts | 8 ++- packages/shared/src/types/hooks.ts | 10 ++- packages/shared/src/types/jwt.ts | 7 +- packages/shared/src/types/jwtv2.ts | 35 +++++++++- packages/shared/src/types/ssr.ts | 3 +- packages/vue/src/plugin.ts | 2 + packages/vue/src/types.ts | 2 + 20 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 .changeset/agent-task-session-claim.md diff --git a/.changeset/agent-task-session-claim.md b/.changeset/agent-task-session-claim.md new file mode 100644 index 00000000000..3223836ed78 --- /dev/null +++ b/.changeset/agent-task-session-claim.md @@ -0,0 +1,9 @@ +--- +"@clerk/shared": minor +"@clerk/backend": minor +"@clerk/react": minor +"@clerk/vue": minor +"@clerk/clerk-js": patch +--- + +Expose agent task sessions on auth objects. Sessions created from an agent task now populate a new `agent` property (`{ sub, type: 'agent', task_id }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, and on `useAuth()`. Use a truthiness check (`if (agent)`) to detect an agent session. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types. diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index fde4ec53c75..b08d51739cc 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -28,6 +28,21 @@ describe('makeAuthObjectSerializable', () => { expect(typeof serializableAuthObject[key]).not.toBe('function'); } }); + + it('keeps the agent property so it reaches the client initial state', () => { + const authObject = signedInAuthObject({} as AuthenticateContext, 'token', { + v: 2, + sid: 'sessionId', + sub: 'userId', + agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com' }, + } as unknown as JwtPayload); + + expect(makeAuthObjectSerializable(authObject).agent).toEqual({ + sub: 'agent_123', + type: 'agent', + task_id: 'agttsk_456', + }); + }); }); describe('signedInAuthObject', () => { @@ -47,6 +62,32 @@ describe('signedInAuthObject', () => { expect(token).toBe('token'); }); + it('exposes agent task sessions on the auth object', () => { + const authObject = signedInAuthObject({} as AuthenticateContext, 'token', { + v: 2, + sid: 'sessionId', + sub: 'userId', + agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com' }, + } as unknown as JwtPayload); + + expect(authObject.agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }); + expect(authObject.actor).toBeUndefined(); + }); + + it('agent is null for sessions without the agt claim', () => { + const authObject = signedInAuthObject({} as AuthenticateContext, 'token', { + v: 2, + sid: 'sessionId', + sub: 'userId', + } as unknown as JwtPayload); + + expect(authObject.agent).toBeNull(); + }); + + it('agent is null on the signed-out auth object', () => { + expect(signedOutAuthObject().agent).toBeNull(); + }); + it('redacts raw session and machine tokens from debug output', () => { const rawSessionToken = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyJ9.this-segment-must-never-be-logged'; const mockAuthenticateContext = { diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 44391b388da..790e1760db1 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -69,6 +69,7 @@ export type SignedOutAuthObject = { sessionId: null; sessionStatus: SessionStatusClaim | null; actor: null; + agent: null; tokenType: SessionTokenType; userId: null; orgId: null; @@ -195,8 +196,18 @@ export function signedInAuthObject( sessionToken: string, sessionClaims: JwtPayload, ): SignedInAuthObject { - const { actor, sessionId, sessionStatus, userId, orgId, orgRole, orgSlug, orgPermissions, factorVerificationAge } = - __experimental_JWTPayloadToAuthObjectProperties(sessionClaims); + const { + actor, + agent, + sessionId, + sessionStatus, + userId, + orgId, + orgRole, + orgSlug, + orgPermissions, + factorVerificationAge, + } = __experimental_JWTPayloadToAuthObjectProperties(sessionClaims); const apiClient = createBackendApiClient(authenticateContext); const getToken = createGetToken({ sessionId, @@ -207,6 +218,7 @@ export function signedInAuthObject( return { tokenType: TokenType.SessionToken, actor, + agent, sessionClaims, sessionId, sessionStatus, @@ -245,6 +257,7 @@ export function signedOutAuthObject( sessionStatus: initialSessionStatus ?? null, userId: null, actor: null, + agent: null, orgId: null, orgRole: null, orgSlug: null, diff --git a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts index b9a2f3e4c22..49ce250c670 100644 --- a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts +++ b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts @@ -4,6 +4,16 @@ import { mockJwt } from '@/test/core-fixtures'; import { createClientFromJwt } from '../jwt-client'; +const jwtWithClaims = (claims: Record) => { + const [header, payload, signature] = mockJwt.split('.'); + const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))); + const encoded = btoa(JSON.stringify({ ...decoded, ...claims })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return `${header}.${encoded}.${signature}`; +}; + describe('createClientFromJwt', () => { it('creates a client with a session and user derived from the JWT claims', () => { const client = createClientFromJwt(mockJwt); @@ -25,4 +35,24 @@ describe('createClientFromJwt', () => { expect(client.signedInSessions).toEqual([]); expect(client.lastActiveSessionId).toBeNull(); }); + + it('exposes the agent on the stub session when the JWT has an agt claim', () => { + const client = createClientFromJwt(jwtWithClaims({ agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'r' } })); + + expect(client.signedInSessions[0]?.agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }); + }); + + it('keeps the actor on the stub session when the JWT has an act claim', () => { + const client = createClientFromJwt(jwtWithClaims({ act: { sub: 'user_impersonator' } })); + + expect(client.signedInSessions[0]?.actor).toEqual({ sub: 'user_impersonator' }); + expect(client.signedInSessions[0]?.agent).toBeNull(); + }); + + it('leaves actor and agent null when the JWT has neither claim', () => { + const client = createClientFromJwt(mockJwt); + + expect(client.signedInSessions[0]?.actor).toBeNull(); + expect(client.signedInSessions[0]?.agent).toBeNull(); + }); }); diff --git a/packages/clerk-js/src/core/jwt-client.ts b/packages/clerk-js/src/core/jwt-client.ts index 9eafe5d088c..e0f232b6460 100644 --- a/packages/clerk-js/src/core/jwt-client.ts +++ b/packages/clerk-js/src/core/jwt-client.ts @@ -44,7 +44,7 @@ export function createClientFromJwt(jwt: string | undefined | null): Client { } as unknown as ClientJSON); } - const { sessionId, userId, orgId, orgRole, orgPermissions, orgSlug, factorVerificationAge } = + const { sessionId, userId, orgId, orgRole, orgPermissions, orgSlug, factorVerificationAge, actor, agent } = __experimental_JWTPayloadToAuthObjectProperties(token.jwt.claims); // TODO(jwt-v2): when JWT version 2 is available, we should revise org permissions @@ -57,6 +57,7 @@ export function createClientFromJwt(jwt: string | undefined | null): Client { object: 'session', id: sessionId, status: 'active', + actor: agent ?? actor ?? null, last_active_organization_id: orgId || null, // @ts-expect-error - ts is not happy about `id:undefined`, but this is allowed and expected last_active_token: { diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index 0a27a3e9b10..c79288abcdc 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -113,6 +113,7 @@ describe('useDerivedAuth', () => { expect(current.sessionClaims).toBeUndefined(); expect(current.userId).toBeUndefined(); expect(current.actor).toBeUndefined(); + expect(current.agent).toBeUndefined(); expect(current.orgId).toBeUndefined(); expect(current.orgRole).toBeUndefined(); expect(current.orgSlug).toBeUndefined(); @@ -130,6 +131,7 @@ describe('useDerivedAuth', () => { expect(current.sessionClaims).toBeNull(); expect(current.userId).toBeNull(); expect(current.actor).toBeNull(); + expect(current.agent).toBeNull(); expect(current.orgId).toBeNull(); expect(current.orgRole).toBeNull(); expect(current.orgSlug).toBeNull(); @@ -137,6 +139,23 @@ describe('useDerivedAuth', () => { expect(current.has?.({ permission: 'test' })).toBe(false); }); + it('returns the agent for a signed in session', () => { + const agent = { sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }; + const { + result: { current }, + } = renderHook(() => + useDerivedAuth({ + sessionId: 'session123', + userId: 'user123', + sessionClaims: {}, + agent, + signOut: vi.fn(), + getToken: vi.fn(), + }), + ); + expect(current.agent).toEqual(agent); + }); + it('returns signed out state when session has pending status by default', () => { const authObject = { sessionId: 'session123', diff --git a/packages/react/src/hooks/useAuthBase.tsx b/packages/react/src/hooks/useAuthBase.tsx index bc1b7e21749..42ac9e79f93 100644 --- a/packages/react/src/hooks/useAuthBase.tsx +++ b/packages/react/src/hooks/useAuthBase.tsx @@ -2,6 +2,7 @@ import { deriveState, type DeriveStateReturnType } from '@clerk/shared/deriveSta import { useClerkInstanceContext, useInitialStateContext } from '@clerk/shared/react'; import type { ActClaim, + AgentActClaim, InitialState, JwtPayload, OrganizationCustomPermissionKey, @@ -17,6 +18,7 @@ type AuthStateValue = { sessionStatus: SessionStatusClaim | null | undefined; sessionClaims: JwtPayload | null | undefined; actor: ActClaim | null | undefined; + agent: AgentActClaim | null | undefined; orgId: string | null | undefined; orgRole: OrganizationCustomRoleKey | null | undefined; orgSlug: string | null | undefined; @@ -26,6 +28,7 @@ type AuthStateValue = { export const defaultDerivedInitialState = { actor: undefined, + agent: undefined, factorVerificationAge: null, orgId: undefined, orgPermissions: undefined, @@ -74,6 +77,7 @@ function authStateFromFull(derivedState: DeriveStateReturnType) { sessionClaims: derivedState.sessionClaims, userId: derivedState.userId, actor: derivedState.actor, + agent: derivedState.agent, orgId: derivedState.orgId, orgRole: derivedState.orgRole, orgSlug: derivedState.orgSlug, diff --git a/packages/shared/src/__tests__/authorization.spec.ts b/packages/shared/src/__tests__/authorization.spec.ts index 42e25c37194..04a0046f81f 100644 --- a/packages/shared/src/__tests__/authorization.spec.ts +++ b/packages/shared/src/__tests__/authorization.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createCheckAuthorization, splitByScope } from '../authorization'; +import { createCheckAuthorization, resolveAuthState, splitByScope } from '../authorization'; describe('createCheckAuthorization', () => { it('correctly parses features', () => { @@ -394,3 +394,37 @@ describe('splitByScope', () => { expect(() => splitByScope('reservations,dashboard')).toThrow('Invalid claim element (missing colon): reservations'); }); }); + +describe('resolveAuthState agent', () => { + const agent = { sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' } as const; + const base = { + userId: 'user_1', + sessionId: 'sess_1', + sessionClaims: { sid: 'sess_1' } as any, + agent, + getToken: (async () => null) as any, + signOut: (async () => {}) as any, + has: () => false, + }; + + it('exposes the agent for an active session', () => { + const result = resolveAuthState({ authObject: { ...base, sessionStatus: 'active' }, options: {} }); + expect(result?.agent).toEqual(agent); + }); + + it('gives a null agent for a pending session treated as signed out', () => { + const result = resolveAuthState({ + authObject: { ...base, sessionStatus: 'pending' }, + options: { treatPendingAsSignedOut: true }, + }); + expect(result?.agent).toBeNull(); + }); + + it('gives an undefined agent before the session is known', () => { + const result = resolveAuthState({ + authObject: { ...base, userId: undefined, sessionId: undefined, agent: undefined }, + options: {}, + }); + expect(result?.agent).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/__tests__/deriveState.spec.ts b/packages/shared/src/__tests__/deriveState.spec.ts index b1445cf9ade..b32a3f47446 100644 --- a/packages/shared/src/__tests__/deriveState.spec.ts +++ b/packages/shared/src/__tests__/deriveState.spec.ts @@ -28,6 +28,33 @@ describe('deriveState', () => { expect(result.orgId).toBe(mockInitialState.orgId); }); + it('derives the agent from the SSR initial state', () => { + const agent = { sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }; + const result = deriveState(false, {} as Resources, { ...mockInitialState, agent } as InitialState); + expect(result.agent).toEqual(agent); + }); + + it('derives the agent from the session when clerk is loaded', () => { + const agent = { sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }; + const result = deriveState( + true, + { ...mockResources, session: { ...mockResources.session, agent } } as Resources, + undefined, + ); + expect(result.agent).toEqual(agent); + }); + + it('agent is null on a session without an agent and undefined without a session', () => { + const withSession = deriveState( + true, + { ...mockResources, session: { id: 's', agent: null } } as Resources, + undefined, + ); + expect(withSession.agent).toBeNull(); + const withoutSession = deriveState(true, { ...mockResources, session: undefined } as Resources, undefined); + expect(withoutSession.agent).toBeUndefined(); + }); + it('handles !clerkLoaded and undefined initialState', () => { const result = deriveState(false, {} as Resources, undefined); expect(result.userId).toBeUndefined(); diff --git a/packages/shared/src/__tests__/jwtPayloadParser.spec.ts b/packages/shared/src/__tests__/jwtPayloadParser.spec.ts index f77c53893b8..3dfa25e943c 100644 --- a/packages/shared/src/__tests__/jwtPayloadParser.spec.ts +++ b/packages/shared/src/__tests__/jwtPayloadParser.spec.ts @@ -42,6 +42,72 @@ const authFromFeaturePermissionMask = (fpm: string) => { }; describe('JWTPayloadToAuthObjectProperties', () => { + test('exposes the agent task session from the agt claim and leaves actor unset', () => { + const { agent, actor } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + v: 2, + agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com' }, + }); + + expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }); + expect(actor).toBeUndefined(); + }); + + test('exposes the agent from the agt claim on a v1 payload', () => { + const { agent, actor } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com' }, + } as any); + + expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }); + expect(actor).toBeUndefined(); + }); + + test('keeps both actor and agent when the payload has act and agt', () => { + const { agent, actor } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + v: 2, + act: { sub: 'impersonator' }, + agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com' }, + } as any); + + expect(actor).toEqual({ sub: 'impersonator' }); + expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }); + }); + + test.each([ + ['an empty object', {}, { sub: '', type: 'agent', task_id: '' }], + ['a missing tsk', { id: 'agent_123' }, { sub: 'agent_123', type: 'agent', task_id: '' }], + ['a missing id', { tsk: 'agttsk_456' }, { sub: '', type: 'agent', task_id: 'agttsk_456' }], + ['empty strings', { id: '', tsk: '' }, { sub: '', type: 'agent', task_id: '' }], + ['non-string fields', { id: 1, tsk: 2 }, { sub: '', type: 'agent', task_id: '' }], + ['a string', 'x', { sub: '', type: 'agent', task_id: '' }], + ['a number', 1, { sub: '', type: 'agent', task_id: '' }], + ['an array', [], { sub: '', type: 'agent', task_id: '' }], + ['true', true, { sub: '', type: 'agent', task_id: '' }], + ['false', false, { sub: '', type: 'agent', task_id: '' }], + ['zero', 0, { sub: '', type: 'agent', task_id: '' }], + ['an empty string', '', { sub: '', type: 'agent', task_id: '' }], + ])('agent is still non-null for a malformed agt claim: %s', (_name, agt, expected) => { + expect(JWTPayloadToAuthObjectProperties({ ...baseClaims, v: 2, agt } as any).agent).toEqual(expected); + }); + + test.each([ + ['null', null], + ['undefined', undefined], + ])('agent is null when the agt claim is %s', (_name, agt) => { + expect(JWTPayloadToAuthObjectProperties({ ...baseClaims, v: 2, agt } as any).agent).toBeNull(); + }); + + test('agent is null when the agt claim is absent', () => { + const v2 = JWTPayloadToAuthObjectProperties({ ...baseClaims, v: 2 }); + const v1 = JWTPayloadToAuthObjectProperties({ ...baseClaims, act: { sub: 'impersonator' } } as any); + + expect(v2.agent).toBeNull(); + expect(v1.agent).toBeNull(); + expect(v1.actor).toEqual({ sub: 'impersonator' }); + }); + test('auth object with JWT v2 does not produces anything org related if there is no org active', () => { const { sessionClaims: v2Claims, ...signedInAuthObjectV2 } = JWTPayloadToAuthObjectProperties({ ...baseClaims, diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 3f5c4b75d91..59b0cf5ffd1 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -1,5 +1,6 @@ import type { ActClaim, + AgentActClaim, CheckAuthorizationWithCustomPermissions, GetToken, JwtPayload, @@ -362,6 +363,7 @@ type AuthStateOptions = { sessionStatus?: SessionStatusClaim | null; sessionClaims?: JwtPayload | null; actor?: ActClaim | null; + agent?: AgentActClaim | null; orgId?: string | null; orgRole?: OrganizationCustomRoleKey | null; orgSlug?: string | null; @@ -385,6 +387,7 @@ const resolveAuthState = ({ sessionStatus, userId, actor, + agent, orgId, orgRole, orgSlug, @@ -398,6 +401,7 @@ const resolveAuthState = ({ if (sessionId === undefined && userId === undefined) { return { actor: undefined, + agent: undefined, getToken, has: () => false, isLoaded: false, @@ -415,6 +419,7 @@ const resolveAuthState = ({ if (sessionId === null && userId === null) { return { actor: null, + agent: null, getToken, has: () => false, isLoaded: true, @@ -432,6 +437,7 @@ const resolveAuthState = ({ if (treatPendingAsSignedOut && sessionStatus === 'pending') { return { actor: null, + agent: null, getToken, has: () => false, isLoaded: true, @@ -449,6 +455,7 @@ const resolveAuthState = ({ if (!!sessionId && !!sessionClaims && !!userId && !!orgId && !!orgRole) { return { actor: actor || null, + agent: agent || null, getToken, has, isLoaded: true, @@ -466,6 +473,7 @@ const resolveAuthState = ({ if (!!sessionId && !!sessionClaims && !!userId && !orgId) { return { actor: actor || null, + agent: agent || null, getToken, has, isLoaded: true, diff --git a/packages/shared/src/deriveState.ts b/packages/shared/src/deriveState.ts index 5994af6726f..d78f13fa6ba 100644 --- a/packages/shared/src/deriveState.ts +++ b/packages/shared/src/deriveState.ts @@ -41,6 +41,7 @@ export const deriveFromSsrInitialState = (initialState: InitialState): ReturnTyp const orgPermissions = initialState.orgPermissions as OrganizationCustomPermissionKey[]; const orgSlug = initialState.orgSlug; const actor = initialState.actor; + const agent = initialState.agent; const factorVerificationAge = initialState.factorVerificationAge; return { @@ -56,6 +57,7 @@ export const deriveFromSsrInitialState = (initialState: InitialState): ReturnTyp orgPermissions, orgSlug, actor, + agent, factorVerificationAge, }; }; @@ -71,6 +73,7 @@ export const deriveFromClientSideState = (state: Resources) => { : null; const factorVerificationAge: [number, number] | null = state.session ? state.session.factorVerificationAge : null; const actor = session?.actor; + const agent = session?.agent; const organization = state.organization; const orgId: string | null | undefined = state.organization ? state.organization.id : state.organization; const orgSlug = organization?.slug; @@ -93,6 +96,7 @@ export const deriveFromClientSideState = (state: Resources) => { orgSlug, orgPermissions, actor, + agent, factorVerificationAge, }; }; diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts index 233bf2dddcf..99477478020 100644 --- a/packages/shared/src/jwtPayloadParser.ts +++ b/packages/shared/src/jwtPayloadParser.ts @@ -1,5 +1,6 @@ import { splitByScope } from './authorization'; import type { + AgentActClaim, JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, @@ -98,6 +99,14 @@ function buildOrgPermissions({ return orgPermissions; } +const parseAgentClaim = (agt: unknown): AgentActClaim | null => { + if (agt === null || agt === undefined) { + return null; + } + const { id, tsk } = (typeof agt === 'object' ? agt : {}) as { id?: unknown; tsk?: unknown }; + return { sub: typeof id === 'string' ? id : '', type: 'agent', task_id: typeof tsk === 'string' ? tsk : '' }; +}; + /** * Resolves the signed-in auth state from JWT claims. * @@ -150,6 +159,7 @@ const __experimental_JWTPayloadToAuthObjectProperties = (claims: JwtPayload): Sh sessionId: claims.sid, sessionStatus, actor: claims.act, + agent: parseAgentClaim(claims.agt), userId: claims.sub, orgId: orgId, orgRole: orgRole, diff --git a/packages/shared/src/types/authObject.ts b/packages/shared/src/types/authObject.ts index 4d7658a3271..a3dfad4b144 100644 --- a/packages/shared/src/types/authObject.ts +++ b/packages/shared/src/types/authObject.ts @@ -1,4 +1,4 @@ -import type { ActClaim, JwtPayload, SessionStatusClaim } from './jwtv2'; +import type { ActClaim, AgentActClaim, JwtPayload, SessionStatusClaim } from './jwtv2'; import type { OrganizationCustomPermissionKey, OrganizationCustomRoleKey } from './organizationMembership'; /** @@ -21,6 +21,12 @@ export type SharedSignedInAuthObjectProperties = { * Holds identifier for the user that is impersonating the current user. Read more about [impersonation](https://clerk.com/docs/guides/users/impersonation). */ actor: ActClaim | undefined; + /** + * The agent and agent task that created the session, from the `agt` claim. `null` for other sessions. + * `sub` and `task_id` can be `''` if the claim is malformed, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. + * `actor` is not set for agent sessions. + */ + agent: AgentActClaim | null; /** * The ID of the current user. */ diff --git a/packages/shared/src/types/hooks.ts b/packages/shared/src/types/hooks.ts index 32e0599c11d..8ea630f15cb 100644 --- a/packages/shared/src/types/hooks.ts +++ b/packages/shared/src/types/hooks.ts @@ -1,5 +1,5 @@ import type { SetActive, SignOut } from './clerk'; -import type { ActClaim, JwtPayload } from './jwtv2'; +import type { ActClaim, AgentActClaim, JwtPayload } from './jwtv2'; import type { OrganizationCustomRoleKey } from './organizationMembership'; import type { CheckAuthorizationWithCustomPermissions, @@ -49,6 +49,11 @@ export type UseAuthReturn = * The JWT actor for the session. Read more about [impersonation](https://clerk.com/docs/guides/users/impersonation). */ actor: undefined; + /** + * The agent and agent task that created the session. `null` for other sessions, and `undefined` before Clerk has loaded. + * `sub` and `task_id` can be `''` if the claim is malformed, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. + */ + agent: undefined; /** * The ID of the user's active Organization. */ @@ -81,6 +86,7 @@ export type UseAuthReturn = sessionId: null; sessionClaims: null; actor: null; + agent: null; orgId: null; orgRole: null; orgSlug: null; @@ -95,6 +101,7 @@ export type UseAuthReturn = sessionId: string; sessionClaims: JwtPayload; actor: ActClaim | null; + agent: AgentActClaim | null; orgId: null; orgRole: null; orgSlug: null; @@ -109,6 +116,7 @@ export type UseAuthReturn = sessionId: string; sessionClaims: JwtPayload; actor: ActClaim | null; + agent: AgentActClaim | null; orgId: string; orgRole: OrganizationCustomRoleKey; orgSlug: string | null; diff --git a/packages/shared/src/types/jwt.ts b/packages/shared/src/types/jwt.ts index f2b47790a75..c0bc7cc565d 100644 --- a/packages/shared/src/types/jwt.ts +++ b/packages/shared/src/types/jwt.ts @@ -1,4 +1,4 @@ -import type { ActClaim, JwtHeader, JwtPayload } from './jwtv2'; +import type { ActClaim, AgentTaskClaim, JwtHeader, JwtPayload } from './jwtv2'; import type { OrganizationCustomRoleKey } from './organizationMembership'; export interface JWT { @@ -82,6 +82,11 @@ export interface ClerkJWTClaims { */ act?: ActClaim; + /** + * Agent task claim (agt). Present on sessions created from an agent task. These sessions do not carry an `act` claim. + */ + agt?: AgentTaskClaim; + /** * Active Organization ID. */ diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index da7e1de736a..5e2a5631bcd 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -87,6 +87,11 @@ type JWTPayloadBase = { */ act?: ActClaim; + /** + * Agent task claim (agt). Present on sessions created from an agent task. These sessions do not carry an `act` claim. + */ + agt?: AgentTaskClaim; + /** * Factor verification age (fva). The tuple represents the minutes that have passed since the last time a first or second factor were verified. * @@ -211,11 +216,37 @@ export interface ActClaim { } /** - * ActClaim narrowed to actor type `'agent'`. Use for session.agent. + * Agent task claim (`agt`). Present on sessions created from an agent task. + * + * @inline + */ +export interface AgentTaskClaim { + /** + * The ID of the agent. + */ + id: string; + /** + * The ID of the agent task that was used to create the session. + */ + tsk: string; + /** + * The registry that issued the agent. + */ + reg: string; +} + +/** + * ActClaim narrowed to actor type `'agent'`. This shape is shared by `Session.agent` and the `agent` property of the auth object. * * @inline */ -export type AgentActClaim = ActClaim & { type: 'agent' }; +export type AgentActClaim = ActClaim & { + type: 'agent'; + /** + * The ID of the agent task that was used to create the session. + */ + task_id: string; +}; /** * The current state of the session which can only be `active` or `pending`. diff --git a/packages/shared/src/types/ssr.ts b/packages/shared/src/types/ssr.ts index da9815deebd..03784b3b57a 100644 --- a/packages/shared/src/types/ssr.ts +++ b/packages/shared/src/types/ssr.ts @@ -1,4 +1,4 @@ -import type { ActClaim, JwtPayload, SessionStatusClaim } from './jwtv2'; +import type { ActClaim, AgentActClaim, JwtPayload, SessionStatusClaim } from './jwtv2'; import type { OrganizationResource } from './organization'; import type { OrganizationCustomPermissionKey, OrganizationCustomRoleKey } from './organizationMembership'; import type { SessionResource } from './session'; @@ -37,6 +37,7 @@ export type InitialState = Serializable<{ sessionStatus: SessionStatusClaim; session: SessionResource | undefined; actor: ActClaim | undefined; + agent: AgentActClaim | null | undefined; userId: string | undefined; user: UserResource | undefined; orgId: string | undefined; diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts index 698c6eef1d5..168f1e8222f 100644 --- a/packages/vue/src/plugin.ts +++ b/packages/vue/src/plugin.ts @@ -141,6 +141,7 @@ export const clerkPlugin: Plugin<[PluginOptions]> = { userId, orgId, actor, + agent, orgRole, orgSlug, orgPermissions, @@ -152,6 +153,7 @@ export const clerkPlugin: Plugin<[PluginOptions]> = { sessionId, userId, actor, + agent, orgId, orgRole, orgSlug, diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 0b793a4b0d5..4db0e9aa5b4 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -1,5 +1,6 @@ import type { ActClaim, + AgentActClaim, Clerk, ClerkOptions, ClientResource, @@ -23,6 +24,7 @@ export interface VueClerkInjectionKeyType { userId: string | null | undefined; sessionId: string | null | undefined; actor: ActClaim | null | undefined; + agent: AgentActClaim | null | undefined; sessionStatus: SessionStatusClaim | null | undefined; sessionClaims: JwtPayload | null | undefined; orgId: string | null | undefined; From afafb118a8844039c469d306ac31bb759b81d549 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Thu, 5 Nov 2026 13:27:06 -0600 Subject: [PATCH 02/12] feat(shared,backend,clerk-js): add agent to session resources without changing actor Add `agent` to the backend `Session` resource and to `SessionWithActivities`, derived from `actor` when `actor.type` is `agent`. `actor` is unchanged and still holds the agent for agent task sessions; its JSDoc now says this will change in a future major version. Make `AgentActClaim.task_id` and `InitialState.agent` optional so the types stay non-breaking. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-task-session-claim.md | 4 +- packages/backend/src/api/resources/Session.ts | 7 ++- .../api/resources/__tests__/Session.test.ts | 44 ++++++++++++++++++ .../src/core/__tests__/jwt-client.test.ts | 10 +++-- packages/clerk-js/src/core/jwt-client.ts | 4 +- .../core/resources/SessionWithActivities.ts | 3 ++ .../__tests__/SessionWithActivities.test.ts | 45 +++++++++++++++++++ .../src/hooks/__tests__/useAuth.test.tsx | 28 ++++++++++++ packages/shared/src/types/authObject.ts | 2 +- packages/shared/src/types/hooks.ts | 1 + packages/shared/src/types/jwtv2.ts | 2 +- packages/shared/src/types/session.ts | 9 ++++ packages/shared/src/types/ssr.ts | 2 +- .../src/composables/__tests__/useAuth.test.ts | 45 +++++++++++++++++++ 14 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 packages/backend/src/api/resources/__tests__/Session.test.ts create mode 100644 packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts create mode 100644 packages/vue/src/composables/__tests__/useAuth.test.ts diff --git a/.changeset/agent-task-session-claim.md b/.changeset/agent-task-session-claim.md index 3223836ed78..c86108db1f9 100644 --- a/.changeset/agent-task-session-claim.md +++ b/.changeset/agent-task-session-claim.md @@ -3,7 +3,7 @@ "@clerk/backend": minor "@clerk/react": minor "@clerk/vue": minor -"@clerk/clerk-js": patch +"@clerk/clerk-js": minor --- -Expose agent task sessions on auth objects. Sessions created from an agent task now populate a new `agent` property (`{ sub, type: 'agent', task_id }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, and on `useAuth()`. Use a truthiness check (`if (agent)`) to detect an agent session. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types. +Expose agent task sessions on auth objects and session resources. Sessions created from an agent task now populate a new `agent` property (`{ sub, type: 'agent', task_id }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, on `useAuth()`, on the backend `Session` resource, and on the sessions returned by `Clerk.user.getSessions()`. Use a truthiness check (`if (agent)`) to detect an agent session. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types. On `Clerk.session`, the sessions from `Clerk.user.getSessions()`, and the backend `Session` resource, `actor` also holds the agent for agent task sessions today; this will change in a future major version, so use `agent`. diff --git a/packages/backend/src/api/resources/Session.ts b/packages/backend/src/api/resources/Session.ts index 968be156748..3da21501999 100644 --- a/packages/backend/src/api/resources/Session.ts +++ b/packages/backend/src/api/resources/Session.ts @@ -1,3 +1,5 @@ +import type { AgentActClaim } from '@clerk/shared/types'; + import type { SessionActivityJSON, SessionJSON } from './JSON'; /** @@ -64,8 +66,10 @@ export class Session { readonly lastActiveOrganizationId?: string, /** An object that provides additional information about this session, focused around user activity data. */ readonly latestActivity?: SessionActivity, - /** The JWT actor for the session. Holds identifier for the user that is impersonating the current user. Read more about [impersonation](https://clerk.com/docs/guides/users/impersonation). */ + /** The JWT actor for the session. Holds identifier for the user that is impersonating the current user. Read more about [impersonation](https://clerk.com/docs/guides/users/impersonation). For agent task sessions, `actor` also holds the agent today. This will change in a future major version; use `agent` to detect an agent session. */ readonly actor: Record | null = null, + /** When the session's actor claim has `type: 'agent'`, this property exposes information about the agent and [Agent Task](https://clerk.com/docs/reference/types/agent-task) that was used to create the session. */ + readonly agent: AgentActClaim | null = null, ) {} static fromJSON(data: SessionJSON): Session { @@ -82,6 +86,7 @@ export class Session { data.last_active_organization_id, data.latest_activity && SessionActivity.fromJSON(data.latest_activity), data.actor, + data.actor?.type === 'agent' ? (data.actor as AgentActClaim) : null, ); } } diff --git a/packages/backend/src/api/resources/__tests__/Session.test.ts b/packages/backend/src/api/resources/__tests__/Session.test.ts new file mode 100644 index 00000000000..b302a2576e5 --- /dev/null +++ b/packages/backend/src/api/resources/__tests__/Session.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import type { SessionJSON } from '../JSON'; +import { Session } from '../Session'; + +const baseJSON = { + object: 'session', + id: 'sess_1', + client_id: 'client_1', + user_id: 'user_1', + status: 'active', + last_active_at: 1, + expire_at: 2, + abandon_at: 3, + created_at: 4, + updated_at: 5, +} as const; + +describe('Session', () => { + describe('fromJSON', () => { + it('sets agent and keeps actor when the actor has type "agent"', () => { + const actor = { sub: 'agent_1', type: 'agent', task_id: 'task_1' }; + const session = Session.fromJSON({ ...baseJSON, actor } as SessionJSON); + + expect(session.actor).toEqual(actor); + expect(session.agent).toEqual(actor); + }); + + it('sets actor and a null agent for an impersonation session', () => { + const actor = { sub: 'user_2' }; + const session = Session.fromJSON({ ...baseJSON, actor } as SessionJSON); + + expect(session.actor).toEqual(actor); + expect(session.agent).toBeNull(); + }); + + it('sets a null actor and a null agent when there is no actor', () => { + const session = Session.fromJSON({ ...baseJSON, actor: null } as SessionJSON); + + expect(session.actor).toBeNull(); + expect(session.agent).toBeNull(); + }); + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts index 49ce250c670..3ebf5524917 100644 --- a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts +++ b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts @@ -36,16 +36,18 @@ describe('createClientFromJwt', () => { expect(client.lastActiveSessionId).toBeNull(); }); - it('exposes the agent on the stub session when the JWT has an agt claim', () => { + it('sets the actor and agent on the stub session to the agent when the JWT has an agt claim', () => { const client = createClientFromJwt(jwtWithClaims({ agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'r' } })); + const agent = { sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }; - expect(client.signedInSessions[0]?.agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }); + expect(client.signedInSessions[0]?.actor).toEqual(agent); + expect(client.signedInSessions[0]?.agent).toEqual(agent); }); - it('keeps the actor on the stub session when the JWT has an act claim', () => { + it('leaves actor and agent null on the stub session when the JWT has an act claim', () => { const client = createClientFromJwt(jwtWithClaims({ act: { sub: 'user_impersonator' } })); - expect(client.signedInSessions[0]?.actor).toEqual({ sub: 'user_impersonator' }); + expect(client.signedInSessions[0]?.actor).toBeNull(); expect(client.signedInSessions[0]?.agent).toBeNull(); }); diff --git a/packages/clerk-js/src/core/jwt-client.ts b/packages/clerk-js/src/core/jwt-client.ts index e0f232b6460..0a0322da5d1 100644 --- a/packages/clerk-js/src/core/jwt-client.ts +++ b/packages/clerk-js/src/core/jwt-client.ts @@ -44,7 +44,7 @@ export function createClientFromJwt(jwt: string | undefined | null): Client { } as unknown as ClientJSON); } - const { sessionId, userId, orgId, orgRole, orgPermissions, orgSlug, factorVerificationAge, actor, agent } = + const { sessionId, userId, orgId, orgRole, orgPermissions, orgSlug, factorVerificationAge, agent } = __experimental_JWTPayloadToAuthObjectProperties(token.jwt.claims); // TODO(jwt-v2): when JWT version 2 is available, we should revise org permissions @@ -57,7 +57,7 @@ export function createClientFromJwt(jwt: string | undefined | null): Client { object: 'session', id: sessionId, status: 'active', - actor: agent ?? actor ?? null, + actor: agent ?? null, last_active_organization_id: orgId || null, // @ts-expect-error - ts is not happy about `id:undefined`, but this is allowed and expected last_active_token: { diff --git a/packages/clerk-js/src/core/resources/SessionWithActivities.ts b/packages/clerk-js/src/core/resources/SessionWithActivities.ts index 23c8ababed2..c65fc920d2f 100644 --- a/packages/clerk-js/src/core/resources/SessionWithActivities.ts +++ b/packages/clerk-js/src/core/resources/SessionWithActivities.ts @@ -1,5 +1,6 @@ import type { ActClaim, + AgentActClaim, SessionActivity, SessionActivityJSON, SessionWithActivitiesJSON, @@ -29,6 +30,7 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc lastActiveAt!: Date; latestActivity!: SessionActivity; actor!: ActClaim | null; + agent!: AgentActClaim | null; constructor(data: SessionWithActivitiesJSON, pathRoot: string) { super(); @@ -70,6 +72,7 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc this.lastActiveAt = unixEpochToDate(data.last_active_at || undefined); this.latestActivity = mapSessionActivityJSONToSessionActivity(data.latest_activity ?? ({} as SessionActivityJSON)); this.actor = data.actor; + this.agent = data.actor?.type === 'agent' ? (data.actor as AgentActClaim) : null; return this; } } diff --git a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts new file mode 100644 index 00000000000..b6fcef4cec2 --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts @@ -0,0 +1,45 @@ +import type { SessionWithActivitiesJSON } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { SessionWithActivities } from '../SessionWithActivities'; + +const build = (actor: SessionWithActivitiesJSON['actor']) => + new SessionWithActivities( + { + object: 'session', + id: 'sess_1', + status: 'active', + user: null, + actor, + latest_activity: {}, + expire_at: 1, + abandon_at: 2, + last_active_at: 3, + } as unknown as SessionWithActivitiesJSON, + '/me/sessions', + ); + +describe('SessionWithActivities', () => { + it('sets agent and keeps actor when the actor has type "agent"', () => { + const actor = { sub: 'agent_1', type: 'agent', task_id: 'task_1' }; + const session = build(actor as SessionWithActivitiesJSON['actor']); + + expect(session.actor).toEqual(actor); + expect(session.agent).toEqual(actor); + }); + + it('sets actor and a null agent for an impersonation session', () => { + const actor = { sub: 'user_2' }; + const session = build(actor); + + expect(session.actor).toEqual(actor); + expect(session.agent).toBeNull(); + }); + + it('sets a null actor and a null agent when there is no actor', () => { + const session = build(null); + + expect(session.actor).toBeNull(); + expect(session.agent).toBeNull(); + }); +}); diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index c79288abcdc..538c5ff567c 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -72,6 +72,34 @@ describe('useAuth', () => { }); }); +describe('useAuth agent', () => { + test('exposes the agent from the initial state', () => { + const agent = { sub: 'agt_1', type: 'agent', task_id: 'agttsk_1' }; + + const { result } = renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + + expect(result.current.agent).toEqual(agent); + }); +}); + describe('useAuth.getToken', () => { test('throws an error if getToken is called in a non-browser environment', async () => { const originalWindow = global.window; diff --git a/packages/shared/src/types/authObject.ts b/packages/shared/src/types/authObject.ts index a3dfad4b144..4afe08ae5cf 100644 --- a/packages/shared/src/types/authObject.ts +++ b/packages/shared/src/types/authObject.ts @@ -24,7 +24,7 @@ export type SharedSignedInAuthObjectProperties = { /** * The agent and agent task that created the session, from the `agt` claim. `null` for other sessions. * `sub` and `task_id` can be `''` if the claim is malformed, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. - * `actor` is not set for agent sessions. + * `actor` is `undefined` for agent sessions, because the session token carries the `agt` claim instead of `act`. */ agent: AgentActClaim | null; /** diff --git a/packages/shared/src/types/hooks.ts b/packages/shared/src/types/hooks.ts index 8ea630f15cb..db7c10f1ba9 100644 --- a/packages/shared/src/types/hooks.ts +++ b/packages/shared/src/types/hooks.ts @@ -47,6 +47,7 @@ export type UseAuthReturn = sessionClaims: undefined; /** * The JWT actor for the session. Read more about [impersonation](https://clerk.com/docs/guides/users/impersonation). + * After Clerk has loaded, `actor` also holds the agent for agent task sessions. During server rendering it is `null` for these sessions. This will change in a future major version; use `agent` to detect an agent session. */ actor: undefined; /** diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index 5e2a5631bcd..006b5e9a3b4 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -245,7 +245,7 @@ export type AgentActClaim = ActClaim & { /** * The ID of the agent task that was used to create the session. */ - task_id: string; + task_id?: string; }; /** diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index fdab3d21fa8..2682556d840 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -246,6 +246,7 @@ export interface SessionResource extends ClerkResource { lastActiveAt: Date; /** * The JWT actor for the session. Holds identifier for the user that is impersonating the current user. Read more about [impersonation](https://clerk.com/docs/guides/users/impersonation). + * For agent task sessions, `actor` also holds the agent today. This will change in a future major version; use `agent` to detect an agent session. */ actor: ActClaim | null; /** @@ -389,7 +390,15 @@ export interface SessionWithActivitiesResource extends ClerkResource { abandonAt: Date; lastActiveAt: Date; latestActivity: SessionActivity; + /** + * The JWT actor for the session. Holds identifier for the user that is impersonating the current user. Read more about [impersonation](https://clerk.com/docs/guides/users/impersonation). + * For agent task sessions, `actor` also holds the agent today. This will change in a future major version; use `agent` to detect an agent session. + */ actor: ActClaim | null; + /** + * When the session's actor claim has `type: 'agent'`, this property exposes information about the agent and [Agent Task](https://clerk.com/docs/reference/types/agent-task) that was used to create the session. + */ + agent: AgentActClaim | null; revoke: () => Promise; } diff --git a/packages/shared/src/types/ssr.ts b/packages/shared/src/types/ssr.ts index 03784b3b57a..96f8aa66b39 100644 --- a/packages/shared/src/types/ssr.ts +++ b/packages/shared/src/types/ssr.ts @@ -37,7 +37,7 @@ export type InitialState = Serializable<{ sessionStatus: SessionStatusClaim; session: SessionResource | undefined; actor: ActClaim | undefined; - agent: AgentActClaim | null | undefined; + agent?: AgentActClaim | null; userId: string | undefined; user: UserResource | undefined; orgId: string | undefined; diff --git a/packages/vue/src/composables/__tests__/useAuth.test.ts b/packages/vue/src/composables/__tests__/useAuth.test.ts new file mode 100644 index 00000000000..e06bfe265f3 --- /dev/null +++ b/packages/vue/src/composables/__tests__/useAuth.test.ts @@ -0,0 +1,45 @@ +import { render } from '@testing-library/vue'; +import { describe, expect, it, vi } from 'vitest'; +import { defineComponent } from 'vue'; + +import { clerkPlugin } from '../../plugin'; +import { useAuth } from '../useAuth'; + +vi.mock('@clerk/shared/loadClerkJsScript', () => ({ + loadClerkJSScript: () => new Promise(() => {}), + loadClerkUIScript: () => new Promise(() => {}), +})); + +describe('useAuth', () => { + it('exposes the agent from the initial state', () => { + const agent = { sub: 'agt_1', type: 'agent', task_id: 'agttsk_1' }; + let auth: ReturnType | undefined; + + const Component = defineComponent(() => { + auth = useAuth(); + return () => null; + }); + + render(Component, { + global: { + plugins: [ + [ + clerkPlugin, + { + publishableKey: 'pk_test_xxx', + initialState: { + sessionId: 'sess_1', + sessionStatus: 'active', + sessionClaims: { __raw: '', exp: 1, iat: 1, iss: '', nbf: 1, sid: 'sess_1', sub: 'user_1' }, + userId: 'user_1', + agent, + }, + }, + ], + ], + }, + }); + + expect(auth?.agent.value).toEqual(agent); + }); +}); From d875f1aa08fba0ae971e7d74681320e9886966a8 Mon Sep 17 00:00:00 2001 From: brkalow Date: Thu, 5 Nov 2026 21:59:08 -0600 Subject: [PATCH 03/12] fix(backend,clerk-js): build session agent from fixed actor fields Build `agent` on the clerk-js `Session` and `SessionWithActivities` resources and on the backend `Session` resource from exactly `{ sub, type: 'agent', task_id }` instead of casting the raw Frontend or Backend API actor. The raw actor can carry extra fields such as `restricted`, which the server-side `agent` parsed from the `agt` claim never has, so the two values disagreed and `agent.restricted` type-checked on both sides through the `ActClaim` index signature. A missing `task_id` becomes `''`, matching the `agt` parser. `actor` is unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/backend/src/api/resources/Session.ts | 8 ++++++- .../api/resources/__tests__/Session.test.ts | 14 ++++++++--- .../src/core/__tests__/jwt-client.test.ts | 8 ++++--- .../clerk-js/src/core/resources/Session.ts | 9 ++++++- .../core/resources/SessionWithActivities.ts | 9 ++++++- .../core/resources/__tests__/Session.test.ts | 24 +++++++++++++++---- .../__tests__/SessionWithActivities.test.ts | 14 ++++++++--- 7 files changed, 70 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/api/resources/Session.ts b/packages/backend/src/api/resources/Session.ts index 3da21501999..3dac2913df6 100644 --- a/packages/backend/src/api/resources/Session.ts +++ b/packages/backend/src/api/resources/Session.ts @@ -86,7 +86,13 @@ export class Session { data.last_active_organization_id, data.latest_activity && SessionActivity.fromJSON(data.latest_activity), data.actor, - data.actor?.type === 'agent' ? (data.actor as AgentActClaim) : null, + data.actor?.type === 'agent' + ? { + sub: typeof data.actor.sub === 'string' ? data.actor.sub : '', + type: 'agent', + task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', + } + : null, ); } } diff --git a/packages/backend/src/api/resources/__tests__/Session.test.ts b/packages/backend/src/api/resources/__tests__/Session.test.ts index b302a2576e5..35721710a56 100644 --- a/packages/backend/src/api/resources/__tests__/Session.test.ts +++ b/packages/backend/src/api/resources/__tests__/Session.test.ts @@ -18,12 +18,20 @@ const baseJSON = { describe('Session', () => { describe('fromJSON', () => { - it('sets agent and keeps actor when the actor has type "agent"', () => { - const actor = { sub: 'agent_1', type: 'agent', task_id: 'task_1' }; + it('sets agent to only sub, type and task_id and keeps the full actor when the actor has type "agent"', () => { + const actor = { sub: 'agent_1', type: 'agent', task_id: 'task_1', restricted: true }; const session = Session.fromJSON({ ...baseJSON, actor } as SessionJSON); expect(session.actor).toEqual(actor); - expect(session.agent).toEqual(actor); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'task_1' }); + }); + + it('sets agent task_id to an empty string when an agent actor has no task_id', () => { + const actor = { sub: 'agent_1', type: 'agent' }; + const session = Session.fromJSON({ ...baseJSON, actor } as SessionJSON); + + expect(session.actor).toEqual(actor); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '' }); }); it('sets actor and a null agent for an impersonation session', () => { diff --git a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts index 3ebf5524917..8a2077ab5bd 100644 --- a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts +++ b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts @@ -37,11 +37,13 @@ describe('createClientFromJwt', () => { }); it('sets the actor and agent on the stub session to the agent when the JWT has an agt claim', () => { - const client = createClientFromJwt(jwtWithClaims({ agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'r' } })); + const client = createClientFromJwt( + jwtWithClaims({ agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'r', restricted: true } }), + ); const agent = { sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }; - expect(client.signedInSessions[0]?.actor).toEqual(agent); - expect(client.signedInSessions[0]?.agent).toEqual(agent); + expect(client.signedInSessions[0]?.actor).toStrictEqual(agent); + expect(client.signedInSessions[0]?.agent).toStrictEqual(agent); }); it('leaves actor and agent null on the stub session when the JWT has an act claim', () => { diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 977a8539c71..e2b0ae1996f 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -448,7 +448,14 @@ export class Session extends BaseResource implements SessionResource { this.lastActiveAt = unixEpochToDate(data.last_active_at || undefined); this.lastActiveOrganizationId = data.last_active_organization_id; this.actor = data.actor || null; - this.agent = data.actor?.type === 'agent' ? (data.actor as AgentActClaim) : null; + this.agent = + data.actor?.type === 'agent' + ? { + sub: data.actor.sub, + type: 'agent', + task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', + } + : null; this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); this.user = new User(data.user); diff --git a/packages/clerk-js/src/core/resources/SessionWithActivities.ts b/packages/clerk-js/src/core/resources/SessionWithActivities.ts index c65fc920d2f..0dfe530d636 100644 --- a/packages/clerk-js/src/core/resources/SessionWithActivities.ts +++ b/packages/clerk-js/src/core/resources/SessionWithActivities.ts @@ -72,7 +72,14 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc this.lastActiveAt = unixEpochToDate(data.last_active_at || undefined); this.latestActivity = mapSessionActivityJSONToSessionActivity(data.latest_activity ?? ({} as SessionActivityJSON)); this.actor = data.actor; - this.agent = data.actor?.type === 'agent' ? (data.actor as AgentActClaim) : null; + this.agent = + data.actor?.type === 'agent' + ? { + sub: data.actor.sub, + type: 'agent', + task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', + } + : null; return this; } } diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 7e2accef9ad..5d5c2d50d61 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2680,8 +2680,8 @@ describe('Session', () => { expect(session.agent).toBeNull(); }); - it('sets agent to the actor when actor has type "agent"', () => { - const actor = { sub: 'user_2', type: 'agent' as const }; + it('sets agent to only sub, type and task_id when actor has type "agent" and extra fields', () => { + const actor = { sub: 'agent_1', type: 'agent' as const, task_id: 'agttsk_1', restricted: true }; const session = new Session({ status: 'active', id: 'session_1', @@ -2694,8 +2694,24 @@ describe('Session', () => { } as SessionJSON); expect(session.actor).toEqual(actor); - expect(session.agent).toEqual(actor); - expect(session.agent?.type).toBe('agent'); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'agttsk_1' }); + }); + + it('sets agent task_id to an empty string when an agent actor has no task_id', () => { + const actor = { sub: 'agent_1', type: 'agent' as const }; + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + expect(session.actor).toEqual(actor); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '' }); }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts index b6fcef4cec2..38cdaca1a91 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts @@ -20,12 +20,20 @@ const build = (actor: SessionWithActivitiesJSON['actor']) => ); describe('SessionWithActivities', () => { - it('sets agent and keeps actor when the actor has type "agent"', () => { - const actor = { sub: 'agent_1', type: 'agent', task_id: 'task_1' }; + it('sets agent to only sub, type and task_id and keeps the full actor when the actor has type "agent"', () => { + const actor = { sub: 'agent_1', type: 'agent', task_id: 'task_1', restricted: true }; const session = build(actor as SessionWithActivitiesJSON['actor']); expect(session.actor).toEqual(actor); - expect(session.agent).toEqual(actor); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'task_1' }); + }); + + it('sets agent task_id to an empty string when an agent actor has no task_id', () => { + const actor = { sub: 'agent_1', type: 'agent' }; + const session = build(actor as SessionWithActivitiesJSON['actor']); + + expect(session.actor).toEqual(actor); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '' }); }); it('sets actor and a null agent for an impersonation session', () => { From b43ce55382c5b4974802e9934f6100e2d70a2d2b Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Mon, 9 Nov 2026 11:20:09 -0600 Subject: [PATCH 04/12] fix(clerk-js): default a non-string agent sub to an empty string The clerk-js Session and SessionWithActivities resources passed the agent actor's sub through unchecked, so a missing or non-string sub broke the AgentActClaim type. Use an empty string instead, as the backend Session resource and the agt claim parser already do, so agent stays non-null for any agent actor. Co-Authored-By: Claude Opus 5.5 --- .../api/resources/__tests__/Session.test.ts | 11 ++++++++++ .../clerk-js/src/core/resources/Session.ts | 2 +- .../core/resources/SessionWithActivities.ts | 2 +- .../core/resources/__tests__/Session.test.ts | 20 +++++++++++++++++++ .../__tests__/SessionWithActivities.test.ts | 11 ++++++++++ 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/api/resources/__tests__/Session.test.ts b/packages/backend/src/api/resources/__tests__/Session.test.ts index 35721710a56..77c5f2f0341 100644 --- a/packages/backend/src/api/resources/__tests__/Session.test.ts +++ b/packages/backend/src/api/resources/__tests__/Session.test.ts @@ -34,6 +34,17 @@ describe('Session', () => { expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '' }); }); + it.each([ + ['a number', { sub: 42, type: 'agent', task_id: 'task_1' }], + ['missing', { type: 'agent', task_id: 'task_1' }], + ['an empty string', { sub: '', type: 'agent', task_id: 'task_1' }], + ])('sets agent sub to an empty string when the agent actor sub is %s', (_, actor) => { + const session = Session.fromJSON({ ...baseJSON, actor } as unknown as SessionJSON); + + expect(session.actor).toEqual(actor); + expect(session.agent).toStrictEqual({ sub: '', type: 'agent', task_id: 'task_1' }); + }); + it('sets actor and a null agent for an impersonation session', () => { const actor = { sub: 'user_2' }; const session = Session.fromJSON({ ...baseJSON, actor } as SessionJSON); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index e2b0ae1996f..2970bdf4218 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -451,7 +451,7 @@ export class Session extends BaseResource implements SessionResource { this.agent = data.actor?.type === 'agent' ? { - sub: data.actor.sub, + sub: typeof data.actor.sub === 'string' ? data.actor.sub : '', type: 'agent', task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', } diff --git a/packages/clerk-js/src/core/resources/SessionWithActivities.ts b/packages/clerk-js/src/core/resources/SessionWithActivities.ts index 0dfe530d636..c168fb00c0a 100644 --- a/packages/clerk-js/src/core/resources/SessionWithActivities.ts +++ b/packages/clerk-js/src/core/resources/SessionWithActivities.ts @@ -75,7 +75,7 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc this.agent = data.actor?.type === 'agent' ? { - sub: data.actor.sub, + sub: typeof data.actor.sub === 'string' ? data.actor.sub : '', type: 'agent', task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', } diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 5d5c2d50d61..6e4ff4a6f5f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2713,6 +2713,26 @@ describe('Session', () => { expect(session.actor).toEqual(actor); expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '' }); }); + + it.each([ + ['a number', { sub: 42, type: 'agent', task_id: 'agttsk_1' }], + ['missing', { type: 'agent', task_id: 'agttsk_1' }], + ['an empty string', { sub: '', type: 'agent', task_id: 'agttsk_1' }], + ])('sets agent sub to an empty string when the agent actor sub is %s', (_, actor) => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as unknown as SessionJSON); + + expect(session.actor).toEqual(actor); + expect(session.agent).toStrictEqual({ sub: '', type: 'agent', task_id: 'agttsk_1' }); + }); }); describe('session-minter monotonic guard', () => { diff --git a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts index 38cdaca1a91..c914ea289f4 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts @@ -36,6 +36,17 @@ describe('SessionWithActivities', () => { expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '' }); }); + it.each([ + ['a number', { sub: 42, type: 'agent', task_id: 'task_1' }], + ['missing', { type: 'agent', task_id: 'task_1' }], + ['an empty string', { sub: '', type: 'agent', task_id: 'task_1' }], + ])('sets agent sub to an empty string when the agent actor sub is %s', (_, actor) => { + const session = build(actor as unknown as SessionWithActivitiesJSON['actor']); + + expect(session.actor).toEqual(actor); + expect(session.agent).toStrictEqual({ sub: '', type: 'agent', task_id: 'task_1' }); + }); + it('sets actor and a null agent for an impersonation session', () => { const actor = { sub: 'user_2' }; const session = build(actor); From d2d8aad28f327bfbc9e55545578b1b48f2d5bac1 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Mon, 9 Nov 2026 12:38:44 -0600 Subject: [PATCH 05/12] feat(backend): add session restriction options to agent tasks Agent tasks can now create a restricted session and choose the Organization the session starts in. agentTasks.create() accepts sessionRestricted and sessionOrganizationId, and the AgentTask resource returns both fields from create() and revoke(). A response without them reads as unrestricted with no Organization. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-task-session-restricted.md | 5 ++ .../src/api/__tests__/AgentTaskApi.test.ts | 90 +++++++++++++++++++ .../backend/src/api/endpoints/AgentTaskApi.ts | 4 + .../backend/src/api/resources/AgentTask.ts | 13 ++- packages/backend/src/api/resources/JSON.ts | 2 + 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 .changeset/agent-task-session-restricted.md diff --git a/.changeset/agent-task-session-restricted.md b/.changeset/agent-task-session-restricted.md new file mode 100644 index 00000000000..ee713589355 --- /dev/null +++ b/.changeset/agent-task-session-restricted.md @@ -0,0 +1,5 @@ +--- +"@clerk/backend": minor +--- + +Add `sessionRestricted` and `sessionOrganizationId` to `clerkClient.agentTasks.create()` and to the `AgentTask` resource. Pass `sessionRestricted: true` to create an agent session in which the Frontend API allows the agent only to read and to refresh its own session token, and refuses other writes with an `agent_session_forbidden` error. Pass `sessionOrganizationId` to choose the Organization the session starts in; a restricted session can only use that Organization. diff --git a/packages/backend/src/api/__tests__/AgentTaskApi.test.ts b/packages/backend/src/api/__tests__/AgentTaskApi.test.ts index 5aa1313cdca..278dfbc642f 100644 --- a/packages/backend/src/api/__tests__/AgentTaskApi.test.ts +++ b/packages/backend/src/api/__tests__/AgentTaskApi.test.ts @@ -94,5 +94,95 @@ describe('AgentTaskAPI', () => { expect(response.taskId).toBe('agent_task_456'); expect(response.agentTaskId).toBe('agent_task_456'); }); + + it('sends sessionRestricted and sessionOrganizationId and reads them from the response', async () => { + server.use( + http.post( + 'https://api.clerk.test/v1/agents/tasks', + validateHeaders(async ({ request }) => { + const body = await request.json(); + + expect(body).toEqual({ + on_behalf_of: { + user_id: 'user_123', + }, + permissions: '*', + agent_name: 'test-agent', + task_description: 'Test task', + redirect_url: 'https://example.com/callback', + session_restricted: true, + session_organization_id: 'org_123', + }); + + return HttpResponse.json({ + ...mockAgentTaskResponse, + session_restricted: true, + session_organization_id: 'org_123', + }); + }), + ), + ); + + const response = await apiClient.agentTasks.create({ + onBehalfOf: { + userId: 'user_123', + }, + permissions: '*', + agentName: 'test-agent', + taskDescription: 'Test task', + redirectUrl: 'https://example.com/callback', + sessionRestricted: true, + sessionOrganizationId: 'org_123', + }); + + expect(response.sessionRestricted).toBe(true); + expect(response.sessionOrganizationId).toBe('org_123'); + }); + + it('defaults sessionRestricted to false and sessionOrganizationId to null when the response omits them', async () => { + server.use( + http.post( + 'https://api.clerk.test/v1/agents/tasks', + validateHeaders(() => HttpResponse.json(mockAgentTaskResponse)), + ), + ); + + const response = await apiClient.agentTasks.create({ + onBehalfOf: { + userId: 'user_123', + }, + permissions: '*', + agentName: 'test-agent', + taskDescription: 'Test task', + redirectUrl: 'https://example.com/callback', + }); + + expect(response.sessionRestricted).toBe(false); + expect(response.sessionOrganizationId).toBeNull(); + }); + }); + + describe('revoke', () => { + it('reads sessionRestricted and sessionOrganizationId from the response', async () => { + server.use( + http.post( + 'https://api.clerk.test/v1/agents/tasks/agent_task_456/revoke', + validateHeaders(() => + HttpResponse.json({ + ...mockAgentTaskResponse, + url: undefined, + session_restricted: true, + session_organization_id: 'org_123', + }), + ), + ), + ); + + const response = await apiClient.agentTasks.revoke('agent_task_456'); + + expect(response.agentTaskId).toBe('agent_task_456'); + expect(response.sessionRestricted).toBe(true); + expect(response.sessionOrganizationId).toBe('org_123'); + }); }); }); diff --git a/packages/backend/src/api/endpoints/AgentTaskApi.ts b/packages/backend/src/api/endpoints/AgentTaskApi.ts index ccdc51ab18e..3d73fc063b3 100644 --- a/packages/backend/src/api/endpoints/AgentTaskApi.ts +++ b/packages/backend/src/api/endpoints/AgentTaskApi.ts @@ -26,6 +26,10 @@ export type CreateAgentTaskParams = { redirectUrl: string; /** The maximum duration that the session created by the Agent Task should last. By default, the duration is `1800` (30 minutes). */ sessionMaxDurationInSeconds?: number; + /** Whether the session created by the Agent Task is restricted. In a restricted session, the Frontend API allows the agent only to read and to refresh its own session token, and refuses other writes with an `agent_session_forbidden` error. Open the Agent Task URL of a restricted session in a browser that has no other session; otherwise the Frontend API refuses it with the same error. By default, the session is not restricted. */ + sessionRestricted?: boolean; + /** The ID of the Organization that the session created by the Agent Task starts in. The user must be a member of this Organization. A restricted session can only use this Organization. By default, the session starts in the user's last active Organization if the user is still a member of it, and otherwise in the personal account. If `sessionRestricted` is `true` and the instance requires an Organization, the request fails when no Organization can be used. */ + sessionOrganizationId?: string; }; const basePath = '/agents/tasks'; diff --git a/packages/backend/src/api/resources/AgentTask.ts b/packages/backend/src/api/resources/AgentTask.ts index 95017849655..58add9e5347 100644 --- a/packages/backend/src/api/resources/AgentTask.ts +++ b/packages/backend/src/api/resources/AgentTask.ts @@ -13,6 +13,10 @@ export class AgentTask { readonly agentTaskId: string, /** The Frontend API URL that, when visited, creates a session for the user. Only returned by [`create()`](https://clerk.com/docs/reference/backend/agent-tasks/create); omitted from [`revoke()`](https://clerk.com/docs/reference/backend/agent-tasks/revoke). */ readonly url: string, + /** Whether the session created by the Agent Task is restricted. */ + readonly sessionRestricted: boolean = false, + /** The ID of the Organization that the session created by the Agent Task starts in, or `null` for the user's personal account. A restricted session can only use this Organization or personal account. */ + readonly sessionOrganizationId: string | null = null, ) {} /** @@ -23,6 +27,13 @@ export class AgentTask { */ static fromJSON(data: AgentTaskJSON): AgentTask { const agentTaskId = data.agent_task_id ?? data.task_id ?? ''; - return new AgentTask(data.agent_id, agentTaskId, agentTaskId, data.url); + return new AgentTask( + data.agent_id, + agentTaskId, + agentTaskId, + data.url, + data.session_restricted, + data.session_organization_id, + ); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 8c979a6d170..26c42622a21 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -610,6 +610,8 @@ export interface AgentTaskJSON extends ClerkResourceJSON { */ task_id: string; url: string; + session_restricted?: boolean; + session_organization_id?: string | null; } export interface SignUpJSON extends ClerkResourceJSON { From 425171118cb408eaa0efc1e8855763261902ca2c Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Mon, 9 Nov 2026 12:48:26 -0600 Subject: [PATCH 06/12] test(backend): pin that OIDC ID tokens are not accepted as session tokens Clerk now tags OIDC ID tokens with their own JWT category. Add that category to the known list and run the SEC-340 signed-out checks for an OIDC ID token as well as a JWT-template token, on the header, cookie and acceptsToken paths. The backend already signs these tokens out, because any category other than the session-token category is rejected, so this pins the behavior and changes no runtime code. Tokens with no category are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../backend/src/tokens/__tests__/request.test.ts | 15 +++++++++------ packages/backend/src/tokens/jwtCategories.ts | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 77db0fe2554..7e54702d9d7 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -21,7 +21,7 @@ import { signJwt } from '../../jwt/signJwt'; import { server } from '../../mock-server'; import type { AuthReason } from '../authStatus'; import { AuthErrorReason, AuthStatus } from '../authStatus'; -import { JWT_CATEGORY_JWT_TEMPLATE } from '../jwtCategories'; +import { JWT_CATEGORY_JWT_TEMPLATE, JWT_CATEGORY_OIDC_ID_TOKEN } from '../jwtCategories'; import { OrganizationMatcher } from '../organizationMatcher'; import { authenticateRequest, RefreshTokenErrorReason } from '../request'; import { type MachineTokenType, TokenType } from '../tokenTypes'; @@ -1328,15 +1328,18 @@ describe('tokens.authenticateRequest(options)', () => { templateInHeader, { acceptsToken: ['session_token', 'm2m_token'] as const }, ], - ])('%s: JWT-template token presented as a session token (SEC-340)', (_label, buildRequest, options) => { - test('returns signed out', async () => { + ])('%s: non-session token presented as a session token (SEC-340)', (_label, buildRequest, options) => { + test.each([ + ['a JWT-template token', JWT_CATEGORY_JWT_TEMPLATE], + ['an OIDC ID token', JWT_CATEGORY_OIDC_ID_TOKEN], + ])('returns signed out for %s', async (_kind, cat) => { const { sid: _sid, ...payloadWithoutSid } = mockJwtPayload; - const { data: templateJwt } = await signJwt(payloadWithoutSid, signingJwks, { + const { data: nonSessionJwt } = await signJwt(payloadWithoutSid, signingJwks, { algorithm: 'RS256', - header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', cat: JWT_CATEGORY_JWT_TEMPLATE }, + header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD', cat }, }); - const requestState = await authenticateRequest(buildRequest(templateJwt!), mockOptions(options)); + const requestState = await authenticateRequest(buildRequest(nonSessionJwt!), mockOptions(options)); expect(requestState).toBeSignedOut({ reason: AuthErrorReason.TokenTypeMismatch, diff --git a/packages/backend/src/tokens/jwtCategories.ts b/packages/backend/src/tokens/jwtCategories.ts index 859c0a36386..5df2a293e32 100644 --- a/packages/backend/src/tokens/jwtCategories.ts +++ b/packages/backend/src/tokens/jwtCategories.ts @@ -5,6 +5,7 @@ import { decodeJwt } from '../jwt/verifyJwt'; export const JWT_CATEGORY_SESSION_TOKEN = 'cl_B7d4PD111AAA'; export const JWT_CATEGORY_JWT_TEMPLATE = 'cl_B7d4PD222AAA'; export const JWT_CATEGORY_M2M_TOKEN = 'cl_B7d4PD333AAA'; +export const JWT_CATEGORY_OIDC_ID_TOKEN = 'cl_B7d4PD444AAA'; /** * Whether `cat` marks a JWT as something other than a session token. Handshake tokens are From 19f32a47ab1b324e470e1b912b07630eea22bce6 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Mon, 9 Nov 2026 13:29:18 -0600 Subject: [PATCH 07/12] docs(backend): note the agent task Organization default and id_token categories The sessionOrganizationId JSDoc now says there is no way yet to ask for the personal account when the user has a last active Organization. The JWT category comment now says that only OIDC ID tokens minted before they got their own category carry no category. Co-Authored-By: Claude Opus 5.5 --- packages/backend/src/api/endpoints/AgentTaskApi.ts | 2 +- packages/backend/src/tokens/jwtCategories.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/api/endpoints/AgentTaskApi.ts b/packages/backend/src/api/endpoints/AgentTaskApi.ts index 3d73fc063b3..212e58f0d8b 100644 --- a/packages/backend/src/api/endpoints/AgentTaskApi.ts +++ b/packages/backend/src/api/endpoints/AgentTaskApi.ts @@ -28,7 +28,7 @@ export type CreateAgentTaskParams = { sessionMaxDurationInSeconds?: number; /** Whether the session created by the Agent Task is restricted. In a restricted session, the Frontend API allows the agent only to read and to refresh its own session token, and refuses other writes with an `agent_session_forbidden` error. Open the Agent Task URL of a restricted session in a browser that has no other session; otherwise the Frontend API refuses it with the same error. By default, the session is not restricted. */ sessionRestricted?: boolean; - /** The ID of the Organization that the session created by the Agent Task starts in. The user must be a member of this Organization. A restricted session can only use this Organization. By default, the session starts in the user's last active Organization if the user is still a member of it, and otherwise in the personal account. If `sessionRestricted` is `true` and the instance requires an Organization, the request fails when no Organization can be used. */ + /** The ID of the Organization that the session created by the Agent Task starts in. The user must be a member of this Organization. A restricted session can only use this Organization. By default, the session starts in the user's last active Organization if the user is still a member of it, and otherwise in the personal account. There is no way yet to ask for the personal account when the user has a last active Organization. If `sessionRestricted` is `true` and the instance requires an Organization, the request fails when no Organization can be used. */ sessionOrganizationId?: string; }; diff --git a/packages/backend/src/tokens/jwtCategories.ts b/packages/backend/src/tokens/jwtCategories.ts index d73d88c4e38..c164e8096bc 100644 --- a/packages/backend/src/tokens/jwtCategories.ts +++ b/packages/backend/src/tokens/jwtCategories.ts @@ -10,8 +10,8 @@ export const JWT_CATEGORY_OIDC_ID_TOKEN = 'cl_B7d4PD444AAA'; /** * Whether `cat` marks a JWT as something other than a session token. An absent `cat` is * non-session: every session token carries one, while OIDC `id_token`s signed by the same - * instance key do not (AISEC-21). Handshake tokens are minted with the session-token category - * too, but their verifier tolerates an absent one. + * instance key before they got their own category carry none (AISEC-21). Handshake tokens are + * minted with the session-token category too, but their verifier tolerates an absent one. */ export function isNonSessionJwtCategory(cat?: string): boolean { return cat !== JWT_CATEGORY_SESSION_TOKEN; From 6392ce26649d90eb151c5c3c41136f467f01173f Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Mon, 9 Nov 2026 13:32:25 -0600 Subject: [PATCH 08/12] feat(backend): name the agent task Organization option organizationId agentTasks.create() takes organizationId and the AgentTask resource returns organizationId, matching the agent task docs. The request still sends session_organization_id, and the response field keeps its name. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-task-session-restricted.md | 2 +- .../backend/src/api/__tests__/AgentTaskApi.test.ts | 14 +++++++------- packages/backend/src/api/endpoints/AgentTaskApi.ts | 5 +++-- packages/backend/src/api/resources/AgentTask.ts | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.changeset/agent-task-session-restricted.md b/.changeset/agent-task-session-restricted.md index ee713589355..ea160de87c3 100644 --- a/.changeset/agent-task-session-restricted.md +++ b/.changeset/agent-task-session-restricted.md @@ -2,4 +2,4 @@ "@clerk/backend": minor --- -Add `sessionRestricted` and `sessionOrganizationId` to `clerkClient.agentTasks.create()` and to the `AgentTask` resource. Pass `sessionRestricted: true` to create an agent session in which the Frontend API allows the agent only to read and to refresh its own session token, and refuses other writes with an `agent_session_forbidden` error. Pass `sessionOrganizationId` to choose the Organization the session starts in; a restricted session can only use that Organization. +Add `sessionRestricted` and `organizationId` to `clerkClient.agentTasks.create()` and to the `AgentTask` resource. Pass `sessionRestricted: true` to create an agent session in which the Frontend API allows the agent only to read and to refresh its own session token, and refuses other writes with an `agent_session_forbidden` error. Pass `organizationId` to choose the Organization the session starts in; a restricted session can only use that Organization. diff --git a/packages/backend/src/api/__tests__/AgentTaskApi.test.ts b/packages/backend/src/api/__tests__/AgentTaskApi.test.ts index 278dfbc642f..e5d95871c0e 100644 --- a/packages/backend/src/api/__tests__/AgentTaskApi.test.ts +++ b/packages/backend/src/api/__tests__/AgentTaskApi.test.ts @@ -95,7 +95,7 @@ describe('AgentTaskAPI', () => { expect(response.agentTaskId).toBe('agent_task_456'); }); - it('sends sessionRestricted and sessionOrganizationId and reads them from the response', async () => { + it('sends sessionRestricted and organizationId and reads them from the response', async () => { server.use( http.post( 'https://api.clerk.test/v1/agents/tasks', @@ -132,14 +132,14 @@ describe('AgentTaskAPI', () => { taskDescription: 'Test task', redirectUrl: 'https://example.com/callback', sessionRestricted: true, - sessionOrganizationId: 'org_123', + organizationId: 'org_123', }); expect(response.sessionRestricted).toBe(true); - expect(response.sessionOrganizationId).toBe('org_123'); + expect(response.organizationId).toBe('org_123'); }); - it('defaults sessionRestricted to false and sessionOrganizationId to null when the response omits them', async () => { + it('defaults sessionRestricted to false and organizationId to null when the response omits them', async () => { server.use( http.post( 'https://api.clerk.test/v1/agents/tasks', @@ -158,12 +158,12 @@ describe('AgentTaskAPI', () => { }); expect(response.sessionRestricted).toBe(false); - expect(response.sessionOrganizationId).toBeNull(); + expect(response.organizationId).toBeNull(); }); }); describe('revoke', () => { - it('reads sessionRestricted and sessionOrganizationId from the response', async () => { + it('reads sessionRestricted and organizationId from the response', async () => { server.use( http.post( 'https://api.clerk.test/v1/agents/tasks/agent_task_456/revoke', @@ -182,7 +182,7 @@ describe('AgentTaskAPI', () => { expect(response.agentTaskId).toBe('agent_task_456'); expect(response.sessionRestricted).toBe(true); - expect(response.sessionOrganizationId).toBe('org_123'); + expect(response.organizationId).toBe('org_123'); }); }); }); diff --git a/packages/backend/src/api/endpoints/AgentTaskApi.ts b/packages/backend/src/api/endpoints/AgentTaskApi.ts index 212e58f0d8b..ff16e91c560 100644 --- a/packages/backend/src/api/endpoints/AgentTaskApi.ts +++ b/packages/backend/src/api/endpoints/AgentTaskApi.ts @@ -29,7 +29,7 @@ export type CreateAgentTaskParams = { /** Whether the session created by the Agent Task is restricted. In a restricted session, the Frontend API allows the agent only to read and to refresh its own session token, and refuses other writes with an `agent_session_forbidden` error. Open the Agent Task URL of a restricted session in a browser that has no other session; otherwise the Frontend API refuses it with the same error. By default, the session is not restricted. */ sessionRestricted?: boolean; /** The ID of the Organization that the session created by the Agent Task starts in. The user must be a member of this Organization. A restricted session can only use this Organization. By default, the session starts in the user's last active Organization if the user is still a member of it, and otherwise in the personal account. There is no way yet to ask for the personal account when the user has a last active Organization. If `sessionRestricted` is `true` and the instance requires an Organization, the request fails when no Organization can be used. */ - sessionOrganizationId?: string; + organizationId?: string; }; const basePath = '/agents/tasks'; @@ -41,10 +41,11 @@ export class AgentTaskAPI extends AbstractAPI { * @returns The created [`AgentTask`](https://clerk.com/docs/reference/backend/types/backend-agent-task) object. */ public async create(params: CreateAgentTaskParams) { + const { organizationId, ...rest } = params; return this.request({ method: 'POST', path: basePath, - bodyParams: params, + bodyParams: { ...rest, sessionOrganizationId: organizationId }, options: { deepSnakecaseBodyParamKeys: true, }, diff --git a/packages/backend/src/api/resources/AgentTask.ts b/packages/backend/src/api/resources/AgentTask.ts index 58add9e5347..7838ba61be8 100644 --- a/packages/backend/src/api/resources/AgentTask.ts +++ b/packages/backend/src/api/resources/AgentTask.ts @@ -16,7 +16,7 @@ export class AgentTask { /** Whether the session created by the Agent Task is restricted. */ readonly sessionRestricted: boolean = false, /** The ID of the Organization that the session created by the Agent Task starts in, or `null` for the user's personal account. A restricted session can only use this Organization or personal account. */ - readonly sessionOrganizationId: string | null = null, + readonly organizationId: string | null = null, ) {} /** From 0654ed6ac4b8774775b34094aa74fe474b256d1a Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Tue, 10 Nov 2026 12:16:42 -0600 Subject: [PATCH 09/12] feat(shared,backend,clerk-js): expose whether an agent session is restricted Add `restricted` to the agent object, read from the `rst` claim of the `agt` session token claim and from the session actor's `restricted` field. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-task-session-claim.md | 2 +- packages/backend/src/api/resources/Session.ts | 1 + .../api/resources/__tests__/Session.test.ts | 8 +-- .../src/tokens/__tests__/authObjects.test.ts | 14 ++++- .../src/core/__tests__/jwt-client.test.ts | 4 +- .../clerk-js/src/core/resources/Session.ts | 1 + .../core/resources/SessionWithActivities.ts | 1 + .../core/resources/__tests__/Session.test.ts | 8 +-- .../__tests__/SessionWithActivities.test.ts | 8 +-- .../src/__tests__/jwtPayloadParser.spec.ts | 55 ++++++++++++++----- packages/shared/src/jwtPayloadParser.ts | 9 ++- packages/shared/src/types/jwtv2.ts | 8 +++ 12 files changed, 86 insertions(+), 33 deletions(-) diff --git a/.changeset/agent-task-session-claim.md b/.changeset/agent-task-session-claim.md index c86108db1f9..09f80cc8749 100644 --- a/.changeset/agent-task-session-claim.md +++ b/.changeset/agent-task-session-claim.md @@ -6,4 +6,4 @@ "@clerk/clerk-js": minor --- -Expose agent task sessions on auth objects and session resources. Sessions created from an agent task now populate a new `agent` property (`{ sub, type: 'agent', task_id }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, on `useAuth()`, on the backend `Session` resource, and on the sessions returned by `Clerk.user.getSessions()`. Use a truthiness check (`if (agent)`) to detect an agent session. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types. On `Clerk.session`, the sessions from `Clerk.user.getSessions()`, and the backend `Session` resource, `actor` also holds the agent for agent task sessions today; this will change in a future major version, so use `agent`. +Expose agent task sessions on auth objects and session resources. Sessions created from an agent task now populate a new `agent` property (`{ sub, type: 'agent', task_id }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, on `useAuth()`, on the backend `Session` resource, and on the sessions returned by `Clerk.user.getSessions()`. Use a truthiness check (`if (agent)`) to detect an agent session. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types. On `Clerk.session`, the sessions from `Clerk.user.getSessions()`, and the backend `Session` resource, `actor` also holds the agent for agent task sessions today; this will change in a future major version, so use `agent`. `agent.restricted` is `true` when the agent task that created the session is restricted. On `auth()` and during server rendering it reads `false` until Clerk adds the flag to the session token, so do not gate on it. diff --git a/packages/backend/src/api/resources/Session.ts b/packages/backend/src/api/resources/Session.ts index 3dac2913df6..3574021de77 100644 --- a/packages/backend/src/api/resources/Session.ts +++ b/packages/backend/src/api/resources/Session.ts @@ -91,6 +91,7 @@ export class Session { sub: typeof data.actor.sub === 'string' ? data.actor.sub : '', type: 'agent', task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', + restricted: data.actor.restricted === true, } : null, ); diff --git a/packages/backend/src/api/resources/__tests__/Session.test.ts b/packages/backend/src/api/resources/__tests__/Session.test.ts index 77c5f2f0341..ad12a63df3d 100644 --- a/packages/backend/src/api/resources/__tests__/Session.test.ts +++ b/packages/backend/src/api/resources/__tests__/Session.test.ts @@ -18,12 +18,12 @@ const baseJSON = { describe('Session', () => { describe('fromJSON', () => { - it('sets agent to only sub, type and task_id and keeps the full actor when the actor has type "agent"', () => { + it('sets agent to only sub, type, task_id and restricted and keeps the full actor when the actor has type "agent"', () => { const actor = { sub: 'agent_1', type: 'agent', task_id: 'task_1', restricted: true }; const session = Session.fromJSON({ ...baseJSON, actor } as SessionJSON); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'task_1' }); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'task_1', restricted: true }); }); it('sets agent task_id to an empty string when an agent actor has no task_id', () => { @@ -31,7 +31,7 @@ describe('Session', () => { const session = Session.fromJSON({ ...baseJSON, actor } as SessionJSON); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '' }); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '', restricted: false }); }); it.each([ @@ -42,7 +42,7 @@ describe('Session', () => { const session = Session.fromJSON({ ...baseJSON, actor } as unknown as SessionJSON); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: '', type: 'agent', task_id: 'task_1' }); + expect(session.agent).toStrictEqual({ sub: '', type: 'agent', task_id: 'task_1', restricted: false }); }); it('sets actor and a null agent for an impersonation session', () => { diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index b08d51739cc..a5d905eaf4f 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -41,6 +41,7 @@ describe('makeAuthObjectSerializable', () => { sub: 'agent_123', type: 'agent', task_id: 'agttsk_456', + restricted: false, }); }); }); @@ -70,10 +71,21 @@ describe('signedInAuthObject', () => { agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com' }, } as unknown as JwtPayload); - expect(authObject.agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }); + expect(authObject.agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456', restricted: false }); expect(authObject.actor).toBeUndefined(); }); + it('exposes a restricted agent task session on the auth object', () => { + const authObject = signedInAuthObject({} as AuthenticateContext, 'token', { + v: 2, + sid: 'sessionId', + sub: 'userId', + agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com', rst: true }, + } as unknown as JwtPayload); + + expect(authObject.agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456', restricted: true }); + }); + it('agent is null for sessions without the agt claim', () => { const authObject = signedInAuthObject({} as AuthenticateContext, 'token', { v: 2, diff --git a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts index 8a2077ab5bd..d27397dec71 100644 --- a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts +++ b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts @@ -38,9 +38,9 @@ describe('createClientFromJwt', () => { it('sets the actor and agent on the stub session to the agent when the JWT has an agt claim', () => { const client = createClientFromJwt( - jwtWithClaims({ agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'r', restricted: true } }), + jwtWithClaims({ agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'r', rst: true } }), ); - const agent = { sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }; + const agent = { sub: 'agent_123', type: 'agent', task_id: 'agttsk_456', restricted: true }; expect(client.signedInSessions[0]?.actor).toStrictEqual(agent); expect(client.signedInSessions[0]?.agent).toStrictEqual(agent); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 2970bdf4218..8e61803f5d6 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -454,6 +454,7 @@ export class Session extends BaseResource implements SessionResource { sub: typeof data.actor.sub === 'string' ? data.actor.sub : '', type: 'agent', task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', + restricted: data.actor.restricted === true, } : null; this.createdAt = unixEpochToDate(data.created_at); diff --git a/packages/clerk-js/src/core/resources/SessionWithActivities.ts b/packages/clerk-js/src/core/resources/SessionWithActivities.ts index c168fb00c0a..7645b7dd607 100644 --- a/packages/clerk-js/src/core/resources/SessionWithActivities.ts +++ b/packages/clerk-js/src/core/resources/SessionWithActivities.ts @@ -78,6 +78,7 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc sub: typeof data.actor.sub === 'string' ? data.actor.sub : '', type: 'agent', task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', + restricted: data.actor.restricted === true, } : null; return this; diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 6e4ff4a6f5f..a0fa03e7863 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2680,7 +2680,7 @@ describe('Session', () => { expect(session.agent).toBeNull(); }); - it('sets agent to only sub, type and task_id when actor has type "agent" and extra fields', () => { + it('sets agent to only sub, type, task_id and restricted when actor has type "agent" and extra fields', () => { const actor = { sub: 'agent_1', type: 'agent' as const, task_id: 'agttsk_1', restricted: true }; const session = new Session({ status: 'active', @@ -2694,7 +2694,7 @@ describe('Session', () => { } as SessionJSON); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'agttsk_1' }); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'agttsk_1', restricted: true }); }); it('sets agent task_id to an empty string when an agent actor has no task_id', () => { @@ -2711,7 +2711,7 @@ describe('Session', () => { } as SessionJSON); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '' }); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '', restricted: false }); }); it.each([ @@ -2731,7 +2731,7 @@ describe('Session', () => { } as unknown as SessionJSON); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: '', type: 'agent', task_id: 'agttsk_1' }); + expect(session.agent).toStrictEqual({ sub: '', type: 'agent', task_id: 'agttsk_1', restricted: false }); }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts index c914ea289f4..c8dc57d1893 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts @@ -20,12 +20,12 @@ const build = (actor: SessionWithActivitiesJSON['actor']) => ); describe('SessionWithActivities', () => { - it('sets agent to only sub, type and task_id and keeps the full actor when the actor has type "agent"', () => { + it('sets agent to only sub, type, task_id and restricted and keeps the full actor when the actor has type "agent"', () => { const actor = { sub: 'agent_1', type: 'agent', task_id: 'task_1', restricted: true }; const session = build(actor as SessionWithActivitiesJSON['actor']); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'task_1' }); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'task_1', restricted: true }); }); it('sets agent task_id to an empty string when an agent actor has no task_id', () => { @@ -33,7 +33,7 @@ describe('SessionWithActivities', () => { const session = build(actor as SessionWithActivitiesJSON['actor']); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '' }); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '', restricted: false }); }); it.each([ @@ -44,7 +44,7 @@ describe('SessionWithActivities', () => { const session = build(actor as unknown as SessionWithActivitiesJSON['actor']); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: '', type: 'agent', task_id: 'task_1' }); + expect(session.agent).toStrictEqual({ sub: '', type: 'agent', task_id: 'task_1', restricted: false }); }); it('sets actor and a null agent for an impersonation session', () => { diff --git a/packages/shared/src/__tests__/jwtPayloadParser.spec.ts b/packages/shared/src/__tests__/jwtPayloadParser.spec.ts index 3dfa25e943c..ca1fb1f0a01 100644 --- a/packages/shared/src/__tests__/jwtPayloadParser.spec.ts +++ b/packages/shared/src/__tests__/jwtPayloadParser.spec.ts @@ -49,7 +49,7 @@ describe('JWTPayloadToAuthObjectProperties', () => { agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com' }, }); - expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }); + expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456', restricted: false }); expect(actor).toBeUndefined(); }); @@ -59,7 +59,7 @@ describe('JWTPayloadToAuthObjectProperties', () => { agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com' }, } as any); - expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }); + expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456', restricted: false }); expect(actor).toBeUndefined(); }); @@ -72,22 +72,47 @@ describe('JWTPayloadToAuthObjectProperties', () => { } as any); expect(actor).toEqual({ sub: 'impersonator' }); - expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }); + expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456', restricted: false }); + }); + + test('sets restricted to true when the agt claim has rst true', () => { + const { agent } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + v: 2, + agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com', rst: true }, + }); + + expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456', restricted: true }); + }); + + test.each([ + ['absent', undefined], + ['false', false], + ['the string "true"', 'true'], + ['1', 1], + ])('sets restricted to false when the agt claim rst is %s', (_name, rst) => { + const { agent } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + v: 2, + agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com', rst }, + } as any); + + expect(agent?.restricted).toBe(false); }); test.each([ - ['an empty object', {}, { sub: '', type: 'agent', task_id: '' }], - ['a missing tsk', { id: 'agent_123' }, { sub: 'agent_123', type: 'agent', task_id: '' }], - ['a missing id', { tsk: 'agttsk_456' }, { sub: '', type: 'agent', task_id: 'agttsk_456' }], - ['empty strings', { id: '', tsk: '' }, { sub: '', type: 'agent', task_id: '' }], - ['non-string fields', { id: 1, tsk: 2 }, { sub: '', type: 'agent', task_id: '' }], - ['a string', 'x', { sub: '', type: 'agent', task_id: '' }], - ['a number', 1, { sub: '', type: 'agent', task_id: '' }], - ['an array', [], { sub: '', type: 'agent', task_id: '' }], - ['true', true, { sub: '', type: 'agent', task_id: '' }], - ['false', false, { sub: '', type: 'agent', task_id: '' }], - ['zero', 0, { sub: '', type: 'agent', task_id: '' }], - ['an empty string', '', { sub: '', type: 'agent', task_id: '' }], + ['an empty object', {}, { sub: '', type: 'agent', task_id: '', restricted: false }], + ['a missing tsk', { id: 'agent_123' }, { sub: 'agent_123', type: 'agent', task_id: '', restricted: false }], + ['a missing id', { tsk: 'agttsk_456' }, { sub: '', type: 'agent', task_id: 'agttsk_456', restricted: false }], + ['empty strings', { id: '', tsk: '' }, { sub: '', type: 'agent', task_id: '', restricted: false }], + ['non-string fields', { id: 1, tsk: 2 }, { sub: '', type: 'agent', task_id: '', restricted: false }], + ['a string', 'x', { sub: '', type: 'agent', task_id: '', restricted: false }], + ['a number', 1, { sub: '', type: 'agent', task_id: '', restricted: false }], + ['an array', [], { sub: '', type: 'agent', task_id: '', restricted: false }], + ['true', true, { sub: '', type: 'agent', task_id: '', restricted: false }], + ['false', false, { sub: '', type: 'agent', task_id: '', restricted: false }], + ['zero', 0, { sub: '', type: 'agent', task_id: '', restricted: false }], + ['an empty string', '', { sub: '', type: 'agent', task_id: '', restricted: false }], ])('agent is still non-null for a malformed agt claim: %s', (_name, agt, expected) => { expect(JWTPayloadToAuthObjectProperties({ ...baseClaims, v: 2, agt } as any).agent).toEqual(expected); }); diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts index 99477478020..78c10f78e9a 100644 --- a/packages/shared/src/jwtPayloadParser.ts +++ b/packages/shared/src/jwtPayloadParser.ts @@ -103,8 +103,13 @@ const parseAgentClaim = (agt: unknown): AgentActClaim | null => { if (agt === null || agt === undefined) { return null; } - const { id, tsk } = (typeof agt === 'object' ? agt : {}) as { id?: unknown; tsk?: unknown }; - return { sub: typeof id === 'string' ? id : '', type: 'agent', task_id: typeof tsk === 'string' ? tsk : '' }; + const { id, tsk, rst } = (typeof agt === 'object' ? agt : {}) as { id?: unknown; tsk?: unknown; rst?: unknown }; + return { + sub: typeof id === 'string' ? id : '', + type: 'agent', + task_id: typeof tsk === 'string' ? tsk : '', + restricted: rst === true, + }; }; /** diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index 006b5e9a3b4..a8007ede588 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -233,6 +233,10 @@ export interface AgentTaskClaim { * The registry that issued the agent. */ reg: string; + /** + * Present and `true` when the agent task that created the session is restricted. + */ + rst?: boolean; } /** @@ -246,6 +250,10 @@ export type AgentActClaim = ActClaim & { * The ID of the agent task that was used to create the session. */ task_id?: string; + /** + * Whether the agent task that created the session is restricted. In a restricted session, Clerk's Frontend API refuses the agent's writes. `false` when the session does not say so. Until Clerk adds the flag to the session token, it reads `false` on the server and during server rendering. Clerk enforces the restriction; to refuse agent sessions, check the agent itself, not this field. + */ + restricted?: boolean; }; /** From ca1c3654c8d49876bd6f1ddc27f30128b21e3fc3 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Thu, 12 Nov 2026 17:14:52 -0600 Subject: [PATCH 10/12] feat(shared,backend,clerk-js): support Poppy sessions in the agent claim Clerk also sets the agt claim on Poppy sessions. Their claim has a poppy object with the Poppy session ID, client ID, user ID and optional agent ID, and no tsk. The agt type is now AgentClaim with an optional tsk and an experimental poppy field. auth().agent exposes poppy, and task_id is undefined instead of '' when there is no task ID. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-task-session-claim.md | 2 +- packages/backend/src/api/resources/Session.ts | 2 +- .../api/resources/__tests__/Session.test.ts | 4 +- .../clerk-js/src/core/resources/Session.ts | 2 +- .../core/resources/SessionWithActivities.ts | 2 +- .../core/resources/__tests__/Session.test.ts | 4 +- .../__tests__/SessionWithActivities.test.ts | 4 +- .../src/__tests__/jwtPayloadParser.spec.ts | 86 ++++++++++++++++--- packages/shared/src/jwtPayloadParser.ts | 28 +++++- packages/shared/src/types/authObject.ts | 2 +- packages/shared/src/types/hooks.ts | 2 +- packages/shared/src/types/jwt.ts | 6 +- packages/shared/src/types/jwtv2.ts | 60 ++++++++++--- 13 files changed, 166 insertions(+), 38 deletions(-) diff --git a/.changeset/agent-task-session-claim.md b/.changeset/agent-task-session-claim.md index 09f80cc8749..f90fd79807d 100644 --- a/.changeset/agent-task-session-claim.md +++ b/.changeset/agent-task-session-claim.md @@ -6,4 +6,4 @@ "@clerk/clerk-js": minor --- -Expose agent task sessions on auth objects and session resources. Sessions created from an agent task now populate a new `agent` property (`{ sub, type: 'agent', task_id }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, on `useAuth()`, on the backend `Session` resource, and on the sessions returned by `Clerk.user.getSessions()`. Use a truthiness check (`if (agent)`) to detect an agent session. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types. On `Clerk.session`, the sessions from `Clerk.user.getSessions()`, and the backend `Session` resource, `actor` also holds the agent for agent task sessions today; this will change in a future major version, so use `agent`. `agent.restricted` is `true` when the agent task that created the session is restricted. On `auth()` and during server rendering it reads `false` until Clerk adds the flag to the session token, so do not gate on it. +Expose agent sessions on auth objects and session resources. Agent sessions, which are agent task sessions and Poppy sessions, now populate a new `agent` property (`{ sub, type: 'agent', task_id, restricted }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, on `useAuth()`, on the backend `Session` resource, and on the sessions returned by `Clerk.user.getSessions()`. Use a truthiness check (`if (agent)`) to detect an agent session. `task_id` is optional: it is `undefined` for a Poppy session. On `auth()` and during server rendering, a Poppy session also has `agent.poppy` (`{ session_id, client_id, user_id, agent_id? }`) from the session token; `poppy` is experimental and can change in a minor release. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types as `AgentClaim`, with an optional `tsk` and an experimental `poppy` field. On `Clerk.session`, the sessions from `Clerk.user.getSessions()`, and the backend `Session` resource, `actor` also holds the agent for agent task sessions today; this will change in a future major version, so use `agent`. `agent.restricted` is `true` when the agent task that created the session is restricted. On `auth()` and during server rendering it reads `false` until Clerk adds the flag to the session token, so do not gate on it. diff --git a/packages/backend/src/api/resources/Session.ts b/packages/backend/src/api/resources/Session.ts index 3574021de77..886459a86aa 100644 --- a/packages/backend/src/api/resources/Session.ts +++ b/packages/backend/src/api/resources/Session.ts @@ -90,7 +90,7 @@ export class Session { ? { sub: typeof data.actor.sub === 'string' ? data.actor.sub : '', type: 'agent', - task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', + task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : undefined, restricted: data.actor.restricted === true, } : null, diff --git a/packages/backend/src/api/resources/__tests__/Session.test.ts b/packages/backend/src/api/resources/__tests__/Session.test.ts index ad12a63df3d..758d69504f8 100644 --- a/packages/backend/src/api/resources/__tests__/Session.test.ts +++ b/packages/backend/src/api/resources/__tests__/Session.test.ts @@ -26,12 +26,12 @@ describe('Session', () => { expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'task_1', restricted: true }); }); - it('sets agent task_id to an empty string when an agent actor has no task_id', () => { + it('leaves agent task_id undefined when an agent actor has no task_id', () => { const actor = { sub: 'agent_1', type: 'agent' }; const session = Session.fromJSON({ ...baseJSON, actor } as SessionJSON); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '', restricted: false }); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: undefined, restricted: false }); }); it.each([ diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 8e61803f5d6..4170a2cd646 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -453,7 +453,7 @@ export class Session extends BaseResource implements SessionResource { ? { sub: typeof data.actor.sub === 'string' ? data.actor.sub : '', type: 'agent', - task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', + task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : undefined, restricted: data.actor.restricted === true, } : null; diff --git a/packages/clerk-js/src/core/resources/SessionWithActivities.ts b/packages/clerk-js/src/core/resources/SessionWithActivities.ts index 7645b7dd607..e2923f00106 100644 --- a/packages/clerk-js/src/core/resources/SessionWithActivities.ts +++ b/packages/clerk-js/src/core/resources/SessionWithActivities.ts @@ -77,7 +77,7 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc ? { sub: typeof data.actor.sub === 'string' ? data.actor.sub : '', type: 'agent', - task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : '', + task_id: typeof data.actor.task_id === 'string' ? data.actor.task_id : undefined, restricted: data.actor.restricted === true, } : null; diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index a0fa03e7863..cd0960c0ac7 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -2697,7 +2697,7 @@ describe('Session', () => { expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'agttsk_1', restricted: true }); }); - it('sets agent task_id to an empty string when an agent actor has no task_id', () => { + it('leaves agent task_id undefined when an agent actor has no task_id', () => { const actor = { sub: 'agent_1', type: 'agent' as const }; const session = new Session({ status: 'active', @@ -2711,7 +2711,7 @@ describe('Session', () => { } as SessionJSON); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '', restricted: false }); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: undefined, restricted: false }); }); it.each([ diff --git a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts index c8dc57d1893..01747389593 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts @@ -28,12 +28,12 @@ describe('SessionWithActivities', () => { expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: 'task_1', restricted: true }); }); - it('sets agent task_id to an empty string when an agent actor has no task_id', () => { + it('leaves agent task_id undefined when an agent actor has no task_id', () => { const actor = { sub: 'agent_1', type: 'agent' }; const session = build(actor as SessionWithActivitiesJSON['actor']); expect(session.actor).toEqual(actor); - expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: '', restricted: false }); + expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: undefined, restricted: false }); }); it.each([ diff --git a/packages/shared/src/__tests__/jwtPayloadParser.spec.ts b/packages/shared/src/__tests__/jwtPayloadParser.spec.ts index ca1fb1f0a01..76dc8992c14 100644 --- a/packages/shared/src/__tests__/jwtPayloadParser.spec.ts +++ b/packages/shared/src/__tests__/jwtPayloadParser.spec.ts @@ -75,6 +75,72 @@ describe('JWTPayloadToAuthObjectProperties', () => { expect(agent).toEqual({ sub: 'agent_123', type: 'agent', task_id: 'agttsk_456', restricted: false }); }); + test('exposes a Poppy session from the agt claim, with no task_id', () => { + const clientId = 'https://agent.example.com/client.json'; + const { agent, actor } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + v: 2, + agt: { + id: clientId, + reg: clientId, + poppy: { session_id: 'ps_123', client_id: clientId, user_id: 'pu_123', agent_id: 'agent-7' }, + rst: true, + }, + } as any); + expect(agent).toStrictEqual({ + sub: clientId, + type: 'agent', + task_id: undefined, + restricted: true, + poppy: { session_id: 'ps_123', client_id: clientId, user_id: 'pu_123', agent_id: 'agent-7' }, + }); + expect(actor).toBeUndefined(); + }); + + test('leaves agent_id undefined for a Poppy session without one', () => { + const clientId = 'https://agent.example.com/client.json'; + const { agent } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + v: 2, + agt: { + id: clientId, + reg: clientId, + poppy: { session_id: 'ps_123', client_id: clientId, user_id: 'pu_123' }, + rst: true, + }, + } as any); + expect(agent?.poppy).toStrictEqual({ + session_id: 'ps_123', + client_id: clientId, + user_id: 'pu_123', + agent_id: undefined, + }); + expect(agent?.task_id).toBeUndefined(); + }); + + test('leaves poppy undefined for an agent task session', () => { + const { agent } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + v: 2, + agt: { id: 'agent_123', tsk: 'agttsk_456', reg: 'https://clerk.com', poppy: null }, + } as any); + expect(agent?.poppy).toBeUndefined(); + expect(agent?.task_id).toBe('agttsk_456'); + }); + + test.each([ + ['a string', 'ps_123'], + ['an empty object', {}], + ['non-string fields', { session_id: 1, client_id: [], user_id: {}, agent_id: 2 }], + ])('poppy is still set for a malformed poppy value: %s', (_name, poppy) => { + const { agent } = JWTPayloadToAuthObjectProperties({ + ...baseClaims, + v: 2, + agt: { id: 'agent_123', reg: 'https://clerk.com', poppy }, + } as any); + expect(agent?.poppy).toStrictEqual({ session_id: '', client_id: '', user_id: '', agent_id: undefined }); + }); + test('sets restricted to true when the agt claim has rst true', () => { const { agent } = JWTPayloadToAuthObjectProperties({ ...baseClaims, @@ -101,18 +167,18 @@ describe('JWTPayloadToAuthObjectProperties', () => { }); test.each([ - ['an empty object', {}, { sub: '', type: 'agent', task_id: '', restricted: false }], - ['a missing tsk', { id: 'agent_123' }, { sub: 'agent_123', type: 'agent', task_id: '', restricted: false }], + ['an empty object', {}, { sub: '', type: 'agent', task_id: undefined, restricted: false }], + ['a missing tsk', { id: 'agent_123' }, { sub: 'agent_123', type: 'agent', task_id: undefined, restricted: false }], ['a missing id', { tsk: 'agttsk_456' }, { sub: '', type: 'agent', task_id: 'agttsk_456', restricted: false }], ['empty strings', { id: '', tsk: '' }, { sub: '', type: 'agent', task_id: '', restricted: false }], - ['non-string fields', { id: 1, tsk: 2 }, { sub: '', type: 'agent', task_id: '', restricted: false }], - ['a string', 'x', { sub: '', type: 'agent', task_id: '', restricted: false }], - ['a number', 1, { sub: '', type: 'agent', task_id: '', restricted: false }], - ['an array', [], { sub: '', type: 'agent', task_id: '', restricted: false }], - ['true', true, { sub: '', type: 'agent', task_id: '', restricted: false }], - ['false', false, { sub: '', type: 'agent', task_id: '', restricted: false }], - ['zero', 0, { sub: '', type: 'agent', task_id: '', restricted: false }], - ['an empty string', '', { sub: '', type: 'agent', task_id: '', restricted: false }], + ['non-string fields', { id: 1, tsk: 2 }, { sub: '', type: 'agent', task_id: undefined, restricted: false }], + ['a string', 'x', { sub: '', type: 'agent', task_id: undefined, restricted: false }], + ['a number', 1, { sub: '', type: 'agent', task_id: undefined, restricted: false }], + ['an array', [], { sub: '', type: 'agent', task_id: undefined, restricted: false }], + ['true', true, { sub: '', type: 'agent', task_id: undefined, restricted: false }], + ['false', false, { sub: '', type: 'agent', task_id: undefined, restricted: false }], + ['zero', 0, { sub: '', type: 'agent', task_id: undefined, restricted: false }], + ['an empty string', '', { sub: '', type: 'agent', task_id: undefined, restricted: false }], ])('agent is still non-null for a malformed agt claim: %s', (_name, agt, expected) => { expect(JWTPayloadToAuthObjectProperties({ ...baseClaims, v: 2, agt } as any).agent).toEqual(expected); }); diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts index 78c10f78e9a..ab929bb02f3 100644 --- a/packages/shared/src/jwtPayloadParser.ts +++ b/packages/shared/src/jwtPayloadParser.ts @@ -4,6 +4,7 @@ import type { JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, + PoppyClaim, SharedSignedInAuthObjectProperties, } from './types'; @@ -99,16 +100,39 @@ function buildOrgPermissions({ return orgPermissions; } +// Any non-null `poppy` value marks a Poppy session, even a malformed one; its unreadable fields are left empty. +const parsePoppyClaim = (poppy: unknown): PoppyClaim | undefined => { + if (poppy === null || poppy === undefined) { + return undefined; + } + const { session_id, client_id, user_id, agent_id } = (typeof poppy === 'object' ? poppy : {}) as Record< + string, + unknown + >; + return { + session_id: typeof session_id === 'string' ? session_id : '', + client_id: typeof client_id === 'string' ? client_id : '', + user_id: typeof user_id === 'string' ? user_id : '', + agent_id: typeof agent_id === 'string' ? agent_id : undefined, + }; +}; + const parseAgentClaim = (agt: unknown): AgentActClaim | null => { if (agt === null || agt === undefined) { return null; } - const { id, tsk, rst } = (typeof agt === 'object' ? agt : {}) as { id?: unknown; tsk?: unknown; rst?: unknown }; + const { id, tsk, rst, poppy } = (typeof agt === 'object' ? agt : {}) as { + id?: unknown; + tsk?: unknown; + rst?: unknown; + poppy?: unknown; + }; return { sub: typeof id === 'string' ? id : '', type: 'agent', - task_id: typeof tsk === 'string' ? tsk : '', + task_id: typeof tsk === 'string' ? tsk : undefined, restricted: rst === true, + poppy: parsePoppyClaim(poppy), }; }; diff --git a/packages/shared/src/types/authObject.ts b/packages/shared/src/types/authObject.ts index 4afe08ae5cf..4caac552e66 100644 --- a/packages/shared/src/types/authObject.ts +++ b/packages/shared/src/types/authObject.ts @@ -23,7 +23,7 @@ export type SharedSignedInAuthObjectProperties = { actor: ActClaim | undefined; /** * The agent and agent task that created the session, from the `agt` claim. `null` for other sessions. - * `sub` and `task_id` can be `''` if the claim is malformed, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. + * `sub` can be `''` if the claim is malformed, and `task_id` is `undefined` for a Poppy session or when the claim has no readable task ID, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. `poppy` is set for a Poppy session (experimental). * `actor` is `undefined` for agent sessions, because the session token carries the `agt` claim instead of `act`. */ agent: AgentActClaim | null; diff --git a/packages/shared/src/types/hooks.ts b/packages/shared/src/types/hooks.ts index db7c10f1ba9..252dbaa0284 100644 --- a/packages/shared/src/types/hooks.ts +++ b/packages/shared/src/types/hooks.ts @@ -52,7 +52,7 @@ export type UseAuthReturn = actor: undefined; /** * The agent and agent task that created the session. `null` for other sessions, and `undefined` before Clerk has loaded. - * `sub` and `task_id` can be `''` if the claim is malformed, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. + * `sub` can be `''` if the claim is malformed, and `task_id` is `undefined` for a Poppy session or when the claim has no readable task ID, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. `poppy` is set for a Poppy session (experimental). */ agent: undefined; /** diff --git a/packages/shared/src/types/jwt.ts b/packages/shared/src/types/jwt.ts index c0bc7cc565d..e91c173e153 100644 --- a/packages/shared/src/types/jwt.ts +++ b/packages/shared/src/types/jwt.ts @@ -1,4 +1,4 @@ -import type { ActClaim, AgentTaskClaim, JwtHeader, JwtPayload } from './jwtv2'; +import type { ActClaim, AgentClaim, JwtHeader, JwtPayload } from './jwtv2'; import type { OrganizationCustomRoleKey } from './organizationMembership'; export interface JWT { @@ -83,9 +83,9 @@ export interface ClerkJWTClaims { act?: ActClaim; /** - * Agent task claim (agt). Present on sessions created from an agent task. These sessions do not carry an `act` claim. + * Agent claim (agt). Present on agent sessions. These sessions do not carry an `act` claim. */ - agt?: AgentTaskClaim; + agt?: AgentClaim; /** * Active Organization ID. diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index a8007ede588..ee18f9bdcbf 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -88,9 +88,9 @@ type JWTPayloadBase = { act?: ActClaim; /** - * Agent task claim (agt). Present on sessions created from an agent task. These sessions do not carry an `act` claim. + * Agent claim (agt). Present on agent sessions. These sessions do not carry an `act` claim. */ - agt?: AgentTaskClaim; + agt?: AgentClaim; /** * Factor verification age (fva). The tuple represents the minutes that have passed since the last time a first or second factor were verified. @@ -216,29 +216,61 @@ export interface ActClaim { } /** - * Agent task claim (`agt`). Present on sessions created from an agent task. + * Agent claim (`agt`). Present on agent sessions: agent task sessions and Poppy sessions. * * @inline */ -export interface AgentTaskClaim { +export interface AgentClaim { /** - * The ID of the agent. + * The ID of the agent. For a Poppy session, the agent's client ID URL. */ id: string; /** - * The ID of the agent task that was used to create the session. + * The ID of the agent task that was used to create the session. Absent for a Poppy session. */ - tsk: string; + tsk?: string; /** - * The registry that issued the agent. + * The registry that issued the agent. For a Poppy session, the same client ID URL as `id`. */ reg: string; /** - * Present and `true` when the agent task that created the session is restricted. + * The Poppy identity of a Poppy session. Absent for an agent task session. + * + * @experimental This field can change in a minor release. + */ + poppy?: PoppyClaim; + /** + * Present and `true` when the session is restricted. Poppy sessions are always restricted. */ rst?: boolean; } +/** + * The Poppy identity of a Poppy session. + * + * @experimental These fields can change in a minor release. + * + * @inline + */ +export interface PoppyClaim { + /** + * The Poppy session ID. + */ + session_id: string; + /** + * The agent's client ID URL. + */ + client_id: string; + /** + * The user ID that the agent uses for the user. It is not a Clerk user ID. + */ + user_id: string; + /** + * An optional ID that the agent sets for itself. It is not a Clerk ID. + */ + agent_id?: string; +} + /** * ActClaim narrowed to actor type `'agent'`. This shape is shared by `Session.agent` and the `agent` property of the auth object. * @@ -247,11 +279,17 @@ export interface AgentTaskClaim { export type AgentActClaim = ActClaim & { type: 'agent'; /** - * The ID of the agent task that was used to create the session. + * The ID of the agent task that was used to create the session. `undefined` for a Poppy session. */ task_id?: string; /** - * Whether the agent task that created the session is restricted. In a restricted session, Clerk's Frontend API refuses the agent's writes. `false` when the session does not say so. Until Clerk adds the flag to the session token, it reads `false` on the server and during server rendering. Clerk enforces the restriction; to refuse agent sessions, check the agent itself, not this field. + * The Poppy identity of a Poppy session. Set only on the `agent` property of the auth object, from the session token; `undefined` for an agent task session. + * + * @experimental This field can change in a minor release. + */ + poppy?: PoppyClaim; + /** + * Whether the session is restricted. In a restricted session, Clerk's Frontend API refuses the agent's writes. `false` when the session does not say so. Until Clerk adds the flag to the session token, it reads `false` on the server and during server rendering. Clerk enforces the restriction; to refuse agent sessions, check the agent itself, not this field. */ restricted?: boolean; }; From c8b947273870cb81f7e727cc01eba1c13777b3e9 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Thu, 12 Nov 2026 17:21:10 -0600 Subject: [PATCH 11/12] fix(shared,backend,clerk-js): omit poppy for agent task sessions Add poppy to the parsed agent only when the claim has one, so agent task sessions keep the same shape as before. Say where poppy is set, cover Poppy actors on the session resources and the serialized auth object, and describe agent sessions instead of only agent task sessions. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-task-session-claim.md | 2 +- .../api/resources/__tests__/Session.test.ts | 20 +++++++++++++++++++ .../src/tokens/__tests__/authObjects.test.ts | 18 +++++++++++++++++ .../__tests__/SessionWithActivities.test.ts | 20 +++++++++++++++++++ packages/shared/src/jwtPayloadParser.ts | 4 ++-- packages/shared/src/types/authObject.ts | 2 +- packages/shared/src/types/hooks.ts | 2 +- packages/shared/src/types/jwtv2.ts | 2 +- packages/shared/src/types/session.ts | 8 ++++---- 9 files changed, 68 insertions(+), 10 deletions(-) diff --git a/.changeset/agent-task-session-claim.md b/.changeset/agent-task-session-claim.md index f90fd79807d..78b23aa4ada 100644 --- a/.changeset/agent-task-session-claim.md +++ b/.changeset/agent-task-session-claim.md @@ -6,4 +6,4 @@ "@clerk/clerk-js": minor --- -Expose agent sessions on auth objects and session resources. Agent sessions, which are agent task sessions and Poppy sessions, now populate a new `agent` property (`{ sub, type: 'agent', task_id, restricted }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, on `useAuth()`, on the backend `Session` resource, and on the sessions returned by `Clerk.user.getSessions()`. Use a truthiness check (`if (agent)`) to detect an agent session. `task_id` is optional: it is `undefined` for a Poppy session. On `auth()` and during server rendering, a Poppy session also has `agent.poppy` (`{ session_id, client_id, user_id, agent_id? }`) from the session token; `poppy` is experimental and can change in a minor release. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types as `AgentClaim`, with an optional `tsk` and an experimental `poppy` field. On `Clerk.session`, the sessions from `Clerk.user.getSessions()`, and the backend `Session` resource, `actor` also holds the agent for agent task sessions today; this will change in a future major version, so use `agent`. `agent.restricted` is `true` when the agent task that created the session is restricted. On `auth()` and during server rendering it reads `false` until Clerk adds the flag to the session token, so do not gate on it. +Expose agent sessions on auth objects and session resources. Agent sessions, which are agent task sessions and Poppy sessions, now populate a new `agent` property (`{ sub, type: 'agent', task_id, restricted }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, on `useAuth()`, on the backend `Session` resource, and on the sessions returned by `Clerk.user.getSessions()`. Use a truthiness check (`if (agent)`) to detect an agent session. `task_id` is optional: it is `undefined` for a Poppy session. On `auth()` and during server rendering, a Poppy session also has `agent.poppy` (`{ session_id, client_id, user_id, agent_id? }`) from the session token; `poppy` is experimental and can change in a minor release. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types as `AgentClaim`, with an optional `tsk` and an experimental `poppy` field. On `Clerk.session`, the sessions from `Clerk.user.getSessions()`, and the backend `Session` resource, `actor` also holds the agent for agent task sessions today; this will change in a future major version, so use `agent`. `agent.restricted` is `true` when the session is restricted: when the agent task that created it is restricted, and always for a Poppy session. On `auth()` and during server rendering it reads `false` until Clerk adds the flag to the session token, so do not gate on it. diff --git a/packages/backend/src/api/resources/__tests__/Session.test.ts b/packages/backend/src/api/resources/__tests__/Session.test.ts index 758d69504f8..ec319764ad8 100644 --- a/packages/backend/src/api/resources/__tests__/Session.test.ts +++ b/packages/backend/src/api/resources/__tests__/Session.test.ts @@ -34,6 +34,26 @@ describe('Session', () => { expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: undefined, restricted: false }); }); + it('does not map the Poppy fields of a Poppy actor into agent', () => { + const actor = { + sub: 'https://agent.example.com/client.json', + type: 'agent', + restricted: true, + source: 'poppy', + source_id: 'ps_1', + external_user_id: 'pu_1', + }; + const session = Session.fromJSON({ ...baseJSON, actor } as SessionJSON); + + expect(session.actor).toEqual(actor); + expect(session.agent).toStrictEqual({ + sub: 'https://agent.example.com/client.json', + type: 'agent', + task_id: undefined, + restricted: true, + }); + }); + it.each([ ['a number', { sub: 42, type: 'agent', task_id: 'task_1' }], ['missing', { type: 'agent', task_id: 'task_1' }], diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index a5d905eaf4f..e35a4a5eeb2 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -44,6 +44,24 @@ describe('makeAuthObjectSerializable', () => { restricted: false, }); }); + + it('keeps the Poppy identity so it reaches the client initial state', () => { + const clientId = 'https://agent.example.com/client.json'; + const poppy = { session_id: 'ps_1', client_id: clientId, user_id: 'pu_1', agent_id: 'agent-7' }; + const authObject = signedInAuthObject({} as AuthenticateContext, 'token', { + v: 2, + sid: 'sessionId', + sub: 'userId', + agt: { id: clientId, reg: clientId, poppy, rst: true }, + } as unknown as JwtPayload); + + expect(makeAuthObjectSerializable(authObject).agent).toEqual({ + sub: clientId, + type: 'agent', + restricted: true, + poppy, + }); + }); }); describe('signedInAuthObject', () => { diff --git a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts index 01747389593..a3f8f4fcad7 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SessionWithActivities.test.ts @@ -36,6 +36,26 @@ describe('SessionWithActivities', () => { expect(session.agent).toStrictEqual({ sub: 'agent_1', type: 'agent', task_id: undefined, restricted: false }); }); + it('does not map the Poppy fields of a Poppy actor into agent', () => { + const actor = { + sub: 'https://agent.example.com/client.json', + type: 'agent', + restricted: true, + source: 'poppy', + source_id: 'ps_1', + external_user_id: 'pu_1', + }; + const session = build(actor as SessionWithActivitiesJSON['actor']); + + expect(session.actor).toEqual(actor); + expect(session.agent).toStrictEqual({ + sub: 'https://agent.example.com/client.json', + type: 'agent', + task_id: undefined, + restricted: true, + }); + }); + it.each([ ['a number', { sub: 42, type: 'agent', task_id: 'task_1' }], ['missing', { type: 'agent', task_id: 'task_1' }], diff --git a/packages/shared/src/jwtPayloadParser.ts b/packages/shared/src/jwtPayloadParser.ts index ab929bb02f3..323a67b8e16 100644 --- a/packages/shared/src/jwtPayloadParser.ts +++ b/packages/shared/src/jwtPayloadParser.ts @@ -100,7 +100,6 @@ function buildOrgPermissions({ return orgPermissions; } -// Any non-null `poppy` value marks a Poppy session, even a malformed one; its unreadable fields are left empty. const parsePoppyClaim = (poppy: unknown): PoppyClaim | undefined => { if (poppy === null || poppy === undefined) { return undefined; @@ -127,12 +126,13 @@ const parseAgentClaim = (agt: unknown): AgentActClaim | null => { rst?: unknown; poppy?: unknown; }; + const poppyClaim = parsePoppyClaim(poppy); return { sub: typeof id === 'string' ? id : '', type: 'agent', task_id: typeof tsk === 'string' ? tsk : undefined, restricted: rst === true, - poppy: parsePoppyClaim(poppy), + ...(poppyClaim && { poppy: poppyClaim }), }; }; diff --git a/packages/shared/src/types/authObject.ts b/packages/shared/src/types/authObject.ts index 4caac552e66..49f49f9e642 100644 --- a/packages/shared/src/types/authObject.ts +++ b/packages/shared/src/types/authObject.ts @@ -23,7 +23,7 @@ export type SharedSignedInAuthObjectProperties = { actor: ActClaim | undefined; /** * The agent and agent task that created the session, from the `agt` claim. `null` for other sessions. - * `sub` can be `''` if the claim is malformed, and `task_id` is `undefined` for a Poppy session or when the claim has no readable task ID, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. `poppy` is set for a Poppy session (experimental). + * `sub` can be `''` if the claim is malformed, and `task_id` is `undefined` for a Poppy session or when the claim has no readable task ID, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. `poppy` is set for a Poppy session (experimental), from the session token only. * `actor` is `undefined` for agent sessions, because the session token carries the `agt` claim instead of `act`. */ agent: AgentActClaim | null; diff --git a/packages/shared/src/types/hooks.ts b/packages/shared/src/types/hooks.ts index 252dbaa0284..2b86dece793 100644 --- a/packages/shared/src/types/hooks.ts +++ b/packages/shared/src/types/hooks.ts @@ -52,7 +52,7 @@ export type UseAuthReturn = actor: undefined; /** * The agent and agent task that created the session. `null` for other sessions, and `undefined` before Clerk has loaded. - * `sub` can be `''` if the claim is malformed, and `task_id` is `undefined` for a Poppy session or when the claim has no readable task ID, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. `poppy` is set for a Poppy session (experimental). + * `sub` can be `''` if the claim is malformed, and `task_id` is `undefined` for a Poppy session or when the claim has no readable task ID, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. `poppy` is set for a Poppy session (experimental) during server rendering only; after Clerk has loaded it is `undefined`. */ agent: undefined; /** diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index ee18f9bdcbf..618aa0849d7 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -283,7 +283,7 @@ export type AgentActClaim = ActClaim & { */ task_id?: string; /** - * The Poppy identity of a Poppy session. Set only on the `agent` property of the auth object, from the session token; `undefined` for an agent task session. + * The Poppy identity of a Poppy session, from the session token. Set on the `agent` property returned by `auth()` and by `useAuth()` during server rendering. `undefined` on `Session.agent`, on `useAuth()` after Clerk has loaded, and for an agent task session. * * @experimental This field can change in a minor release. */ diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 2682556d840..565a6c3141a 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -246,11 +246,11 @@ export interface SessionResource extends ClerkResource { lastActiveAt: Date; /** * The JWT actor for the session. Holds identifier for the user that is impersonating the current user. Read more about [impersonation](https://clerk.com/docs/guides/users/impersonation). - * For agent task sessions, `actor` also holds the agent today. This will change in a future major version; use `agent` to detect an agent session. + * For agent sessions, `actor` also holds the agent today. This will change in a future major version; use `agent` to detect an agent session. */ actor: ActClaim | null; /** - * When the session's actor claim has `type: 'agent'`, this property exposes information about the agent and [Agent Task](https://clerk.com/docs/reference/types/agent-task) that was used to create the session. + * When the session's actor claim has `type: 'agent'`, this property exposes information about the agent and, for an agent task session, the [Agent Task](https://clerk.com/docs/reference/types/agent-task) that was used to create the session. `task_id` is `undefined` for a Poppy session. */ agent: AgentActClaim | null; /** @@ -392,11 +392,11 @@ export interface SessionWithActivitiesResource extends ClerkResource { latestActivity: SessionActivity; /** * The JWT actor for the session. Holds identifier for the user that is impersonating the current user. Read more about [impersonation](https://clerk.com/docs/guides/users/impersonation). - * For agent task sessions, `actor` also holds the agent today. This will change in a future major version; use `agent` to detect an agent session. + * For agent sessions, `actor` also holds the agent today. This will change in a future major version; use `agent` to detect an agent session. */ actor: ActClaim | null; /** - * When the session's actor claim has `type: 'agent'`, this property exposes information about the agent and [Agent Task](https://clerk.com/docs/reference/types/agent-task) that was used to create the session. + * When the session's actor claim has `type: 'agent'`, this property exposes information about the agent and, for an agent task session, the [Agent Task](https://clerk.com/docs/reference/types/agent-task) that was used to create the session. `task_id` is `undefined` for a Poppy session. */ agent: AgentActClaim | null; From 96319889c1a8b42c04682bb73b069df9c3bce9d1 Mon Sep 17 00:00:00 2001 From: Mitchel Vostrez Date: Thu, 12 Nov 2026 17:47:23 -0600 Subject: [PATCH 12/12] fix(shared,clerk-js): keep poppy on the server only useAuth() no longer carries agent.poppy: the agent from the SSR initial state drops it, so the agent is the same before and after Clerk loads. The stub session that clerk-js builds from a session token also leaves poppy off its actor. poppy stays on the agent returned by auth(). Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-task-session-claim.md | 2 +- .../src/tokens/__tests__/authObjects.test.ts | 18 ------------------ .../src/core/__tests__/jwt-client.test.ts | 18 ++++++++++++++++++ packages/clerk-js/src/core/jwt-client.ts | 7 ++++++- .../shared/src/__tests__/deriveState.spec.ts | 11 +++++++++++ packages/shared/src/deriveState.ts | 6 +++++- packages/shared/src/types/authObject.ts | 2 +- packages/shared/src/types/hooks.ts | 2 +- packages/shared/src/types/jwtv2.ts | 2 +- 9 files changed, 44 insertions(+), 24 deletions(-) diff --git a/.changeset/agent-task-session-claim.md b/.changeset/agent-task-session-claim.md index 78b23aa4ada..76e4bb39841 100644 --- a/.changeset/agent-task-session-claim.md +++ b/.changeset/agent-task-session-claim.md @@ -6,4 +6,4 @@ "@clerk/clerk-js": minor --- -Expose agent sessions on auth objects and session resources. Agent sessions, which are agent task sessions and Poppy sessions, now populate a new `agent` property (`{ sub, type: 'agent', task_id, restricted }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, on `useAuth()`, on the backend `Session` resource, and on the sessions returned by `Clerk.user.getSessions()`. Use a truthiness check (`if (agent)`) to detect an agent session. `task_id` is optional: it is `undefined` for a Poppy session. On `auth()` and during server rendering, a Poppy session also has `agent.poppy` (`{ session_id, client_id, user_id, agent_id? }`) from the session token; `poppy` is experimental and can change in a minor release. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types as `AgentClaim`, with an optional `tsk` and an experimental `poppy` field. On `Clerk.session`, the sessions from `Clerk.user.getSessions()`, and the backend `Session` resource, `actor` also holds the agent for agent task sessions today; this will change in a future major version, so use `agent`. `agent.restricted` is `true` when the session is restricted: when the agent task that created it is restricted, and always for a Poppy session. On `auth()` and during server rendering it reads `false` until Clerk adds the flag to the session token, so do not gate on it. +Expose agent sessions on auth objects and session resources. Agent sessions, which are agent task sessions and Poppy sessions, now populate a new `agent` property (`{ sub, type: 'agent', task_id, restricted }`, the same shape as `Clerk.session.agent`) on the object returned by `auth()` and related helpers, on `useAuth()`, on the backend `Session` resource, and on the sessions returned by `Clerk.user.getSessions()`. Use a truthiness check (`if (agent)`) to detect an agent session. `task_id` is optional: it is `undefined` for a Poppy session. On the server, `auth()` also gives a Poppy session `agent.poppy` (`{ session_id, client_id, user_id, agent_id? }`) from the session token; `useAuth()` and the session resources never set `agent.poppy`. `poppy` is experimental and can change in a minor release. `agent` is `null` for other sessions and `undefined` before Clerk has loaded. The `agt` claim is now part of the `JwtPayload` types as `AgentClaim`, with an optional `tsk` and an experimental `poppy` field. On `Clerk.session`, the sessions from `Clerk.user.getSessions()`, and the backend `Session` resource, `actor` also holds the agent for agent task sessions today; this will change in a future major version, so use `agent`. `agent.restricted` is `true` when the session is restricted: when the agent task that created it is restricted, and always for a Poppy session. On `auth()` and during server rendering it reads `false` until Clerk adds the flag to the session token, so do not gate on it. diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index e35a4a5eeb2..a5d905eaf4f 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -44,24 +44,6 @@ describe('makeAuthObjectSerializable', () => { restricted: false, }); }); - - it('keeps the Poppy identity so it reaches the client initial state', () => { - const clientId = 'https://agent.example.com/client.json'; - const poppy = { session_id: 'ps_1', client_id: clientId, user_id: 'pu_1', agent_id: 'agent-7' }; - const authObject = signedInAuthObject({} as AuthenticateContext, 'token', { - v: 2, - sid: 'sessionId', - sub: 'userId', - agt: { id: clientId, reg: clientId, poppy, rst: true }, - } as unknown as JwtPayload); - - expect(makeAuthObjectSerializable(authObject).agent).toEqual({ - sub: clientId, - type: 'agent', - restricted: true, - poppy, - }); - }); }); describe('signedInAuthObject', () => { diff --git a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts index d27397dec71..9509b3224e5 100644 --- a/packages/clerk-js/src/core/__tests__/jwt-client.test.ts +++ b/packages/clerk-js/src/core/__tests__/jwt-client.test.ts @@ -46,6 +46,24 @@ describe('createClientFromJwt', () => { expect(client.signedInSessions[0]?.agent).toStrictEqual(agent); }); + it('leaves poppy off the actor and agent on the stub session for a Poppy JWT', () => { + const clientId = 'https://agent.example.com/client.json'; + const client = createClientFromJwt( + jwtWithClaims({ + agt: { + id: clientId, + reg: clientId, + poppy: { session_id: 'ps_1', client_id: clientId, user_id: 'pu_1' }, + rst: true, + }, + }), + ); + const agent = { sub: clientId, type: 'agent', task_id: undefined, restricted: true }; + + expect(client.signedInSessions[0]?.actor).toStrictEqual(agent); + expect(client.signedInSessions[0]?.agent).toStrictEqual(agent); + }); + it('leaves actor and agent null on the stub session when the JWT has an act claim', () => { const client = createClientFromJwt(jwtWithClaims({ act: { sub: 'user_impersonator' } })); diff --git a/packages/clerk-js/src/core/jwt-client.ts b/packages/clerk-js/src/core/jwt-client.ts index 0a0322da5d1..3aba2020a41 100644 --- a/packages/clerk-js/src/core/jwt-client.ts +++ b/packages/clerk-js/src/core/jwt-client.ts @@ -46,6 +46,11 @@ export function createClientFromJwt(jwt: string | undefined | null): Client { const { sessionId, userId, orgId, orgRole, orgPermissions, orgSlug, factorVerificationAge, agent } = __experimental_JWTPayloadToAuthObjectProperties(token.jwt.claims); + let actor = agent; + if (actor?.poppy) { + const { poppy: _poppy, ...actorWithoutPoppy } = actor; + actor = actorWithoutPoppy; + } // TODO(jwt-v2): when JWT version 2 is available, we should revise org permissions const defaultClient = { @@ -57,7 +62,7 @@ export function createClientFromJwt(jwt: string | undefined | null): Client { object: 'session', id: sessionId, status: 'active', - actor: agent ?? null, + actor: actor ?? null, last_active_organization_id: orgId || null, // @ts-expect-error - ts is not happy about `id:undefined`, but this is allowed and expected last_active_token: { diff --git a/packages/shared/src/__tests__/deriveState.spec.ts b/packages/shared/src/__tests__/deriveState.spec.ts index b32a3f47446..ca5a8f2130d 100644 --- a/packages/shared/src/__tests__/deriveState.spec.ts +++ b/packages/shared/src/__tests__/deriveState.spec.ts @@ -34,6 +34,17 @@ describe('deriveState', () => { expect(result.agent).toEqual(agent); }); + it('drops poppy from the agent in the SSR initial state', () => { + const agent = { sub: 'https://agent.example.com/client.json', type: 'agent', restricted: true }; + const poppy = { session_id: 'ps_1', client_id: agent.sub, user_id: 'pu_1' }; + const result = deriveState( + false, + {} as Resources, + { ...mockInitialState, agent: { ...agent, poppy } } as InitialState, + ); + expect(result.agent).toStrictEqual(agent); + }); + it('derives the agent from the session when clerk is loaded', () => { const agent = { sub: 'agent_123', type: 'agent', task_id: 'agttsk_456' }; const result = deriveState( diff --git a/packages/shared/src/deriveState.ts b/packages/shared/src/deriveState.ts index d78f13fa6ba..730f216a26c 100644 --- a/packages/shared/src/deriveState.ts +++ b/packages/shared/src/deriveState.ts @@ -41,7 +41,11 @@ export const deriveFromSsrInitialState = (initialState: InitialState): ReturnTyp const orgPermissions = initialState.orgPermissions as OrganizationCustomPermissionKey[]; const orgSlug = initialState.orgSlug; const actor = initialState.actor; - const agent = initialState.agent; + let agent = initialState.agent; + if (agent?.poppy) { + const { poppy: _poppy, ...agentWithoutPoppy } = agent; + agent = agentWithoutPoppy; + } const factorVerificationAge = initialState.factorVerificationAge; return { diff --git a/packages/shared/src/types/authObject.ts b/packages/shared/src/types/authObject.ts index 49f49f9e642..5c4f1533374 100644 --- a/packages/shared/src/types/authObject.ts +++ b/packages/shared/src/types/authObject.ts @@ -23,7 +23,7 @@ export type SharedSignedInAuthObjectProperties = { actor: ActClaim | undefined; /** * The agent and agent task that created the session, from the `agt` claim. `null` for other sessions. - * `sub` can be `''` if the claim is malformed, and `task_id` is `undefined` for a Poppy session or when the claim has no readable task ID, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. `poppy` is set for a Poppy session (experimental), from the session token only. + * `sub` can be `''` if the claim is malformed, and `task_id` is `undefined` for a Poppy session or when the claim has no readable task ID, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. `poppy` is set for a Poppy session (experimental), from the session token. `useAuth()` never sets it; read it from `auth()` on the server. * `actor` is `undefined` for agent sessions, because the session token carries the `agt` claim instead of `act`. */ agent: AgentActClaim | null; diff --git a/packages/shared/src/types/hooks.ts b/packages/shared/src/types/hooks.ts index 2b86dece793..3cdd266fa3d 100644 --- a/packages/shared/src/types/hooks.ts +++ b/packages/shared/src/types/hooks.ts @@ -52,7 +52,7 @@ export type UseAuthReturn = actor: undefined; /** * The agent and agent task that created the session. `null` for other sessions, and `undefined` before Clerk has loaded. - * `sub` can be `''` if the claim is malformed, and `task_id` is `undefined` for a Poppy session or when the claim has no readable task ID, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. `poppy` is set for a Poppy session (experimental) during server rendering only; after Clerk has loaded it is `undefined`. + * `sub` can be `''` if the claim is malformed, and `task_id` is `undefined` for a Poppy session or when the claim has no readable task ID, so use a truthiness check (`if (agent)`), not its fields, to detect an agent session. `useAuth()` never sets `poppy`; read it from `auth()` on the server. */ agent: undefined; /** diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index 618aa0849d7..92da29f2876 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -283,7 +283,7 @@ export type AgentActClaim = ActClaim & { */ task_id?: string; /** - * The Poppy identity of a Poppy session, from the session token. Set on the `agent` property returned by `auth()` and by `useAuth()` during server rendering. `undefined` on `Session.agent`, on `useAuth()` after Clerk has loaded, and for an agent task session. + * The Poppy identity of a Poppy session, from the session token. Set on the `agent` property returned by `auth()` on the server. `useAuth()` and `Session.agent` never set it, and it is `undefined` for an agent task session. * * @experimental This field can change in a minor release. */