From 40b4a4d795191242bfdc41f006469348a0ab35c3 Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:00:47 +0800 Subject: [PATCH 1/6] fix(mcp): preserve invoking turn identity Route delegated session creation and chat through the exact persisted Turn principal while keeping machine credentials scoped to the executor. Freeze that provenance for retries, recovery, and continuation delivery. Model: gpt-5 --- apps/cli/src/commands/session.test.ts | 20 +++ apps/cli/src/commands/session.ts | 102 +++++++++-- apps/cli/src/lib/message-handler.ts | 14 +- apps/cli/src/mcp/AGENTS.md | 4 + apps/cli/src/mcp/lody-mcp-server.test.ts | 69 +++++++- apps/cli/src/mcp/lody-mcp-server.ts | 162 ++++++++++++++---- apps/cli/src/orchestration/AGENTS.md | 5 + .../operation-coordinator.test.ts | 1 + .../orchestration/operation-coordinator.ts | 1 + .../src/orchestration/operation-store.test.ts | 14 ++ apps/cli/src/orchestration/operation-store.ts | 10 ++ apps/cli/src/session/AGENTS.md | 14 +- packages/shared/src/session-orchestration.ts | 11 ++ 13 files changed, 362 insertions(+), 65 deletions(-) diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index 5d97136ca..fab1b05f9 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -46,6 +46,7 @@ import { resolveOpenedBySessionRelation, resolveSessionCreateOwnerUserId, selectDefaultAgentConfigForCreate, + resolveSessionCommandPrincipalUserId, resolveSessionCommandRequesterUserId, resolveChatArgs, resolveRenameArgs, @@ -347,6 +348,25 @@ describe('session command helpers', () => { ); }); + it('accepts only a delegated principal bound to the authenticated executor', () => { + const principal = { + userId: 'collaborator-b', + sourceSessionId: 'source-session' as SessionId, + sourceTurnId: 'source-turn', + actor: 'agent' as const, + executorUserId: 'machine-owner-a', + }; + expect( + resolveSessionCommandPrincipalUserId({ userId: 'machine-owner-a' }, undefined, principal) + ).toBe('collaborator-b'); + expect(() => + resolveSessionCommandPrincipalUserId({ userId: 'machine-owner-c' }, undefined, principal) + ).toThrow('executor must match'); + expect(() => + resolveSessionCommandPrincipalUserId({ userId: 'machine-owner-a' }, 'someone-else', principal) + ).toThrow('Requester identity must match the delegated Session principal.'); + }); + it('keeps Session ownership separate from the authenticated requester', () => { expect(resolveSessionCreateOwnerUserId('machine-owner', 'session-owner')).toBe('session-owner'); expect(resolveSessionCreateOwnerUserId('machine-owner', ' ')).toBe('machine-owner'); diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 198bf0d26..07ce656ef 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -70,6 +70,7 @@ import { type SessionTurnInputConfig, type SessionId, type SessionMeta, + type SessionOperationPrincipal, type TaskId, type WorkspaceId, shouldQueueMachineDeleteSession, @@ -100,6 +101,7 @@ import { LoroDocumentManager, type SessionDocument } from '@/lib/loro/doc'; import { renderTerminalTable } from '@/lib/terminal-table'; import { canRequestMachineForCliToken, + canUseMachineForCliToken, type WorkspaceBillingEntitlement, listWorkspaceGitHubRepositoriesForCliToken, listWorkspacesForToken, @@ -146,9 +148,11 @@ export type CreateOptions = CommonOptions & defaultMachineId?: MachineId; requesterUserId?: string; /** - * Trusted Session attribution supplied by an internal caller. Access checks - * must continue to use requesterUserId, which is bound to CLI auth. + * Trusted identity derived from an already-persisted invoking Turn. The + * executor still authenticates with CLI auth; authorization is evaluated + * for this principal. */ + principal?: SessionOperationPrincipal; sessionOwnerUserId?: string; parent?: string; useCurrentSessionAsParent?: boolean; @@ -1886,11 +1890,17 @@ export async function readSessionMachineAccess(args: { workspaceId: WorkspaceId; machineId: MachineId; requesterUserId?: string; + principal?: SessionOperationPrincipal; localProjectId?: string; }): Promise { - const requesterUserId = resolveSessionCommandRequesterUserId(args.auth, args.requesterUserId); + const requesterUserId = resolveSessionCommandPrincipalUserId( + args.auth, + args.requesterUserId, + args.principal + ); try { - return await canRequestMachineForCliToken({ + const readAccess = args.principal ? canUseMachineForCliToken : canRequestMachineForCliToken; + return await readAccess({ token: args.auth.token, workspaceId: args.workspaceId, machineId: args.machineId, @@ -1909,6 +1919,7 @@ async function assertMachineAccess(args: { workspaceId: WorkspaceId; machineId: MachineId; requesterUserId?: string; + principal?: SessionOperationPrincipal; localProjectId?: string; }): Promise { const access = await readSessionMachineAccess(args); @@ -2060,6 +2071,24 @@ export function resolveSessionCommandRequesterUserId( return auth.userId; } +export function resolveSessionCommandPrincipalUserId( + auth: Pick, + requesterUserId?: string, + principal?: SessionOperationPrincipal +): string { + if (!principal) { + return resolveSessionCommandRequesterUserId(auth, requesterUserId); + } + if (principal.executorUserId !== auth.userId) { + throw new Error('Delegated Session executor must match the authenticated CLI user.'); + } + const requested = normalizeCliValue(requesterUserId); + if (requested !== undefined && requested !== principal.userId) { + throw new Error('Requester identity must match the delegated Session principal.'); + } + return principal.userId; +} + export function resolveSessionCreateOwnerUserId( requesterUserId: string, sessionOwnerUserId?: string @@ -2097,6 +2126,7 @@ async function listAuthorizedMachineMetasForCreate(args: { workspaceId: WorkspaceId; machines: readonly MachineMeta[]; requesterUserId?: string; + principal?: SessionOperationPrincipal; }): Promise { const rows = await Promise.all( args.machines.map(async (machine) => ({ @@ -2106,6 +2136,7 @@ async function listAuthorizedMachineMetasForCreate(args: { workspaceId: args.workspaceId, machineId: machine.id, requesterUserId: args.requesterUserId, + principal: args.principal, }), })) ); @@ -2123,6 +2154,7 @@ async function filterAuthorizedLocalProjectsForCreate< machineId: MachineId; localProjects: readonly T[]; requesterUserId?: string; + principal?: SessionOperationPrincipal; }): Promise { const rows = await Promise.all( args.localProjects.map(async (project) => ({ @@ -2132,6 +2164,7 @@ async function filterAuthorizedLocalProjectsForCreate< workspaceId: args.workspaceId, machineId: args.machineId, requesterUserId: args.requesterUserId, + principal: args.principal, localProjectId: project.id, }), })) @@ -2149,6 +2182,7 @@ async function resolveTargetMachineForCreate(args: { machineSelector?: string; defaultMachineId?: MachineId; requesterUserId?: string; + principal?: SessionOperationPrincipal; parentSessionId?: SessionId; }): Promise { const machines = await listMachineMetasForWorkspace(args.manager); @@ -2160,6 +2194,7 @@ async function resolveTargetMachineForCreate(args: { workspaceId: args.workspaceId, machines, requesterUserId: args.requesterUserId, + principal: args.principal, }); if (authorizedMachines.length === 0) { throw new Error('No authorized machines are available in this workspace.'); @@ -2250,11 +2285,16 @@ async function assertGitHubRepoAccess(args: { workspaceId: WorkspaceId; repoFullName: string; requesterUserId?: string; + principal?: SessionOperationPrincipal; }): Promise { const repos = await listWorkspaceGitHubRepositoriesForCliToken({ token: args.auth.token, workspaceId: args.workspaceId, - requesterUserId: resolveSessionCommandRequesterUserId(args.auth, args.requesterUserId), + requesterUserId: resolveSessionCommandPrincipalUserId( + args.auth, + args.requesterUserId, + args.principal + ), enabledOnly: true, }); const normalized = args.repoFullName.toLowerCase(); @@ -2270,6 +2310,7 @@ export async function readLocalProjectGitStateOnMachine(args: { localProjectId: string; localRootPath: string; requesterUserId?: string; + principal?: SessionOperationPrincipal; }): Promise< | { success: true; state: Awaited> } | { success: false; error: string; message?: string } @@ -2290,7 +2331,11 @@ export async function readLocalProjectGitStateOnMachine(args: { async (client) => await client.requestLocalProjectGitState({ localProjectId: args.localProjectId as LocalProjectId, - requestedByUserId: resolveSessionCommandRequesterUserId(args.auth, args.requesterUserId), + requestedByUserId: resolveSessionCommandPrincipalUserId( + args.auth, + args.requesterUserId, + args.principal + ), timeoutMs: 30_000, }) ); @@ -2396,12 +2441,17 @@ async function listWorkspaceGitHubRepositoriesBestEffort(args: { auth: AuthContext; workspaceId: WorkspaceId; requesterUserId?: string; + principal?: SessionOperationPrincipal; }): Promise<{ fullName: string }[]> { try { return await listWorkspaceGitHubRepositoriesForCliToken({ token: args.auth.token, workspaceId: args.workspaceId, - requesterUserId: resolveSessionCommandRequesterUserId(args.auth, args.requesterUserId), + requesterUserId: resolveSessionCommandPrincipalUserId( + args.auth, + args.requesterUserId, + args.principal + ), enabledOnly: true, }); } catch (error) { @@ -2421,6 +2471,7 @@ async function resolveLocalProjectCreateGitContextOnMachine(args: { localProjectId: string; localRootPath: string; requesterUserId?: string; + principal?: SessionOperationPrincipal; requestedBranch?: string; useWorktree?: boolean; }): Promise { @@ -2454,6 +2505,7 @@ async function resolveLocalProjectRefOnMachineOrThrow( machineId: MachineId, selector: string, requesterUserId: string | undefined, + principal: SessionOperationPrincipal | undefined, requestedBranch?: string, useWorktree?: boolean ): Promise { @@ -2470,6 +2522,7 @@ async function resolveLocalProjectRefOnMachineOrThrow( machineId, localProjects, requesterUserId, + principal, }); if (authorizedLocalProjects.length === 0) { throw new Error('No authorized local projects are available on the target machine.'); @@ -2499,6 +2552,7 @@ async function resolveLocalProjectRefOnMachineOrThrow( localProjectId: project.id, localRootPath: project.rootPath, requesterUserId, + principal, requestedBranch, useWorktree, }); @@ -2574,9 +2628,10 @@ async function resolveCreateContext(args: { }): Promise { const workspaceId = args.workspace.id as WorkspaceId; const agentSelector = resolveCreateAgentSelector(args.options); - const requesterUserId = resolveSessionCommandRequesterUserId( + const requesterUserId = resolveSessionCommandPrincipalUserId( args.auth, - args.options.requesterUserId + args.options.requesterUserId, + args.options.principal ); const parentSelector = normalizeCliValue(args.options.parent); const currentSessionId = resolveCreateCurrentSessionId(args.options); @@ -2623,6 +2678,7 @@ async function resolveCreateContext(args: { machineSelector: args.options.machine, defaultMachineId: args.options.defaultMachineId, requesterUserId, + principal: args.options.principal, parentSessionId, }); await assertMachineAccess({ @@ -2630,6 +2686,7 @@ async function resolveCreateContext(args: { workspaceId, machineId: targetMachine.id, requesterUserId, + principal: args.options.principal, }); if (args.skipMachineAvailabilityCheck !== true) { await ensureTargetMachineOnline({ @@ -2665,6 +2722,7 @@ async function resolveCreateContext(args: { workspaceId, repoFullName: parentRepoFullName, requesterUserId, + principal: args.options.principal, }); } } else if (normalizedRepo) { @@ -2673,6 +2731,7 @@ async function resolveCreateContext(args: { workspaceId, repoFullName: normalizedRepo, requesterUserId, + principal: args.options.principal, }); const branch = resolveBaseBranchPreference({ preferredBranch: requestedBranch, @@ -2687,6 +2746,7 @@ async function resolveCreateContext(args: { targetMachine.id, normalizedLocalProject, requesterUserId, + args.options.principal, requestedBranch, args.options.worktree === true ); @@ -2697,6 +2757,7 @@ async function resolveCreateContext(args: { workspaceId, machineId: targetMachine.id, requesterUserId, + principal: args.options.principal, localProjectId: project?.kind === 'local' ? project.localProjectId : undefined, }); @@ -2915,7 +2976,11 @@ export async function createSessionResult( sessionId: options.sessionId, }); } - const requesterUserId = resolveSessionCommandRequesterUserId(auth, options.requesterUserId); + const requesterUserId = resolveSessionCommandPrincipalUserId( + auth, + options.requesterUserId, + options.principal + ); const sessionOwnerUserId = resolveSessionCreateOwnerUserId( requesterUserId, options.sessionOwnerUserId @@ -3087,11 +3152,13 @@ export async function validateSessionChatTarget(args: { manager: LoroDocumentManager; sessionId: SessionId; requesterUserIdOverride?: string; + principal?: SessionOperationPrincipal; }): Promise { await syncWorkspaceMetaForRead(args.manager, `session.chat:${args.sessionId}:prewrite:meta`); - const requesterUserId = resolveSessionCommandRequesterUserId( + const requesterUserId = resolveSessionCommandPrincipalUserId( args.auth, - args.requesterUserIdOverride + args.requesterUserIdOverride, + args.principal ); const session = await resolveSessionMetaOrThrow(args.manager, args.sessionId); if (session.isArchived) { @@ -3102,6 +3169,7 @@ export async function validateSessionChatTarget(args: { workspaceId: args.workspace.id as WorkspaceId, machineId: session.machineId, requesterUserId, + principal: args.principal, localProjectId: session.project?.kind === 'local' ? session.project.localProjectId : undefined, }); await ensureTargetMachineOnline({ @@ -3129,7 +3197,8 @@ export async function sendSessionChatResult( userTurnId: string; chainDepth: number; bypassSessionQuota?: boolean; - } + }, + principal?: SessionOperationPrincipal ): Promise<{ sessionId: SessionId; machineId: MachineId; @@ -3137,13 +3206,18 @@ export async function sendSessionChatResult( userTurnId: string; completionPromise?: Promise>>; }> { - const requesterUserId = resolveSessionCommandRequesterUserId(auth, requesterUserIdOverride); + const requesterUserId = resolveSessionCommandPrincipalUserId( + auth, + requesterUserIdOverride, + principal + ); const session = await validateSessionChatTarget({ auth, workspace, manager, sessionId, requesterUserIdOverride, + principal, }); if (dispatchConfig.modeId || dispatchConfig.modelId || dispatchConfig.configOptionValues) { const capability = await readAgentAcpCapability({ diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index ed64fce45..75937af45 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -2836,6 +2836,7 @@ export class MessageHandler { throw new Error(`Requester Session not found: ${operation.requesterSessionId}`); } const requester = requesterRecord.meta as SessionMeta; + const principal = operation.frozenContinuationConfig.principal; if (operation.kind === 'session_create' || operation.kind === 'session_create_many') { const runConfig: AgentRunConfigSelection = { @@ -2860,8 +2861,12 @@ export class MessageHandler { workspace: this.workspaceId, currentSessionId: operation.requesterSessionId, workspaceMetaPrewriteSatisfied: true, - requesterUserId: operation.requesterUserId, - sessionOwnerUserId: requester.userId, + ...(principal + ? { principal, sessionOwnerUserId: principal.userId } + : { + requesterUserId: operation.requesterUserId, + sessionOwnerUserId: requester.userId, + }), defaultMachineId: requester.machineId, sessionId: item.target.sessionId, userTurnId: item.target.userTurnId, @@ -2910,12 +2915,13 @@ export class MessageHandler { taskToolsEnabled: operation.frozenContinuationConfig.inputConfig.taskToolsEnabled === true, }, undefined, - operation.requesterUserId, + principal ? undefined : operation.requesterUserId, { userTurnId: item.target.userTurnId, chainDepth: operation.initiatorChainDepth + 1, bypassSessionQuota: shouldBypassSessionQuota(operation.kind), - } + }, + principal ); } diff --git a/apps/cli/src/mcp/AGENTS.md b/apps/cli/src/mcp/AGENTS.md index 6f609459e..18ca33f32 100644 --- a/apps/cli/src/mcp/AGENTS.md +++ b/apps/cli/src/mcp/AGENTS.md @@ -28,6 +28,10 @@ Root and `apps/cli/AGENTS.md` instructions apply. the workspace catalog; no driving-Turn mention authorization is required. Resolve its target, Prompt prefix, revision, and concrete run config before Operation acceptance. Recovery uses the frozen canonical Prompt and target dispatch config and never rereads the mutable catalog. +- Session orchestration derives its human principal from the exact persisted user/system Turn + driving the current Agent execution, not from the daemon credential or Session owner. Freeze + source Session/Turn ids, principal user, actor, and executor with every accepted Operation; + legacy synchronous paths use the same derivation and reject a source Turn without userId. - Direct Role creation stays on the ordinary `lody_session_create` and `lody_session_create_many` tools. When `agentRoleId` is present, tolerate manual Machine, Agent, and run-config fields but remove them before resolution: the current Role row is authoritative diff --git a/apps/cli/src/mcp/lody-mcp-server.test.ts b/apps/cli/src/mcp/lody-mcp-server.test.ts index 38c3a41f4..4491e446b 100644 --- a/apps/cli/src/mcp/lody-mcp-server.test.ts +++ b/apps/cli/src/mcp/lody-mcp-server.test.ts @@ -16,6 +16,7 @@ import { type SessionHistoryInput, type SessionId, type SessionTurnInputConfig, + type StoredLodyOperation, type WorkspaceId, } from '@lody/shared'; import { @@ -80,6 +81,8 @@ const { resolveOperationStorePathForContext, resolveUploadPath, resolveInvokingHistoryInput, + buildInvokingTurnPrincipal, + assertOperationRetryPrincipal, summarizeProjectRefForMcp, resolveSessionExecutionSnapshot, makeMachineOnlineLookupForMcp, @@ -297,6 +300,52 @@ describe('session MCP input schemas', () => { ); }); + it('derives delegated identity from the exact driving Turn', () => { + expect( + buildInvokingTurnPrincipal( + { id: 'source-session' as SessionId }, + { id: 'source-turn', userId: 'collaborator-b' }, + 'machine-owner-a' + ) + ).toEqual({ + userId: 'collaborator-b', + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + actor: 'agent', + executorUserId: 'machine-owner-a', + }); + expect(() => + buildInvokingTurnPrincipal( + { id: 'source-session' as SessionId }, + { id: 'legacy-turn' }, + 'machine-owner-a' + ) + ).toThrow('has no authenticated human identity'); + }); + + it('binds Operation retries to the original invoking Turn', () => { + const principal = { + userId: 'collaborator-b', + sourceSessionId: 'source-session' as SessionId, + sourceTurnId: 'source-turn-b', + actor: 'agent' as const, + executorUserId: 'machine-owner-a', + }; + const operation = { + operationId: 'review-1', + frozenContinuationConfig: { inputConfig: {}, principal }, + } as StoredLodyOperation; + + expect(() => assertOperationRetryPrincipal(operation, principal)).not.toThrow(); + expect(() => + assertOperationRetryPrincipal(operation, { + ...principal, + userId: 'collaborator-c', + sourceTurnId: 'source-turn-c', + }) + ).toThrow('already bound to a different invoking Turn'); + }); + it('uses stable ids and rejects legacy selector names', () => { expect(SessionCreateOptionsToolInputSchema.safeParse({ machineId: 'machine-id' }).success).toBe( true @@ -477,8 +526,14 @@ describe('session MCP input schemas', () => { ); bindMcpCreateContext( options, - { userId: 'machine-owner' }, - { machineId: 'machine-id', userId: 'session-owner' } + { + userId: 'collaborator-b', + sourceSessionId: 'current-session-id' as SessionId, + sourceTurnId: 'source-turn', + actor: 'agent', + executorUserId: 'machine-owner-a', + }, + { machineId: 'machine-id' } ); expect(options).toMatchObject({ @@ -487,8 +542,14 @@ describe('session MCP input schemas', () => { machine: 'machine-id', agentConfig: 'agent-config-id', useCurrentSessionAsParent: true, - requesterUserId: 'machine-owner', - sessionOwnerUserId: 'session-owner', + principal: { + userId: 'collaborator-b', + sourceSessionId: 'current-session-id', + sourceTurnId: 'source-turn', + actor: 'agent', + executorUserId: 'machine-owner-a', + }, + sessionOwnerUserId: 'collaborator-b', defaultMachineId: 'machine-id', }); }); diff --git a/apps/cli/src/mcp/lody-mcp-server.ts b/apps/cli/src/mcp/lody-mcp-server.ts index 273a3d7c1..1aacadab0 100644 --- a/apps/cli/src/mcp/lody-mcp-server.ts +++ b/apps/cli/src/mcp/lody-mcp-server.ts @@ -51,6 +51,7 @@ import { type SessionHistoryInput, type SessionId, type SessionMeta, + type SessionOperationPrincipal, type TaskId, type TaskIndexRow, type TaskPrProvider, @@ -71,6 +72,7 @@ import { resolveActiveAssistantTurnId, resolveProjectGitHubRepo, type LodyOperationItemResult, + type StoredLodyOperation, type SessionTurnInputConfig, REVIEW_SEVERITY_VALUES, REVIEW_VERDICT_VALUES, @@ -2088,6 +2090,7 @@ const canUseMachineForOptions = async (args: { workspaceId: WorkspaceId; machineId: MachineId; requesterUserId: string; + principal: SessionOperationPrincipal; localProjectId?: string; }): Promise => { const access = await readSessionMachineAccess({ @@ -2095,6 +2098,7 @@ const canUseMachineForOptions = async (args: { workspaceId: args.workspaceId, machineId: args.machineId, requesterUserId: args.requesterUserId, + principal: args.principal, ...(args.localProjectId ? { localProjectId: args.localProjectId } : {}), }); return access.allowed; @@ -2104,7 +2108,8 @@ const filterAuthorizedMachinesForOptions = async ( auth: AuthContext, workspaceId: WorkspaceId, machines: readonly MachineMeta[], - requesterUserId: string + requesterUserId: string, + principal: SessionOperationPrincipal ): Promise => { const rows = await Promise.all( machines.map(async (machine) => ({ @@ -2114,6 +2119,7 @@ const filterAuthorizedMachinesForOptions = async ( workspaceId, machineId: machine.id, requesterUserId, + principal, }), })) ); @@ -2125,7 +2131,8 @@ const filterAuthorizedLocalProjectsForOptions = async ( workspaceId: WorkspaceId, machineId: MachineId, localProjects: readonly LocalProjectMeta[], - requesterUserId: string + requesterUserId: string, + principal: SessionOperationPrincipal ): Promise => { const rows = await Promise.all( localProjects.map(async (project) => ({ @@ -2135,6 +2142,7 @@ const filterAuthorizedLocalProjectsForOptions = async ( workspaceId, machineId, requesterUserId, + principal, localProjectId: project.id, }), })) @@ -2170,17 +2178,61 @@ const readCurrentSessionMeta = async ( const bindMcpCreateContext = ( options: CreateOptions, - auth: Pick, - requester: Pick + principal: SessionOperationPrincipal, + requester: Pick ): void => { - options.requesterUserId = auth.userId; - options.sessionOwnerUserId = requester.userId; + options.principal = principal; + options.sessionOwnerUserId = principal.userId; options.defaultMachineId = requester.machineId; }; type InvokingTurnContext = { chainDepth: number; frozenInputConfig: SessionTurnInputConfig; + principal: SessionOperationPrincipal; +}; + +const buildInvokingTurnPrincipal = ( + session: Pick, + source: Pick, + executorUserId: string +): SessionOperationPrincipal => { + const userId = source.userId?.trim(); + if (!userId) { + throw new LodyOperationStoreError( + 'INVOKING_PRINCIPAL_UNAVAILABLE', + `The driving Turn ${source.id} has no authenticated human identity.`, + false + ); + } + return { + userId, + sourceSessionId: session.id, + sourceTurnId: source.id, + actor: 'agent', + executorUserId, + }; +}; + +const assertOperationRetryPrincipal = ( + operation: StoredLodyOperation, + principal: SessionOperationPrincipal +): void => { + const stored = operation.frozenContinuationConfig.principal; + if (!stored) return; + if ( + stored.userId !== principal.userId || + stored.sourceSessionId !== principal.sourceSessionId || + stored.sourceTurnId !== principal.sourceTurnId || + stored.actor !== principal.actor || + stored.executorUserId !== principal.executorUserId + ) { + throw new LodyOperationStoreError( + 'OPERATION_ID_REUSED', + `Operation id ${operation.operationId} is already bound to a different invoking Turn.`, + false + ); + } }; const resolveInvokingHistoryInput = ( @@ -2229,11 +2281,19 @@ const assertInvokingTurnTaskToolsEnabled = async ( const resolveInvokingTurnContext = async ( manager: LoroDocumentManager, - session: SessionMeta + session: SessionMeta, + executorUserId: string ): Promise => { const sessionDoc = await manager.getOrCreateSessionDoc(session.id); const history = await sessionDoc.getHistory(); const source = resolveInvokingHistoryInput(history); + if (!source) { + throw new LodyOperationStoreError( + 'INVOKING_TURN_NOT_FOUND', + 'The exact Turn driving this MCP invocation is unavailable.', + false + ); + } const chainDepth = source?.inputConfig?.chainDepth ?? 0; if (chainDepth >= LODY_MAX_CHAIN_DEPTH) { throw new LodyOperationStoreError( @@ -2244,6 +2304,7 @@ const resolveInvokingTurnContext = async ( } return { chainDepth, + principal: buildInvokingTurnPrincipal(session, source, executorUserId), frozenInputConfig: { ...(source?.inputConfig ?? {}), cliType: source?.inputConfig?.cliType ?? session.cliType, @@ -2432,6 +2493,7 @@ const summarizeLocalProjectForOptions = async ( machine: MachineMeta, project: LocalProjectMeta, requesterUserId: string, + principal: SessionOperationPrincipal, machineOnline: boolean ) => { const gitState = @@ -2443,6 +2505,7 @@ const summarizeLocalProjectForOptions = async ( localProjectId: project.id, localRootPath: project.rootPath, requesterUserId, + principal, }).catch((error: unknown) => ({ success: false as const, error: String(error), @@ -2474,7 +2537,8 @@ const buildSessionCreateOptions = async ( if (!currentSession) { throw new Error(`Session not found: ${ctx.sessionId}`); } - const requesterUserId = auth.userId; + const invoking = await resolveInvokingTurnContext(manager, currentSession, auth.userId); + const requesterUserId = invoking.principal.userId; const machineEntries = await listAliveDocMetas(manager, isMachineDocRoomId); const onlineMachineIds = await manager.getOnlineMachineIds(); const isMachineOnline = (machineId: MachineId): boolean => @@ -2487,7 +2551,8 @@ const buildSessionCreateOptions = async ( auth, workspaceId, machineCandidates, - requesterUserId + requesterUserId, + invoking.principal ); const selectedMachine = selectMachineForOptions( machines, @@ -2540,7 +2605,8 @@ const buildSessionCreateOptions = async ( workspaceId, selectedMachine.id, localProjectCandidates, - requesterUserId + requesterUserId, + invoking.principal ) ).slice(0, MAX_MCP_CREATE_OPTION_MATCHES); const summarizedLocalProjects = await Promise.all( @@ -2551,6 +2617,7 @@ const buildSessionCreateOptions = async ( selectedMachine, project, requesterUserId, + invoking.principal, isMachineOnline(selectedMachine.id) ) ) @@ -2608,7 +2675,7 @@ const startSessionCreateOperation = async (args: SessionCreateCommandInput): Pro false ); } - const invoking = await resolveInvokingTurnContext(manager, currentSession); + const invoking = await resolveInvokingTurnContext(manager, currentSession, auth.userId); const roleCatalog = args.agentRoleId ? await loadWorkspaceAgentRoleCatalog(manager, workspace.id as WorkspaceId) : undefined; @@ -2627,11 +2694,14 @@ const startSessionCreateOperation = async (args: SessionCreateCommandInput): Pro canonicalCommand ) ); - if (retry) return await withOperationStore((store) => store.snapshot(retry)); + if (retry) { + assertOperationRetryPrincipal(retry, invoking.principal); + return await withOperationStore((store) => store.snapshot(retry)); + } const targetMachineId = (resolved.input.machineId ?? currentSession.machineId) as MachineId; await assertMachineOnlineForSingleCommand(manager, targetMachineId, ctx); const createOptions = buildMcpCreateOptions(resolved.input, ctx); - bindMcpCreateContext(createOptions, auth, currentSession); + bindMcpCreateContext(createOptions, invoking.principal, currentSession); bindAgentRoleCreateOptions(createOptions, resolved.role); createOptions.workspaceMetaPrewriteSatisfied = true; let effectiveDispatchConfig: ResolvedTurnDispatchConfig; @@ -2660,7 +2730,7 @@ const startSessionCreateOperation = async (args: SessionCreateCommandInput): Pro workspaceId: workspace.id as WorkspaceId, ownerMachineId: ctx.machineId as MachineId, requesterSessionId: ctx.sessionId as SessionId, - requesterUserId: auth.userId, + requesterUserId: invoking.principal.userId, operationId: args.operationId!, kind: 'session_create', canonicalCommand, @@ -2669,6 +2739,7 @@ const startSessionCreateOperation = async (args: SessionCreateCommandInput): Pro ? { agentConfigId: currentSession.agentConfigId } : {}), inputConfig: invoking.frozenInputConfig, + principal: invoking.principal, targetDispatchConfigs: [effectiveDispatchConfig], }, initiatorChainDepth: invoking.chainDepth, @@ -2752,6 +2823,7 @@ const startSessionChatOperation = async (args: SessionChatToolInput): Promise store.snapshot(retry)); + if (retry) { + assertOperationRetryPrincipal(retry, invoking.principal); + return await withOperationStore((store) => store.snapshot(retry)); + } const targetSession = await readCurrentSessionMeta(manager, args.sessionId as SessionId); if (!targetSession) { throw new LodyOperationStoreError( @@ -2782,7 +2857,7 @@ const startSessionChatOperation = async (args: SessionChatToolInput): Promise ({ ...(args.defaults ?? {}), ...item })); - const invoking = await resolveInvokingTurnContext(manager, requester); + const invoking = await resolveInvokingTurnContext(manager, requester, auth.userId); const roleCatalog = expanded.some((item) => Boolean(item.agentRoleId)) ? await loadWorkspaceAgentRoleCatalog(manager, workspace.id as WorkspaceId) : undefined; @@ -3066,7 +3142,10 @@ const startSessionCreateManyOperation = async ( canonicalCommand ) ); - if (retry) return await withOperationStore((store) => store.snapshot(retry)); + if (retry) { + assertOperationRetryPrincipal(retry, invoking.principal); + return await withOperationStore((store) => store.snapshot(retry)); + } const isMachineOnline = makeMachineOnlineLookupForMcp(manager, ctx); const validatedItems = await mapWithConcurrency( expanded, @@ -3137,7 +3216,7 @@ const startSessionCreateManyOperation = async ( }; } const options = buildMcpCreateOptions(resolved.input, ctx); - bindMcpCreateContext(options, auth, requester); + bindMcpCreateContext(options, invoking.principal, requester); bindAgentRoleCreateOptions(options, resolved.role); try { const effectiveDispatchConfig = await validateSessionCreateOptions({ @@ -3176,13 +3255,14 @@ const startSessionCreateManyOperation = async ( workspaceId: workspace.id as WorkspaceId, ownerMachineId: ctx.machineId as MachineId, requesterSessionId: ctx.sessionId as SessionId, - requesterUserId: auth.userId, + requesterUserId: invoking.principal.userId, operationId: args.operationId, kind: 'session_create_many', canonicalCommand, frozenContinuationConfig: { ...(requester.agentConfigId ? { agentConfigId: requester.agentConfigId } : {}), inputConfig: invoking.frozenInputConfig, + principal: invoking.principal, targetDispatchConfigs, }, initiatorChainDepth: invoking.chainDepth, @@ -3219,7 +3299,7 @@ const startSessionCreateManyOperation = async ( } try { const options = buildMcpCreateOptions(resolved.input, ctx); - bindMcpCreateContext(options, auth, requester); + bindMcpCreateContext(options, invoking.principal, requester); bindAgentRoleCreateOptions(options, resolved.role); options.sessionId = storedItem.target.sessionId; options.userTurnId = storedItem.target.userTurnId; @@ -3288,6 +3368,7 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr ); } const expanded = args.items.map((item) => ({ ...(args.defaults ?? {}), ...item })); + const invoking = await resolveInvokingTurnContext(manager, requester, auth.userId); const canonicalCommand = { items: expanded, ...(args.deadlineSeconds !== undefined ? { deadlineSeconds: args.deadlineSeconds } : {}), @@ -3300,8 +3381,10 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr canonicalCommand ) ); - if (retry) return await withOperationStore((store) => store.snapshot(retry)); - const invoking = await resolveInvokingTurnContext(manager, requester); + if (retry) { + assertOperationRetryPrincipal(retry, invoking.principal); + return await withOperationStore((store) => store.snapshot(retry)); + } const isMachineOnline = makeMachineOnlineLookupForMcp(manager, ctx); const initialItems = await mapWithConcurrency( expanded, @@ -3346,7 +3429,7 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr workspace, manager, sessionId: target.id, - requesterUserIdOverride: auth.userId, + principal: invoking.principal, }); } catch (error) { if (error instanceof WorkspaceSyncUnavailableError) { @@ -3365,13 +3448,14 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr workspaceId: workspace.id as WorkspaceId, ownerMachineId: ctx.machineId as MachineId, requesterSessionId: ctx.sessionId as SessionId, - requesterUserId: auth.userId, + requesterUserId: invoking.principal.userId, operationId: args.operationId, kind: 'session_chat_many', canonicalCommand, frozenContinuationConfig: { ...(requester.agentConfigId ? { agentConfigId: requester.agentConfigId } : {}), inputConfig: invoking.frozenInputConfig, + principal: invoking.principal, }, initiatorChainDepth: invoking.chainDepth, ...timing, @@ -3416,12 +3500,13 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr taskToolsEnabled: invoking.frozenInputConfig.taskToolsEnabled === true, }, undefined, - auth.userId, + undefined, { userTurnId: storedItem.target.userTurnId, chainDepth: invoking.chainDepth + 1, bypassSessionQuota: shouldBypassSessionQuota('session_chat_many'), - } + }, + invoking.principal ); await withOperationStore((store) => store.markItemInputDurable( @@ -3916,6 +4001,8 @@ export const __lodyMcpServerInternals = { applySessionRenameItems, persistSessionRenameItems, resolveInvokingHistoryInput, + buildInvokingTurnPrincipal, + assertOperationRetryPrincipal, buildOperationTargetCancelArgs, summarizeProjectRefForMcp, resolveSessionExecutionSnapshot, @@ -4240,11 +4327,7 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): if (!currentSession) { throw new Error(`Session not found: ${ctx.sessionId}`); } - // Only a Role needs the driving Turn here, for the task-tool flag. - // This legacy path does not persist a continuation config. - const invoking = args.agentRoleId - ? await resolveInvokingTurnContext(manager, currentSession) - : undefined; + const invoking = await resolveInvokingTurnContext(manager, currentSession, auth.userId); const roleCatalog = args.agentRoleId ? await loadWorkspaceAgentRoleCatalog(manager, workspace.id as WorkspaceId) : undefined; @@ -4255,7 +4338,7 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): args.agentRoleId ? roleCatalog?.get(args.agentRoleId) : undefined ); const options = buildMcpCreateOptions(resolved.input, ctx); - bindMcpCreateContext(options, auth, currentSession); + bindMcpCreateContext(options, invoking.principal, currentSession); bindAgentRoleCreateOptions(options, resolved.role); options.workspaceMetaPrewriteSatisfied = true; const result = await createSessionResult( @@ -4337,6 +4420,7 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): throw new Error(`Session not found: ${sessionId}`); } assertDifferentMcpSession(currentSession, targetSession); + const invoking = await resolveInvokingTurnContext(manager, currentSession, auth.userId); const result = await sendSessionChatResult( auth, workspace, @@ -4345,7 +4429,9 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): args.prompt, resolveTurnDispatchConfig({}), buildStructuredOutputOptions(args), - auth.userId + undefined, + undefined, + invoking.principal ); const response = { ok: true, diff --git a/apps/cli/src/orchestration/AGENTS.md b/apps/cli/src/orchestration/AGENTS.md index 786eda812..9f4545726 100644 --- a/apps/cli/src/orchestration/AGENTS.md +++ b/apps/cli/src/orchestration/AGENTS.md @@ -25,6 +25,11 @@ Root and `apps/cli/AGENTS.md` apply. Normative behavior lives in - Create Operations freeze each target's effective dispatch config at acceptance; recovery must not re-read mutable requester history defaults. Full content stays in the target Session history. +- Accepted Operations freeze the exact invoking principal and source Session/Turn. Recovery + routes attribution and member-scoped authorization through that frozen principal while the + owner Machine credential remains the executor credential. Completion system Turns retain the + same userId so a continuation cannot silently switch principals. Idempotent retries must match + the frozen principal; a later Turn reusing the id is `OPERATION_ID_REUSED`, not a retry. - `operation-coordinator.ts` is owned only by the local Host-lease Worker. MCP subprocesses may accept Operations but never schedule completion Turns. - Reconciliation is level-checked. Loro subscriptions and SQLite directory diff --git a/apps/cli/src/orchestration/operation-coordinator.test.ts b/apps/cli/src/orchestration/operation-coordinator.test.ts index 5811a626d..6e733657c 100644 --- a/apps/cli/src/orchestration/operation-coordinator.test.ts +++ b/apps/cli/src/orchestration/operation-coordinator.test.ts @@ -903,6 +903,7 @@ describe('LodyOperationCoordinator', () => { expect(harness.histories.get(harness.requesterSessionId)).toEqual([ expect.objectContaining({ role: 'system', + userId: 'user-1', items: [ expect.objectContaining({ type: 'operation_completion', diff --git a/apps/cli/src/orchestration/operation-coordinator.ts b/apps/cli/src/orchestration/operation-coordinator.ts index f069af2ee..200acc715 100644 --- a/apps/cli/src/orchestration/operation-coordinator.ts +++ b/apps/cli/src/orchestration/operation-coordinator.ts @@ -1006,6 +1006,7 @@ export class LodyOperationCoordinator { const turn: SessionHistoryInput = { id: delivery.systemTurnId, role: 'system', + userId: operation.requesterUserId, timestamp: new Date(this.now()).toISOString(), items: [item], fileDiff: [], diff --git a/apps/cli/src/orchestration/operation-store.test.ts b/apps/cli/src/orchestration/operation-store.test.ts index 2575012d2..91288ce7d 100644 --- a/apps/cli/src/orchestration/operation-store.test.ts +++ b/apps/cli/src/orchestration/operation-store.test.ts @@ -39,6 +39,13 @@ const baseInput = () => ({ frozenContinuationConfig: { agentConfigId: 'agent-1', inputConfig: { cliType: 'builtin' as const, agentType: 'codex', chainDepth: 0 }, + principal: { + userId: 'user-1', + sourceSessionId: 'requester-1' as SessionId, + sourceTurnId: 'source-turn-1', + actor: 'agent' as const, + executorUserId: 'machine-owner-1', + }, }, initiatorChainDepth: 0, createdAt: '2026-07-20T00:00:00.000Z', @@ -84,6 +91,13 @@ describe('LodyOperationStore', () => { expect(first.created).toBe(true); expect(retry.created).toBe(false); expect(retry.operation.operationId).toBe('review-round-1'); + expect(retry.operation.frozenContinuationConfig.principal).toEqual({ + userId: 'user-1', + sourceSessionId: 'requester-1', + sourceTurnId: 'source-turn-1', + actor: 'agent', + executorUserId: 'machine-owner-1', + }); expect(store.snapshot(retry.operation)).toMatchObject({ state: 'active' }); } finally { store.close(); diff --git a/apps/cli/src/orchestration/operation-store.ts b/apps/cli/src/orchestration/operation-store.ts index 08a973965..8cc53bf0d 100644 --- a/apps/cli/src/orchestration/operation-store.ts +++ b/apps/cli/src/orchestration/operation-store.ts @@ -112,6 +112,16 @@ const FrozenConfigSchema = z .object({ agentConfigId: z.string().optional(), inputConfig: z.record(z.string(), z.unknown()), + principal: z + .object({ + userId: z.string().trim().min(1), + sourceSessionId: z.string().trim().min(1), + sourceTurnId: z.string().trim().min(1), + actor: z.literal('agent'), + executorUserId: z.string().trim().min(1), + }) + .strict() + .optional(), targetDispatchConfigs: z .array( z diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index a2b7f1adc..cd399e115 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -9,13 +9,17 @@ message bus. The WS/DO path is DEPRECATED. Session CLI/MCP orchestration contract: specs/session-orchestration.md. Target-machine authorization is checked by the injected access capability with the source CLI token, -which derives the requester identity at the trusted boundary. Do not send a caller-supplied requester -through workspace Machine RPC: that transport does not authenticate member identity. +which derives the requester identity for ordinary CLI calls and verifies the frozen Turn +principal for MCP delegation. Do not send an untrusted caller-supplied requester through +workspace Machine RPC: that transport does not authenticate member identity. Live status is a target-daemon Machine RPC read, and durable session metadata is not a live-presence substitute. -Session orchestration MCP intentionally runs with the daemon owner's CLI credential, -including for teammate-started Sessions on a shared machine. Do not add requester -delegation proofs or a shared-machine gate without a new product and security decision. +Session orchestration MCP authenticates execution with the daemon owner's CLI credential, +but derives the human principal from the exact persisted Turn driving the Agent. Freeze that +principal and its source Session/Turn into durable Operations; retries and recovery must not +reread mutable history. Machine and Provider credentials remain execution-host scoped, while +Session/Turn attribution, member authorization, GitHub access, and downstream Git identity use +the frozen principal. Never fall back to the Session owner when the driving Turn has no userId. - `session-dispatch-watcher.ts` — the current dispatch entry: watches `repo.watch('doc-metadata')` + per-session mirror subscribe; dispatches when diff --git a/packages/shared/src/session-orchestration.ts b/packages/shared/src/session-orchestration.ts index 17778e902..a911fb844 100644 --- a/packages/shared/src/session-orchestration.ts +++ b/packages/shared/src/session-orchestration.ts @@ -117,9 +117,20 @@ export type LodyOperationSnapshot = completion: LodyOperationCompletion; }; +export type SessionOperationPrincipal = { + userId: string; + sourceSessionId: SessionId; + sourceTurnId: string; + actor: 'agent'; + /** Authenticated machine user whose daemon executes the delegated action. */ + executorUserId: string; +}; + export type FrozenOperationContinuationConfig = { agentConfigId?: string; inputConfig: SessionTurnInputConfig; + /** Exact driving Turn identity; retries and recovery must not re-resolve it. */ + principal?: SessionOperationPrincipal; /** * Effective per-target create config captured at acceptance. Null entries * correspond to batch items rejected before a target was accepted. From 1a6cda4c7a131c1679de6b04c28c144e2786774c Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:34:04 +0800 Subject: [PATCH 2/6] test(mcp): add invoking turn to sync fixtures Model: gpt-5 --- apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts b/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts index 22fb55d39..0d825e51e 100644 --- a/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts +++ b/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts @@ -77,6 +77,15 @@ const targetSession = { agentType: 'codex', }; +const invokingTurn = { + id: 'requester-turn-id', + role: 'user' as const, + userId: 'requester-user-id', + timestamp: '2026-09-04T00:00:00.000Z', + items: [], + fileDiff: [], +}; + const expectRetryableSyncResult = (result: ReturnType): void => { const content = result.content[0]; if (!content || content.type !== 'text') throw new Error('expected text result'); @@ -106,7 +115,7 @@ describe('session chat prevalidation sync failures', () => { vi.stubEnv('LODY_MCP_WORKSPACE_ID', 'workspace-id'); vi.stubEnv('LODY_MCP_SESSION_ID', requesterSession.id); mocks.findMatchingRetry.mockReturnValue(undefined); - mocks.getHistory.mockResolvedValue([]); + mocks.getHistory.mockResolvedValue([invokingTurn]); mocks.getDocMeta .mockResolvedValueOnce({ meta: requesterSession }) .mockResolvedValueOnce({ meta: targetSession }); From 0c462984b07f030d232a146952cd37eaa5bfbe59 Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:04:58 +0800 Subject: [PATCH 3/6] refactor(mcp): normalize operation principal storage Model: gpt-5 --- apps/cli/src/commands/session.test.ts | 2 -- apps/cli/src/lib/message-handler.ts | 3 +- apps/cli/src/mcp/AGENTS.md | 5 ++- apps/cli/src/mcp/lody-mcp-server.test.ts | 33 +++++++------------ apps/cli/src/mcp/lody-mcp-server.ts | 24 +++++++------- apps/cli/src/orchestration/AGENTS.md | 4 ++- .../src/orchestration/operation-store.test.ts | 10 ++---- apps/cli/src/orchestration/operation-store.ts | 5 +-- apps/cli/src/session/AGENTS.md | 3 +- packages/shared/src/session-orchestration.ts | 8 ++--- 10 files changed, 42 insertions(+), 55 deletions(-) diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index fab1b05f9..b3590b721 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -351,9 +351,7 @@ describe('session command helpers', () => { it('accepts only a delegated principal bound to the authenticated executor', () => { const principal = { userId: 'collaborator-b', - sourceSessionId: 'source-session' as SessionId, sourceTurnId: 'source-turn', - actor: 'agent' as const, executorUserId: 'machine-owner-a', }; expect( diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 75937af45..92c4a7ee9 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -2836,7 +2836,8 @@ export class MessageHandler { throw new Error(`Requester Session not found: ${operation.requesterSessionId}`); } const requester = requesterRecord.meta as SessionMeta; - const principal = operation.frozenContinuationConfig.principal; + const delegation = operation.frozenContinuationConfig.delegation; + const principal = delegation ? { userId: operation.requesterUserId, ...delegation } : undefined; if (operation.kind === 'session_create' || operation.kind === 'session_create_many') { const runConfig: AgentRunConfigSelection = { diff --git a/apps/cli/src/mcp/AGENTS.md b/apps/cli/src/mcp/AGENTS.md index 18ca33f32..bfdbf0327 100644 --- a/apps/cli/src/mcp/AGENTS.md +++ b/apps/cli/src/mcp/AGENTS.md @@ -30,7 +30,10 @@ Root and `apps/cli/AGENTS.md` instructions apply. the frozen canonical Prompt and target dispatch config and never rereads the mutable catalog. - Session orchestration derives its human principal from the exact persisted user/system Turn driving the current Agent execution, not from the daemon credential or Session owner. Freeze - source Session/Turn ids, principal user, actor, and executor with every accepted Operation; + the source Turn id, principal user, and executor with every accepted Operation. Store the user + once as `requesterUserId`; the delegation binding stores only source Turn and executor. The + Operation's requester Session id already identifies the source Session, and a single-value + actor tag adds no information. legacy synchronous paths use the same derivation and reject a source Turn without userId. - Direct Role creation stays on the ordinary `lody_session_create` and `lody_session_create_many` tools. When `agentRoleId` is present, tolerate manual Machine, Agent, diff --git a/apps/cli/src/mcp/lody-mcp-server.test.ts b/apps/cli/src/mcp/lody-mcp-server.test.ts index 4491e446b..72728cd65 100644 --- a/apps/cli/src/mcp/lody-mcp-server.test.ts +++ b/apps/cli/src/mcp/lody-mcp-server.test.ts @@ -302,38 +302,33 @@ describe('session MCP input schemas', () => { it('derives delegated identity from the exact driving Turn', () => { expect( - buildInvokingTurnPrincipal( - { id: 'source-session' as SessionId }, - { id: 'source-turn', userId: 'collaborator-b' }, - 'machine-owner-a' - ) + buildInvokingTurnPrincipal({ id: 'source-turn', userId: 'collaborator-b' }, 'machine-owner-a') ).toEqual({ userId: 'collaborator-b', - sourceSessionId: 'source-session', sourceTurnId: 'source-turn', - actor: 'agent', executorUserId: 'machine-owner-a', }); - expect(() => - buildInvokingTurnPrincipal( - { id: 'source-session' as SessionId }, - { id: 'legacy-turn' }, - 'machine-owner-a' - ) - ).toThrow('has no authenticated human identity'); + expect(() => buildInvokingTurnPrincipal({ id: 'legacy-turn' }, 'machine-owner-a')).toThrow( + 'has no authenticated human identity' + ); }); it('binds Operation retries to the original invoking Turn', () => { const principal = { userId: 'collaborator-b', - sourceSessionId: 'source-session' as SessionId, sourceTurnId: 'source-turn-b', - actor: 'agent' as const, executorUserId: 'machine-owner-a', }; const operation = { operationId: 'review-1', - frozenContinuationConfig: { inputConfig: {}, principal }, + requesterUserId: principal.userId, + frozenContinuationConfig: { + inputConfig: {}, + delegation: { + sourceTurnId: principal.sourceTurnId, + executorUserId: principal.executorUserId, + }, + }, } as StoredLodyOperation; expect(() => assertOperationRetryPrincipal(operation, principal)).not.toThrow(); @@ -528,9 +523,7 @@ describe('session MCP input schemas', () => { options, { userId: 'collaborator-b', - sourceSessionId: 'current-session-id' as SessionId, sourceTurnId: 'source-turn', - actor: 'agent', executorUserId: 'machine-owner-a', }, { machineId: 'machine-id' } @@ -544,9 +537,7 @@ describe('session MCP input schemas', () => { useCurrentSessionAsParent: true, principal: { userId: 'collaborator-b', - sourceSessionId: 'current-session-id', sourceTurnId: 'source-turn', - actor: 'agent', executorUserId: 'machine-owner-a', }, sessionOwnerUserId: 'collaborator-b', diff --git a/apps/cli/src/mcp/lody-mcp-server.ts b/apps/cli/src/mcp/lody-mcp-server.ts index 1aacadab0..99444d6cf 100644 --- a/apps/cli/src/mcp/lody-mcp-server.ts +++ b/apps/cli/src/mcp/lody-mcp-server.ts @@ -2193,7 +2193,6 @@ type InvokingTurnContext = { }; const buildInvokingTurnPrincipal = ( - session: Pick, source: Pick, executorUserId: string ): SessionOperationPrincipal => { @@ -2207,24 +2206,25 @@ const buildInvokingTurnPrincipal = ( } return { userId, - sourceSessionId: session.id, sourceTurnId: source.id, - actor: 'agent', executorUserId, }; }; +const freezeOperationDelegation = ({ + sourceTurnId, + executorUserId, +}: SessionOperationPrincipal) => ({ sourceTurnId, executorUserId }); + const assertOperationRetryPrincipal = ( operation: StoredLodyOperation, principal: SessionOperationPrincipal ): void => { - const stored = operation.frozenContinuationConfig.principal; + const stored = operation.frozenContinuationConfig.delegation; if (!stored) return; if ( - stored.userId !== principal.userId || - stored.sourceSessionId !== principal.sourceSessionId || + operation.requesterUserId !== principal.userId || stored.sourceTurnId !== principal.sourceTurnId || - stored.actor !== principal.actor || stored.executorUserId !== principal.executorUserId ) { throw new LodyOperationStoreError( @@ -2304,7 +2304,7 @@ const resolveInvokingTurnContext = async ( } return { chainDepth, - principal: buildInvokingTurnPrincipal(session, source, executorUserId), + principal: buildInvokingTurnPrincipal(source, executorUserId), frozenInputConfig: { ...(source?.inputConfig ?? {}), cliType: source?.inputConfig?.cliType ?? session.cliType, @@ -2739,7 +2739,7 @@ const startSessionCreateOperation = async (args: SessionCreateCommandInput): Pro ? { agentConfigId: currentSession.agentConfigId } : {}), inputConfig: invoking.frozenInputConfig, - principal: invoking.principal, + delegation: freezeOperationDelegation(invoking.principal), targetDispatchConfigs: [effectiveDispatchConfig], }, initiatorChainDepth: invoking.chainDepth, @@ -2883,7 +2883,7 @@ const startSessionChatOperation = async (args: SessionChatToolInput): Promise ({ frozenContinuationConfig: { agentConfigId: 'agent-1', inputConfig: { cliType: 'builtin' as const, agentType: 'codex', chainDepth: 0 }, - principal: { - userId: 'user-1', - sourceSessionId: 'requester-1' as SessionId, + delegation: { sourceTurnId: 'source-turn-1', - actor: 'agent' as const, executorUserId: 'machine-owner-1', }, }, @@ -91,11 +88,8 @@ describe('LodyOperationStore', () => { expect(first.created).toBe(true); expect(retry.created).toBe(false); expect(retry.operation.operationId).toBe('review-round-1'); - expect(retry.operation.frozenContinuationConfig.principal).toEqual({ - userId: 'user-1', - sourceSessionId: 'requester-1', + expect(retry.operation.frozenContinuationConfig.delegation).toEqual({ sourceTurnId: 'source-turn-1', - actor: 'agent', executorUserId: 'machine-owner-1', }); expect(store.snapshot(retry.operation)).toMatchObject({ state: 'active' }); diff --git a/apps/cli/src/orchestration/operation-store.ts b/apps/cli/src/orchestration/operation-store.ts index 8cc53bf0d..a169ca721 100644 --- a/apps/cli/src/orchestration/operation-store.ts +++ b/apps/cli/src/orchestration/operation-store.ts @@ -112,12 +112,9 @@ const FrozenConfigSchema = z .object({ agentConfigId: z.string().optional(), inputConfig: z.record(z.string(), z.unknown()), - principal: z + delegation: z .object({ - userId: z.string().trim().min(1), - sourceSessionId: z.string().trim().min(1), sourceTurnId: z.string().trim().min(1), - actor: z.literal('agent'), executorUserId: z.string().trim().min(1), }) .strict() diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index cd399e115..27219dc56 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -16,7 +16,8 @@ Live status is a target-daemon Machine RPC read, and durable session metadata is live-presence substitute. Session orchestration MCP authenticates execution with the daemon owner's CLI credential, but derives the human principal from the exact persisted Turn driving the Agent. Freeze that -principal and its source Session/Turn into durable Operations; retries and recovery must not +principal and source Turn into durable Operations; the Operation already identifies the source +Session and stores the principal user once as `requesterUserId`. Retries and recovery must not reread mutable history. Machine and Provider credentials remain execution-host scoped, while Session/Turn attribution, member authorization, GitHub access, and downstream Git identity use the frozen principal. Never fall back to the Session owner when the driving Turn has no userId. diff --git a/packages/shared/src/session-orchestration.ts b/packages/shared/src/session-orchestration.ts index a911fb844..2d1ced8f7 100644 --- a/packages/shared/src/session-orchestration.ts +++ b/packages/shared/src/session-orchestration.ts @@ -119,18 +119,18 @@ export type LodyOperationSnapshot = export type SessionOperationPrincipal = { userId: string; - sourceSessionId: SessionId; sourceTurnId: string; - actor: 'agent'; /** Authenticated machine user whose daemon executes the delegated action. */ executorUserId: string; }; +export type SessionOperationDelegation = Omit; + export type FrozenOperationContinuationConfig = { agentConfigId?: string; inputConfig: SessionTurnInputConfig; - /** Exact driving Turn identity; retries and recovery must not re-resolve it. */ - principal?: SessionOperationPrincipal; + /** Frozen provenance for the top-level requester; recovery must not re-resolve it. */ + delegation?: SessionOperationDelegation; /** * Effective per-target create config captured at acceptance. Null entries * correspond to batch items rejected before a target was accepted. From 86eeb55c4590ffc5d194836d8021676cd835ddc4 Mon Sep 17 00:00:00 2001 From: Wibus Wu <62133302+wibus-wee@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:44:10 +0800 Subject: [PATCH 4/6] fix(mcp): reject legacy delegated operation retries --- apps/cli/src/mcp/lody-mcp-server.test.ts | 9 +++++++++ apps/cli/src/mcp/lody-mcp-server.ts | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/mcp/lody-mcp-server.test.ts b/apps/cli/src/mcp/lody-mcp-server.test.ts index 72728cd65..70807a47a 100644 --- a/apps/cli/src/mcp/lody-mcp-server.test.ts +++ b/apps/cli/src/mcp/lody-mcp-server.test.ts @@ -332,6 +332,15 @@ describe('session MCP input schemas', () => { } as StoredLodyOperation; expect(() => assertOperationRetryPrincipal(operation, principal)).not.toThrow(); + expect(() => + assertOperationRetryPrincipal( + { + ...operation, + frozenContinuationConfig: { inputConfig: {} }, + } as StoredLodyOperation, + principal + ) + ).toThrow('has no frozen delegated principal provenance'); expect(() => assertOperationRetryPrincipal(operation, { ...principal, diff --git a/apps/cli/src/mcp/lody-mcp-server.ts b/apps/cli/src/mcp/lody-mcp-server.ts index 99444d6cf..cce5b1e55 100644 --- a/apps/cli/src/mcp/lody-mcp-server.ts +++ b/apps/cli/src/mcp/lody-mcp-server.ts @@ -2221,7 +2221,13 @@ const assertOperationRetryPrincipal = ( principal: SessionOperationPrincipal ): void => { const stored = operation.frozenContinuationConfig.delegation; - if (!stored) return; + if (!stored) { + throw new LodyOperationStoreError( + 'OPERATION_ID_REUSED', + `Operation id ${operation.operationId} has no frozen delegated principal provenance.`, + false + ); + } if ( operation.requesterUserId !== principal.userId || stored.sourceTurnId !== principal.sourceTurnId || From 79c36ca193e7229c038bb3b701138b7278f76637 Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:46:44 +0800 Subject: [PATCH 5/6] fix(mcp): bind invocation identity to active turn Use the execution runtime as the authoritative source for delegated Session identity, keeping persisted history only as a legacy fallback. Preserve source-turn operation binding while narrowing Session authorization to the effective request subject. Model: gpt-5 --- apps/cli/src/commands/session.test.ts | 23 ++- apps/cli/src/commands/session.ts | 140 +++++++------- apps/cli/src/lib/message-handler.ts | 28 ++- apps/cli/src/mcp/AGENTS.md | 14 +- .../src/mcp/lody-mcp-server-chat-sync.test.ts | 20 ++ apps/cli/src/mcp/lody-mcp-server.test.ts | 62 ++++-- apps/cli/src/mcp/lody-mcp-server.ts | 180 +++++++++++++----- apps/cli/src/orchestration/AGENTS.md | 5 +- .../src/orchestration/operation-store.test.ts | 24 ++- apps/cli/src/orchestration/operation-store.ts | 5 +- apps/cli/src/session/AGENTS.md | 8 +- .../src/session/session-dispatch-watcher.ts | 2 + .../src/session/session-execution-service.ts | 76 ++++++-- .../tests/session-execution-service.test.ts | 23 ++- packages/shared/src/local-machine-rpc.ts | 32 ++++ packages/shared/src/session-orchestration.ts | 7 +- .../shared/tests/local-machine-rpc.test.ts | 31 +++ 17 files changed, 499 insertions(+), 181 deletions(-) diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index b3590b721..87bd34024 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -46,7 +46,7 @@ import { resolveOpenedBySessionRelation, resolveSessionCreateOwnerUserId, selectDefaultAgentConfigForCreate, - resolveSessionCommandPrincipalUserId, + resolveSessionRequestSubject, resolveSessionCommandRequesterUserId, resolveChatArgs, resolveRenameArgs, @@ -348,21 +348,20 @@ describe('session command helpers', () => { ); }); - it('accepts only a delegated principal bound to the authenticated executor', () => { - const principal = { + it('keeps delegated requester identity separate from the authenticated executor', () => { + const requestSubject = { userId: 'collaborator-b', - sourceTurnId: 'source-turn', - executorUserId: 'machine-owner-a', + kind: 'delegated' as const, }; expect( - resolveSessionCommandPrincipalUserId({ userId: 'machine-owner-a' }, undefined, principal) - ).toBe('collaborator-b'); - expect(() => - resolveSessionCommandPrincipalUserId({ userId: 'machine-owner-c' }, undefined, principal) - ).toThrow('executor must match'); + resolveSessionRequestSubject({ userId: 'machine-owner-a' }, undefined, requestSubject) + ).toEqual(requestSubject); + expect( + resolveSessionRequestSubject({ userId: 'machine-owner-c' }, undefined, requestSubject) + ).toEqual(requestSubject); expect(() => - resolveSessionCommandPrincipalUserId({ userId: 'machine-owner-a' }, 'someone-else', principal) - ).toThrow('Requester identity must match the delegated Session principal.'); + resolveSessionRequestSubject({ userId: 'machine-owner-a' }, 'someone-else', requestSubject) + ).toThrow('Requester identity must match the Session request subject.'); }); it('keeps Session ownership separate from the authenticated requester', () => { diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 07ce656ef..375a2d50d 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -70,7 +70,6 @@ import { type SessionTurnInputConfig, type SessionId, type SessionMeta, - type SessionOperationPrincipal, type TaskId, type WorkspaceId, shouldQueueMachineDeleteSession, @@ -128,6 +127,11 @@ import { getCliHttpFetch } from '@/utils/http-transport'; type CommonOptions = CommonCommandOptions; +export type SessionRequestSubject = { + userId: string; + kind: 'direct' | 'delegated'; +}; + export const DEFAULT_SESSION_LIST_LIMIT = 50; export const MAX_MCP_SESSION_LIST_LIMIT = 200; export const DEFAULT_SESSION_HISTORY_LIMIT = 50; @@ -147,12 +151,8 @@ export type CreateOptions = CommonOptions & currentSessionId?: SessionId; defaultMachineId?: MachineId; requesterUserId?: string; - /** - * Trusted identity derived from an already-persisted invoking Turn. The - * executor still authenticates with CLI auth; authorization is evaluated - * for this principal. - */ - principal?: SessionOperationPrincipal; + /** Trusted requester and access mode supplied by an internal caller. */ + requestSubject?: SessionRequestSubject; sessionOwnerUserId?: string; parent?: string; useCurrentSessionAsParent?: boolean; @@ -1890,21 +1890,22 @@ export async function readSessionMachineAccess(args: { workspaceId: WorkspaceId; machineId: MachineId; requesterUserId?: string; - principal?: SessionOperationPrincipal; + requestSubject?: SessionRequestSubject; localProjectId?: string; }): Promise { - const requesterUserId = resolveSessionCommandPrincipalUserId( + const subject = resolveSessionRequestSubject( args.auth, args.requesterUserId, - args.principal + args.requestSubject ); try { - const readAccess = args.principal ? canUseMachineForCliToken : canRequestMachineForCliToken; + const readAccess = + subject.kind === 'delegated' ? canUseMachineForCliToken : canRequestMachineForCliToken; return await readAccess({ token: args.auth.token, workspaceId: args.workspaceId, machineId: args.machineId, - requesterUserId, + requesterUserId: subject.userId, ...(args.localProjectId ? { localProjectId: args.localProjectId } : {}), }); } catch (error) { @@ -1919,7 +1920,7 @@ async function assertMachineAccess(args: { workspaceId: WorkspaceId; machineId: MachineId; requesterUserId?: string; - principal?: SessionOperationPrincipal; + requestSubject?: SessionRequestSubject; localProjectId?: string; }): Promise { const access = await readSessionMachineAccess(args); @@ -2071,22 +2072,29 @@ export function resolveSessionCommandRequesterUserId( return auth.userId; } -export function resolveSessionCommandPrincipalUserId( +export function resolveSessionRequestSubject( auth: Pick, requesterUserId?: string, - principal?: SessionOperationPrincipal -): string { - if (!principal) { - return resolveSessionCommandRequesterUserId(auth, requesterUserId); + requestSubject?: SessionRequestSubject +): SessionRequestSubject { + if (!requestSubject) { + return { + userId: resolveSessionCommandRequesterUserId(auth, requesterUserId), + kind: 'direct', + }; } - if (principal.executorUserId !== auth.userId) { - throw new Error('Delegated Session executor must match the authenticated CLI user.'); + const subjectUserId = normalizeCliValue(requestSubject.userId); + if (!subjectUserId) { + throw new Error('Session request subject must identify a user.'); + } + if (requestSubject.kind === 'direct' && subjectUserId !== auth.userId) { + throw new Error('Direct Session request subject must match the authenticated CLI user.'); } const requested = normalizeCliValue(requesterUserId); - if (requested !== undefined && requested !== principal.userId) { - throw new Error('Requester identity must match the delegated Session principal.'); + if (requested !== undefined && requested !== subjectUserId) { + throw new Error('Requester identity must match the Session request subject.'); } - return principal.userId; + return { userId: subjectUserId, kind: requestSubject.kind }; } export function resolveSessionCreateOwnerUserId( @@ -2126,7 +2134,7 @@ async function listAuthorizedMachineMetasForCreate(args: { workspaceId: WorkspaceId; machines: readonly MachineMeta[]; requesterUserId?: string; - principal?: SessionOperationPrincipal; + requestSubject?: SessionRequestSubject; }): Promise { const rows = await Promise.all( args.machines.map(async (machine) => ({ @@ -2136,7 +2144,7 @@ async function listAuthorizedMachineMetasForCreate(args: { workspaceId: args.workspaceId, machineId: machine.id, requesterUserId: args.requesterUserId, - principal: args.principal, + requestSubject: args.requestSubject, }), })) ); @@ -2154,7 +2162,7 @@ async function filterAuthorizedLocalProjectsForCreate< machineId: MachineId; localProjects: readonly T[]; requesterUserId?: string; - principal?: SessionOperationPrincipal; + requestSubject?: SessionRequestSubject; }): Promise { const rows = await Promise.all( args.localProjects.map(async (project) => ({ @@ -2164,7 +2172,7 @@ async function filterAuthorizedLocalProjectsForCreate< workspaceId: args.workspaceId, machineId: args.machineId, requesterUserId: args.requesterUserId, - principal: args.principal, + requestSubject: args.requestSubject, localProjectId: project.id, }), })) @@ -2182,7 +2190,7 @@ async function resolveTargetMachineForCreate(args: { machineSelector?: string; defaultMachineId?: MachineId; requesterUserId?: string; - principal?: SessionOperationPrincipal; + requestSubject?: SessionRequestSubject; parentSessionId?: SessionId; }): Promise { const machines = await listMachineMetasForWorkspace(args.manager); @@ -2194,7 +2202,7 @@ async function resolveTargetMachineForCreate(args: { workspaceId: args.workspaceId, machines, requesterUserId: args.requesterUserId, - principal: args.principal, + requestSubject: args.requestSubject, }); if (authorizedMachines.length === 0) { throw new Error('No authorized machines are available in this workspace.'); @@ -2285,16 +2293,16 @@ async function assertGitHubRepoAccess(args: { workspaceId: WorkspaceId; repoFullName: string; requesterUserId?: string; - principal?: SessionOperationPrincipal; + requestSubject?: SessionRequestSubject; }): Promise { const repos = await listWorkspaceGitHubRepositoriesForCliToken({ token: args.auth.token, workspaceId: args.workspaceId, - requesterUserId: resolveSessionCommandPrincipalUserId( + requesterUserId: resolveSessionRequestSubject( args.auth, args.requesterUserId, - args.principal - ), + args.requestSubject + ).userId, enabledOnly: true, }); const normalized = args.repoFullName.toLowerCase(); @@ -2310,7 +2318,7 @@ export async function readLocalProjectGitStateOnMachine(args: { localProjectId: string; localRootPath: string; requesterUserId?: string; - principal?: SessionOperationPrincipal; + requestSubject?: SessionRequestSubject; }): Promise< | { success: true; state: Awaited> } | { success: false; error: string; message?: string } @@ -2331,11 +2339,11 @@ export async function readLocalProjectGitStateOnMachine(args: { async (client) => await client.requestLocalProjectGitState({ localProjectId: args.localProjectId as LocalProjectId, - requestedByUserId: resolveSessionCommandPrincipalUserId( + requestedByUserId: resolveSessionRequestSubject( args.auth, args.requesterUserId, - args.principal - ), + args.requestSubject + ).userId, timeoutMs: 30_000, }) ); @@ -2441,17 +2449,17 @@ async function listWorkspaceGitHubRepositoriesBestEffort(args: { auth: AuthContext; workspaceId: WorkspaceId; requesterUserId?: string; - principal?: SessionOperationPrincipal; + requestSubject?: SessionRequestSubject; }): Promise<{ fullName: string }[]> { try { return await listWorkspaceGitHubRepositoriesForCliToken({ token: args.auth.token, workspaceId: args.workspaceId, - requesterUserId: resolveSessionCommandPrincipalUserId( + requesterUserId: resolveSessionRequestSubject( args.auth, args.requesterUserId, - args.principal - ), + args.requestSubject + ).userId, enabledOnly: true, }); } catch (error) { @@ -2471,7 +2479,7 @@ async function resolveLocalProjectCreateGitContextOnMachine(args: { localProjectId: string; localRootPath: string; requesterUserId?: string; - principal?: SessionOperationPrincipal; + requestSubject?: SessionRequestSubject; requestedBranch?: string; useWorktree?: boolean; }): Promise { @@ -2505,7 +2513,7 @@ async function resolveLocalProjectRefOnMachineOrThrow( machineId: MachineId, selector: string, requesterUserId: string | undefined, - principal: SessionOperationPrincipal | undefined, + requestSubject: SessionRequestSubject | undefined, requestedBranch?: string, useWorktree?: boolean ): Promise { @@ -2522,7 +2530,7 @@ async function resolveLocalProjectRefOnMachineOrThrow( machineId, localProjects, requesterUserId, - principal, + requestSubject, }); if (authorizedLocalProjects.length === 0) { throw new Error('No authorized local projects are available on the target machine.'); @@ -2552,7 +2560,7 @@ async function resolveLocalProjectRefOnMachineOrThrow( localProjectId: project.id, localRootPath: project.rootPath, requesterUserId, - principal, + requestSubject, requestedBranch, useWorktree, }); @@ -2628,11 +2636,12 @@ async function resolveCreateContext(args: { }): Promise { const workspaceId = args.workspace.id as WorkspaceId; const agentSelector = resolveCreateAgentSelector(args.options); - const requesterUserId = resolveSessionCommandPrincipalUserId( + const requestSubject = resolveSessionRequestSubject( args.auth, args.options.requesterUserId, - args.options.principal + args.options.requestSubject ); + const requesterUserId = requestSubject.userId; const parentSelector = normalizeCliValue(args.options.parent); const currentSessionId = resolveCreateCurrentSessionId(args.options); if (parentSelector && args.options.useCurrentSessionAsParent === true) { @@ -2678,7 +2687,7 @@ async function resolveCreateContext(args: { machineSelector: args.options.machine, defaultMachineId: args.options.defaultMachineId, requesterUserId, - principal: args.options.principal, + requestSubject, parentSessionId, }); await assertMachineAccess({ @@ -2686,7 +2695,7 @@ async function resolveCreateContext(args: { workspaceId, machineId: targetMachine.id, requesterUserId, - principal: args.options.principal, + requestSubject, }); if (args.skipMachineAvailabilityCheck !== true) { await ensureTargetMachineOnline({ @@ -2722,7 +2731,7 @@ async function resolveCreateContext(args: { workspaceId, repoFullName: parentRepoFullName, requesterUserId, - principal: args.options.principal, + requestSubject, }); } } else if (normalizedRepo) { @@ -2731,7 +2740,7 @@ async function resolveCreateContext(args: { workspaceId, repoFullName: normalizedRepo, requesterUserId, - principal: args.options.principal, + requestSubject, }); const branch = resolveBaseBranchPreference({ preferredBranch: requestedBranch, @@ -2746,7 +2755,7 @@ async function resolveCreateContext(args: { targetMachine.id, normalizedLocalProject, requesterUserId, - args.options.principal, + requestSubject, requestedBranch, args.options.worktree === true ); @@ -2757,7 +2766,7 @@ async function resolveCreateContext(args: { workspaceId, machineId: targetMachine.id, requesterUserId, - principal: args.options.principal, + requestSubject, localProjectId: project?.kind === 'local' ? project.localProjectId : undefined, }); @@ -2976,11 +2985,11 @@ export async function createSessionResult( sessionId: options.sessionId, }); } - const requesterUserId = resolveSessionCommandPrincipalUserId( + const requesterUserId = resolveSessionRequestSubject( auth, options.requesterUserId, - options.principal - ); + options.requestSubject + ).userId; const sessionOwnerUserId = resolveSessionCreateOwnerUserId( requesterUserId, options.sessionOwnerUserId @@ -3152,14 +3161,15 @@ export async function validateSessionChatTarget(args: { manager: LoroDocumentManager; sessionId: SessionId; requesterUserIdOverride?: string; - principal?: SessionOperationPrincipal; + requestSubject?: SessionRequestSubject; }): Promise { await syncWorkspaceMetaForRead(args.manager, `session.chat:${args.sessionId}:prewrite:meta`); - const requesterUserId = resolveSessionCommandPrincipalUserId( + const requestSubject = resolveSessionRequestSubject( args.auth, args.requesterUserIdOverride, - args.principal + args.requestSubject ); + const requesterUserId = requestSubject.userId; const session = await resolveSessionMetaOrThrow(args.manager, args.sessionId); if (session.isArchived) { throw new Error(`Session ${args.sessionId} is archived. Restore it before chatting.`); @@ -3169,7 +3179,7 @@ export async function validateSessionChatTarget(args: { workspaceId: args.workspace.id as WorkspaceId, machineId: session.machineId, requesterUserId, - principal: args.principal, + requestSubject, localProjectId: session.project?.kind === 'local' ? session.project.localProjectId : undefined, }); await ensureTargetMachineOnline({ @@ -3198,7 +3208,7 @@ export async function sendSessionChatResult( chainDepth: number; bypassSessionQuota?: boolean; }, - principal?: SessionOperationPrincipal + requestSubject?: SessionRequestSubject ): Promise<{ sessionId: SessionId; machineId: MachineId; @@ -3206,18 +3216,18 @@ export async function sendSessionChatResult( userTurnId: string; completionPromise?: Promise>>; }> { - const requesterUserId = resolveSessionCommandPrincipalUserId( + const requesterUserId = resolveSessionRequestSubject( auth, requesterUserIdOverride, - principal - ); + requestSubject + ).userId; const session = await validateSessionChatTarget({ auth, workspace, manager, sessionId, requesterUserIdOverride, - principal, + requestSubject, }); if (dispatchConfig.modeId || dispatchConfig.modelId || dispatchConfig.configOptionValues) { const capability = await readAgentAcpCapability({ diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 92c4a7ee9..cd1f33c82 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -2837,7 +2837,9 @@ export class MessageHandler { } const requester = requesterRecord.meta as SessionMeta; const delegation = operation.frozenContinuationConfig.delegation; - const principal = delegation ? { userId: operation.requesterUserId, ...delegation } : undefined; + const requestSubject = delegation + ? ({ userId: operation.requesterUserId, kind: 'delegated' } as const) + : undefined; if (operation.kind === 'session_create' || operation.kind === 'session_create_many') { const runConfig: AgentRunConfigSelection = { @@ -2862,8 +2864,8 @@ export class MessageHandler { workspace: this.workspaceId, currentSessionId: operation.requesterSessionId, workspaceMetaPrewriteSatisfied: true, - ...(principal - ? { principal, sessionOwnerUserId: principal.userId } + ...(requestSubject + ? { requestSubject } : { requesterUserId: operation.requesterUserId, sessionOwnerUserId: requester.userId, @@ -2916,13 +2918,13 @@ export class MessageHandler { taskToolsEnabled: operation.frozenContinuationConfig.inputConfig.taskToolsEnabled === true, }, undefined, - principal ? undefined : operation.requesterUserId, + requestSubject ? undefined : operation.requesterUserId, { userTurnId: item.target.userTurnId, chainDepth: operation.initiatorChainDepth + 1, bypassSessionQuota: shouldBypassSessionQuota(operation.kind), }, - principal + requestSubject ); } @@ -6598,6 +6600,22 @@ export class MessageHandler { return await this.filePreviewService.previewFile(request.params, { allowArbitraryPaths: true, }); + case 'session/get-active-invocation-context': { + const sessionId = request.params.sessionId as SessionId; + const invocation = this.executionService.getActiveInvocationContext(sessionId); + return invocation + ? { + type: 'session/active-invocation-context' as const, + sessionId, + active: true as const, + ...invocation, + } + : { + type: 'session/active-invocation-context' as const, + sessionId, + active: false as const, + }; + } case 'session/cancel': { const result = await this.executionService.cancelSession({ type: 'session/cancel', diff --git a/apps/cli/src/mcp/AGENTS.md b/apps/cli/src/mcp/AGENTS.md index bfdbf0327..18ec25e47 100644 --- a/apps/cli/src/mcp/AGENTS.md +++ b/apps/cli/src/mcp/AGENTS.md @@ -28,13 +28,15 @@ Root and `apps/cli/AGENTS.md` instructions apply. the workspace catalog; no driving-Turn mention authorization is required. Resolve its target, Prompt prefix, revision, and concrete run config before Operation acceptance. Recovery uses the frozen canonical Prompt and target dispatch config and never rereads the mutable catalog. -- Session orchestration derives its human principal from the exact persisted user/system Turn - driving the current Agent execution, not from the daemon credential or Session owner. Freeze - the source Turn id, principal user, and executor with every accepted Operation. Store the user - once as `requesterUserId`; the delegation binding stores only source Turn and executor. The +- Session orchestration derives its human principal from the active execution runtime populated + by the dispatch payload, not from the daemon credential, Session owner, or observed history. + Persisted history is only a legacy fallback when no active runtime identity exists. Freeze + the source Turn id and principal user with every accepted Operation. Store the user + once as `requesterUserId`; the delegation binding stores only the source Turn. The Operation's requester Session id already identifies the source Session, and a single-value - actor tag adds no information. - legacy synchronous paths use the same derivation and reject a source Turn without userId. + actor tag adds no information. Recovery uses the Operation's owner Machine plus current + authorization; it does not freeze the daemon account that originally accepted the Operation. + Legacy synchronous paths reject a source Turn without userId. - Direct Role creation stays on the ordinary `lody_session_create` and `lody_session_create_many` tools. When `agentRoleId` is present, tolerate manual Machine, Agent, and run-config fields but remove them before resolution: the current Role row is authoritative diff --git a/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts b/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts index 0d825e51e..0768ecdb4 100644 --- a/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts +++ b/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts @@ -51,6 +51,26 @@ vi.mock('@/orchestration/operation-store', async (importOriginal) => { }; }); +vi.mock('@lody/shared/node/local-ipc', async (importOriginal) => { + const actual = await importOriginal(); + const { Effect } = await import('effect'); + return { + ...actual, + makeLocalControlClientAuto: vi.fn(() => ({ + machineRpc: vi.fn(() => + Effect.succeed({ + ok: true as const, + result: { + type: 'session/active-invocation-context' as const, + sessionId: 'requester-session-id', + active: false as const, + }, + }) + ), + })), + }; +}); + import { WORKSPACE_SYNC_UNAVAILABLE_MESSAGE, WorkspaceSyncUnavailableError, diff --git a/apps/cli/src/mcp/lody-mcp-server.test.ts b/apps/cli/src/mcp/lody-mcp-server.test.ts index 70807a47a..ebe387370 100644 --- a/apps/cli/src/mcp/lody-mcp-server.test.ts +++ b/apps/cli/src/mcp/lody-mcp-server.test.ts @@ -81,6 +81,7 @@ const { resolveOperationStorePathForContext, resolveUploadPath, resolveInvokingHistoryInput, + selectInvokingTurnSource, buildInvokingTurnPrincipal, assertOperationRetryPrincipal, summarizeProjectRefForMcp, @@ -300,15 +301,59 @@ describe('session MCP input schemas', () => { ); }); - it('derives delegated identity from the exact driving Turn', () => { + it('uses RPC runtime identity while the driving user Turn is absent from local history', () => { + const previousUser = { ...historyTurn('turn-a', 'user'), userId: 'user-a' }; + const previousAssistant = { + ...historyTurn('assistant:turn-a', 'assistant'), + userTurnId: previousUser.id, + }; + expect( - buildInvokingTurnPrincipal({ id: 'source-turn', userId: 'collaborator-b' }, 'machine-owner-a') + selectInvokingTurnSource([previousUser, previousAssistant], { + type: 'session/active-invocation-context', + sessionId: 'current-session-id', + active: true, + requesterUserId: 'user-b', + sourceTurnId: 'turn-b', + inputConfig: { chainDepth: 1, taskToolsEnabled: true }, + }) ).toEqual({ + id: 'turn-b', + userId: 'user-b', + inputConfig: { chainDepth: 1, taskToolsEnabled: true }, + }); + }); + + it('keeps RPC runtime identity after the history gate times out', () => { + const previousUser = { ...historyTurn('turn-a', 'user'), userId: 'user-a' }; + const previousAssistant = { + ...historyTurn('assistant:turn-a', 'assistant'), + userTurnId: previousUser.id, + }; + const unlinkedCurrentAssistant = { + ...historyTurn('assistant:turn-b', 'assistant'), + userTurnId: 'turn-b', + }; + const history = [previousUser, previousAssistant, unlinkedCurrentAssistant]; + expect(resolveInvokingHistoryInput(history)).toBe(previousUser); + expect( + selectInvokingTurnSource(history, { + type: 'session/active-invocation-context', + sessionId: 'current-session-id', + active: true, + requesterUserId: 'user-b', + sourceTurnId: 'turn-b', + inputConfig: { chainDepth: 2 }, + }) + ).toMatchObject({ id: 'turn-b', userId: 'user-b' }); + }); + + it('derives delegated identity from the exact driving Turn', () => { + expect(buildInvokingTurnPrincipal({ id: 'source-turn', userId: 'collaborator-b' })).toEqual({ userId: 'collaborator-b', sourceTurnId: 'source-turn', - executorUserId: 'machine-owner-a', }); - expect(() => buildInvokingTurnPrincipal({ id: 'legacy-turn' }, 'machine-owner-a')).toThrow( + expect(() => buildInvokingTurnPrincipal({ id: 'legacy-turn' })).toThrow( 'has no authenticated human identity' ); }); @@ -317,7 +362,6 @@ describe('session MCP input schemas', () => { const principal = { userId: 'collaborator-b', sourceTurnId: 'source-turn-b', - executorUserId: 'machine-owner-a', }; const operation = { operationId: 'review-1', @@ -326,7 +370,6 @@ describe('session MCP input schemas', () => { inputConfig: {}, delegation: { sourceTurnId: principal.sourceTurnId, - executorUserId: principal.executorUserId, }, }, } as StoredLodyOperation; @@ -533,7 +576,6 @@ describe('session MCP input schemas', () => { { userId: 'collaborator-b', sourceTurnId: 'source-turn', - executorUserId: 'machine-owner-a', }, { machineId: 'machine-id' } ); @@ -544,12 +586,10 @@ describe('session MCP input schemas', () => { machine: 'machine-id', agentConfig: 'agent-config-id', useCurrentSessionAsParent: true, - principal: { + requestSubject: { userId: 'collaborator-b', - sourceTurnId: 'source-turn', - executorUserId: 'machine-owner-a', + kind: 'delegated', }, - sessionOwnerUserId: 'collaborator-b', defaultMachineId: 'machine-id', }); }); diff --git a/apps/cli/src/mcp/lody-mcp-server.ts b/apps/cli/src/mcp/lody-mcp-server.ts index cce5b1e55..6112a515d 100644 --- a/apps/cli/src/mcp/lody-mcp-server.ts +++ b/apps/cli/src/mcp/lody-mcp-server.ts @@ -51,7 +51,8 @@ import { type SessionHistoryInput, type SessionId, type SessionMeta, - type SessionOperationPrincipal, + SessionActiveInvocationContextResultSchema, + type SessionActiveInvocationContextResult, type TaskId, type TaskIndexRow, type TaskPrProvider, @@ -78,6 +79,7 @@ import { REVIEW_VERDICT_VALUES, ReviewSubmissionSchema, hasPendingUserTurnActivation, + normalizeSessionTurnInputConfig, } from '@lody/shared'; import { makeLocalControlClientAuto } from '@lody/shared/node/local-ipc'; import { @@ -128,6 +130,7 @@ import { type CreateOptions, type ResolvedTurnDispatchConfig, type SessionLiveStatusBatchItem, + type SessionRequestSubject, } from '@/commands/session'; import type { SessionTurnOutputEvent, @@ -1047,6 +1050,45 @@ const postSessionControl = async ( return responses.map((response) => LocalSessionControlResponseSchema.parse(response)); }; +const readActiveInvocationContext = async ( + ctx: McpSessionContext +): Promise => { + const response = await Effect.runPromise( + makeLocalControlClientAuto({ socketPath: ctx.localControlSocketPath }) + .machineRpc( + { + method: 'session/get-active-invocation-context', + machineId: ctx.machineId, + workspaceId: ctx.workspaceId, + params: { sessionId: ctx.sessionId }, + }, + { timeoutMs: SESSION_CONTROL_TIMEOUT_MS } + ) + .pipe( + Effect.catchTag('IpcTimeoutError', (error) => + Effect.fail( + new Error(`local control timed out after ${SESSION_CONTROL_TIMEOUT_MS}ms`, { + cause: error, + }) + ) + ), + Effect.catchTag('IpcProtocolError', (error) => + Effect.fail(new Error(error.message, { cause: error })) + ) + ) + ); + if (!response.ok) { + throw new Error(response.error); + } + const invocation = SessionActiveInvocationContextResultSchema.parse(response.result); + if (invocation.sessionId !== ctx.sessionId) { + throw new Error( + `Active invocation context session mismatch: expected ${ctx.sessionId}, received ${invocation.sessionId}` + ); + } + return invocation; +}; + const pickResponse = ( responses: LocalSessionControlResponsePayload[], expectedType: TType, @@ -2090,7 +2132,7 @@ const canUseMachineForOptions = async (args: { workspaceId: WorkspaceId; machineId: MachineId; requesterUserId: string; - principal: SessionOperationPrincipal; + requestSubject: SessionRequestSubject; localProjectId?: string; }): Promise => { const access = await readSessionMachineAccess({ @@ -2098,7 +2140,7 @@ const canUseMachineForOptions = async (args: { workspaceId: args.workspaceId, machineId: args.machineId, requesterUserId: args.requesterUserId, - principal: args.principal, + requestSubject: args.requestSubject, ...(args.localProjectId ? { localProjectId: args.localProjectId } : {}), }); return access.allowed; @@ -2109,7 +2151,7 @@ const filterAuthorizedMachinesForOptions = async ( workspaceId: WorkspaceId, machines: readonly MachineMeta[], requesterUserId: string, - principal: SessionOperationPrincipal + requestSubject: SessionRequestSubject ): Promise => { const rows = await Promise.all( machines.map(async (machine) => ({ @@ -2119,7 +2161,7 @@ const filterAuthorizedMachinesForOptions = async ( workspaceId, machineId: machine.id, requesterUserId, - principal, + requestSubject, }), })) ); @@ -2132,7 +2174,7 @@ const filterAuthorizedLocalProjectsForOptions = async ( machineId: MachineId, localProjects: readonly LocalProjectMeta[], requesterUserId: string, - principal: SessionOperationPrincipal + requestSubject: SessionRequestSubject ): Promise => { const rows = await Promise.all( localProjects.map(async (project) => ({ @@ -2142,7 +2184,7 @@ const filterAuthorizedLocalProjectsForOptions = async ( workspaceId, machineId, requesterUserId, - principal, + requestSubject, localProjectId: project.id, }), })) @@ -2178,24 +2220,32 @@ const readCurrentSessionMeta = async ( const bindMcpCreateContext = ( options: CreateOptions, - principal: SessionOperationPrincipal, + identity: InvocationIdentity, requester: Pick ): void => { - options.principal = principal; - options.sessionOwnerUserId = principal.userId; + options.requestSubject = toDelegatedSessionRequestSubject(identity); options.defaultMachineId = requester.machineId; }; +type InvocationIdentity = { + userId: string; + sourceTurnId: string; +}; + +const toDelegatedSessionRequestSubject = (identity: InvocationIdentity): SessionRequestSubject => ({ + userId: identity.userId, + kind: 'delegated', +}); + type InvokingTurnContext = { chainDepth: number; frozenInputConfig: SessionTurnInputConfig; - principal: SessionOperationPrincipal; + principal: InvocationIdentity; }; const buildInvokingTurnPrincipal = ( - source: Pick, - executorUserId: string -): SessionOperationPrincipal => { + source: Pick +): InvocationIdentity => { const userId = source.userId?.trim(); if (!userId) { throw new LodyOperationStoreError( @@ -2207,18 +2257,14 @@ const buildInvokingTurnPrincipal = ( return { userId, sourceTurnId: source.id, - executorUserId, }; }; -const freezeOperationDelegation = ({ - sourceTurnId, - executorUserId, -}: SessionOperationPrincipal) => ({ sourceTurnId, executorUserId }); +const freezeOperationDelegation = ({ sourceTurnId }: InvocationIdentity) => ({ sourceTurnId }); const assertOperationRetryPrincipal = ( operation: StoredLodyOperation, - principal: SessionOperationPrincipal + principal: InvocationIdentity ): void => { const stored = operation.frozenContinuationConfig.delegation; if (!stored) { @@ -2230,8 +2276,7 @@ const assertOperationRetryPrincipal = ( } if ( operation.requesterUserId !== principal.userId || - stored.sourceTurnId !== principal.sourceTurnId || - stored.executorUserId !== principal.executorUserId + stored.sourceTurnId !== principal.sourceTurnId ) { throw new LodyOperationStoreError( 'OPERATION_ID_REUSED', @@ -2262,6 +2307,42 @@ const resolveInvokingHistoryInput = ( .find((entry) => entry.role === 'user' || entry.role === 'system'); }; +type InvokingTurnSource = Pick; + +const selectInvokingTurnSource = ( + history: SessionHistoryInput[], + active: SessionActiveInvocationContextResult +): InvokingTurnSource | undefined => { + if (!active.active) { + return resolveInvokingHistoryInput(history); + } + const inputConfig = normalizeSessionTurnInputConfig(active.inputConfig); + if (!inputConfig) { + throw new LodyOperationStoreError( + 'INVOKING_TURN_NOT_FOUND', + `The active Turn ${active.sourceTurnId} has an invalid execution configuration.`, + false + ); + } + return { + id: active.sourceTurnId, + userId: active.requesterUserId, + inputConfig, + }; +}; + +const resolveInvokingTurnSource = async ( + manager: LoroDocumentManager, + sessionId: SessionId +): Promise => { + const active = await readActiveInvocationContext(getSessionContext()); + if (active.active) { + return selectInvokingTurnSource([], active); + } + const sessionDoc = await manager.getOrCreateSessionDoc(sessionId); + return selectInvokingTurnSource(await sessionDoc.getHistory(), active); +}; + const assertInvokingTurnTaskToolsEnabled = async ( manager: LoroDocumentManager, sessionId: SessionId @@ -2274,8 +2355,7 @@ const assertInvokingTurnTaskToolsEnabled = async ( false ); } - const sessionDoc = await manager.getOrCreateSessionDoc(session.id); - const source = resolveInvokingHistoryInput(await sessionDoc.getHistory()); + const source = await resolveInvokingTurnSource(manager, session.id); if (source?.inputConfig?.taskToolsEnabled !== true) { throw new LodyOperationStoreError( 'TASK_TOOLS_DISABLED', @@ -2287,12 +2367,9 @@ const assertInvokingTurnTaskToolsEnabled = async ( const resolveInvokingTurnContext = async ( manager: LoroDocumentManager, - session: SessionMeta, - executorUserId: string + session: SessionMeta ): Promise => { - const sessionDoc = await manager.getOrCreateSessionDoc(session.id); - const history = await sessionDoc.getHistory(); - const source = resolveInvokingHistoryInput(history); + const source = await resolveInvokingTurnSource(manager, session.id); if (!source) { throw new LodyOperationStoreError( 'INVOKING_TURN_NOT_FOUND', @@ -2300,7 +2377,7 @@ const resolveInvokingTurnContext = async ( false ); } - const chainDepth = source?.inputConfig?.chainDepth ?? 0; + const chainDepth = source.inputConfig?.chainDepth ?? 0; if (chainDepth >= LODY_MAX_CHAIN_DEPTH) { throw new LodyOperationStoreError( 'CHAIN_DEPTH_EXCEEDED', @@ -2310,11 +2387,11 @@ const resolveInvokingTurnContext = async ( } return { chainDepth, - principal: buildInvokingTurnPrincipal(source, executorUserId), + principal: buildInvokingTurnPrincipal(source), frozenInputConfig: { - ...(source?.inputConfig ?? {}), - cliType: source?.inputConfig?.cliType ?? session.cliType, - agentType: source?.inputConfig?.agentType ?? session.agentType, + ...(source.inputConfig ?? {}), + cliType: source.inputConfig?.cliType ?? session.cliType, + agentType: source.inputConfig?.agentType ?? session.agentType, chainDepth, }, }; @@ -2499,7 +2576,7 @@ const summarizeLocalProjectForOptions = async ( machine: MachineMeta, project: LocalProjectMeta, requesterUserId: string, - principal: SessionOperationPrincipal, + requestSubject: SessionRequestSubject, machineOnline: boolean ) => { const gitState = @@ -2511,7 +2588,7 @@ const summarizeLocalProjectForOptions = async ( localProjectId: project.id, localRootPath: project.rootPath, requesterUserId, - principal, + requestSubject, }).catch((error: unknown) => ({ success: false as const, error: String(error), @@ -2543,7 +2620,7 @@ const buildSessionCreateOptions = async ( if (!currentSession) { throw new Error(`Session not found: ${ctx.sessionId}`); } - const invoking = await resolveInvokingTurnContext(manager, currentSession, auth.userId); + const invoking = await resolveInvokingTurnContext(manager, currentSession); const requesterUserId = invoking.principal.userId; const machineEntries = await listAliveDocMetas(manager, isMachineDocRoomId); const onlineMachineIds = await manager.getOnlineMachineIds(); @@ -2558,7 +2635,7 @@ const buildSessionCreateOptions = async ( workspaceId, machineCandidates, requesterUserId, - invoking.principal + toDelegatedSessionRequestSubject(invoking.principal) ); const selectedMachine = selectMachineForOptions( machines, @@ -2612,7 +2689,7 @@ const buildSessionCreateOptions = async ( selectedMachine.id, localProjectCandidates, requesterUserId, - invoking.principal + toDelegatedSessionRequestSubject(invoking.principal) ) ).slice(0, MAX_MCP_CREATE_OPTION_MATCHES); const summarizedLocalProjects = await Promise.all( @@ -2623,7 +2700,7 @@ const buildSessionCreateOptions = async ( selectedMachine, project, requesterUserId, - invoking.principal, + toDelegatedSessionRequestSubject(invoking.principal), isMachineOnline(selectedMachine.id) ) ) @@ -2681,7 +2758,7 @@ const startSessionCreateOperation = async (args: SessionCreateCommandInput): Pro false ); } - const invoking = await resolveInvokingTurnContext(manager, currentSession, auth.userId); + const invoking = await resolveInvokingTurnContext(manager, currentSession); const roleCatalog = args.agentRoleId ? await loadWorkspaceAgentRoleCatalog(manager, workspace.id as WorkspaceId) : undefined; @@ -2829,7 +2906,7 @@ const startSessionChatOperation = async (args: SessionChatToolInput): Promise ({ ...(args.defaults ?? {}), ...item })); - const invoking = await resolveInvokingTurnContext(manager, requester, auth.userId); + const invoking = await resolveInvokingTurnContext(manager, requester); const roleCatalog = expanded.some((item) => Boolean(item.agentRoleId)) ? await loadWorkspaceAgentRoleCatalog(manager, workspace.id as WorkspaceId) : undefined; @@ -3374,7 +3451,7 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr ); } const expanded = args.items.map((item) => ({ ...(args.defaults ?? {}), ...item })); - const invoking = await resolveInvokingTurnContext(manager, requester, auth.userId); + const invoking = await resolveInvokingTurnContext(manager, requester); const canonicalCommand = { items: expanded, ...(args.deadlineSeconds !== undefined ? { deadlineSeconds: args.deadlineSeconds } : {}), @@ -3435,7 +3512,7 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr workspace, manager, sessionId: target.id, - principal: invoking.principal, + requestSubject: toDelegatedSessionRequestSubject(invoking.principal), }); } catch (error) { if (error instanceof WorkspaceSyncUnavailableError) { @@ -3512,7 +3589,7 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr chainDepth: invoking.chainDepth + 1, bypassSessionQuota: shouldBypassSessionQuota('session_chat_many'), }, - invoking.principal + toDelegatedSessionRequestSubject(invoking.principal) ); await withOperationStore((store) => store.markItemInputDurable( @@ -4007,6 +4084,7 @@ export const __lodyMcpServerInternals = { applySessionRenameItems, persistSessionRenameItems, resolveInvokingHistoryInput, + selectInvokingTurnSource, buildInvokingTurnPrincipal, assertOperationRetryPrincipal, buildOperationTargetCancelArgs, @@ -4333,7 +4411,7 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): if (!currentSession) { throw new Error(`Session not found: ${ctx.sessionId}`); } - const invoking = await resolveInvokingTurnContext(manager, currentSession, auth.userId); + const invoking = await resolveInvokingTurnContext(manager, currentSession); const roleCatalog = args.agentRoleId ? await loadWorkspaceAgentRoleCatalog(manager, workspace.id as WorkspaceId) : undefined; @@ -4426,7 +4504,7 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): throw new Error(`Session not found: ${sessionId}`); } assertDifferentMcpSession(currentSession, targetSession); - const invoking = await resolveInvokingTurnContext(manager, currentSession, auth.userId); + const invoking = await resolveInvokingTurnContext(manager, currentSession); const result = await sendSessionChatResult( auth, workspace, @@ -4437,7 +4515,7 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): buildStructuredOutputOptions(args), undefined, undefined, - invoking.principal + toDelegatedSessionRequestSubject(invoking.principal) ); const response = { ok: true, diff --git a/apps/cli/src/orchestration/AGENTS.md b/apps/cli/src/orchestration/AGENTS.md index c7a0c1473..f401acfd8 100644 --- a/apps/cli/src/orchestration/AGENTS.md +++ b/apps/cli/src/orchestration/AGENTS.md @@ -26,9 +26,8 @@ Root and `apps/cli/AGENTS.md` apply. Normative behavior lives in acceptance; recovery must not re-read mutable requester history defaults. Full content stays in the target Session history. - Accepted Operations store the principal user once as `requesterUserId`; their frozen delegation - binds the exact source Turn and executor. `requesterSessionId` already identifies the source - Session. Recovery - routes attribution and member-scoped authorization through that frozen principal while the + binds the exact source Turn. `requesterSessionId` already identifies the source Session. Recovery + routes attribution and member-scoped authorization through that frozen user while the current owner Machine credential remains the executor credential. Completion system Turns retain the same userId so a continuation cannot silently switch principals. Idempotent retries must match the frozen principal; a later Turn reusing the id is `OPERATION_ID_REUSED`, not a retry. diff --git a/apps/cli/src/orchestration/operation-store.test.ts b/apps/cli/src/orchestration/operation-store.test.ts index 28b13c2b1..7cdec6711 100644 --- a/apps/cli/src/orchestration/operation-store.test.ts +++ b/apps/cli/src/orchestration/operation-store.test.ts @@ -41,7 +41,6 @@ const baseInput = () => ({ inputConfig: { cliType: 'builtin' as const, agentType: 'codex', chainDepth: 0 }, delegation: { sourceTurnId: 'source-turn-1', - executorUserId: 'machine-owner-1', }, }, initiatorChainDepth: 0, @@ -90,7 +89,6 @@ describe('LodyOperationStore', () => { expect(retry.operation.operationId).toBe('review-round-1'); expect(retry.operation.frozenContinuationConfig.delegation).toEqual({ sourceTurnId: 'source-turn-1', - executorUserId: 'machine-owner-1', }); expect(store.snapshot(retry.operation)).toMatchObject({ state: 'active' }); } finally { @@ -98,6 +96,28 @@ describe('LodyOperationStore', () => { } }); + it('reads legacy delegation records that froze an executor account', async () => { + const store = await makeStore(); + try { + const input = baseInput(); + const accepted = store.accept({ + ...input, + frozenContinuationConfig: { + ...input.frozenContinuationConfig, + delegation: { + sourceTurnId: 'source-turn-1', + executorUserId: 'machine-owner-1', + }, + }, + }); + expect(accepted.operation.frozenContinuationConfig.delegation).toMatchObject({ + sourceTurnId: 'source-turn-1', + }); + } finally { + store.close(); + } + }); + it('round-trips a frozen create dispatch config including the task tools gate', async () => { const store = await makeStore(); try { diff --git a/apps/cli/src/orchestration/operation-store.ts b/apps/cli/src/orchestration/operation-store.ts index a169ca721..ec3ba464c 100644 --- a/apps/cli/src/orchestration/operation-store.ts +++ b/apps/cli/src/orchestration/operation-store.ts @@ -115,7 +115,10 @@ const FrozenConfigSchema = z delegation: z .object({ sourceTurnId: z.string().trim().min(1), - executorUserId: z.string().trim().min(1), + // Accepted for upgrade compatibility; new Operations do not freeze the + // daemon account because machine ownership plus current authorization + // govern recovery. + executorUserId: z.string().trim().min(1).optional(), }) .strict() .optional(), diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index 27219dc56..c444b6c40 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -10,13 +10,15 @@ Session CLI/MCP orchestration contract: specs/session-orchestration.md. Target-machine authorization is checked by the injected access capability with the source CLI token, which derives the requester identity for ordinary CLI calls and verifies the frozen Turn -principal for MCP delegation. Do not send an untrusted caller-supplied requester through +request subject for MCP delegation. Session commands receive only `{ userId, kind }`; source-Turn +provenance stays at the MCP/Operation boundary. Do not send an untrusted requester through workspace Machine RPC: that transport does not authenticate member identity. Live status is a target-daemon Machine RPC read, and durable session metadata is not a live-presence substitute. Session orchestration MCP authenticates execution with the daemon owner's CLI credential, -but derives the human principal from the exact persisted Turn driving the Agent. Freeze that -principal and source Turn into durable Operations; the Operation already identifies the source +but derives the human principal causally from the active dispatch/execution runtime. Persisted +history is only a legacy fallback when no active runtime identity exists. Freeze that principal +and source Turn into durable Operations; the Operation already identifies the source Session and stores the principal user once as `requesterUserId`. Retries and recovery must not reread mutable history. Machine and Provider credentials remain execution-host scoped, while Session/Turn attribution, member authorization, GitHub access, and downstream Git identity use diff --git a/apps/cli/src/session/session-dispatch-watcher.ts b/apps/cli/src/session/session-dispatch-watcher.ts index 764f5ceda..35a9c1723 100644 --- a/apps/cli/src/session/session-dispatch-watcher.ts +++ b/apps/cli/src/session/session-dispatch-watcher.ts @@ -1574,6 +1574,8 @@ export class SessionDispatchWatcher { sessionId, sessionDoc, userTurnId: nextUserTurn.id, + requesterUserId: nextUserTurn.userId, + invocationInputConfig: nextUserTurn.inputConfig ?? {}, dispatchSource, accessPromise: executionAccessPromise, requestPromise, diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index c2327af57..ac99158f8 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -217,7 +217,10 @@ type TurnRuntimeState = { /** Logical chain tail exposed to Web, cancel, and optimistic steer validation. */ turnId: string; userTurnId?: string; + /** Causal input Turn for authorization and durable provenance. */ + sourceTurnId?: string; requesterUserId?: string; + invocationInputConfig?: SessionTurnInputConfig; session?: ISession; project?: ProjectRef; baseCommitHash?: string | null; @@ -309,6 +312,9 @@ type VisibleSessionTurnOptions = { sessionDoc: SessionDocument; session?: ISession; userTurnId?: string; + sourceTurnId?: string; + requesterUserId?: string; + invocationInputConfig: SessionTurnInputConfig; /** * How the turn payload reached this machine. 'rpc' turns can start before the * user's history entry syncs locally, so their turn-scoped history writes go @@ -346,6 +352,9 @@ export type PreparedSessionDispatchOptions = { sessionId: SessionId; sessionDoc: SessionDocument; userTurnId: string; + /** Exact requester carried by the driving Turn; absent legacy values stay absent. */ + requesterUserId?: string; + invocationInputConfig: SessionTurnInputConfig; dispatchSource: SessionDispatchSource; accessPromise: Promise; requestPromise: Promise; @@ -1273,6 +1282,12 @@ export class SessionExecutionService { return reject('stale-turn', 'Steer application arrived after ownership changed'); } + // The provider has accepted this steer and may execute tools before + // history/finalization catches up. Switch causal identity first. + runtime.sourceTurnId = options.userTurnId; + runtime.requesterUserId = options.userId; + runtime.invocationInputConfig = options.inputConfig; + try { await this.finalizeYieldedTurnOutput(runtime, options.sessionId, previousTurnId); } catch (error) { @@ -1325,7 +1340,6 @@ export class SessionExecutionService { runtime.activePromptRun = nextPromptRun; runtime.turnId = nextTurnId; runtime.userTurnId = options.userTurnId; - runtime.requesterUserId = options.userId; this.markCurrentTurn(options.sessionId, nextTurnId); ownedPromptRun.signalSuccessor(); return { @@ -1458,6 +1472,9 @@ export class SessionExecutionService { sessionId, sessionDoc: options.sessionDoc, userTurnId, + sourceTurnId: userTurnId, + requesterUserId: options.requesterUserId, + invocationInputConfig: options.invocationInputConfig, dispatchSource, unhandledErrorCode: 'session_chat_failed', describeUnhandledError: (error) => @@ -1529,16 +1546,24 @@ export class SessionExecutionService { } private createTurnRuntime( - sessionId: SessionId, - turnId: string, - userTurnId?: string, - session?: ISession + options: Pick< + VisibleSessionTurnOptions, + | 'sessionId' + | 'session' + | 'userTurnId' + | 'sourceTurnId' + | 'requesterUserId' + | 'invocationInputConfig' + > & { turnId: string } ): TurnRuntimeState { return { - sessionId, - turnId, - userTurnId, - session, + sessionId: options.sessionId, + turnId: options.turnId, + userTurnId: options.userTurnId, + sourceTurnId: options.sourceTurnId, + requesterUserId: options.requesterUserId, + invocationInputConfig: options.invocationInputConfig, + session: options.session, promptStarted: false, promptInFlight: false, autoPromptInFlight: false, @@ -2529,7 +2554,7 @@ export class SessionExecutionService { options: VisibleSessionTurnOptions, body: (ctx: VisibleSessionTurnContext) => Effect.Effect ): Promise { - const { sessionId, sessionDoc, session, userTurnId } = options; + const { sessionId, sessionDoc, userTurnId } = options; const span = startTraceSpan(this.deps.logger, 'execution.visible_turn', { sessionId, ...(userTurnId ? { userTurnId } : {}), @@ -2564,7 +2589,7 @@ export class SessionExecutionService { deferACPUpdateTarget: true, }); this.markCurrentTurn(sessionId, turnId); - runtime = this.createTurnRuntime(sessionId, turnId, userTurnId, session); + runtime = this.createTurnRuntime({ ...options, turnId }); this.registerTurnRuntime(runtime); } finally { releaseConflict(); @@ -2986,6 +3011,27 @@ export class SessionExecutionService { return this.turnRuntimeBySession.get(sessionId)?.userTurnId; } + getActiveInvocationContext(sessionId: SessionId): + | { + requesterUserId: string; + sourceTurnId: string; + inputConfig: SessionTurnInputConfig; + } + | undefined { + const runtime = this.turnRuntimeBySession.get(sessionId); + if (!runtime) { + return undefined; + } + if (!runtime.requesterUserId || !runtime.sourceTurnId || !runtime.invocationInputConfig) { + throw new Error(`Active invocation identity is unavailable for session ${sessionId}`); + } + return { + requesterUserId: runtime.requesterUserId, + sourceTurnId: runtime.sourceTurnId, + inputConfig: runtime.invocationInputConfig, + }; + } + private async setDispatchProcessing( sessionId: SessionId, sessionDoc: SessionDocument, @@ -3605,7 +3651,6 @@ export class SessionExecutionService { ): Effect.Effect => Effect.gen(function* () { const { turnId, runtime, abortIfCancelled, openAssistantEntry, prompt } = ctx; - runtime.requesterUserId = message.userId; let activeSession = readySession; let staleAcpPromptRecoveryAttempted = false; let baseCommitHash: string | null = null; @@ -4002,6 +4047,9 @@ export class SessionExecutionService { sessionDoc, ...(session ? { session } : {}), userTurnId: executionUserTurnId, + sourceTurnId: userTurnId, + requesterUserId: userId, + invocationInputConfig: acpSessionConfig, ...(dispatchOptions?.dispatchSource ? { dispatchSource: dispatchOptions.dispatchSource } : {}), @@ -4322,6 +4370,9 @@ export class SessionExecutionService { sessionId, sessionDoc, userTurnId, + sourceTurnId: userTurnId, + requesterUserId: message.userId, + invocationInputConfig: acpSessionConfig, ...(dispatchOptions?.dispatchSource ? { dispatchSource: dispatchOptions.dispatchSource } : {}), @@ -4343,7 +4394,6 @@ export class SessionExecutionService { }) => Effect.gen(function* () { setUnhandledErrorContext(turnErrorContext); - runtime.requesterUserId = message.userId; const memoryPressureResult = yield* self.tryPromise(() => self.evictForTurnStart(sessionId) ); diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index aac6e1be1..42dcf3fe0 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -267,9 +267,11 @@ describe('SessionExecutionService', () => { sessionId, turnId: 'assistant:user-1', userTurnId: 'user-1', + sourceTurnId: 'user-1', session: activeSession, promptInFlight: true, requesterUserId: 'user-1', + invocationInputConfig: { prompt: 'initial prompt' }, activePromptRun: initialPromptRun, yieldedFinalization: Promise.resolve(), }; @@ -305,6 +307,11 @@ describe('SessionExecutionService', () => { ); expect(runtime.turnId).toBe('assistant:user-2'); expect(runtime.userTurnId).toBe('user-2'); + expect(service.getActiveInvocationContext(sessionId)).toEqual({ + requesterUserId: 'user-1', + sourceTurnId: 'user-2', + inputConfig: { prompt: 'change direction' }, + }); expect(initialPromptRun.successor?.turnId).toBe('assistant:user-2'); expect(runtime.activePromptRun.turnId).toBe('assistant:user-2'); @@ -1342,7 +1349,7 @@ describe('SessionExecutionService', () => { ); }); - it('starts active presence before prepared dispatch awaits machine access', async () => { + it('exposes RPC invocation identity before prepared dispatch awaits machine access', async () => { let resolveAccess!: (value: { outcome: 'indeterminate'; cause: 'network'; @@ -1370,7 +1377,9 @@ describe('SessionExecutionService', () => { sessionId: 'session-prepared-presence' as SessionId, sessionDoc, userTurnId: 'turn-prepared-presence', - dispatchSource: 'crdt', + requesterUserId: 'user-b', + invocationInputConfig: { prompt: 'fast path prompt', taskToolsEnabled: true }, + dispatchSource: 'rpc', accessPromise, requestPromise: new Promise(() => {}), onAccessAllowed, @@ -1385,12 +1394,17 @@ describe('SessionExecutionService', () => { expect(deps.beginConversationTurn).toHaveBeenCalledWith( 'session-prepared-presence', 'turn-prepared-presence', - { dispatchSource: 'crdt', sessionDoc, deferACPUpdateTarget: true } + { dispatchSource: 'rpc', sessionDoc, deferACPUpdateTarget: true } ); expect(service.getExecutionSnapshot('session-prepared-presence' as SessionId)).toMatchObject({ activeTurnId: 'assistant:turn-prepared-presence', hasActiveTurn: true, }); + expect(service.getActiveInvocationContext('session-prepared-presence' as SessionId)).toEqual({ + requesterUserId: 'user-b', + sourceTurnId: 'turn-prepared-presence', + inputConfig: { prompt: 'fast path prompt', taskToolsEnabled: true }, + }); expect(onAccessAllowed).not.toHaveBeenCalled(); resolveAccess({ outcome: 'indeterminate', cause: 'network', error: 'offline' }); @@ -1458,6 +1472,9 @@ describe('SessionExecutionService', () => { activeTurnId: turnId, hasActiveTurn: true, }); + expect(() => service.getActiveInvocationContext(sessionId)).toThrow( + 'Active invocation identity is unavailable' + ); await expect( service.cancelSession({ diff --git a/packages/shared/src/local-machine-rpc.ts b/packages/shared/src/local-machine-rpc.ts index 512028d7c..98264f1b8 100644 --- a/packages/shared/src/local-machine-rpc.ts +++ b/packages/shared/src/local-machine-rpc.ts @@ -50,7 +50,38 @@ const BaseLocalMachineRpcRequestSchema = z }) .strict(); +export const SessionActiveInvocationContextResultSchema = z.discriminatedUnion('active', [ + z + .object({ + type: z.literal('session/active-invocation-context'), + sessionId: SessionIdSchema, + active: z.literal(false), + }) + .strict(), + z + .object({ + type: z.literal('session/active-invocation-context'), + sessionId: SessionIdSchema, + active: z.literal(true), + requesterUserId: z.string().trim().min(1), + sourceTurnId: z.string().trim().min(1), + inputConfig: z.record(z.string(), z.unknown()), + }) + .strict(), +]); +export type SessionActiveInvocationContextResult = z.infer< + typeof SessionActiveInvocationContextResultSchema +>; + export const LocalMachineRpcRequestSchema = z.discriminatedUnion('method', [ + BaseLocalMachineRpcRequestSchema.extend({ + method: z.literal('session/get-active-invocation-context'), + params: z + .object({ + sessionId: SessionIdSchema, + }) + .strict(), + }).strict(), BaseLocalMachineRpcRequestSchema.extend({ method: z.literal('code-collab/get-file-index'), params: CodeCollabV2FileIndexRequestSchema, @@ -204,6 +235,7 @@ export type LocalMachineRpcRequest = z.infer; - export type FrozenOperationContinuationConfig = { agentConfigId?: string; inputConfig: SessionTurnInputConfig; diff --git a/packages/shared/tests/local-machine-rpc.test.ts b/packages/shared/tests/local-machine-rpc.test.ts index 83c8f01a4..7f373502a 100644 --- a/packages/shared/tests/local-machine-rpc.test.ts +++ b/packages/shared/tests/local-machine-rpc.test.ts @@ -6,6 +6,10 @@ import { describe('local Machine RPC', () => { it.each([ + { + method: 'session/get-active-invocation-context', + params: { sessionId: 'session-1' }, + }, { method: 'session/fork', params: { @@ -230,4 +234,31 @@ describe('local Machine RPC', () => { }; expect(LocalMachineRpcResponseSchema.safeParse(endpointWithoutTarget).success).toBe(false); }); + + it('validates active invocation identity and its frozen input config', () => { + expect( + LocalMachineRpcResponseSchema.safeParse({ + ok: true, + result: { + type: 'session/active-invocation-context', + sessionId: 'session-1', + active: true, + requesterUserId: 'user-b', + sourceTurnId: 'turn-b', + inputConfig: { chainDepth: 1, taskToolsEnabled: true }, + }, + }).success + ).toBe(true); + expect( + LocalMachineRpcResponseSchema.safeParse({ + ok: true, + result: { + type: 'session/active-invocation-context', + sessionId: 'session-1', + active: true, + requesterUserId: 'user-b', + }, + }).success + ).toBe(false); + }); }); From 7aec46ad4a636566bb6bc1604b9344029c14055c Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:05:25 +0800 Subject: [PATCH 6/6] refactor(mcp): simplify invocation identity flow Remove history-based identity reconstruction and keep active execution runtime identity fail-closed. Flatten source-turn provenance, centralize retry identity matching in the operation store, and narrow session delegation/runtime state. Model: gpt-5 --- apps/cli/src/commands/session.test.ts | 19 +- apps/cli/src/commands/session.ts | 213 +++++++++-------- apps/cli/src/lib/message-handler.ts | 13 +- apps/cli/src/mcp/AGENTS.md | 10 +- .../src/mcp/lody-mcp-server-chat-sync.test.ts | 59 +++-- apps/cli/src/mcp/lody-mcp-server.test.ts | 128 +--------- apps/cli/src/mcp/lody-mcp-server.ts | 225 ++++++------------ apps/cli/src/orchestration/AGENTS.md | 9 +- .../src/orchestration/operation-store.test.ts | 74 +++--- apps/cli/src/orchestration/operation-store.ts | 44 ++-- apps/cli/src/session/AGENTS.md | 17 +- .../src/session/session-dispatch-watcher.ts | 7 +- .../src/session/session-execution-service.ts | 77 +++--- .../tests/session-execution-service.test.ts | 22 +- packages/shared/src/session-orchestration.ts | 8 +- 15 files changed, 400 insertions(+), 525 deletions(-) diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index 87bd34024..f2157418c 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -46,7 +46,7 @@ import { resolveOpenedBySessionRelation, resolveSessionCreateOwnerUserId, selectDefaultAgentConfigForCreate, - resolveSessionRequestSubject, + resolveSessionRequester, resolveSessionCommandRequesterUserId, resolveChatArgs, resolveRenameArgs, @@ -349,19 +349,16 @@ describe('session command helpers', () => { }); it('keeps delegated requester identity separate from the authenticated executor', () => { - const requestSubject = { - userId: 'collaborator-b', - kind: 'delegated' as const, - }; + const delegatedRequester = { userId: 'collaborator-b' }; expect( - resolveSessionRequestSubject({ userId: 'machine-owner-a' }, undefined, requestSubject) - ).toEqual(requestSubject); + resolveSessionRequester({ userId: 'machine-owner-a' }, undefined, delegatedRequester) + ).toEqual({ userId: 'collaborator-b', isDelegated: true }); expect( - resolveSessionRequestSubject({ userId: 'machine-owner-c' }, undefined, requestSubject) - ).toEqual(requestSubject); + resolveSessionRequester({ userId: 'machine-owner-c' }, undefined, delegatedRequester) + ).toEqual({ userId: 'collaborator-b', isDelegated: true }); expect(() => - resolveSessionRequestSubject({ userId: 'machine-owner-a' }, 'someone-else', requestSubject) - ).toThrow('Requester identity must match the Session request subject.'); + resolveSessionRequester({ userId: 'machine-owner-a' }, 'someone-else', delegatedRequester) + ).toThrow('Requester identity must match the delegated Session requester.'); }); it('keeps Session ownership separate from the authenticated requester', () => { diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 375a2d50d..799a27ba3 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -127,9 +127,13 @@ import { getCliHttpFetch } from '@/utils/http-transport'; type CommonOptions = CommonCommandOptions; -export type SessionRequestSubject = { +export type DelegatedSessionRequester = { userId: string; - kind: 'direct' | 'delegated'; +}; + +type ResolvedSessionRequester = { + userId: string; + isDelegated: boolean; }; export const DEFAULT_SESSION_LIST_LIMIT = 50; @@ -151,8 +155,8 @@ export type CreateOptions = CommonOptions & currentSessionId?: SessionId; defaultMachineId?: MachineId; requesterUserId?: string; - /** Trusted requester and access mode supplied by an internal caller. */ - requestSubject?: SessionRequestSubject; + /** Trusted human requester supplied by a delegated internal caller. */ + delegatedRequester?: DelegatedSessionRequester; sessionOwnerUserId?: string; parent?: string; useCurrentSessionAsParent?: boolean; @@ -1890,22 +1894,39 @@ export async function readSessionMachineAccess(args: { workspaceId: WorkspaceId; machineId: MachineId; requesterUserId?: string; - requestSubject?: SessionRequestSubject; + delegatedRequester?: DelegatedSessionRequester; localProjectId?: string; }): Promise { - const subject = resolveSessionRequestSubject( + const requester = resolveSessionRequester( args.auth, args.requesterUserId, - args.requestSubject + args.delegatedRequester ); + return await readResolvedSessionMachineAccess({ + auth: args.auth, + workspaceId: args.workspaceId, + machineId: args.machineId, + requester, + ...(args.localProjectId ? { localProjectId: args.localProjectId } : {}), + }); +} + +async function readResolvedSessionMachineAccess(args: { + auth: AuthContext; + workspaceId: WorkspaceId; + machineId: MachineId; + requester: ResolvedSessionRequester; + localProjectId?: string; +}): Promise { try { - const readAccess = - subject.kind === 'delegated' ? canUseMachineForCliToken : canRequestMachineForCliToken; + const readAccess = args.requester.isDelegated + ? canUseMachineForCliToken + : canRequestMachineForCliToken; return await readAccess({ token: args.auth.token, workspaceId: args.workspaceId, machineId: args.machineId, - requesterUserId: subject.userId, + requesterUserId: args.requester.userId, ...(args.localProjectId ? { localProjectId: args.localProjectId } : {}), }); } catch (error) { @@ -1919,11 +1940,10 @@ async function assertMachineAccess(args: { auth: AuthContext; workspaceId: WorkspaceId; machineId: MachineId; - requesterUserId?: string; - requestSubject?: SessionRequestSubject; + requester: ResolvedSessionRequester; localProjectId?: string; }): Promise { - const access = await readSessionMachineAccess(args); + const access = await readResolvedSessionMachineAccess(args); if (!access.allowed) { throw new Error(`Machine access denied for ${args.machineId}: ${access.reason}`); } @@ -2072,29 +2092,26 @@ export function resolveSessionCommandRequesterUserId( return auth.userId; } -export function resolveSessionRequestSubject( +export function resolveSessionRequester( auth: Pick, requesterUserId?: string, - requestSubject?: SessionRequestSubject -): SessionRequestSubject { - if (!requestSubject) { + delegatedRequester?: DelegatedSessionRequester +): ResolvedSessionRequester { + if (!delegatedRequester) { return { userId: resolveSessionCommandRequesterUserId(auth, requesterUserId), - kind: 'direct', + isDelegated: false, }; } - const subjectUserId = normalizeCliValue(requestSubject.userId); - if (!subjectUserId) { - throw new Error('Session request subject must identify a user.'); - } - if (requestSubject.kind === 'direct' && subjectUserId !== auth.userId) { - throw new Error('Direct Session request subject must match the authenticated CLI user.'); + const delegatedUserId = normalizeCliValue(delegatedRequester.userId); + if (!delegatedUserId) { + throw new Error('Delegated Session requester must identify a user.'); } const requested = normalizeCliValue(requesterUserId); - if (requested !== undefined && requested !== subjectUserId) { - throw new Error('Requester identity must match the Session request subject.'); + if (requested !== undefined && requested !== delegatedUserId) { + throw new Error('Requester identity must match the delegated Session requester.'); } - return { userId: subjectUserId, kind: requestSubject.kind }; + return { userId: delegatedUserId, isDelegated: true }; } export function resolveSessionCreateOwnerUserId( @@ -2133,18 +2150,16 @@ async function listAuthorizedMachineMetasForCreate(args: { auth: AuthContext; workspaceId: WorkspaceId; machines: readonly MachineMeta[]; - requesterUserId?: string; - requestSubject?: SessionRequestSubject; + requester: ResolvedSessionRequester; }): Promise { const rows = await Promise.all( args.machines.map(async (machine) => ({ machine, - access: await readSessionMachineAccess({ + access: await readResolvedSessionMachineAccess({ auth: args.auth, workspaceId: args.workspaceId, machineId: machine.id, - requesterUserId: args.requesterUserId, - requestSubject: args.requestSubject, + requester: args.requester, }), })) ); @@ -2161,18 +2176,16 @@ async function filterAuthorizedLocalProjectsForCreate< workspaceId: WorkspaceId; machineId: MachineId; localProjects: readonly T[]; - requesterUserId?: string; - requestSubject?: SessionRequestSubject; + requester: ResolvedSessionRequester; }): Promise { const rows = await Promise.all( args.localProjects.map(async (project) => ({ project, - access: await readSessionMachineAccess({ + access: await readResolvedSessionMachineAccess({ auth: args.auth, workspaceId: args.workspaceId, machineId: args.machineId, - requesterUserId: args.requesterUserId, - requestSubject: args.requestSubject, + requester: args.requester, localProjectId: project.id, }), })) @@ -2189,8 +2202,7 @@ async function resolveTargetMachineForCreate(args: { auth: AuthContext; machineSelector?: string; defaultMachineId?: MachineId; - requesterUserId?: string; - requestSubject?: SessionRequestSubject; + requester: ResolvedSessionRequester; parentSessionId?: SessionId; }): Promise { const machines = await listMachineMetasForWorkspace(args.manager); @@ -2201,8 +2213,7 @@ async function resolveTargetMachineForCreate(args: { auth: args.auth, workspaceId: args.workspaceId, machines, - requesterUserId: args.requesterUserId, - requestSubject: args.requestSubject, + requester: args.requester, }); if (authorizedMachines.length === 0) { throw new Error('No authorized machines are available in this workspace.'); @@ -2292,17 +2303,12 @@ async function assertGitHubRepoAccess(args: { auth: AuthContext; workspaceId: WorkspaceId; repoFullName: string; - requesterUserId?: string; - requestSubject?: SessionRequestSubject; + requesterUserId: string; }): Promise { const repos = await listWorkspaceGitHubRepositoriesForCliToken({ token: args.auth.token, workspaceId: args.workspaceId, - requesterUserId: resolveSessionRequestSubject( - args.auth, - args.requesterUserId, - args.requestSubject - ).userId, + requesterUserId: args.requesterUserId, enabledOnly: true, }); const normalized = args.repoFullName.toLowerCase(); @@ -2317,8 +2323,7 @@ export async function readLocalProjectGitStateOnMachine(args: { machineId: MachineId; localProjectId: string; localRootPath: string; - requesterUserId?: string; - requestSubject?: SessionRequestSubject; + requesterUserId: string; }): Promise< | { success: true; state: Awaited> } | { success: false; error: string; message?: string } @@ -2339,11 +2344,7 @@ export async function readLocalProjectGitStateOnMachine(args: { async (client) => await client.requestLocalProjectGitState({ localProjectId: args.localProjectId as LocalProjectId, - requestedByUserId: resolveSessionRequestSubject( - args.auth, - args.requesterUserId, - args.requestSubject - ).userId, + requestedByUserId: args.requesterUserId, timeoutMs: 30_000, }) ); @@ -2448,18 +2449,13 @@ export function resolveLocalProjectCreateGitContext(args: { async function listWorkspaceGitHubRepositoriesBestEffort(args: { auth: AuthContext; workspaceId: WorkspaceId; - requesterUserId?: string; - requestSubject?: SessionRequestSubject; + requesterUserId: string; }): Promise<{ fullName: string }[]> { try { return await listWorkspaceGitHubRepositoriesForCliToken({ token: args.auth.token, workspaceId: args.workspaceId, - requesterUserId: resolveSessionRequestSubject( - args.auth, - args.requesterUserId, - args.requestSubject - ).userId, + requesterUserId: args.requesterUserId, enabledOnly: true, }); } catch (error) { @@ -2478,8 +2474,7 @@ async function resolveLocalProjectCreateGitContextOnMachine(args: { machineId: MachineId; localProjectId: string; localRootPath: string; - requesterUserId?: string; - requestSubject?: SessionRequestSubject; + requesterUserId: string; requestedBranch?: string; useWorktree?: boolean; }): Promise { @@ -2512,8 +2507,7 @@ async function resolveLocalProjectRefOnMachineOrThrow( auth: AuthContext, machineId: MachineId, selector: string, - requesterUserId: string | undefined, - requestSubject: SessionRequestSubject | undefined, + requester: ResolvedSessionRequester, requestedBranch?: string, useWorktree?: boolean ): Promise { @@ -2529,8 +2523,7 @@ async function resolveLocalProjectRefOnMachineOrThrow( workspaceId, machineId, localProjects, - requesterUserId, - requestSubject, + requester, }); if (authorizedLocalProjects.length === 0) { throw new Error('No authorized local projects are available on the target machine.'); @@ -2559,8 +2552,7 @@ async function resolveLocalProjectRefOnMachineOrThrow( machineId, localProjectId: project.id, localRootPath: project.rootPath, - requesterUserId, - requestSubject, + requesterUserId: requester.userId, requestedBranch, useWorktree, }); @@ -2632,16 +2624,12 @@ async function resolveCreateContext(args: { workspace: WorkspaceSummary; manager: LoroDocumentManager; options: CreateOptions; + requester: ResolvedSessionRequester; skipMachineAvailabilityCheck?: boolean; }): Promise { const workspaceId = args.workspace.id as WorkspaceId; const agentSelector = resolveCreateAgentSelector(args.options); - const requestSubject = resolveSessionRequestSubject( - args.auth, - args.options.requesterUserId, - args.options.requestSubject - ); - const requesterUserId = requestSubject.userId; + const requesterUserId = args.requester.userId; const parentSelector = normalizeCliValue(args.options.parent); const currentSessionId = resolveCreateCurrentSessionId(args.options); if (parentSelector && args.options.useCurrentSessionAsParent === true) { @@ -2686,16 +2674,14 @@ async function resolveCreateContext(args: { auth: args.auth, machineSelector: args.options.machine, defaultMachineId: args.options.defaultMachineId, - requesterUserId, - requestSubject, + requester: args.requester, parentSessionId, }); await assertMachineAccess({ auth: args.auth, workspaceId, machineId: targetMachine.id, - requesterUserId, - requestSubject, + requester: args.requester, }); if (args.skipMachineAvailabilityCheck !== true) { await ensureTargetMachineOnline({ @@ -2731,7 +2717,6 @@ async function resolveCreateContext(args: { workspaceId, repoFullName: parentRepoFullName, requesterUserId, - requestSubject, }); } } else if (normalizedRepo) { @@ -2740,7 +2725,6 @@ async function resolveCreateContext(args: { workspaceId, repoFullName: normalizedRepo, requesterUserId, - requestSubject, }); const branch = resolveBaseBranchPreference({ preferredBranch: requestedBranch, @@ -2754,8 +2738,7 @@ async function resolveCreateContext(args: { args.auth, targetMachine.id, normalizedLocalProject, - requesterUserId, - requestSubject, + args.requester, requestedBranch, args.options.worktree === true ); @@ -2765,8 +2748,7 @@ async function resolveCreateContext(args: { auth: args.auth, workspaceId, machineId: targetMachine.id, - requesterUserId, - requestSubject, + requester: args.requester, localProjectId: project?.kind === 'local' ? project.localProjectId : undefined, }); @@ -2798,7 +2780,12 @@ export async function validateSessionCreateOptions(args: { */ dispatchConfig?: ResolvedTurnDispatchConfig; }): Promise { - const resolved = await resolveCreateContext(args); + const requester = resolveSessionRequester( + args.auth, + args.options.requesterUserId, + args.options.delegatedRequester + ); + const resolved = await resolveCreateContext({ ...args, requester }); return await resolveEffectiveSessionCreateDispatchConfig({ manager: args.manager, workspaceId: args.workspace.id as WorkspaceId, @@ -2985,16 +2972,17 @@ export async function createSessionResult( sessionId: options.sessionId, }); } - const requesterUserId = resolveSessionRequestSubject( + const requester = resolveSessionRequester( auth, options.requesterUserId, - options.requestSubject - ).userId; + options.delegatedRequester + ); + const requesterUserId = requester.userId; const sessionOwnerUserId = resolveSessionCreateOwnerUserId( requesterUserId, options.sessionOwnerUserId ); - const resolved = await resolveCreateContext({ auth, workspace, manager, options }); + const resolved = await resolveCreateContext({ auth, workspace, manager, options, requester }); const { targetMachine, agentConfig, @@ -3161,15 +3149,30 @@ export async function validateSessionChatTarget(args: { manager: LoroDocumentManager; sessionId: SessionId; requesterUserIdOverride?: string; - requestSubject?: SessionRequestSubject; + delegatedRequester?: DelegatedSessionRequester; }): Promise { - await syncWorkspaceMetaForRead(args.manager, `session.chat:${args.sessionId}:prewrite:meta`); - const requestSubject = resolveSessionRequestSubject( + const requester = resolveSessionRequester( args.auth, args.requesterUserIdOverride, - args.requestSubject + args.delegatedRequester ); - const requesterUserId = requestSubject.userId; + return await validateSessionChatTargetForRequester({ + auth: args.auth, + workspace: args.workspace, + manager: args.manager, + sessionId: args.sessionId, + requester, + }); +} + +async function validateSessionChatTargetForRequester(args: { + auth: AuthContext; + workspace: WorkspaceSummary; + manager: LoroDocumentManager; + sessionId: SessionId; + requester: ResolvedSessionRequester; +}): Promise { + await syncWorkspaceMetaForRead(args.manager, `session.chat:${args.sessionId}:prewrite:meta`); const session = await resolveSessionMetaOrThrow(args.manager, args.sessionId); if (session.isArchived) { throw new Error(`Session ${args.sessionId} is archived. Restore it before chatting.`); @@ -3178,8 +3181,7 @@ export async function validateSessionChatTarget(args: { auth: args.auth, workspaceId: args.workspace.id as WorkspaceId, machineId: session.machineId, - requesterUserId, - requestSubject, + requester: args.requester, localProjectId: session.project?.kind === 'local' ? session.project.localProjectId : undefined, }); await ensureTargetMachineOnline({ @@ -3208,7 +3210,7 @@ export async function sendSessionChatResult( chainDepth: number; bypassSessionQuota?: boolean; }, - requestSubject?: SessionRequestSubject + delegatedRequester?: DelegatedSessionRequester ): Promise<{ sessionId: SessionId; machineId: MachineId; @@ -3216,18 +3218,14 @@ export async function sendSessionChatResult( userTurnId: string; completionPromise?: Promise>>; }> { - const requesterUserId = resolveSessionRequestSubject( - auth, - requesterUserIdOverride, - requestSubject - ).userId; - const session = await validateSessionChatTarget({ + const requester = resolveSessionRequester(auth, requesterUserIdOverride, delegatedRequester); + const requesterUserId = requester.userId; + const session = await validateSessionChatTargetForRequester({ auth, workspace, manager, sessionId, - requesterUserIdOverride, - requestSubject, + requester, }); if (dispatchConfig.modeId || dispatchConfig.modelId || dispatchConfig.configOptionValues) { const capability = await readAgentAcpCapability({ @@ -4054,6 +4052,7 @@ const sessionCancelCommand = new Command('cancel') auth, workspaceId, machineId: session.machineId, + requester: resolveSessionRequester(auth), localProjectId: session.project?.kind === 'local' ? session.project.localProjectId : undefined, }); diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index cd1f33c82..3c18242be 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -2836,9 +2836,8 @@ export class MessageHandler { throw new Error(`Requester Session not found: ${operation.requesterSessionId}`); } const requester = requesterRecord.meta as SessionMeta; - const delegation = operation.frozenContinuationConfig.delegation; - const requestSubject = delegation - ? ({ userId: operation.requesterUserId, kind: 'delegated' } as const) + const delegatedRequester = operation.frozenContinuationConfig.sourceTurnId + ? ({ userId: operation.requesterUserId } as const) : undefined; if (operation.kind === 'session_create' || operation.kind === 'session_create_many') { @@ -2864,8 +2863,8 @@ export class MessageHandler { workspace: this.workspaceId, currentSessionId: operation.requesterSessionId, workspaceMetaPrewriteSatisfied: true, - ...(requestSubject - ? { requestSubject } + ...(delegatedRequester + ? { delegatedRequester } : { requesterUserId: operation.requesterUserId, sessionOwnerUserId: requester.userId, @@ -2918,13 +2917,13 @@ export class MessageHandler { taskToolsEnabled: operation.frozenContinuationConfig.inputConfig.taskToolsEnabled === true, }, undefined, - requestSubject ? undefined : operation.requesterUserId, + delegatedRequester ? undefined : operation.requesterUserId, { userTurnId: item.target.userTurnId, chainDepth: operation.initiatorChainDepth + 1, bypassSessionQuota: shouldBypassSessionQuota(operation.kind), }, - requestSubject + delegatedRequester ); } diff --git a/apps/cli/src/mcp/AGENTS.md b/apps/cli/src/mcp/AGENTS.md index 18ec25e47..23b4276d9 100644 --- a/apps/cli/src/mcp/AGENTS.md +++ b/apps/cli/src/mcp/AGENTS.md @@ -28,15 +28,15 @@ Root and `apps/cli/AGENTS.md` instructions apply. the workspace catalog; no driving-Turn mention authorization is required. Resolve its target, Prompt prefix, revision, and concrete run config before Operation acceptance. Recovery uses the frozen canonical Prompt and target dispatch config and never rereads the mutable catalog. -- Session orchestration derives its human principal from the active execution runtime populated +- Session orchestration derives its human identity from the active execution runtime populated by the dispatch payload, not from the daemon credential, Session owner, or observed history. - Persisted history is only a legacy fallback when no active runtime identity exists. Freeze - the source Turn id and principal user with every accepted Operation. Store the user - once as `requesterUserId`; the delegation binding stores only the source Turn. The + An absent active runtime fails closed; never reconstruct invocation identity from history. + Freeze the source Turn id and invoking user with every accepted Operation. Store the user + once as `requesterUserId` and the causal Turn as `sourceTurnId`. The Operation's requester Session id already identifies the source Session, and a single-value actor tag adds no information. Recovery uses the Operation's owner Machine plus current authorization; it does not freeze the daemon account that originally accepted the Operation. - Legacy synchronous paths reject a source Turn without userId. + Every MCP Session path rejects a runtime invocation without userId. - Direct Role creation stays on the ordinary `lody_session_create` and `lody_session_create_many` tools. When `agentRoleId` is present, tolerate manual Machine, Agent, and run-config fields but remove them before resolution: the current Role row is authoritative diff --git a/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts b/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts index 0768ecdb4..5989ae7fa 100644 --- a/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts +++ b/apps/cli/src/mcp/lody-mcp-server-chat-sync.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ accept: vi.fn(), + activeInvocation: vi.fn(), findMatchingRetry: vi.fn(), getDocMeta: vi.fn(), - getHistory: vi.fn(), validateSessionChatTarget: vi.fn(), })); @@ -24,7 +24,6 @@ vi.mock('@/lib/command-runtime', async (importOriginal) => { ) => await fn({ repo: { getDocMeta: mocks.getDocMeta }, - getOrCreateSessionDoc: vi.fn(async () => ({ getHistory: mocks.getHistory })), getOnlineMachineIds: vi.fn(async () => new Set(['machine-id'])), }) ), @@ -58,14 +57,10 @@ vi.mock('@lody/shared/node/local-ipc', async (importOriginal) => { ...actual, makeLocalControlClientAuto: vi.fn(() => ({ machineRpc: vi.fn(() => - Effect.succeed({ + Effect.sync(() => ({ ok: true as const, - result: { - type: 'session/active-invocation-context' as const, - sessionId: 'requester-session-id', - active: false as const, - }, - }) + result: mocks.activeInvocation(), + })) ), })), }; @@ -97,15 +92,6 @@ const targetSession = { agentType: 'codex', }; -const invokingTurn = { - id: 'requester-turn-id', - role: 'user' as const, - userId: 'requester-user-id', - timestamp: '2026-09-04T00:00:00.000Z', - items: [], - fileDiff: [], -}; - const expectRetryableSyncResult = (result: ReturnType): void => { const content = result.content[0]; if (!content || content.type !== 'text') throw new Error('expected text result'); @@ -134,8 +120,15 @@ describe('session chat prevalidation sync failures', () => { vi.stubEnv('LODY_MCP_MACHINE_ID', 'machine-id'); vi.stubEnv('LODY_MCP_WORKSPACE_ID', 'workspace-id'); vi.stubEnv('LODY_MCP_SESSION_ID', requesterSession.id); + mocks.activeInvocation.mockReturnValue({ + type: 'session/active-invocation-context' as const, + sessionId: requesterSession.id, + active: true as const, + requesterUserId: requesterSession.userId, + sourceTurnId: 'requester-turn-id', + inputConfig: {}, + }); mocks.findMatchingRetry.mockReturnValue(undefined); - mocks.getHistory.mockResolvedValue([invokingTurn]); mocks.getDocMeta .mockResolvedValueOnce({ meta: requesterSession }) .mockResolvedValueOnce({ meta: targetSession }); @@ -175,4 +168,32 @@ describe('session chat prevalidation sync failures', () => { expectRetryableSyncResult(result); expect(mocks.accept).not.toHaveBeenCalled(); }); + + it('fails closed when the active execution runtime has already been released', async () => { + mocks.activeInvocation.mockReturnValue({ + type: 'session/active-invocation-context' as const, + sessionId: requesterSession.id, + active: false as const, + }); + + const result = await callAndMapMcpError(() => + startSessionChatOperation({ + operationId: 'inactive-runtime-operation', + sessionId: targetSession.id, + prompt: 'continue', + }) + ); + const content = result.content[0]; + if (!content || content.type !== 'text') throw new Error('expected text result'); + expect(JSON.parse(content.text)).toEqual({ + ok: false, + error: { + code: 'INVOKING_TURN_NOT_FOUND', + message: 'The exact Turn driving this MCP invocation is no longer active.', + retryable: false, + }, + }); + expect(mocks.validateSessionChatTarget).not.toHaveBeenCalled(); + expect(mocks.accept).not.toHaveBeenCalled(); + }); }); diff --git a/apps/cli/src/mcp/lody-mcp-server.test.ts b/apps/cli/src/mcp/lody-mcp-server.test.ts index ebe387370..4b1632a29 100644 --- a/apps/cli/src/mcp/lody-mcp-server.test.ts +++ b/apps/cli/src/mcp/lody-mcp-server.test.ts @@ -13,10 +13,8 @@ import { type AgentRole, type AgentRoleId, type MachineId, - type SessionHistoryInput, type SessionId, type SessionTurnInputConfig, - type StoredLodyOperation, type WorkspaceId, } from '@lody/shared'; import { @@ -80,29 +78,13 @@ const { getSessionContext, resolveOperationStorePathForContext, resolveUploadPath, - resolveInvokingHistoryInput, - selectInvokingTurnSource, - buildInvokingTurnPrincipal, - assertOperationRetryPrincipal, + buildInvocationIdentity, summarizeProjectRefForMcp, resolveSessionExecutionSnapshot, makeMachineOnlineLookupForMcp, truncateUtf8HeadTail, } = __lodyMcpServerInternals; -const historyTurn = ( - id: string, - role: SessionHistoryInput['role'], - chainDepth?: number -): SessionHistoryInput => ({ - id, - role, - timestamp: '2026-07-20T00:00:00.000Z', - items: [], - fileDiff: [], - ...(chainDepth === undefined ? {} : { inputConfig: { chainDepth } }), -}); - const createMcpContext = (): ReturnType => ({ machineId: 'machine-id', workspaceId: 'workspace-id', @@ -291,106 +273,13 @@ describe('session MCP input schemas', () => { expect(result.isError).toBe(true); }); - it('anchors continuation depth before a later queued human input', () => { - const completion = historyTurn('operation-completion', 'system', 5); - const executingAssistant = historyTurn('assistant:continuation', 'assistant'); - const queuedHumanInput = historyTurn('queued-human', 'user', 0); - - expect(resolveInvokingHistoryInput([completion, executingAssistant, queuedHumanInput])).toBe( - completion - ); - }); - - it('uses RPC runtime identity while the driving user Turn is absent from local history', () => { - const previousUser = { ...historyTurn('turn-a', 'user'), userId: 'user-a' }; - const previousAssistant = { - ...historyTurn('assistant:turn-a', 'assistant'), - userTurnId: previousUser.id, - }; - - expect( - selectInvokingTurnSource([previousUser, previousAssistant], { - type: 'session/active-invocation-context', - sessionId: 'current-session-id', - active: true, - requesterUserId: 'user-b', - sourceTurnId: 'turn-b', - inputConfig: { chainDepth: 1, taskToolsEnabled: true }, - }) - ).toEqual({ - id: 'turn-b', - userId: 'user-b', - inputConfig: { chainDepth: 1, taskToolsEnabled: true }, - }); - }); - - it('keeps RPC runtime identity after the history gate times out', () => { - const previousUser = { ...historyTurn('turn-a', 'user'), userId: 'user-a' }; - const previousAssistant = { - ...historyTurn('assistant:turn-a', 'assistant'), - userTurnId: previousUser.id, - }; - const unlinkedCurrentAssistant = { - ...historyTurn('assistant:turn-b', 'assistant'), - userTurnId: 'turn-b', - }; - const history = [previousUser, previousAssistant, unlinkedCurrentAssistant]; - expect(resolveInvokingHistoryInput(history)).toBe(previousUser); - expect( - selectInvokingTurnSource(history, { - type: 'session/active-invocation-context', - sessionId: 'current-session-id', - active: true, - requesterUserId: 'user-b', - sourceTurnId: 'turn-b', - inputConfig: { chainDepth: 2 }, - }) - ).toMatchObject({ id: 'turn-b', userId: 'user-b' }); - }); - it('derives delegated identity from the exact driving Turn', () => { - expect(buildInvokingTurnPrincipal({ id: 'source-turn', userId: 'collaborator-b' })).toEqual({ - userId: 'collaborator-b', - sourceTurnId: 'source-turn', - }); - expect(() => buildInvokingTurnPrincipal({ id: 'legacy-turn' })).toThrow( - 'has no authenticated human identity' - ); - }); - - it('binds Operation retries to the original invoking Turn', () => { - const principal = { - userId: 'collaborator-b', - sourceTurnId: 'source-turn-b', - }; - const operation = { - operationId: 'review-1', - requesterUserId: principal.userId, - frozenContinuationConfig: { - inputConfig: {}, - delegation: { - sourceTurnId: principal.sourceTurnId, - }, - }, - } as StoredLodyOperation; - - expect(() => assertOperationRetryPrincipal(operation, principal)).not.toThrow(); - expect(() => - assertOperationRetryPrincipal( - { - ...operation, - frozenContinuationConfig: { inputConfig: {} }, - } as StoredLodyOperation, - principal - ) - ).toThrow('has no frozen delegated principal provenance'); + expect( + buildInvocationIdentity({ id: 'source-turn', userId: 'collaborator-b', inputConfig: {} }) + ).toEqual({ userId: 'collaborator-b', sourceTurnId: 'source-turn' }); expect(() => - assertOperationRetryPrincipal(operation, { - ...principal, - userId: 'collaborator-c', - sourceTurnId: 'source-turn-c', - }) - ).toThrow('already bound to a different invoking Turn'); + buildInvocationIdentity({ id: 'legacy-turn', userId: ' ', inputConfig: {} }) + ).toThrow('has no authenticated human identity'); }); it('uses stable ids and rejects legacy selector names', () => { @@ -586,10 +475,7 @@ describe('session MCP input schemas', () => { machine: 'machine-id', agentConfig: 'agent-config-id', useCurrentSessionAsParent: true, - requestSubject: { - userId: 'collaborator-b', - kind: 'delegated', - }, + delegatedRequester: { userId: 'collaborator-b' }, defaultMachineId: 'machine-id', }); }); diff --git a/apps/cli/src/mcp/lody-mcp-server.ts b/apps/cli/src/mcp/lody-mcp-server.ts index 6112a515d..3b8433b30 100644 --- a/apps/cli/src/mcp/lody-mcp-server.ts +++ b/apps/cli/src/mcp/lody-mcp-server.ts @@ -48,7 +48,6 @@ import { type MachineId, type MachineMeta, type ProjectRef, - type SessionHistoryInput, type SessionId, type SessionMeta, SessionActiveInvocationContextResultSchema, @@ -73,7 +72,6 @@ import { resolveActiveAssistantTurnId, resolveProjectGitHubRepo, type LodyOperationItemResult, - type StoredLodyOperation, type SessionTurnInputConfig, REVIEW_SEVERITY_VALUES, REVIEW_VERDICT_VALUES, @@ -130,7 +128,7 @@ import { type CreateOptions, type ResolvedTurnDispatchConfig, type SessionLiveStatusBatchItem, - type SessionRequestSubject, + type DelegatedSessionRequester, } from '@/commands/session'; import type { SessionTurnOutputEvent, @@ -2131,16 +2129,14 @@ const canUseMachineForOptions = async (args: { auth: AuthContext; workspaceId: WorkspaceId; machineId: MachineId; - requesterUserId: string; - requestSubject: SessionRequestSubject; + delegatedRequester: DelegatedSessionRequester; localProjectId?: string; }): Promise => { const access = await readSessionMachineAccess({ auth: args.auth, workspaceId: args.workspaceId, machineId: args.machineId, - requesterUserId: args.requesterUserId, - requestSubject: args.requestSubject, + delegatedRequester: args.delegatedRequester, ...(args.localProjectId ? { localProjectId: args.localProjectId } : {}), }); return access.allowed; @@ -2150,8 +2146,7 @@ const filterAuthorizedMachinesForOptions = async ( auth: AuthContext, workspaceId: WorkspaceId, machines: readonly MachineMeta[], - requesterUserId: string, - requestSubject: SessionRequestSubject + delegatedRequester: DelegatedSessionRequester ): Promise => { const rows = await Promise.all( machines.map(async (machine) => ({ @@ -2160,8 +2155,7 @@ const filterAuthorizedMachinesForOptions = async ( auth, workspaceId, machineId: machine.id, - requesterUserId, - requestSubject, + delegatedRequester, }), })) ); @@ -2173,8 +2167,7 @@ const filterAuthorizedLocalProjectsForOptions = async ( workspaceId: WorkspaceId, machineId: MachineId, localProjects: readonly LocalProjectMeta[], - requesterUserId: string, - requestSubject: SessionRequestSubject + delegatedRequester: DelegatedSessionRequester ): Promise => { const rows = await Promise.all( localProjects.map(async (project) => ({ @@ -2183,8 +2176,7 @@ const filterAuthorizedLocalProjectsForOptions = async ( auth, workspaceId, machineId, - requesterUserId, - requestSubject, + delegatedRequester, localProjectId: project.id, }), })) @@ -2223,7 +2215,7 @@ const bindMcpCreateContext = ( identity: InvocationIdentity, requester: Pick ): void => { - options.requestSubject = toDelegatedSessionRequestSubject(identity); + options.delegatedRequester = toDelegatedSessionRequester(identity); options.defaultMachineId = requester.machineId; }; @@ -2232,24 +2224,21 @@ type InvocationIdentity = { sourceTurnId: string; }; -const toDelegatedSessionRequestSubject = (identity: InvocationIdentity): SessionRequestSubject => ({ +const toDelegatedSessionRequester = (identity: InvocationIdentity): DelegatedSessionRequester => ({ userId: identity.userId, - kind: 'delegated', }); type InvokingTurnContext = { chainDepth: number; frozenInputConfig: SessionTurnInputConfig; - principal: InvocationIdentity; + identity: InvocationIdentity; }; -const buildInvokingTurnPrincipal = ( - source: Pick -): InvocationIdentity => { +const buildInvocationIdentity = (source: InvokingTurnSource): InvocationIdentity => { const userId = source.userId?.trim(); if (!userId) { throw new LodyOperationStoreError( - 'INVOKING_PRINCIPAL_UNAVAILABLE', + 'INVOKING_USER_UNAVAILABLE', `The driving Turn ${source.id} has no authenticated human identity.`, false ); @@ -2260,63 +2249,24 @@ const buildInvokingTurnPrincipal = ( }; }; -const freezeOperationDelegation = ({ sourceTurnId }: InvocationIdentity) => ({ sourceTurnId }); +type InvokingTurnSource = { + id: string; + userId: string; + inputConfig: SessionTurnInputConfig; +}; -const assertOperationRetryPrincipal = ( - operation: StoredLodyOperation, - principal: InvocationIdentity -): void => { - const stored = operation.frozenContinuationConfig.delegation; - if (!stored) { - throw new LodyOperationStoreError( - 'OPERATION_ID_REUSED', - `Operation id ${operation.operationId} has no frozen delegated principal provenance.`, - false - ); - } - if ( - operation.requesterUserId !== principal.userId || - stored.sourceTurnId !== principal.sourceTurnId - ) { +const resolveInvokingTurnSource = async (): Promise => { + const active = await readActiveInvocationContext(getSessionContext()); + if (!active.active) { throw new LodyOperationStoreError( - 'OPERATION_ID_REUSED', - `Operation id ${operation.operationId} is already bound to a different invoking Turn.`, + 'INVOKING_TURN_NOT_FOUND', + 'The exact Turn driving this MCP invocation is no longer active.', false ); } -}; - -const resolveInvokingHistoryInput = ( - history: SessionHistoryInput[] -): SessionHistoryInput | undefined => { - let assistantIndex = -1; - for (let index = history.length - 1; index >= 0; index -= 1) { - if (history[index]?.role === 'assistant') { - assistantIndex = index; - break; - } - } - const assistant = assistantIndex >= 0 ? history[assistantIndex] : undefined; - if (assistant?.userTurnId) { - const linked = history.find((entry) => entry.id === assistant.userTurnId); - if (linked) return linked; - } - const inputsBeforeExecution = assistantIndex >= 0 ? history.slice(0, assistantIndex) : history; - return [...inputsBeforeExecution] - .reverse() - .find((entry) => entry.role === 'user' || entry.role === 'system'); -}; - -type InvokingTurnSource = Pick; - -const selectInvokingTurnSource = ( - history: SessionHistoryInput[], - active: SessionActiveInvocationContextResult -): InvokingTurnSource | undefined => { - if (!active.active) { - return resolveInvokingHistoryInput(history); - } - const inputConfig = normalizeSessionTurnInputConfig(active.inputConfig); + const inputConfig = + normalizeSessionTurnInputConfig(active.inputConfig) ?? + (Object.keys(active.inputConfig).length === 0 ? {} : undefined); if (!inputConfig) { throw new LodyOperationStoreError( 'INVOKING_TURN_NOT_FOUND', @@ -2331,18 +2281,6 @@ const selectInvokingTurnSource = ( }; }; -const resolveInvokingTurnSource = async ( - manager: LoroDocumentManager, - sessionId: SessionId -): Promise => { - const active = await readActiveInvocationContext(getSessionContext()); - if (active.active) { - return selectInvokingTurnSource([], active); - } - const sessionDoc = await manager.getOrCreateSessionDoc(sessionId); - return selectInvokingTurnSource(await sessionDoc.getHistory(), active); -}; - const assertInvokingTurnTaskToolsEnabled = async ( manager: LoroDocumentManager, sessionId: SessionId @@ -2355,8 +2293,8 @@ const assertInvokingTurnTaskToolsEnabled = async ( false ); } - const source = await resolveInvokingTurnSource(manager, session.id); - if (source?.inputConfig?.taskToolsEnabled !== true) { + const source = await resolveInvokingTurnSource(); + if (source.inputConfig.taskToolsEnabled !== true) { throw new LodyOperationStoreError( 'TASK_TOOLS_DISABLED', 'Lody Task tools are disabled for the driving user turn.', @@ -2365,19 +2303,9 @@ const assertInvokingTurnTaskToolsEnabled = async ( } }; -const resolveInvokingTurnContext = async ( - manager: LoroDocumentManager, - session: SessionMeta -): Promise => { - const source = await resolveInvokingTurnSource(manager, session.id); - if (!source) { - throw new LodyOperationStoreError( - 'INVOKING_TURN_NOT_FOUND', - 'The exact Turn driving this MCP invocation is unavailable.', - false - ); - } - const chainDepth = source.inputConfig?.chainDepth ?? 0; +const resolveInvokingTurnContext = async (session: SessionMeta): Promise => { + const source = await resolveInvokingTurnSource(); + const chainDepth = source.inputConfig.chainDepth ?? 0; if (chainDepth >= LODY_MAX_CHAIN_DEPTH) { throw new LodyOperationStoreError( 'CHAIN_DEPTH_EXCEEDED', @@ -2387,11 +2315,11 @@ const resolveInvokingTurnContext = async ( } return { chainDepth, - principal: buildInvokingTurnPrincipal(source), + identity: buildInvocationIdentity(source), frozenInputConfig: { - ...(source.inputConfig ?? {}), - cliType: source.inputConfig?.cliType ?? session.cliType, - agentType: source.inputConfig?.agentType ?? session.agentType, + ...source.inputConfig, + cliType: source.inputConfig.cliType ?? session.cliType, + agentType: source.inputConfig.agentType ?? session.agentType, chainDepth, }, }; @@ -2576,7 +2504,6 @@ const summarizeLocalProjectForOptions = async ( machine: MachineMeta, project: LocalProjectMeta, requesterUserId: string, - requestSubject: SessionRequestSubject, machineOnline: boolean ) => { const gitState = @@ -2588,7 +2515,6 @@ const summarizeLocalProjectForOptions = async ( localProjectId: project.id, localRootPath: project.rootPath, requesterUserId, - requestSubject, }).catch((error: unknown) => ({ success: false as const, error: String(error), @@ -2620,8 +2546,9 @@ const buildSessionCreateOptions = async ( if (!currentSession) { throw new Error(`Session not found: ${ctx.sessionId}`); } - const invoking = await resolveInvokingTurnContext(manager, currentSession); - const requesterUserId = invoking.principal.userId; + const invoking = await resolveInvokingTurnContext(currentSession); + const requesterUserId = invoking.identity.userId; + const delegatedRequester = toDelegatedSessionRequester(invoking.identity); const machineEntries = await listAliveDocMetas(manager, isMachineDocRoomId); const onlineMachineIds = await manager.getOnlineMachineIds(); const isMachineOnline = (machineId: MachineId): boolean => @@ -2634,8 +2561,7 @@ const buildSessionCreateOptions = async ( auth, workspaceId, machineCandidates, - requesterUserId, - toDelegatedSessionRequestSubject(invoking.principal) + delegatedRequester ); const selectedMachine = selectMachineForOptions( machines, @@ -2688,8 +2614,7 @@ const buildSessionCreateOptions = async ( workspaceId, selectedMachine.id, localProjectCandidates, - requesterUserId, - toDelegatedSessionRequestSubject(invoking.principal) + delegatedRequester ) ).slice(0, MAX_MCP_CREATE_OPTION_MATCHES); const summarizedLocalProjects = await Promise.all( @@ -2700,7 +2625,6 @@ const buildSessionCreateOptions = async ( selectedMachine, project, requesterUserId, - toDelegatedSessionRequestSubject(invoking.principal), isMachineOnline(selectedMachine.id) ) ) @@ -2758,7 +2682,7 @@ const startSessionCreateOperation = async (args: SessionCreateCommandInput): Pro false ); } - const invoking = await resolveInvokingTurnContext(manager, currentSession); + const invoking = await resolveInvokingTurnContext(currentSession); const roleCatalog = args.agentRoleId ? await loadWorkspaceAgentRoleCatalog(manager, workspace.id as WorkspaceId) : undefined; @@ -2774,17 +2698,18 @@ const startSessionCreateOperation = async (args: SessionCreateCommandInput): Pro ctx.sessionId as SessionId, args.operationId!, 'session_create', - canonicalCommand + canonicalCommand, + invoking.identity.userId, + invoking.identity.sourceTurnId ) ); if (retry) { - assertOperationRetryPrincipal(retry, invoking.principal); return await withOperationStore((store) => store.snapshot(retry)); } const targetMachineId = (resolved.input.machineId ?? currentSession.machineId) as MachineId; await assertMachineOnlineForSingleCommand(manager, targetMachineId, ctx); const createOptions = buildMcpCreateOptions(resolved.input, ctx); - bindMcpCreateContext(createOptions, invoking.principal, currentSession); + bindMcpCreateContext(createOptions, invoking.identity, currentSession); bindAgentRoleCreateOptions(createOptions, resolved.role); createOptions.workspaceMetaPrewriteSatisfied = true; let effectiveDispatchConfig: ResolvedTurnDispatchConfig; @@ -2813,7 +2738,7 @@ const startSessionCreateOperation = async (args: SessionCreateCommandInput): Pro workspaceId: workspace.id as WorkspaceId, ownerMachineId: ctx.machineId as MachineId, requesterSessionId: ctx.sessionId as SessionId, - requesterUserId: invoking.principal.userId, + requesterUserId: invoking.identity.userId, operationId: args.operationId!, kind: 'session_create', canonicalCommand, @@ -2822,7 +2747,7 @@ const startSessionCreateOperation = async (args: SessionCreateCommandInput): Pro ? { agentConfigId: currentSession.agentConfigId } : {}), inputConfig: invoking.frozenInputConfig, - delegation: freezeOperationDelegation(invoking.principal), + sourceTurnId: invoking.identity.sourceTurnId, targetDispatchConfigs: [effectiveDispatchConfig], }, initiatorChainDepth: invoking.chainDepth, @@ -2906,7 +2831,7 @@ const startSessionChatOperation = async (args: SessionChatToolInput): Promise store.snapshot(retry)); } const targetSession = await readCurrentSessionMeta(manager, args.sessionId as SessionId); @@ -2940,7 +2866,7 @@ const startSessionChatOperation = async (args: SessionChatToolInput): Promise ({ ...(args.defaults ?? {}), ...item })); - const invoking = await resolveInvokingTurnContext(manager, requester); + const invoking = await resolveInvokingTurnContext(requester); const roleCatalog = expanded.some((item) => Boolean(item.agentRoleId)) ? await loadWorkspaceAgentRoleCatalog(manager, workspace.id as WorkspaceId) : undefined; @@ -3222,11 +3148,12 @@ const startSessionCreateManyOperation = async ( ctx.sessionId as SessionId, args.operationId, 'session_create_many', - canonicalCommand + canonicalCommand, + invoking.identity.userId, + invoking.identity.sourceTurnId ) ); if (retry) { - assertOperationRetryPrincipal(retry, invoking.principal); return await withOperationStore((store) => store.snapshot(retry)); } const isMachineOnline = makeMachineOnlineLookupForMcp(manager, ctx); @@ -3299,7 +3226,7 @@ const startSessionCreateManyOperation = async ( }; } const options = buildMcpCreateOptions(resolved.input, ctx); - bindMcpCreateContext(options, invoking.principal, requester); + bindMcpCreateContext(options, invoking.identity, requester); bindAgentRoleCreateOptions(options, resolved.role); try { const effectiveDispatchConfig = await validateSessionCreateOptions({ @@ -3338,14 +3265,14 @@ const startSessionCreateManyOperation = async ( workspaceId: workspace.id as WorkspaceId, ownerMachineId: ctx.machineId as MachineId, requesterSessionId: ctx.sessionId as SessionId, - requesterUserId: invoking.principal.userId, + requesterUserId: invoking.identity.userId, operationId: args.operationId, kind: 'session_create_many', canonicalCommand, frozenContinuationConfig: { ...(requester.agentConfigId ? { agentConfigId: requester.agentConfigId } : {}), inputConfig: invoking.frozenInputConfig, - delegation: freezeOperationDelegation(invoking.principal), + sourceTurnId: invoking.identity.sourceTurnId, targetDispatchConfigs, }, initiatorChainDepth: invoking.chainDepth, @@ -3382,7 +3309,7 @@ const startSessionCreateManyOperation = async ( } try { const options = buildMcpCreateOptions(resolved.input, ctx); - bindMcpCreateContext(options, invoking.principal, requester); + bindMcpCreateContext(options, invoking.identity, requester); bindAgentRoleCreateOptions(options, resolved.role); options.sessionId = storedItem.target.sessionId; options.userTurnId = storedItem.target.userTurnId; @@ -3451,7 +3378,7 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr ); } const expanded = args.items.map((item) => ({ ...(args.defaults ?? {}), ...item })); - const invoking = await resolveInvokingTurnContext(manager, requester); + const invoking = await resolveInvokingTurnContext(requester); const canonicalCommand = { items: expanded, ...(args.deadlineSeconds !== undefined ? { deadlineSeconds: args.deadlineSeconds } : {}), @@ -3461,11 +3388,12 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr ctx.sessionId as SessionId, args.operationId, 'session_chat_many', - canonicalCommand + canonicalCommand, + invoking.identity.userId, + invoking.identity.sourceTurnId ) ); if (retry) { - assertOperationRetryPrincipal(retry, invoking.principal); return await withOperationStore((store) => store.snapshot(retry)); } const isMachineOnline = makeMachineOnlineLookupForMcp(manager, ctx); @@ -3512,7 +3440,7 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr workspace, manager, sessionId: target.id, - requestSubject: toDelegatedSessionRequestSubject(invoking.principal), + delegatedRequester: toDelegatedSessionRequester(invoking.identity), }); } catch (error) { if (error instanceof WorkspaceSyncUnavailableError) { @@ -3531,14 +3459,14 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr workspaceId: workspace.id as WorkspaceId, ownerMachineId: ctx.machineId as MachineId, requesterSessionId: ctx.sessionId as SessionId, - requesterUserId: invoking.principal.userId, + requesterUserId: invoking.identity.userId, operationId: args.operationId, kind: 'session_chat_many', canonicalCommand, frozenContinuationConfig: { ...(requester.agentConfigId ? { agentConfigId: requester.agentConfigId } : {}), inputConfig: invoking.frozenInputConfig, - delegation: freezeOperationDelegation(invoking.principal), + sourceTurnId: invoking.identity.sourceTurnId, }, initiatorChainDepth: invoking.chainDepth, ...timing, @@ -3589,7 +3517,7 @@ const startSessionChatManyOperation = async (args: SessionChatManyToolInput): Pr chainDepth: invoking.chainDepth + 1, bypassSessionQuota: shouldBypassSessionQuota('session_chat_many'), }, - toDelegatedSessionRequestSubject(invoking.principal) + toDelegatedSessionRequester(invoking.identity) ); await withOperationStore((store) => store.markItemInputDurable( @@ -4083,10 +4011,7 @@ export const __lodyMcpServerInternals = { resolveSessionRenameItems, applySessionRenameItems, persistSessionRenameItems, - resolveInvokingHistoryInput, - selectInvokingTurnSource, - buildInvokingTurnPrincipal, - assertOperationRetryPrincipal, + buildInvocationIdentity, buildOperationTargetCancelArgs, summarizeProjectRefForMcp, resolveSessionExecutionSnapshot, @@ -4411,7 +4336,7 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): if (!currentSession) { throw new Error(`Session not found: ${ctx.sessionId}`); } - const invoking = await resolveInvokingTurnContext(manager, currentSession); + const invoking = await resolveInvokingTurnContext(currentSession); const roleCatalog = args.agentRoleId ? await loadWorkspaceAgentRoleCatalog(manager, workspace.id as WorkspaceId) : undefined; @@ -4422,7 +4347,7 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): args.agentRoleId ? roleCatalog?.get(args.agentRoleId) : undefined ); const options = buildMcpCreateOptions(resolved.input, ctx); - bindMcpCreateContext(options, invoking.principal, currentSession); + bindMcpCreateContext(options, invoking.identity, currentSession); bindAgentRoleCreateOptions(options, resolved.role); options.workspaceMetaPrewriteSatisfied = true; const result = await createSessionResult( @@ -4504,7 +4429,7 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): throw new Error(`Session not found: ${sessionId}`); } assertDifferentMcpSession(currentSession, targetSession); - const invoking = await resolveInvokingTurnContext(manager, currentSession); + const invoking = await resolveInvokingTurnContext(currentSession); const result = await sendSessionChatResult( auth, workspace, @@ -4515,7 +4440,7 @@ export function buildLodyMcpServer(config: { taskToolsEnabled?: boolean } = {}): buildStructuredOutputOptions(args), undefined, undefined, - toDelegatedSessionRequestSubject(invoking.principal) + toDelegatedSessionRequester(invoking.identity) ); const response = { ok: true, diff --git a/apps/cli/src/orchestration/AGENTS.md b/apps/cli/src/orchestration/AGENTS.md index f401acfd8..7850b8887 100644 --- a/apps/cli/src/orchestration/AGENTS.md +++ b/apps/cli/src/orchestration/AGENTS.md @@ -25,12 +25,13 @@ Root and `apps/cli/AGENTS.md` apply. Normative behavior lives in - Create Operations freeze each target's effective dispatch config at acceptance; recovery must not re-read mutable requester history defaults. Full content stays in the target Session history. -- Accepted Operations store the principal user once as `requesterUserId`; their frozen delegation - binds the exact source Turn. `requesterSessionId` already identifies the source Session. Recovery +- Accepted Operations store the invoking user once as `requesterUserId` and bind the exact source + Turn as `sourceTurnId`. `requesterSessionId` already identifies the source Session. Recovery routes attribution and member-scoped authorization through that frozen user while the current owner Machine credential remains the executor credential. Completion system Turns retain the - same userId so a continuation cannot silently switch principals. Idempotent retries must match - the frozen principal; a later Turn reusing the id is `OPERATION_ID_REUSED`, not a retry. + same userId so a continuation cannot silently switch identities. The Operation store matches + requester user and source Turn together with kind and command fingerprint; a later Turn reusing + the id is `OPERATION_ID_REUSED`, not a retry. - `operation-coordinator.ts` is owned only by the local Host-lease Worker. MCP subprocesses may accept Operations but never schedule completion Turns. - Reconciliation is level-checked. Loro subscriptions and SQLite directory diff --git a/apps/cli/src/orchestration/operation-store.test.ts b/apps/cli/src/orchestration/operation-store.test.ts index 7cdec6711..4755933fb 100644 --- a/apps/cli/src/orchestration/operation-store.test.ts +++ b/apps/cli/src/orchestration/operation-store.test.ts @@ -39,9 +39,7 @@ const baseInput = () => ({ frozenContinuationConfig: { agentConfigId: 'agent-1', inputConfig: { cliType: 'builtin' as const, agentType: 'codex', chainDepth: 0 }, - delegation: { - sourceTurnId: 'source-turn-1', - }, + sourceTurnId: 'source-turn-1', }, initiatorChainDepth: 0, createdAt: '2026-07-20T00:00:00.000Z', @@ -87,37 +85,13 @@ describe('LodyOperationStore', () => { expect(first.created).toBe(true); expect(retry.created).toBe(false); expect(retry.operation.operationId).toBe('review-round-1'); - expect(retry.operation.frozenContinuationConfig.delegation).toEqual({ - sourceTurnId: 'source-turn-1', - }); + expect(retry.operation.frozenContinuationConfig.sourceTurnId).toBe('source-turn-1'); expect(store.snapshot(retry.operation)).toMatchObject({ state: 'active' }); } finally { store.close(); } }); - it('reads legacy delegation records that froze an executor account', async () => { - const store = await makeStore(); - try { - const input = baseInput(); - const accepted = store.accept({ - ...input, - frozenContinuationConfig: { - ...input.frozenContinuationConfig, - delegation: { - sourceTurnId: 'source-turn-1', - executorUserId: 'machine-owner-1', - }, - }, - }); - expect(accepted.operation.frozenContinuationConfig.delegation).toMatchObject({ - sourceTurnId: 'source-turn-1', - }); - } finally { - store.close(); - } - }); - it('round-trips a frozen create dispatch config including the task tools gate', async () => { const store = await makeStore(); try { @@ -168,6 +142,50 @@ describe('LodyOperationStore', () => { } }); + it('binds accept and retry lookup to requester and source Turn identity', async () => { + const store = await makeStore(); + try { + const input = baseInput(); + store.accept(input); + + expect( + store.findMatchingRetry( + input.requesterSessionId, + input.operationId, + input.kind, + input.canonicalCommand, + input.requesterUserId, + input.frozenContinuationConfig.sourceTurnId + ) + ).toMatchObject({ operationId: input.operationId }); + expect(() => + store.findMatchingRetry( + input.requesterSessionId, + input.operationId, + input.kind, + input.canonicalCommand, + 'user-2', + input.frozenContinuationConfig.sourceTurnId + ) + ).toThrowError( + expect.objectContaining({ code: 'OPERATION_ID_REUSED' }) + ); + expect(() => + store.accept({ + ...input, + frozenContinuationConfig: { + ...input.frozenContinuationConfig, + sourceTurnId: 'source-turn-2', + }, + }) + ).toThrowError( + expect.objectContaining({ code: 'OPERATION_ID_REUSED' }) + ); + } finally { + store.close(); + } + }); + it('scopes lookup to the requester Session', async () => { const store = await makeStore(); try { diff --git a/apps/cli/src/orchestration/operation-store.ts b/apps/cli/src/orchestration/operation-store.ts index ec3ba464c..e5c4651c0 100644 --- a/apps/cli/src/orchestration/operation-store.ts +++ b/apps/cli/src/orchestration/operation-store.ts @@ -112,16 +112,7 @@ const FrozenConfigSchema = z .object({ agentConfigId: z.string().optional(), inputConfig: z.record(z.string(), z.unknown()), - delegation: z - .object({ - sourceTurnId: z.string().trim().min(1), - // Accepted for upgrade compatibility; new Operations do not freeze the - // daemon account because machine ownership plus current authorization - // govern recovery. - executorUserId: z.string().trim().min(1).optional(), - }) - .strict() - .optional(), + sourceTurnId: z.string().trim().min(1).optional(), targetDispatchConfigs: z .array( z @@ -448,7 +439,13 @@ export class LodyOperationStore { if (existing) return { created: false, - operation: this.assertMatching(existing, input.kind, fingerprint), + operation: this.assertMatching( + existing, + input.kind, + fingerprint, + input.requesterUserId, + frozenConfig.sourceTurnId + ), claimedItemIndexes: [], }; const inserted = this.db @@ -498,7 +495,13 @@ export class LodyOperationStore { } return { created: inserted.changes === 1, - operation: this.assertMatching(operation, input.kind, fingerprint), + operation: this.assertMatching( + operation, + input.kind, + fingerprint, + input.requesterUserId, + frozenConfig.sourceTurnId + ), claimedItemIndexes, }; } @@ -529,12 +532,14 @@ export class LodyOperationStore { requesterSessionId: SessionId, operationId: string, kind: LodyOperationKind, - canonicalCommand: unknown + canonicalCommand: unknown, + requesterUserId: string, + sourceTurnId?: string ): StoredLodyOperation | undefined { const existing = this.getStored(requesterSessionId, operationId); if (!existing) return undefined; const fingerprint = fingerprintLodyCommand(kind, canonicalizeLodyCommand(canonicalCommand)); - return this.assertMatching(existing, kind, fingerprint); + return this.assertMatching(existing, kind, fingerprint, requesterUserId, sourceTurnId); } listActive(workspaceId: WorkspaceId, ownerMachineId: MachineId): StoredLodyOperation[] { @@ -801,9 +806,16 @@ export class LodyOperationStore { private assertMatching( operation: StoredLodyOperation, kind: LodyOperationKind, - fingerprint: string + fingerprint: string, + requesterUserId: string, + sourceTurnId?: string ): StoredLodyOperation { - if (operation.kind !== kind || operation.fingerprint !== fingerprint) { + if ( + operation.kind !== kind || + operation.fingerprint !== fingerprint || + operation.requesterUserId !== requesterUserId || + operation.frozenContinuationConfig.sourceTurnId !== sourceTurnId + ) { throw new LodyOperationStoreError( 'OPERATION_ID_REUSED', `Operation id ${operation.operationId} is already bound to different input.`, diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index c444b6c40..e975e9979 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -10,19 +10,20 @@ Session CLI/MCP orchestration contract: specs/session-orchestration.md. Target-machine authorization is checked by the injected access capability with the source CLI token, which derives the requester identity for ordinary CLI calls and verifies the frozen Turn -request subject for MCP delegation. Session commands receive only `{ userId, kind }`; source-Turn -provenance stays at the MCP/Operation boundary. Do not send an untrusted requester through +requester for MCP delegation. Session command boundaries receive an optional delegated requester; +its presence selects delegated access, while source-Turn provenance stays at the MCP/Operation +boundary. Do not send an untrusted requester through workspace Machine RPC: that transport does not authenticate member identity. Live status is a target-daemon Machine RPC read, and durable session metadata is not a live-presence substitute. Session orchestration MCP authenticates execution with the daemon owner's CLI credential, -but derives the human principal causally from the active dispatch/execution runtime. Persisted -history is only a legacy fallback when no active runtime identity exists. Freeze that principal +but derives the human identity causally from the active dispatch/execution runtime. Persisted +history must never reconstruct a missing invocation; fail closed when no active runtime exists. Freeze that identity and source Turn into durable Operations; the Operation already identifies the source -Session and stores the principal user once as `requesterUserId`. Retries and recovery must not +Session and stores the invoking user once as `requesterUserId`. Retries and recovery must not reread mutable history. Machine and Provider credentials remain execution-host scoped, while Session/Turn attribution, member authorization, GitHub access, and downstream Git identity use -the frozen principal. Never fall back to the Session owner when the driving Turn has no userId. +the frozen identity. Never fall back to the Session owner when the driving Turn has no userId. - `session-dispatch-watcher.ts` — the current dispatch entry: watches `repo.watch('doc-metadata')` + per-session mirror subscribe; dispatches when @@ -118,7 +119,9 @@ the frozen principal. Never fall back to the Session owner when the driving Turn quiescent. Use live execution/presence for current-work signals; goal activity may still protect history rewrites or an in-memory runtime that can resume autonomously. It is the per-session execution mutex: never mint a second visible turn while a - `TurnRuntimeState` is registered. User-dispatch turns derive assistant entry ids + `TurnRuntimeState` is registered. Its optional `invocation` atomically owns source Turn, + requester, and input config; steer replaces that object before tool execution can continue. + User-dispatch turns derive assistant entry ids from `userTurnId` (`assistant:`), so a retried/recovered dispatch reuses the same history entry. INVARIANT: a steer (guide) the agent never accepted must not stay parked in diff --git a/apps/cli/src/session/session-dispatch-watcher.ts b/apps/cli/src/session/session-dispatch-watcher.ts index 35a9c1723..52d3757f0 100644 --- a/apps/cli/src/session/session-dispatch-watcher.ts +++ b/apps/cli/src/session/session-dispatch-watcher.ts @@ -1574,8 +1574,11 @@ export class SessionDispatchWatcher { sessionId, sessionDoc, userTurnId: nextUserTurn.id, - requesterUserId: nextUserTurn.userId, - invocationInputConfig: nextUserTurn.inputConfig ?? {}, + invocation: { + sourceTurnId: nextUserTurn.id, + requesterUserId: nextUserTurn.userId, + inputConfig: nextUserTurn.inputConfig ?? {}, + }, dispatchSource, accessPromise: executionAccessPromise, requestPromise, diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index ac99158f8..93dc966df 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -212,15 +212,19 @@ type PromptHandoffRun = { signalSuccessor: () => void; }; +type TurnInvocation = { + /** Causal input Turn for authorization and durable provenance. */ + sourceTurnId: string; + requesterUserId?: string; + inputConfig: SessionTurnInputConfig; +}; + type TurnRuntimeState = { sessionId: SessionId; /** Logical chain tail exposed to Web, cancel, and optimistic steer validation. */ turnId: string; userTurnId?: string; - /** Causal input Turn for authorization and durable provenance. */ - sourceTurnId?: string; - requesterUserId?: string; - invocationInputConfig?: SessionTurnInputConfig; + invocation?: TurnInvocation; session?: ISession; project?: ProjectRef; baseCommitHash?: string | null; @@ -312,9 +316,7 @@ type VisibleSessionTurnOptions = { sessionDoc: SessionDocument; session?: ISession; userTurnId?: string; - sourceTurnId?: string; - requesterUserId?: string; - invocationInputConfig: SessionTurnInputConfig; + invocation?: TurnInvocation; /** * How the turn payload reached this machine. 'rpc' turns can start before the * user's history entry syncs locally, so their turn-scoped history writes go @@ -352,9 +354,7 @@ export type PreparedSessionDispatchOptions = { sessionId: SessionId; sessionDoc: SessionDocument; userTurnId: string; - /** Exact requester carried by the driving Turn; absent legacy values stay absent. */ - requesterUserId?: string; - invocationInputConfig: SessionTurnInputConfig; + invocation: TurnInvocation; dispatchSource: SessionDispatchSource; accessPromise: Promise; requestPromise: Promise; @@ -1284,9 +1284,11 @@ export class SessionExecutionService { // The provider has accepted this steer and may execute tools before // history/finalization catches up. Switch causal identity first. - runtime.sourceTurnId = options.userTurnId; - runtime.requesterUserId = options.userId; - runtime.invocationInputConfig = options.inputConfig; + runtime.invocation = { + sourceTurnId: options.userTurnId, + requesterUserId: options.userId, + inputConfig: options.inputConfig, + }; try { await this.finalizeYieldedTurnOutput(runtime, options.sessionId, previousTurnId); @@ -1472,9 +1474,7 @@ export class SessionExecutionService { sessionId, sessionDoc: options.sessionDoc, userTurnId, - sourceTurnId: userTurnId, - requesterUserId: options.requesterUserId, - invocationInputConfig: options.invocationInputConfig, + invocation: options.invocation, dispatchSource, unhandledErrorCode: 'session_chat_failed', describeUnhandledError: (error) => @@ -1548,21 +1548,14 @@ export class SessionExecutionService { private createTurnRuntime( options: Pick< VisibleSessionTurnOptions, - | 'sessionId' - | 'session' - | 'userTurnId' - | 'sourceTurnId' - | 'requesterUserId' - | 'invocationInputConfig' + 'sessionId' | 'session' | 'userTurnId' | 'invocation' > & { turnId: string } ): TurnRuntimeState { return { sessionId: options.sessionId, turnId: options.turnId, userTurnId: options.userTurnId, - sourceTurnId: options.sourceTurnId, - requesterUserId: options.requesterUserId, - invocationInputConfig: options.invocationInputConfig, + invocation: options.invocation, session: options.session, promptStarted: false, promptInFlight: false, @@ -3022,13 +3015,14 @@ export class SessionExecutionService { if (!runtime) { return undefined; } - if (!runtime.requesterUserId || !runtime.sourceTurnId || !runtime.invocationInputConfig) { + const { invocation } = runtime; + if (!invocation?.requesterUserId) { throw new Error(`Active invocation identity is unavailable for session ${sessionId}`); } return { - requesterUserId: runtime.requesterUserId, - sourceTurnId: runtime.sourceTurnId, - inputConfig: runtime.invocationInputConfig, + requesterUserId: invocation.requesterUserId, + sourceTurnId: invocation.sourceTurnId, + inputConfig: invocation.inputConfig, }; } @@ -3918,7 +3912,7 @@ export class SessionExecutionService { const completedTurnId = runtime.turnId; const completedUserTurnId = runtime.userTurnId ?? executionUserTurnId; - const completedRequesterUserId = runtime.requesterUserId ?? userId; + const completedRequesterUserId = runtime.invocation?.requesterUserId ?? userId; // Read before finalization clears the turn's ACP update state. const producedOutput = self.turnProducedVisibleOutput(sessionId, completedTurnId); @@ -4047,9 +4041,11 @@ export class SessionExecutionService { sessionDoc, ...(session ? { session } : {}), userTurnId: executionUserTurnId, - sourceTurnId: userTurnId, - requesterUserId: userId, - invocationInputConfig: acpSessionConfig, + invocation: { + sourceTurnId: userTurnId, + requesterUserId: userId, + inputConfig: acpSessionConfig, + }, ...(dispatchOptions?.dispatchSource ? { dispatchSource: dispatchOptions.dispatchSource } : {}), @@ -4370,9 +4366,15 @@ export class SessionExecutionService { sessionId, sessionDoc, userTurnId, - sourceTurnId: userTurnId, - requesterUserId: message.userId, - invocationInputConfig: acpSessionConfig, + ...(userTurnId + ? { + invocation: { + sourceTurnId: userTurnId, + requesterUserId: message.userId, + inputConfig: acpSessionConfig, + }, + } + : {}), ...(dispatchOptions?.dispatchSource ? { dispatchSource: dispatchOptions.dispatchSource } : {}), @@ -4595,7 +4597,8 @@ export class SessionExecutionService { const completedTurnId = runtime.turnId; const completedUserTurnId = runtime.userTurnId ?? userTurnId; - const completedRequesterUserId = runtime.requesterUserId ?? sessionConfig.requesterUserId; + const completedRequesterUserId = + runtime.invocation?.requesterUserId ?? sessionConfig.requesterUserId; // Read before finalization clears the turn's ACP update state. const producedOutput = self.turnProducedVisibleOutput(sessionId, completedTurnId); diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 42dcf3fe0..3c9fa25d1 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -267,11 +267,13 @@ describe('SessionExecutionService', () => { sessionId, turnId: 'assistant:user-1', userTurnId: 'user-1', - sourceTurnId: 'user-1', session: activeSession, promptInFlight: true, - requesterUserId: 'user-1', - invocationInputConfig: { prompt: 'initial prompt' }, + invocation: { + sourceTurnId: 'user-1', + requesterUserId: 'user-1', + inputConfig: { prompt: 'initial prompt' }, + }, activePromptRun: initialPromptRun, yieldedFinalization: Promise.resolve(), }; @@ -307,6 +309,11 @@ describe('SessionExecutionService', () => { ); expect(runtime.turnId).toBe('assistant:user-2'); expect(runtime.userTurnId).toBe('user-2'); + expect(runtime.invocation).toEqual({ + requesterUserId: 'user-1', + sourceTurnId: 'user-2', + inputConfig: { prompt: 'change direction' }, + }); expect(service.getActiveInvocationContext(sessionId)).toEqual({ requesterUserId: 'user-1', sourceTurnId: 'user-2', @@ -1377,8 +1384,11 @@ describe('SessionExecutionService', () => { sessionId: 'session-prepared-presence' as SessionId, sessionDoc, userTurnId: 'turn-prepared-presence', - requesterUserId: 'user-b', - invocationInputConfig: { prompt: 'fast path prompt', taskToolsEnabled: true }, + invocation: { + sourceTurnId: 'turn-prepared-presence', + requesterUserId: 'user-b', + inputConfig: { prompt: 'fast path prompt', taskToolsEnabled: true }, + }, dispatchSource: 'rpc', accessPromise, requestPromise: new Promise(() => {}), @@ -1460,6 +1470,7 @@ describe('SessionExecutionService', () => { sessionId, sessionDoc: sessionDoc as never, userTurnId, + invocation: { sourceTurnId: userTurnId, inputConfig: {} }, dispatchSource: 'crdt', accessPromise: new Promise(() => {}), requestPromise: new Promise(() => {}), @@ -1575,6 +1586,7 @@ describe('SessionExecutionService', () => { sessionId, sessionDoc: preparedSessionDoc as never, userTurnId, + invocation: { sourceTurnId: userTurnId, inputConfig: {} }, dispatchSource: 'rpc', accessPromise: Promise.resolve({ outcome: 'allowed' as const }), requestPromise: new Promise(() => {}), diff --git a/packages/shared/src/session-orchestration.ts b/packages/shared/src/session-orchestration.ts index ac91b69ac..9ae2c6343 100644 --- a/packages/shared/src/session-orchestration.ts +++ b/packages/shared/src/session-orchestration.ts @@ -117,15 +117,11 @@ export type LodyOperationSnapshot = completion: LodyOperationCompletion; }; -export type SessionOperationDelegation = { - sourceTurnId: string; -}; - export type FrozenOperationContinuationConfig = { agentConfigId?: string; inputConfig: SessionTurnInputConfig; - /** Frozen provenance for the top-level requester; recovery must not re-resolve it. */ - delegation?: SessionOperationDelegation; + /** Frozen causal Turn for delegated Operations; recovery must not re-resolve it. */ + sourceTurnId?: string; /** * Effective per-target create config captured at acceptance. Null entries * correspond to batch items rejected before a target was accepted.