diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index ca9813b87c..a63f993a52 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -2588,10 +2588,6 @@ class ReadOnlyStore implements SessionStore { throw new Error('not implemented'); } - async setExecutionBoundaryKind(): Promise { - throw new Error('not implemented'); - } - async readExecutionBoundary(): Promise { throw new Error('not implemented'); } diff --git a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts index dbc0d047ff..6c5e99557e 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts @@ -615,9 +615,6 @@ function memoryStore(): SessionStore { return { create: async () => header, createSubagent: async () => ({ header, created: false }), - setExecutionBoundaryKind: async () => { - throw new Error('not implemented'); - }, readExecutionBoundary: async () => { throw new Error('not implemented'); }, diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 46729664d0..00ebeffdfa 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -2305,10 +2305,6 @@ class TinySessionStore implements SessionStore { return clone(header); } - async setExecutionBoundaryKind(): Promise { - throw new Error('not implemented'); - } - async readExecutionBoundary(): Promise { throw new Error('not implemented'); } diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 584126dad2..d998634dae 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4697,104 +4697,183 @@ describe('SessionManager manual compaction and quiescent session changes', () => }); describe('SessionManager permission mode updates', () => { - for (const route of ['direct', 'legacy'] as const) { - test(`serializes concurrent ${route} boundary commits before they can become narrowing`, { - timeout: 10_000, - }, async (t) => { - const root = await mkdtemp(join(tmpdir(), 'maka-boundary-commit-race-')); - const store = createSessionStore(root); - // Hide optional capabilities from Runtime, without changing SQLite's own - // internal method calls, to exercise the legacy SessionStore contract. - const runtimeStore = - route === 'legacy' - ? new Proxy(store, { - get(target, key) { - if (key === 'readHeaderRecordSnapshot' || key === 'updateSessionConfiguration') - return undefined; - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; - }, - }) - : store; - const gate = makeGate(); - t.after(async () => { - gate.release(); - await store.close?.(); - await rm(root, { recursive: true, force: true }); - }); - const calls: string[] = []; - const backends = new BackendRegistry(); - let backend: TestBackend | undefined; - backends.register('ai-sdk', (ctx) => (backend = new TestBackend(ctx, gate))); - const manager = new SessionManager({ - store: runtimeStore, - backends, - newId: nextId(), - now: nextNow(979), - shellRuns: { - async terminateSession(sessionId: string) { - calls.push(`terminate:${sessionId}`); - return { sessionId, token: Symbol('test') }; - }, - async commitSessionClose() { - calls.push('commit'); - }, - rollbackSessionClose() { - calls.push('rollback'); - }, - resumeSession(sessionId: string) { - calls.push(`resume:${sessionId}`); - }, - } as never, + test('preserves the plugin executor when widening permission with an active turn', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const kernel = new DelegatingRuntimeKernel(); + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(977), + runtimeKernel: kernel, + }); + const session = await manager.createSession( + makeInput({ permissionMode: 'explore', executorId: 'codex' }), + ); + const current = await store.readHeaderRecordSnapshot(session.id); + kernel.activeRuns = true; + + const next = await manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'bypass' }), + }); + + assert.strictEqual(next.header.backend, 'plugin-executor'); + assert.strictEqual(next.header.executorId, 'codex'); + assert.strictEqual(next.header.permissionMode, 'bypass'); + assert.strictEqual(next.revision, current.revision + 1); + assert.deepStrictEqual((await store.readHeaderRecordSnapshot(session.id)).header, next.header); + assert.strictEqual((await store.readExecutionBoundary(session.id)).kind, 'bypass'); + assert.deepStrictEqual(kernel.stopped, []); + assert.deepStrictEqual(kernel.disposed, []); + }); + + test('configuration authority rejects a stale revision without changing the committed grant', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(978), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'explore' })); + const original = await store.readHeaderRecordSnapshot(session.id); + const update = (permissionMode: PermissionMode) => + manager.transitionSessionConfiguration(session.id, { + expectedRevision: original.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(original.header, { permissionMode }), }); - const session = await manager.createSession(makeInput({ permissionMode: 'explore' })); - const update = (bypass: boolean) => - route === 'direct' - ? manager.setExecutionBoundaryKind(session.id, bypass ? 'bypass' : 'managed') - : manager.setPermissionMode(session.id, bypass ? 'bypass' : 'ask'); - const turn = manager - .sendMessage(session.id, { turnId: 'turn-racing', text: 'keep running' }) - [Symbol.asyncIterator](); - try { - await turn.next(); - // Both requests initially observe Explore. The second must not reuse - // that classification after the first has committed Bypass. - const results = await Promise.allSettled([update(true), update(false)]); - assert.deepStrictEqual( - results.map((result) => result.status), - ['fulfilled', 'rejected'], - ); - const conflict = results[1]; - assert.ok(conflict?.status === 'rejected'); - assert.ok(conflict.reason instanceof SessionConfigurationTransitionError); - assert.strictEqual(conflict.reason.code, 'operation_conflict'); - assert.deepStrictEqual(await store.readExecutionBoundary(session.id), { - kind: 'bypass', - revision: 1, - }); - assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'bypass'); - assert.deepStrictEqual(manager.runningTurnIds(session.id), ['turn-racing']); - assert.strictEqual(backend?.stopCalls, 0); - assert.deepStrictEqual(calls, []); - // A fresh retry is now correctly classified as narrowing. - await assert.rejects(update(false), (error: unknown) => { - assert.ok(error instanceof SessionConfigurationTransitionError); - assert.strictEqual(error.code, 'session_busy'); - return true; - }); - } finally { - gate.release(); - while (!(await turn.next()).done) {} - } - // The conflict released the mutation lane; idle narrowing still revokes shells. - await update(false); - assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'ask'); - assert.deepStrictEqual(calls, [`terminate:${session.id}`, 'commit', `resume:${session.id}`]); + + await update('bypass'); + const committed = await store.readHeaderRecordSnapshot(session.id); + const boundary = await store.readExecutionBoundary(session.id); + assert.strictEqual(committed.revision, original.revision + 1); + assert.strictEqual(committed.header.permissionMode, 'bypass'); + await assert.rejects(update('ask'), (error: unknown) => { + assert.ok(error instanceof SessionConfigurationRevisionConflictError); + assert.strictEqual(error.expectedRevision, original.revision); + assert.strictEqual(error.actualRevision, committed.revision); + return true; }); - } + assert.deepStrictEqual(await store.readHeaderRecordSnapshot(session.id), committed); + assert.deepStrictEqual(await store.readExecutionBoundary(session.id), boundary); + }); + + test('serializes configuration commits that observed the same execution boundary', { + timeout: 10_000, + }, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-boundary-commit-race-')); + const store = createSessionStore(root); + const gate = makeGate(); + const bothBoundariesRead = makeGate(); + const releaseBoundaryReads = makeGate(); + const readExecutionBoundary = store.readExecutionBoundary.bind(store); + t.after(async () => { + releaseBoundaryReads.release(); + gate.release(); + await store.close?.(); + await rm(root, { recursive: true, force: true }); + }); + const calls: string[] = []; + const backends = new BackendRegistry(); + let backend: TestBackend | undefined; + backends.register('ai-sdk', (ctx) => (backend = new TestBackend(ctx, gate))); + const manager = new SessionManager({ + store, + backends, + newId: nextId(), + now: nextNow(979), + shellRuns: { + async terminateSession(sessionId: string) { + calls.push(`terminate:${sessionId}`); + return { sessionId, token: Symbol('test') }; + }, + async commitSessionClose() { + calls.push('commit'); + }, + rollbackSessionClose() { + calls.push('rollback'); + }, + resumeSession(sessionId: string) { + calls.push(`resume:${sessionId}`); + }, + } as never, + }); + const session = await manager.createSession(makeInput({ permissionMode: 'explore' })); + const update = (snapshot: VersionedSessionHeader, permissionMode: PermissionMode) => + manager.transitionSessionConfiguration(session.id, { + expectedRevision: snapshot.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(snapshot.header, { permissionMode }), + }); + const turn = manager + .sendMessage(session.id, { turnId: 'turn-racing', text: 'keep running' }) + [Symbol.asyncIterator](); + try { + await turn.next(); + const snapshot = await store.readHeaderRecordSnapshot(session.id); + let boundaryReads = 0; + store.readExecutionBoundary = async (sessionId) => { + const boundary = await readExecutionBoundary(sessionId); + if (++boundaryReads <= 2) { + if (boundaryReads === 2) bothBoundariesRead.release(); + await releaseBoundaryReads.promise; + } + return boundary; + }; + // A shared configuration snapshot alone does not fix which fence wins. + // Hold both initial boundary reads until each has observed Explore, so + // the second commit hits the boundary revision fence before its CAS. + const pending = Promise.allSettled([update(snapshot, 'bypass'), update(snapshot, 'ask')]); + await bothBoundariesRead.promise; + releaseBoundaryReads.release(); + const results = await pending; + store.readExecutionBoundary = readExecutionBoundary; + assert.deepStrictEqual( + results.map((result) => result.status), + ['fulfilled', 'rejected'], + ); + const conflict = results[1]; + assert.ok(conflict?.status === 'rejected'); + assert.ok(conflict.reason instanceof SessionConfigurationTransitionError); + assert.strictEqual(conflict.reason.code, 'operation_conflict'); + assert.match(conflict.reason.message, /execution boundary changed/); + const committed = await store.readHeaderRecordSnapshot(session.id); + assert.strictEqual(committed.revision, snapshot.revision + 1); + assert.strictEqual(committed.header.permissionMode, 'bypass'); + assert.deepStrictEqual(await store.readExecutionBoundary(session.id), { + kind: 'bypass', + revision: 1, + }); + assert.deepStrictEqual(manager.runningTurnIds(session.id), ['turn-racing']); + assert.strictEqual(backend?.stopCalls, 0); + assert.deepStrictEqual(calls, []); + // A fresh retry sees Bypass and is now correctly classified as narrowing. + await assert.rejects(update(committed, 'ask'), (error: unknown) => { + assert.ok(error instanceof SessionConfigurationTransitionError); + assert.strictEqual(error.code, 'session_busy'); + return true; + }); + assert.deepStrictEqual(await store.readHeaderRecordSnapshot(session.id), committed); + } finally { + store.readExecutionBoundary = readExecutionBoundary; + releaseBoundaryReads.release(); + gate.release(); + while (!(await turn.next()).done) {} + } + // The conflict released the mutation lane; idle narrowing still revokes shells. + await update(await store.readHeaderRecordSnapshot(session.id), 'ask'); + assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'ask'); + assert.deepStrictEqual(calls, [`terminate:${session.id}`, 'commit', `resume:${session.id}`]); + }); test('rejects unprotected boundary commits when admission mutation authority is unavailable', async () => { - const store = new MemorySessionStore(); + const store = new VersionedConfigurationMemorySessionStore(); const kernel = new DelegatingRuntimeKernel(); Object.defineProperty(kernel, 'runSessionAdmissionMutation', { value: undefined }); const manager = new SessionManager({ @@ -4805,8 +4884,14 @@ describe('SessionManager permission mode updates', () => { now: nextNow(979), }); const session = await manager.createSession(makeInput({ permissionMode: 'explore' })); + const current = await store.readHeaderRecordSnapshot(session.id); await assert.rejects( - manager.setExecutionBoundaryKind(session.id, 'bypass'), + manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'bypass' }), + }), (error: unknown) => { assert.ok(error instanceof SessionConfigurationTransitionError); assert.strictEqual(error.code, 'operation_unavailable'); @@ -5208,143 +5293,126 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'explore'); }); - for (const route of ['configuration', 'boundary'] as const) { - for (const grant of ['read', 'write', 'network'] as const) { - test(`restoring Explore revokes an approved ${grant} through the durable ${route} path`, async (t) => { - const root = await mkdtemp(join(tmpdir(), 'maka-explore-read-revocation-')); - const store = createSessionStore(root); - t.after(async () => { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - }); - const gate = makeGate(); - const calls: string[] = []; - const backends = new BackendRegistry(); - const runStore = new MemoryAgentRunStore(); - backends.register('ai-sdk', (ctx) => new TestBackend(ctx, gate)); - const manager = new SessionManager({ - store, - runStore, - runtimeEventStore: runStore, - backends, - newId: nextId(), - now: nextNow(988), - shellRuns: { - async terminateSession(sessionId: string) { - calls.push(`terminate:${sessionId}`); - return { sessionId, token: Symbol('test') }; - }, - async commitSessionClose() { - calls.push('commit'); - }, - rollbackSessionClose() { - calls.push('rollback'); - }, - resumeSession(sessionId: string) { - calls.push(`resume:${sessionId}`); - }, - } as never, - }); - const workspaceRoot = join(root, 'workspace'); - const outsidePath = join(root, 'approved', 'input.txt'); - const session = await manager.createSession( - makeInput({ permissionMode: 'explore', cwd: workspaceRoot }), - ); - const updatePermissionMode = async (permissionMode: PermissionMode) => { - const current = await store.readHeaderRecordSnapshot(session.id); - return manager.transitionSessionConfiguration(session.id, { - expectedRevision: current.revision, - clearConnectionBlock: false, - permissionModeOnly: true, - configuration: configurationForHeader(current.header, { permissionMode }), - }); - }; - await store.createSandboxBoundaryRequest({ - sessionId: session.id, - requestId: 'approved-expansion', - turnId: 'turn-approval', - runId: 'run-approval', - expansion: - grant === 'network' - ? { network: { enabled: true } } - : { - filesystem: { entries: [{ path: outsidePath, access: grant, scope: 'exact' }] }, - }, - justification: 'Approve one specific sandbox expansion.', + for (const grant of ['read', 'write', 'network'] as const) { + test(`restoring Explore revokes an approved ${grant} through configuration authority`, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-explore-read-revocation-')); + const store = createSessionStore(root); + t.after(async () => { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + }); + const gate = makeGate(); + const calls: string[] = []; + const backends = new BackendRegistry(); + const runStore = new MemoryAgentRunStore(); + backends.register('ai-sdk', (ctx) => new TestBackend(ctx, gate)); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(988), + shellRuns: { + async terminateSession(sessionId: string) { + calls.push(`terminate:${sessionId}`); + return { sessionId, token: Symbol('test') }; + }, + async commitSessionClose() { + calls.push('commit'); + }, + rollbackSessionClose() { + calls.push('rollback'); + }, + resumeSession(sessionId: string) { + calls.push(`resume:${sessionId}`); + }, + } as never, + }); + const workspaceRoot = join(root, 'workspace'); + const outsidePath = join(root, 'approved', 'input.txt'); + const session = await manager.createSession( + makeInput({ permissionMode: 'explore', cwd: workspaceRoot }), + ); + const updatePermissionMode = async (permissionMode: PermissionMode) => { + const current = await store.readHeaderRecordSnapshot(session.id); + return manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode }), }); - const settlement = await store.settleSandboxBoundaryRequest({ - sessionId: session.id, - requestId: 'approved-expansion', - decision: 'allow', + }; + await store.createSandboxBoundaryRequest({ + sessionId: session.id, + requestId: 'approved-expansion', + turnId: 'turn-approval', + runId: 'run-approval', + expansion: + grant === 'network' + ? { network: { enabled: true } } + : { + filesystem: { entries: [{ path: outsidePath, access: grant, scope: 'exact' }] }, + }, + justification: 'Approve one specific sandbox expansion.', + }); + const settlement = await store.settleSandboxBoundaryRequest({ + sessionId: session.id, + requestId: 'approved-expansion', + decision: 'allow', + }); + assert.strictEqual(settlement.request.status, 'approved'); + await updatePermissionMode('ask'); + const restoreExplore = () => updatePermissionMode('explore'); + const expanded = await store.readExecutionBoundary(session.id); + assert.strictEqual(expanded.kind, 'managed'); + if (expanded.kind !== 'managed') throw new Error('Expected a managed boundary'); + assert.strictEqual(expanded.profile.name, 'read-only'); + assert.strictEqual(isReadOnlyPermissionProfile(expanded.profile), grant === 'read'); + assert.strictEqual( + expanded.profile.network.kind, + grant === 'network' ? 'enabled' : 'restricted', + ); + assert.strictEqual( + canReadPath(expanded.profile, outsidePath, { workspaceRoots: [workspaceRoot] }), + grant !== 'network', + ); + assert.deepStrictEqual(calls, []); + + const activeTurn = manager + .sendMessage(session.id, { turnId: 'turn-expanded-read', text: 'keep reading' }) + [Symbol.asyncIterator](); + try { + await activeTurn.next(); + await assert.rejects(restoreExplore(), (error: unknown) => { + assert.ok(error instanceof SessionConfigurationTransitionError); + assert.strictEqual(error.code, 'session_busy'); + return true; }); - assert.strictEqual(settlement.request.status, 'approved'); - if (route === 'configuration') await updatePermissionMode('ask'); - const restoreExplore = () => - route === 'configuration' - ? updatePermissionMode('explore') - : manager.setExecutionBoundaryKind(session.id, 'managed'); - const expanded = await store.readExecutionBoundary(session.id); - assert.strictEqual(expanded.kind, 'managed'); - if (expanded.kind !== 'managed') throw new Error('Expected a managed boundary'); - assert.strictEqual(expanded.profile.name, 'read-only'); - assert.strictEqual(isReadOnlyPermissionProfile(expanded.profile), grant === 'read'); - assert.strictEqual( - expanded.profile.network.kind, - grant === 'network' ? 'enabled' : 'restricted', - ); - assert.strictEqual( - canReadPath(expanded.profile, outsidePath, { workspaceRoots: [workspaceRoot] }), - grant !== 'network', - ); + assert.deepStrictEqual(await store.readExecutionBoundary(session.id), expanded); + assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'ask'); assert.deepStrictEqual(calls, []); + } finally { + gate.release(); + while (!(await activeTurn.next()).done) {} + } - const activeTurn = manager - .sendMessage(session.id, { turnId: 'turn-expanded-read', text: 'keep reading' }) - [Symbol.asyncIterator](); - try { - await activeTurn.next(); - await assert.rejects(restoreExplore(), (error: unknown) => { - if (route === 'boundary') { - assert.ok(error instanceof SessionConfigurationTransitionError); - assert.strictEqual(error.code, 'session_busy'); - return true; - } - assert.ok(error instanceof SessionConfigurationTransitionError); - assert.strictEqual(error.code, 'session_busy'); - return true; - }); - assert.deepStrictEqual(await store.readExecutionBoundary(session.id), expanded); - assert.strictEqual( - (await store.readHeader(session.id)).permissionMode, - route === 'configuration' ? 'ask' : 'explore', - ); - assert.deepStrictEqual(calls, []); - } finally { - gate.release(); - while (!(await activeTurn.next()).done) {} - } - - await restoreExplore(); - assert.deepStrictEqual(calls, [ - `terminate:${session.id}`, - 'commit', - `resume:${session.id}`, - ]); - const narrowed = await store.readExecutionBoundary(session.id); - assert.strictEqual(narrowed.kind, 'managed'); - if (narrowed.kind !== 'managed') throw new Error('Expected a managed boundary'); - assert.deepStrictEqual(narrowed.profile, createReadOnlyPermissionProfile()); - assert.strictEqual( - canReadPath(narrowed.profile, outsidePath, { workspaceRoots: [workspaceRoot] }), - false, - ); - assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'explore'); - }); - } + await restoreExplore(); + assert.deepStrictEqual(calls, [`terminate:${session.id}`, 'commit', `resume:${session.id}`]); + const narrowed = await store.readExecutionBoundary(session.id); + assert.strictEqual(narrowed.kind, 'managed'); + if (narrowed.kind !== 'managed') throw new Error('Expected a managed boundary'); + assert.deepStrictEqual(narrowed.profile, createReadOnlyPermissionProfile()); + assert.strictEqual( + canReadPath(narrowed.profile, outsidePath, { workspaceRoots: [workspaceRoot] }), + false, + ); + assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'explore'); + }); } - test('revokes descendant background shell authority through the direct boundary API', async () => { - const store = new AtomicBoundaryMemorySessionStore(); + test('configuration narrowing revokes descendant background shell authority', async () => { + const store = new VersionedConfigurationMemorySessionStore(); const calls: string[] = []; const backends = new BackendRegistry(); backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); @@ -5440,7 +5508,13 @@ describe('SessionManager permission mode updates', () => { ); store.disposeCount = 0; - await manager.setExecutionBoundaryKind(session.id, 'managed'); + const current = await store.readHeaderRecordSnapshot(session.id); + await manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'ask' }), + }); assert.deepStrictEqual(calls, [ `terminate:${session.id}`, @@ -5529,11 +5603,17 @@ describe('SessionManager permission mode updates', () => { ['turn-2', 'completed'], ], ); - const summary = await manager.setPermissionMode(session.id, 'bypass'); - assert.strictEqual(summary.permissionMode, 'bypass'); + const afterTurns = await store.readHeaderRecordSnapshot(session.id); + const unchanged = await manager.transitionSessionConfiguration(session.id, { + expectedRevision: afterTurns.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(afterTurns.header, { permissionMode: 'bypass' }), + }); + assert.deepStrictEqual(unchanged, afterTurns); }); - test('the setPermissionMode wrapper delegates deep research cleanup to configuration authority', async () => { + test('configuration authority removes only the deep research label when leaving Explore', async () => { const store = new VersionedConfigurationMemorySessionStore(); const backends = new BackendRegistry(); backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); @@ -5545,29 +5625,54 @@ describe('SessionManager permission mode updates', () => { }), ); - const summary = await manager.setPermissionMode(session.id, 'ask'); + const current = await store.readHeaderRecordSnapshot(session.id); + const next = await manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'ask' }), + }); - assert.strictEqual(summary.permissionMode, 'ask'); - assert.deepStrictEqual(summary.labels, ['kept']); - assert.deepStrictEqual((await store.readHeader(session.id)).labels, ['kept']); + assert.strictEqual(next.header.permissionMode, 'ask'); + assert.strictEqual(next.revision, current.revision + 1); + assert.deepStrictEqual(next.header.labels, ['kept']); + const persisted = await store.readHeaderRecordSnapshot(session.id); + assert.deepStrictEqual(persisted.header, next.header); + assert.strictEqual(persisted.revision, next.revision); }); - test('temporarily preserves setPermissionMode for legacy SessionStore implementations', async () => { - const store = new MemorySessionStore(); - const manager = new SessionManager({ - store, - backends: new BackendRegistry(), - newId: nextId(), - now: nextNow(6_100), - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - - const summary = await manager.setPermissionMode(session.id, 'bypass'); + for (const missing of ['readHeaderRecordSnapshot', 'updateSessionConfiguration'] as const) { + test(`configuration changes require Store capability: ${missing}`, async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(6_100), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + const readSnapshot = store.readHeaderRecordSnapshot.bind(store); + const current = await readSnapshot(session.id); + const boundary = await store.readExecutionBoundary(session.id); + Object.defineProperty(store, missing, { value: undefined }); - assert.strictEqual(summary.permissionMode, 'bypass'); - assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'bypass'); - assert.strictEqual((await store.readExecutionBoundary(session.id)).kind, 'bypass'); - }); + await assert.rejects( + manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'bypass' }), + }), + (error: unknown) => { + assert.ok(error instanceof SessionConfigurationTransitionError); + assert.strictEqual(error.code, 'operation_unavailable'); + return true; + }, + ); + assert.deepStrictEqual(await readSnapshot(session.id), current); + assert.deepStrictEqual(await store.readExecutionBoundary(session.id), boundary); + }); + } test('starts a new turn without workspace identity when safety inspection fails', async () => { const store = new MemorySessionStore(); @@ -11337,8 +11442,24 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual((await store.readHeader(session.id)).status, 'waiting_for_user'); const [run] = await runStore.listSessionInvocations(session.id); assert.strictEqual(run?.terminalEvent, undefined); - await expectRejects(manager.setPermissionMode(session.id, 'bypass'), /pending Interaction/); - assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'ask'); + const current = await store.readHeaderRecordSnapshot(session.id); + const boundary = await store.readExecutionBoundary(session.id); + await assert.rejects( + manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'bypass' }), + }), + (error: unknown) => { + assert.ok(error instanceof SessionConfigurationTransitionError); + assert.strictEqual(error.code, 'session_busy'); + assert.match(error.message, /pending Interaction/); + return true; + }, + ); + assert.deepStrictEqual(await store.readHeaderRecordSnapshot(session.id), current); + assert.deepStrictEqual(await store.readExecutionBoundary(session.id), boundary); await manager.respondToSandboxBoundary(session.id, { requestId: 'boundary-1', @@ -13773,7 +13894,7 @@ class CheckpointRecorderContractProbeBackend implements AgentBackend { class MemorySessionStore implements SessionStore { private headers = new Map(); private messages = new Map(); - private executionBoundaries = new Map(); + protected readonly executionBoundaries = new Map(); private sandboxBoundaryRequests = new Map(); readonly failReadMessagesFor = new Set(); readonly failNextReadMessagesFor = new Map(); @@ -13906,31 +14027,6 @@ class MemorySessionStore implements SessionStore { return header; } - async setExecutionBoundaryKind( - sessionId: string, - kind: 'managed' | 'bypass', - projection?: { - permissionMode: SessionHeader['permissionMode']; - labels?: readonly string[]; - }, - ) { - const current = await this.readHeader(sessionId); - const permissionMode = - projection?.permissionMode ?? - (kind === 'bypass' - ? 'bypass' - : current.permissionMode === 'bypass' - ? 'ask' - : current.permissionMode); - await this.updateHeader(sessionId, { - permissionMode, - ...(projection?.labels ? { labels: [...projection.labels] } : {}), - }); - const boundary = createGenesisExecutionBoundary(permissionMode); - this.executionBoundaries.set(sessionId, boundary); - return boundary; - } - async readExecutionBoundary(sessionId: string): Promise { const boundary = this.executionBoundaries.get(sessionId); if (!boundary) throw new Error(`Unknown session ${sessionId}`); @@ -14120,6 +14216,16 @@ class VersionedConfigurationMemorySessionStore extends MemorySessionStore { }; } + override async updateHeader( + sessionId: string, + patch: Partial, + ): Promise { + if (Object.hasOwn(patch, 'permissionMode')) { + throw new Error('permissionMode must be projected by the configuration transition'); + } + return super.updateHeader(sessionId, patch); + } + async updateSessionConfiguration( sessionId: string, input: SessionConfigurationStoreUpdate, @@ -14134,14 +14240,7 @@ class VersionedConfigurationMemorySessionStore extends MemorySessionStore { if (revision !== input.expectedVersion) { throw new Error('injected configuration revision conflict'); } - await super.setExecutionBoundaryKind( - sessionId, - input.configuration.permissionMode === 'bypass' ? 'bypass' : 'managed', - { - permissionMode: input.configuration.permissionMode, - labels: input.configuration.labels, - }, - ); + // Only configuration authority may project permissionMode into the header. const header = await super.updateHeader(sessionId, { ...input.configuration, labels: [...input.configuration.labels], @@ -14153,6 +14252,10 @@ class VersionedConfigurationMemorySessionStore extends MemorySessionStore { } : {}), }); + this.executionBoundaries.set( + sessionId, + createGenesisExecutionBoundary(input.configuration.permissionMode), + ); this.forcedBoundaries.delete(sessionId); this.revisions.set(sessionId, revision + 1); return { header, revision: revision + 1, committedAt: revision + 1 }; @@ -14167,85 +14270,12 @@ class VersionedConfigurationMemorySessionStore extends MemorySessionStore { if (revision !== expectedRevision) { throw new SessionConfigurationRevisionConflictError(expectedRevision, revision); } - const header = await super.updateHeader(sessionId, patch); + const header = await this.updateHeader(sessionId, patch); this.revisions.set(sessionId, revision + 1); return { header, revision: revision + 1, committedAt: revision + 1 }; } } -class AtomicBoundaryMemorySessionStore extends MemorySessionStore { - failAppends = false; - readonly boundaryCalls: Array<{ - sessionId: string; - kind: 'managed' | 'bypass'; - projection: - | { - permissionMode: SessionHeader['permissionMode']; - labels?: readonly string[]; - } - | undefined; - }> = []; - private readonly boundaries = new Map(); - private projectingBoundary = false; - - forceBoundary(sessionId: string, boundary: ExecutionBoundary): void { - this.boundaries.set(sessionId, boundary); - } - - override async readExecutionBoundary(sessionId: string): Promise { - return this.boundaries.get(sessionId) ?? super.readExecutionBoundary(sessionId); - } - - async setExecutionBoundaryKind( - sessionId: string, - kind: 'managed' | 'bypass', - projection?: { - permissionMode: SessionHeader['permissionMode']; - labels?: readonly string[]; - }, - ) { - this.boundaryCalls.push({ sessionId, kind, projection }); - const current = await this.readHeader(sessionId); - const permissionMode = - projection?.permissionMode ?? - (kind === 'bypass' - ? 'bypass' - : current.permissionMode === 'bypass' - ? 'ask' - : current.permissionMode); - this.projectingBoundary = true; - try { - await super.updateHeader(sessionId, { - permissionMode, - ...(projection?.labels ? { labels: [...projection.labels] } : {}), - }); - } finally { - this.projectingBoundary = false; - } - const boundary = { - ...createGenesisExecutionBoundary(permissionMode), - revision: 1, - }; - this.boundaries.set(sessionId, boundary); - return boundary; - } - - override async updateHeader( - sessionId: string, - patch: Partial, - ): Promise { - if (!this.projectingBoundary && Object.hasOwn(patch, 'permissionMode')) { - throw new Error('permissionMode must be projected by the boundary transition'); - } - return super.updateHeader(sessionId, patch); - } - - override async appendMessage(sessionId: string, message: StoredMessage): Promise { - if (this.failAppends) throw new Error('audit append failed'); - return super.appendMessage(sessionId, message); - } -} - class MemoryAgentRunStore implements AgentRunStore, RuntimeEventStore, RuntimeContinuationAuthorityStore { @@ -14889,6 +14919,7 @@ function configurationForHeader( ): SessionConfigurationTransitionRequest['configuration'] { return { backend: header.backend, + executorId: header.executorId, ...(header.llmConnectionId === undefined ? {} : { llmConnectionId: header.llmConnectionId }), llmConnectionSlug: header.llmConnectionSlug, connectionLocked: header.connectionLocked, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 214c6efc2c..0cac2beb3d 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -613,14 +613,6 @@ export interface SessionStore { settleSandboxBoundaryRequest?( input: SettleSandboxBoundaryRequest, ): Promise; - setExecutionBoundaryKind( - sessionId: string, - kind: 'managed' | 'bypass', - projection?: { - permissionMode: SessionHeader['permissionMode']; - labels?: readonly string[]; - }, - ): Promise; createAgentGraphOperator?( input: CreateSessionInput, request: AgentGraphOperatorProvisionRequest, @@ -648,6 +640,14 @@ export interface SessionStore { patch: SessionHeaderPatch, expectedRevision: number, ): Promise; + /** + * Configuration changes require both readHeaderRecordSnapshot and + * updateSessionConfiguration. Production SessionAuthorityStore requires both; + * Runtime keeps them optional for stores that do not mutate configuration, + * such as execution-only test fixtures. Missing either makes changes unavailable, + * without an unversioned fallback. Implementations must atomically check the + * expected revision and commit configuration, execution boundary and revision. + */ readHeaderRecordSnapshot?(sessionId: string): Promise; updateSessionConfiguration?( sessionId: string, @@ -1640,101 +1640,6 @@ export class SessionManager { return this.runtimeKernel.listActiveInteractions?.(sessionId) ?? []; } - async setPermissionMode(sessionId: string, mode: PermissionMode): Promise { - const readHeaderRecordSnapshot = this.deps.store.readHeaderRecordSnapshot?.bind( - this.deps.store, - ); - if (!readHeaderRecordSnapshot || !this.deps.store.updateSessionConfiguration) { - // Temporary compatibility bridge for SessionStore embeddings that predate - // versioned configuration authority. A follow-up PR will shortly remove - // setPermissionMode and this redundant fallback after callers migrate. - return this.setPermissionModeWithLegacyStore(sessionId, mode); - } - const current = await readHeaderRecordSnapshot(sessionId); - const next = await this.transitionSessionConfiguration(sessionId, { - expectedRevision: current.revision, - clearConnectionBlock: false, - permissionModeOnly: true, - configuration: sessionConfigurationWithPermissionMode(current.header, mode), - }); - return headerToSummary(next.header); - } - - private async setPermissionModeWithLegacyStore( - sessionId: string, - mode: PermissionMode, - ): Promise { - const previous = await this.deps.store.readHeader(sessionId); - const boundary = await this.deps.store.readExecutionBoundary(sessionId); - const leavingDeepResearch = isDeepResearchSession(previous.labels) && mode !== 'explore'; - if ( - previous.permissionMode === mode && - executionBoundaryMatchesPermissionMode(boundary, mode) && - !leavingDeepResearch - ) { - return headerToSummary(previous); - } - - const labels = leavingDeepResearch - ? previous.labels.filter((label) => label !== DEEP_RESEARCH_SESSION_LABEL) - : previous.labels; - const kind = mode === 'bypass' ? 'bypass' : 'managed'; - await this.commitExecutionBoundaryTransition(sessionId, boundary, mode, async () => { - const current = await this.deps.store.readHeader(sessionId); - if (current.status === 'waiting_for_user') { - throw new SessionConfigurationTransitionError( - 'session_busy', - 'Session has a pending Interaction', - ); - } - return () => - this.deps.store.setExecutionBoundaryKind(sessionId, kind, { - permissionMode: mode, - labels, - }); - }); - const next = await this.deps.store.readHeader(sessionId); - this.runtimeKernel.updateCachedHeader(sessionId, next); - return headerToSummary(next); - } - - async setExecutionBoundaryKind( - sessionId: string, - kind: 'managed' | 'bypass', - ): Promise { - const current = await this.deps.store.readExecutionBoundary(sessionId); - const header = await this.deps.store.readHeader(sessionId); - // Managed includes Explore. Match Storage's default projection, then pass - // it explicitly so classification and commit describe the same transition. - const permissionMode = - kind === 'bypass' - ? 'bypass' - : header.permissionMode === 'bypass' - ? 'ask' - : header.permissionMode; - const narrows = narrowsExecutionAuthority(current, permissionMode); - if (narrows && this.runtimeKernel.hasActiveRuns(sessionId)) { - throw new SessionConfigurationTransitionError( - 'session_busy', - 'Execution boundary cannot change while a Turn is running', - ); - } - if (header.status === 'waiting_for_user') { - throw new SessionConfigurationTransitionError( - 'session_busy', - 'Execution boundary cannot change while an Interaction is pending', - ); - } - const boundary = await this.commitExecutionBoundaryTransition( - sessionId, - current, - permissionMode, - async () => () => - this.deps.store.setExecutionBoundaryKind(sessionId, kind, { permissionMode }), - ); - return boundary; - } - private async commitExecutionBoundaryTransition( sessionId: string, current: ExecutionBoundary, @@ -5243,24 +5148,6 @@ function claimedAgentGraphIntentResult( }; } -function sessionConfigurationWithPermissionMode( - header: SessionHeader, - permissionMode: PermissionMode, -): SessionConfigurationTransitionRequest['configuration'] { - return { - backend: header.backend, - executorId: header.executorId, - llmConnectionId: header.llmConnectionId, - llmConnectionSlug: header.llmConnectionSlug, - connectionLocked: header.connectionLocked, - model: header.model, - thinkingLevel: header.thinkingLevel, - permissionMode, - collaborationMode: header.collaborationMode ?? 'agent', - orchestrationMode: header.orchestrationMode ?? 'default', - }; -} - function sessionConfigurationMatchesExceptPermissionMode( header: SessionHeader, configuration: SessionConfigurationTransitionRequest['configuration'], @@ -5288,17 +5175,6 @@ function sessionConfigurationMatches( ); } -function executionBoundaryMatchesPermissionMode( - boundary: ExecutionBoundary, - mode: PermissionMode, -): boolean { - if (mode === 'bypass') return boundary.kind === 'bypass'; - if (boundary.kind !== 'managed') return false; - return mode === 'explore' - ? boundary.profile.name === 'read-only' - : boundary.profile.name !== 'read-only'; -} - function narrowsExecutionAuthority( boundary: ExecutionBoundary, nextPermissionMode: PermissionMode,