From ce7fbfc6e118edf2ab4097952c1e57ca4ccc3e9e Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Thu, 3 Sep 2026 11:32:40 +0800 Subject: [PATCH 01/12] fix(runtime): derive the tool permission mode from the live boundary (#3349) The header carries the permission mode the backend was composed with, and a backend generation outlives many turns. A permission change does not recompose it, so `ctx.permissionMode` stayed at whatever the mode was when the backend was built while the boundary the same dispatch reads for sandboxing had already moved. The picker said Bypass, Bash stayed sandboxed, and approvals kept prompting. The boundary is the authority, so the mode is read off the boundary this dispatch is about to run against. The header answers only for an externally isolated boundary, which projects to no local mode at all. Plan mode writes only the header, never the boundary, so the collaboration overlay still has to apply on top; deriving purely from the boundary would turn plan+managed from explore into ask and open the client-capability gate. That rule now lives in @maka/core because both the composer and tool dispatch have to reach the same answer, and packages/runtime cannot reach runtime-host. Generated-by: OpenAI Codex --- packages/core/src/collaboration.ts | 18 ++++ .../src/server/execution-model-composition.ts | 10 +-- .../tool-runtime-sandbox-boundary.test.ts | 84 +++++++++++++++++++ packages/runtime/src/tool-runtime.ts | 27 +++++- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/packages/core/src/collaboration.ts b/packages/core/src/collaboration.ts index a6294ec2e8..993f1fe514 100644 --- a/packages/core/src/collaboration.ts +++ b/packages/core/src/collaboration.ts @@ -17,6 +17,8 @@ * under the License. */ +import type { PermissionMode } from './permission.js'; + export const COLLABORATION_MODES = ['agent', 'plan'] as const; export type CollaborationMode = (typeof COLLABORATION_MODES)[number]; @@ -24,3 +26,19 @@ export type CollaborationMode = (typeof COLLABORATION_MODES)[number]; export function isCollaborationMode(value: unknown): value is CollaborationMode { return typeof value === 'string' && (COLLABORATION_MODES as readonly string[]).includes(value); } + +/** + * The permission mode a session runs under once its collaboration mode is + * applied: Plan mode holds the session to read-only unless it is on Bypass. + * + * Lives here because both the model composer and tool dispatch have to reach + * the same answer; a second copy of the rule is a second authority. + */ +export function resolveCollaborationPermissionMode(input: { + readonly collaborationMode: CollaborationMode; + readonly permissionMode: PermissionMode; +}): PermissionMode { + return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass' + ? 'explore' + : input.permissionMode; +} diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 628a82ad86..6ea3b0e6cf 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -24,6 +24,7 @@ import { relayModelProfile } from '@maka/core/model-thinking'; import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; import type { ModelCallCommit } from '@maka/core/agent-run'; import type { PermissionMode } from '@maka/core/permission'; +import { resolveCollaborationPermissionMode } from '@maka/core/collaboration'; import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend'; import { buildDefaultContextBudgetPolicy, @@ -544,11 +545,4 @@ class HostAiSdkBackend extends AiSdkBackend { } } -export function resolveCollaborationPermissionMode(input: { - readonly collaborationMode: 'agent' | 'plan'; - readonly permissionMode: PermissionMode; -}): PermissionMode { - return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass' - ? 'explore' - : input.permissionMode; -} +export { resolveCollaborationPermissionMode }; diff --git a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts index 4ce516d68a..d442a8d3f6 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -148,6 +148,90 @@ describe('ToolRuntime session sandbox boundary', () => { ); }); + // #3349: the header carries the mode the backend was built with. A picker + // switch to Bypass between two turns widens the boundary without rebuilding + // that header, so a dispatch that trusts the header keeps sandboxing and + // keeps prompting while the picker already reads Bypass. + test('reads the permission mode off the live boundary, not the header it was built with', async () => { + let boundary: ExecutionBoundary = { + kind: 'managed', + profile: createWorkspaceWritePermissionProfile(), + revision: 0, + }; + const observed: Array<{ kind: string; permissionMode: string | undefined }> = []; + const runtime = new ToolRuntime({ + turnId: 'turn-1', + sessionId: 'session-1', + header: header(), + connection: { providerType: 'openai', slug: 'test' } as never, + modelId: 'test', + appendMessage: async () => {}, + readExecutionBoundary: async () => boundary, + newId: nextId(), + now: () => 1, + getPermissionPauseTarget: () => null, + }); + const tool: MakaTool = { + name: 'Bash', + description: 'test', + parameters: {}, + impl: (_args, context) => { + assert.ok(context.executionBoundary); + observed.push({ + kind: context.executionBoundary.kind, + permissionMode: context.permissionMode, + }); + return { ok: true }; + }, + }; + + await settle(runtime, tool, 'tool-1'); + boundary = { kind: 'bypass', revision: 1 }; + await settle(runtime, tool, 'tool-2'); + + assert.equal(header().permissionMode, 'ask'); + assert.deepEqual(observed, [ + { kind: 'managed', permissionMode: 'ask' }, + { kind: 'bypass', permissionMode: 'bypass' }, + ]); + }); + + test('holds Plan mode to read-only even when the live boundary allows writes', async () => { + let observed: string | undefined; + const runtime = new ToolRuntime({ + turnId: 'turn-1', + sessionId: 'session-1', + header: { ...header(), collaborationMode: 'plan' }, + connection: { providerType: 'openai', slug: 'test' } as never, + modelId: 'test', + appendMessage: async () => {}, + readExecutionBoundary: async () => ({ + kind: 'managed', + profile: createWorkspaceWritePermissionProfile(), + revision: 0, + }), + newId: nextId(), + now: () => 1, + getPermissionPauseTarget: () => null, + }); + + await settle( + runtime, + { + name: 'Bash', + description: 'test', + parameters: {}, + impl: (_args, context) => { + observed = context.permissionMode; + return { ok: true }; + }, + }, + 'tool-1', + ); + + assert.equal(observed, 'explore'); + }); + test('parks the dedicated tool and admits only one boundary request at a time', async () => { const events: SessionEvent[] = []; const managed: ExecutionBoundary = { diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index f435aade4e..d145aababb 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -20,9 +20,11 @@ import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { projectAgentSwarmResult } from '@maka/core/agent-swarm'; import { projectToolActivityArgs } from '@maka/core/tool-activity-args'; +import { resolveCollaborationPermissionMode } from '@maka/core/collaboration'; import { type CreateSandboxBoundaryRequest, type ExecutionBoundary, + executionBoundaryDisplayMode, type SandboxBoundaryDecision, type SandboxBoundaryExpansion, type SandboxBoundaryRequest, @@ -619,6 +621,24 @@ export class ToolRuntime { this.sandboxBoundaryDenied = input.inheritedSandboxBoundaryDenied === true; } + /** + * The permission mode in force for this dispatch. + * + * The header carries the mode this backend was built with, which goes stale + * the moment the boundary widens under a live Session. The boundary is the + * authority, so read the mode off the boundary we are about to dispatch + * against; the header only answers for an externally isolated boundary, + * which projects to no local mode at all. + */ + private livePermissionMode(boundary: ExecutionBoundary): PermissionMode { + const displayed = executionBoundaryDisplayMode(boundary); + if (displayed === undefined) return this.input.header.permissionMode; + return resolveCollaborationPermissionMode({ + collaborationMode: this.input.header.collaborationMode ?? 'agent', + permissionMode: displayed, + }); + } + async endTurn(reason: 'completed' | 'aborted' = 'completed'): Promise { const turnId = this.turnId; const boundaryRequests = this.sandboxBoundaryRequests.entries(); @@ -1475,7 +1495,8 @@ export class ToolRuntime { } const admissionFailure = !tool.prepareExecution ? CLIENT_CAPABILITY_PREPARATION_MESSAGE - : clientCapabilityBoundary.kind !== 'bypass' && this.input.header.permissionMode !== 'ask' + : clientCapabilityBoundary.kind !== 'bypass' && + this.livePermissionMode(clientCapabilityBoundary) !== 'ask' ? CLIENT_CAPABILITY_BOUNDARY_MESSAGE : undefined; if (admissionFailure) { @@ -1504,7 +1525,7 @@ export class ToolRuntime { ...(runId ? { runId } : {}), cwd: this.input.header.cwd, executionBoundary: clientCapabilityBoundary, - permissionMode: this.input.header.permissionMode, + permissionMode: this.livePermissionMode(clientCapabilityBoundary), toolCallId: toolUseId, abortSignal: ctx.abortSignal, }); @@ -1634,7 +1655,7 @@ export class ToolRuntime { : {}), cwd: this.input.header.cwd, executionBoundary, - permissionMode: this.input.header.permissionMode, + permissionMode: this.livePermissionMode(executionBoundary), toolCallId: toolUseId, // The id the call event actually carries, not the candidate: by here // `prepareDurableToolAttempt` has pushed it on the dispatch lane. From 6b8fa115f9be0ae298b7fb11592a966a59b3b869 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Thu, 3 Sep 2026 11:32:40 +0800 Subject: [PATCH 02/12] fix(runtime): commit a widening permission change without waiting for quiescence (#3349) A permission change was refused whenever the Session was not quiescent. That requirement is load-bearing for a narrowing: quiescence is what lets it terminate lineage shells and settle pending boundary requests with no extra machinery. It buys a widening nothing. Every consumer holding the older, tighter value fails closed against a wider boundary, and a descendant's admission check only gets easier, so a grant cannot over-authorize anyone. The refusal was applied one direction too wide, and under a Goal the continuation holds a claim near-continuously, so the user's own grant could not land at all. A widening now writes the boundary and returns; a narrowing keeps the existing path unchanged. The fork sits in commitExecutionBoundaryTransition rather than commitExecutionResourceTransition, which also serves relocateSessionWorkspace where the next mode frequently equals the current one: forking there would let a model, orchestration or cwd change slip past a fence that is not protecting the permission boundary. Backend refresh moves to invalidateBackend, which disposes now when the Session is idle and otherwise defers to the next activation. Disposing directly would call stop('user_stop') on a live Turn and kill the Turn the user is watching. setExecutionBoundaryKind gets the same treatment, so both entry points answer alike. Generated-by: OpenAI Codex --- .../src/__tests__/session-manager.test.ts | 13 +++++++--- packages/runtime/src/session-manager.ts | 26 +++++++++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index a55bccb1a4..be06769a6c 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4676,7 +4676,7 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(store.disposeCount, 3); }); - test('keeps mode changes blocked until all overlapping turns finish', async () => { + test('keeps narrowing blocked until all overlapping turns finish', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); @@ -4716,7 +4716,14 @@ describe('SessionManager permission mode updates', () => { ], ); - await expectRejects(manager.setPermissionMode(session.id, 'bypass'), /当前任务正在运行/); + // Widening is a grant, so it commits against the live Turn instead of + // making the user wait for it out (#3349). + const widened = await manager.setPermissionMode(session.id, 'bypass'); + assert.strictEqual(widened.permissionMode, 'bypass'); + assert.strictEqual((await manager.readExecutionBoundary(session.id)).kind, 'bypass'); + // Narrowing still requires quiescence: that is what lets it terminate the + // lineage's shells safely. + await expectRejects(manager.setPermissionMode(session.id, 'explore'), /当前任务正在运行/); secondGate.release(); await second.next(); @@ -10530,7 +10537,7 @@ 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'), /当前任务正在运行/); + await expectRejects(manager.setPermissionMode(session.id, 'bypass'), /等待确认/); assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'ask'); await manager.respondToSandboxBoundary(session.id, { diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 51fcb07ccb..3228afeeb7 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1610,7 +1610,7 @@ export class SessionManager { return headerToSummary(previous); } - if (this.runtimeKernel.hasActiveRuns(sessionId)) { + if (narrowsExecutionAuthority(boundary, mode) && this.runtimeKernel.hasActiveRuns(sessionId)) { throw new Error('当前任务正在运行,等结束后再切换权限模式。'); } if (previous.status === 'waiting_for_user') { @@ -1634,14 +1634,15 @@ export class SessionManager { sessionId: string, kind: 'managed' | 'bypass', ): Promise { - if (this.runtimeKernel.hasActiveRuns(sessionId)) { + const current = await this.deps.store.readExecutionBoundary(sessionId); + const narrows = narrowsExecutionAuthority(current, kind === 'bypass' ? 'bypass' : 'ask'); + if (narrows && this.runtimeKernel.hasActiveRuns(sessionId)) { throw new Error('当前任务正在运行,等结束后再切换沙箱边界。'); } const header = await this.deps.store.readHeader(sessionId); if (header.status === 'waiting_for_user') { throw new Error('当前有沙箱边界请求正在等待确认,处理后再切换。'); } - const current = await this.deps.store.readExecutionBoundary(sessionId); const boundary = await this.commitExecutionBoundaryTransition(sessionId, current, kind); return boundary; } @@ -1656,7 +1657,7 @@ export class SessionManager { }, ): Promise { const nextPermissionMode = projection?.permissionMode ?? (kind === 'bypass' ? 'bypass' : 'ask'); - return this.commitExecutionResourceTransition(sessionId, nextPermissionMode, async () => { + const prepareCommit = async (): Promise<() => Promise> => { const latest = await this.deps.store.readExecutionBoundary(sessionId); if (latest.revision !== current.revision) { throw new SessionConfigurationTransitionError( @@ -1665,7 +1666,22 @@ export class SessionManager { ); } return () => this.deps.store.setExecutionBoundaryKind(sessionId, kind, projection); - }); + }; + if (!narrowsExecutionAuthority(current, nextPermissionMode)) { + // Widening needs no quiescence. Every consumer that froze the old, tighter + // boundary fails closed against a wider one, and a descendant's admission + // check only gets easier — so the grant is just written. Waiting for the + // Session to go idle is what let a running Turn, or a Goal's continuation + // holding a claim near-continuously, keep the user's own grant out. + const commit = await prepareCommit(); + const boundary = await commit(); + // Not `disposeBackend`: disposing a live Turn's backend stops that Turn. + // Invalidation refreshes it now when the Session is idle, and otherwise + // defers to the next activation, which disposes before it starts. + await this.runtimeKernel.invalidateBackend(sessionId); + return boundary; + } + return this.commitExecutionResourceTransition(sessionId, nextPermissionMode, prepareCommit); } private async commitExecutionResourceTransition( From 94ad8e2cc47d1f0c8cf43d639e49f78dd75ce52a Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 14:18:29 +0800 Subject: [PATCH 03/12] fix(runtime): route live permission updates through configuration authority (#3349) Desktop and CLI permission pickers both enter through session.configuration.update, but the widening fork was reachable only through the unused setPermissionMode helper. Mark an exact permission-only Host patch and let transitionSessionConfiguration select the boundary transition path after independently verifying that no other configuration field changed. The live widening path can now commit while a Turn is active, while mixed configuration updates and every narrowing continue through the existing quiescent resource transition. setPermissionMode is reduced to a compatibility wrapper over the same configuration authority, leaving one implementation of the transition rules. Cover the production Host operation route and the runtime behavior with regressions for an active widening, a blocked narrowing, mixed patches, shell revocation, and Deep Research label cleanup. Generated-by: OpenAI Codex --- .../session-catalog-coordinator.test.ts | 49 ++++ .../src/server/session-catalog-coordinator.ts | 11 + .../workhub-coordination-coordinator.ts | 1 + .../src/__tests__/session-manager.test.ts | 168 +++++++++++- packages/runtime/src/session-manager.ts | 245 ++++++++++-------- 5 files changed, 359 insertions(+), 115 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index bab0bd5ceb..79e575ab2b 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -1119,6 +1119,55 @@ test('configuration update admits Plan mode through Runtime authority', async () assert.equal(fixture.drainRequests(), 0); }); +test('permission-only Host updates select the live boundary transition path', async () => { + const observed: boolean[] = []; + const fixture = createFixture({ + manager: { + transitionSessionConfiguration: async (_sessionId, input) => { + observed.push(input.permissionModeOnly); + if (!input.permissionModeOnly) { + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Session configuration cannot change while a linked Turn is active', + ); + } + return headerSnapshot( + { ...fixture.header(), permissionMode: input.configuration.permissionMode }, + fixture.revision() + 1, + ); + }, + }, + }); + + const widening = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { permissionMode: 'bypass' }, + }, + context, + ); + const mixed = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { permissionMode: 'bypass', collaborationMode: 'plan' }, + }, + context, + ); + + assert.equal(widening.ok, true); + assert.deepEqual(mixed, { + ok: false, + error: { + code: 'session_busy', + message: 'Session configuration cannot change while a linked Turn is active', + }, + }); + assert.deepEqual(observed, [true, false]); + assert.equal(fixture.drainRequests(), 0); +}); + test('configuration update never rebinds a bound Session through a reused slug', async () => { let observedRef: unknown; const fixture = createFixture({ diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index c2a3b1b5d2..f069bdb741 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -763,6 +763,7 @@ export class HostSessionCatalogCoordinator { await this.#manager.transitionSessionConfiguration(input.sessionId, { expectedRevision: input.expectedRevision, clearConnectionBlock: input.patch.modelTarget !== undefined, + permissionModeOnly: isPermissionModeOnlyPatch(input.patch), configuration, }); return configurationSuccess( @@ -1274,6 +1275,16 @@ function sessionConfigurationMatches( ); } +function isPermissionModeOnlyPatch(patch: SessionConfigurationUpdateInput['patch']): boolean { + return ( + patch.permissionMode !== undefined && + patch.modelTarget === undefined && + patch.thinkingLevel === undefined && + patch.collaborationMode === undefined && + patch.orchestrationMode === undefined + ); +} + interface PreparedSessionCreate { readonly name: string; readonly labels: readonly string[]; diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 8725ff5363..895a989d69 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -880,6 +880,7 @@ export class HostWorkHubCoordinationCoordinator { const configured = await this.#transitionConfiguration({ expectedRevision: record.revision, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: record.header.backend, llmConnectionId: record.header.llmConnectionId, diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index be06769a6c..2b48c43509 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -46,7 +46,10 @@ import { createGenesisExecutionBoundary, isSandboxBoundaryRestartClosure, } from '@maka/core/sandbox-boundary'; -import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { + createReadOnlyPermissionProfile, + createWorkspaceWritePermissionProfile, +} from '@maka/core/permission-profile'; import { DEEP_RESEARCH_SESSION_LABEL } from '@maka/core/deep-research'; import { RUNTIME_CONTINUATION_AUTHORITY_V1 } from '@maka/core/runtime-event-store'; import { deriveTurnRecords } from '@maka/core/session'; @@ -103,6 +106,7 @@ import { SessionManager, headerToSummary, type BackendFactoryContext, + type SessionConfigurationTransitionRequest, type SessionConfigurationStoreUpdate, type SessionStore, type VersionedSessionHeader, @@ -485,6 +489,7 @@ describe('SessionManager Plan control boundaries', () => { manager.transitionSessionConfiguration(child.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: child.backend, llmConnectionId: 'test-connection-id', @@ -636,6 +641,7 @@ describe('SessionManager graph operator provisioning', () => { .transitionSessionConfiguration(parent.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: parent.backend, llmConnectionId: 'test-connection-id', @@ -2408,8 +2414,6 @@ describe('SessionManager child-session runtime primitive', () => { ), true, ); - await manager.setPermissionMode(result.childSessionId, 'bypass'); - assert.strictEqual((await store.readHeader(result.childSessionId)).permissionMode, 'bypass'); const projection = await manager.listChildAgents(parent.id); assert.deepStrictEqual(projection.runs, []); assert.strictEqual(projection.executions.length, 1); @@ -4086,6 +4090,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: baseConfiguration, }), (error: unknown) => { @@ -4100,16 +4105,33 @@ describe('SessionManager manual compaction and quiescent session changes', () => const committed = await manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: baseConfiguration, }); assert.equal(committed.revision, 2); assert.equal(committed.header.orchestrationMode, 'graph'); assert.deepEqual(kernel.disposed, [session.id]); + await assert.rejects( + manager.transitionSessionConfiguration(session.id, { + expectedRevision: 1, + clearConnectionBlock: false, + permissionModeOnly: false, + configuration: baseConfiguration, + }), + (error: unknown) => { + assert.ok(error instanceof SessionConfigurationRevisionConflictError); + assert.equal(error.expectedRevision, 1); + assert.equal(error.actualRevision, 2); + return true; + }, + ); + await assert.rejects( manager.transitionSessionConfiguration(session.id, { expectedRevision: 2, clearConnectionBlock: false, + permissionModeOnly: true, configuration: { ...baseConfiguration, permissionMode: 'explore', @@ -4152,6 +4174,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => const preserved = await manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration, }); assert.equal(preserved.header.blockedReason, 'NO_REAL_CONNECTION'); @@ -4160,6 +4183,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => const recovered = await manager.transitionSessionConfiguration(session.id, { expectedRevision: 2, clearConnectionBlock: true, + permissionModeOnly: false, configuration, }); assert.equal(recovered.header.blockedReason, undefined); @@ -4254,6 +4278,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => .transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: session.backend, llmConnectionId: 'test-connection-id', @@ -4307,6 +4332,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => const transition = manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: session.backend, llmConnectionId: 'test-connection-id', @@ -4532,7 +4558,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => describe('SessionManager permission mode updates', () => { test('revokes background shell authority before narrowing Auto to Explore', async () => { - const store = new AtomicBoundaryMemorySessionStore(); + const store = new VersionedConfigurationMemorySessionStore(); const calls: string[] = []; const manager = new SessionManager({ store, @@ -4556,8 +4582,14 @@ describe('SessionManager permission mode updates', () => { } as never, }); const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + const current = await store.readHeaderRecordSnapshot(session.id); - await manager.setPermissionMode(session.id, 'explore'); + await manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'explore' }), + }); assert.deepStrictEqual(calls, [`terminate:${session.id}`, 'commit', `resume:${session.id}`]); const boundary = await store.readExecutionBoundary(session.id); @@ -4565,6 +4597,73 @@ describe('SessionManager permission mode updates', () => { if (boundary.kind === 'managed') assert.strictEqual(boundary.profile.name, 'read-only'); }); + test('treats an expanded Explore profile as narrowing before restoring Explore', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + 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(987), + 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: 'ask' })); + store.forceBoundary(session.id, { + kind: 'managed', + profile: applySandboxBoundaryExpansion(createReadOnlyPermissionProfile(), { + filesystem: { + entries: [{ path: '/approved/output', access: 'write', scope: 'subtree' }], + }, + }), + revision: 1, + }); + const activeTurn = manager + .sendMessage(session.id, { turnId: 'turn-expanded-explore', text: 'keep running' }) + [Symbol.asyncIterator](); + await activeTurn.next(); + + const current = await store.readHeaderRecordSnapshot(session.id); + const narrowing = { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'explore' }), + } as const; + await expectRejects( + manager.transitionSessionConfiguration(session.id, narrowing), + /linked Turn is active/, + ); + assert.deepStrictEqual(calls, []); + assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'ask'); + + gate.release(); + while (!(await activeTurn.next()).done) {} + + await manager.transitionSessionConfiguration(session.id, narrowing); + assert.deepStrictEqual(calls, [`terminate:${session.id}`, 'commit', `resume:${session.id}`]); + 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(); const calls: string[] = []; @@ -4677,7 +4776,7 @@ describe('SessionManager permission mode updates', () => { }); test('keeps narrowing blocked until all overlapping turns finish', async () => { - const store = new MemorySessionStore(); + const store = new VersionedConfigurationMemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const firstGate = makeGate(); @@ -4718,12 +4817,26 @@ describe('SessionManager permission mode updates', () => { // Widening is a grant, so it commits against the live Turn instead of // making the user wait for it out (#3349). - const widened = await manager.setPermissionMode(session.id, 'bypass'); - assert.strictEqual(widened.permissionMode, 'bypass'); + const current = await store.readHeaderRecordSnapshot(session.id); + const widened = await manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'bypass' }), + }); + assert.strictEqual(widened.header.permissionMode, 'bypass'); assert.strictEqual((await manager.readExecutionBoundary(session.id)).kind, 'bypass'); // Narrowing still requires quiescence: that is what lets it terminate the // lineage's shells safely. - await expectRejects(manager.setPermissionMode(session.id, 'explore'), /当前任务正在运行/); + await expectRejects( + manager.transitionSessionConfiguration(session.id, { + expectedRevision: widened.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(widened.header, { permissionMode: 'explore' }), + }), + /linked Turn is active/, + ); secondGate.release(); await second.next(); @@ -4737,13 +4850,12 @@ describe('SessionManager permission mode updates', () => { ['turn-2', 'completed'], ], ); - const summary = await manager.setPermissionMode(session.id, 'bypass'); assert.strictEqual(summary.permissionMode, 'bypass'); }); - test('leaving explore clears the deep research label so visible read-only copy stays truthful', async () => { - const store = new MemorySessionStore(); + test('the setPermissionMode wrapper delegates deep research cleanup to configuration authority', async () => { + const store = new VersionedConfigurationMemorySessionStore(); const backends = new BackendRegistry(); backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(6_000) }); @@ -10504,7 +10616,7 @@ describe('SessionManager permission mode updates', () => { }); test('marks a sandbox boundary request waiting and blocks boundary mode changes', async () => { - const store = new MemorySessionStore(); + const store = new VersionedConfigurationMemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); let backend: SandboxBoundaryWaitBackend | undefined; @@ -10537,7 +10649,7 @@ 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'), /等待确认/); + await expectRejects(manager.setPermissionMode(session.id, 'bypass'), /pending Interaction/); assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'ask'); await manager.respondToSandboxBoundary(session.id, { @@ -13290,8 +13402,17 @@ class MemorySessionStore implements SessionStore { class VersionedConfigurationMemorySessionStore extends MemorySessionStore { private readonly revisions = new Map(); + private readonly forcedBoundaries = new Map(); nextConfigurationUpdateGate: { started: Gate; release: Gate } | undefined; + forceBoundary(sessionId: string, boundary: ExecutionBoundary): void { + this.forcedBoundaries.set(sessionId, boundary); + } + + override async readExecutionBoundary(sessionId: string): Promise { + return this.forcedBoundaries.get(sessionId) ?? super.readExecutionBoundary(sessionId); + } + override async create( input: CreateSessionInput, initialBoundary?: ExecutionBoundary, @@ -13342,6 +13463,7 @@ class VersionedConfigurationMemorySessionStore extends MemorySessionStore { } : {}), }); + this.forcedBoundaries.delete(sessionId); this.revisions.set(sessionId, revision + 1); return { header, revision: revision + 1, committedAt: revision + 1 }; } @@ -14071,6 +14193,24 @@ function makeInput(overrides: Partial = {}): CreateSessionIn }; } +function configurationForHeader( + header: SessionHeader, + overrides: Partial = {}, +): SessionConfigurationTransitionRequest['configuration'] { + return { + backend: header.backend, + ...(header.llmConnectionId === undefined ? {} : { llmConnectionId: header.llmConnectionId }), + llmConnectionSlug: header.llmConnectionSlug, + connectionLocked: header.connectionLocked, + model: header.model, + thinkingLevel: header.thinkingLevel, + permissionMode: header.permissionMode, + collaborationMode: header.collaborationMode ?? 'agent', + orchestrationMode: header.orchestrationMode ?? 'default', + ...overrides, + }; +} + function createGraphOperatorSession( store: MemorySessionStore, parentSessionId: string, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 3228afeeb7..e8d1e8b989 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -70,6 +70,7 @@ import type { import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; +import { isReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; import type { CreateSandboxBoundaryRequest, @@ -541,6 +542,7 @@ export interface SessionConfigurationStoreUpdate { export interface SessionConfigurationTransitionRequest { readonly expectedRevision: number; readonly clearConnectionBlock: boolean; + readonly permissionModeOnly: boolean; readonly configuration: Omit; } @@ -1144,56 +1146,80 @@ export class SessionManager { input: SessionConfigurationTransitionRequest, ): Promise { const store = this.requireSessionConfigurationStore(); - const next = await this.commitExecutionResourceTransition( - sessionId, - input.configuration.permissionMode, - async () => { - const current = await store.readHeaderRecordSnapshot(sessionId); - if (current.revision !== input.expectedRevision) { - throw new SessionConfigurationRevisionConflictError( - input.expectedRevision, - current.revision, - ); - } - if (current.header.isArchived) { - throw new SessionConfigurationTransitionError( - 'operation_conflict', - 'Archived Session configuration cannot be changed', - ); - } - if (current.header.status === 'waiting_for_user') { - throw new SessionConfigurationTransitionError( - 'session_busy', - 'Session has a pending Interaction', - ); - } - await this.assertCollaborationTransition( - current.header, - input.configuration.collaborationMode, + const observed = await store.readHeaderRecordSnapshot(sessionId); + if (observed.revision !== input.expectedRevision) { + throw new SessionConfigurationRevisionConflictError( + input.expectedRevision, + observed.revision, + ); + } + if ( + !input.clearConnectionBlock && + sessionConfigurationMatches(observed.header, input.configuration) + ) { + return observed; + } + const permissionModeOnly = + input.permissionModeOnly && + sessionConfigurationMatchesExceptPermissionMode(observed.header, input.configuration); + const prepareCommit = async (): Promise<() => Promise> => { + const current = await store.readHeaderRecordSnapshot(sessionId); + if (current.revision !== input.expectedRevision) { + throw new SessionConfigurationRevisionConflictError( + input.expectedRevision, + current.revision, + ); + } + if (current.header.isArchived) { + throw new SessionConfigurationTransitionError( + 'operation_conflict', + 'Archived Session configuration cannot be changed', + ); + } + if (current.header.status === 'waiting_for_user') { + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Session has a pending Interaction', + ); + } + await this.assertCollaborationTransition( + current.header, + input.configuration.collaborationMode, + ); + const leavingDeepResearch = + isDeepResearchSession(current.header.labels) && + input.configuration.permissionMode !== 'explore'; + const labels = leavingDeepResearch + ? current.header.labels.filter((label) => label !== DEEP_RESEARCH_SESSION_LABEL) + : current.header.labels; + return () => + store.updateSessionConfiguration(sessionId, { + expectedVersion: input.expectedRevision, + configuration: { + ...input.configuration, + labels, + }, + lifecycle: + input.clearConnectionBlock && current.header.blockedReason === 'NO_REAL_CONNECTION' + ? { + kind: 'clear_connection_block', + statusUpdatedAt: this.deps.now(), + } + : { kind: 'preserve' }, + }); + }; + const next = permissionModeOnly + ? await this.commitExecutionBoundaryTransition( + sessionId, + await this.deps.store.readExecutionBoundary(sessionId), + input.configuration.permissionMode, + prepareCommit, + ) + : await this.commitExecutionResourceTransition( + sessionId, + input.configuration.permissionMode, + prepareCommit, ); - const leavingDeepResearch = - isDeepResearchSession(current.header.labels) && - input.configuration.permissionMode !== 'explore'; - const labels = leavingDeepResearch - ? current.header.labels.filter((label) => label !== DEEP_RESEARCH_SESSION_LABEL) - : current.header.labels; - return () => - store.updateSessionConfiguration(sessionId, { - expectedVersion: input.expectedRevision, - configuration: { - ...input.configuration, - labels, - }, - lifecycle: - input.clearConnectionBlock && current.header.blockedReason === 'NO_REAL_CONNECTION' - ? { - kind: 'clear_connection_block', - statusUpdatedAt: this.deps.now(), - } - : { kind: 'preserve' }, - }); - }, - ); this.runtimeKernel.updateCachedHeader(sessionId, next.header); return next; } @@ -1599,35 +1625,15 @@ export class SessionManager { } async setPermissionMode(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); - } - - if (narrowsExecutionAuthority(boundary, mode) && this.runtimeKernel.hasActiveRuns(sessionId)) { - throw new Error('当前任务正在运行,等结束后再切换权限模式。'); - } - if (previous.status === 'waiting_for_user') { - throw new Error('当前有工具调用正在等待确认,处理后再切换权限模式。'); - } - - const labels = leavingDeepResearch - ? previous.labels.filter((label) => label !== DEEP_RESEARCH_SESSION_LABEL) - : previous.labels; - const nextKind = mode === 'bypass' ? 'bypass' : 'managed'; - await this.commitExecutionBoundaryTransition(sessionId, boundary, nextKind, { - permissionMode: mode, - labels, + const store = this.requireSessionConfigurationStore(); + const current = await store.readHeaderRecordSnapshot(sessionId); + const next = await this.transitionSessionConfiguration(sessionId, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: sessionConfigurationWithPermissionMode(current.header, mode), }); - const next = await this.deps.store.readHeader(sessionId); - this.runtimeKernel.updateCachedHeader(sessionId, next); - return headerToSummary(next); + return headerToSummary(next.header); } async setExecutionBoundaryKind( @@ -1643,21 +1649,22 @@ export class SessionManager { if (header.status === 'waiting_for_user') { throw new Error('当前有沙箱边界请求正在等待确认,处理后再切换。'); } - const boundary = await this.commitExecutionBoundaryTransition(sessionId, current, kind); + const boundary = await this.commitExecutionBoundaryTransition( + sessionId, + current, + kind === 'bypass' ? 'bypass' : 'ask', + async () => () => this.deps.store.setExecutionBoundaryKind(sessionId, kind), + ); return boundary; } - private async commitExecutionBoundaryTransition( + private async commitExecutionBoundaryTransition( sessionId: string, current: ExecutionBoundary, - kind: 'managed' | 'bypass', - projection?: { - permissionMode: SessionHeader['permissionMode']; - labels?: readonly string[]; - }, - ): Promise { - const nextPermissionMode = projection?.permissionMode ?? (kind === 'bypass' ? 'bypass' : 'ask'); - const prepareCommit = async (): Promise<() => Promise> => { + nextPermissionMode: PermissionMode, + prepareCommit: () => Promise<() => Promise>, + ): Promise { + const prepareBoundaryCommit = async (): Promise<() => Promise> => { const latest = await this.deps.store.readExecutionBoundary(sessionId); if (latest.revision !== current.revision) { throw new SessionConfigurationTransitionError( @@ -1665,7 +1672,7 @@ export class SessionManager { 'Session execution boundary changed before the transition', ); } - return () => this.deps.store.setExecutionBoundaryKind(sessionId, kind, projection); + return prepareCommit(); }; if (!narrowsExecutionAuthority(current, nextPermissionMode)) { // Widening needs no quiescence. Every consumer that froze the old, tighter @@ -1673,15 +1680,19 @@ export class SessionManager { // check only gets easier — so the grant is just written. Waiting for the // Session to go idle is what let a running Turn, or a Goal's continuation // holding a claim near-continuously, keep the user's own grant out. - const commit = await prepareCommit(); - const boundary = await commit(); + const commit = await prepareBoundaryCommit(); + const result = await commit(); // Not `disposeBackend`: disposing a live Turn's backend stops that Turn. // Invalidation refreshes it now when the Session is idle, and otherwise // defers to the next activation, which disposes before it starts. await this.runtimeKernel.invalidateBackend(sessionId); - return boundary; + return result; } - return this.commitExecutionResourceTransition(sessionId, nextPermissionMode, prepareCommit); + return this.commitExecutionResourceTransition( + sessionId, + nextPermissionMode, + prepareBoundaryCommit, + ); } private async commitExecutionResourceTransition( @@ -5071,15 +5082,47 @@ function claimedAgentGraphIntentResult( }; } -function executionBoundaryMatchesPermissionMode( - boundary: ExecutionBoundary, - mode: PermissionMode, +function sessionConfigurationWithPermissionMode( + header: SessionHeader, + permissionMode: PermissionMode, +): SessionConfigurationTransitionRequest['configuration'] { + return { + backend: header.backend, + ...(header.llmConnectionId === undefined ? {} : { 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'], +): boolean { + return ( + header.backend === configuration.backend && + header.llmConnectionId === configuration.llmConnectionId && + header.llmConnectionSlug === configuration.llmConnectionSlug && + header.connectionLocked === configuration.connectionLocked && + header.model === configuration.model && + header.thinkingLevel === configuration.thinkingLevel && + (header.collaborationMode ?? 'agent') === configuration.collaborationMode && + (header.orchestrationMode ?? 'default') === configuration.orchestrationMode + ); +} + +function sessionConfigurationMatches( + header: SessionHeader, + configuration: SessionConfigurationTransitionRequest['configuration'], ): 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'; + return ( + header.permissionMode === configuration.permissionMode && + sessionConfigurationMatchesExceptPermissionMode(header, configuration) + ); } function narrowsExecutionAuthority( @@ -5088,7 +5131,7 @@ function narrowsExecutionAuthority( ): boolean { if (nextPermissionMode === 'bypass') return false; if (boundary.kind !== 'managed') return true; - return nextPermissionMode === 'explore' && boundary.profile.name !== 'read-only'; + return nextPermissionMode === 'explore' && !isReadOnlyPermissionProfile(boundary.profile); } function agentRunStatusForSpawnResult( From 98d708434b44ecd5758ae17a3f3b0c72f35887bc Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 14:26:32 +0800 Subject: [PATCH 04/12] fix(runtime): read the selected permission mode live (#3349) A managed boundary cannot identify the mode the user selected. Approving one path or network expansion makes an Explore profile structurally writable, so deriving the mode from that profile promoted dispatch to Auto and could open the Client Capability admission gate. Tool dispatch now reads the Session permission selection live. Only an unambiguous Bypass boundary overrides that value, after which the existing collaboration overlay still keeps Plan read-only. The same per-dispatch value is shared by Client Capability preparation and execution context construction. Cover the expanded Explore profile directly and verify that it remains Explore and cannot admit Client Capability work, while a live selection change and a Bypass boundary are both observed without rebuilding the backend. Generated-by: OpenAI Codex --- ...t-capability-admission-integration.test.ts | 1 + .../src/server/execution-model-composition.ts | 2 + .../test-only/client-capability-form-host.ts | 4 +- .../src/__tests__/ai-sdk-backend.test.ts | 1 + .../execution-boundary-test-helpers.ts | 16 +++-- .../tool-runtime-sandbox-boundary.test.ts | 71 ++++++++++++------- .../__tests__/tool-runtime-settlement.test.ts | 59 ++++++++++++++- packages/runtime/src/ai-sdk-backend.ts | 3 + packages/runtime/src/tool-runtime.ts | 34 +++++---- scripts/computer-use/lab-root.test.mjs | 7 ++ scripts/computer-use/real-ax-harness.mjs | 1 + 11 files changed, 153 insertions(+), 46 deletions(-) diff --git a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts index 9f9f40e286..2dfece5ff8 100644 --- a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts @@ -152,6 +152,7 @@ test('cancels managed approval owners and joiners with the canonical provider id modelId: 'model-1', readExecutionBoundary: async () => createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + readPermissionMode: async () => 'ask', newId: nextId(), now: nextNow(), getPermissionPauseTarget: () => null, diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 6ea3b0e6cf..f505837565 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -372,6 +372,8 @@ async function buildHostAiSdkBackend( : {}), readExecutionBoundary: () => input.context.store.readExecutionBoundary(input.context.sessionId), + readPermissionMode: async () => + (await input.context.store.readHeader(input.context.sessionId)).permissionMode, ...(input.context.store.createSandboxBoundaryRequest ? { createSandboxBoundaryRequest: (request) => diff --git a/packages/runtime-host/src/test-only/client-capability-form-host.ts b/packages/runtime-host/src/test-only/client-capability-form-host.ts index 1c9a04d4b4..d32c825ce7 100644 --- a/packages/runtime-host/src/test-only/client-capability-form-host.ts +++ b/packages/runtime-host/src/test-only/client-capability-form-host.ts @@ -114,13 +114,15 @@ async function createFormHost( assert.ok(offer); const descriptor = offer.tools[0]; assert.ok(descriptor); + const header = sessionHeader(); const runtime = new ToolRuntime({ sessionId: RUN.sessionId, - header: sessionHeader(), + header, connection: llmConnection(), modelId: 'model-1', readExecutionBoundary: async () => createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + readPermissionMode: async () => header.permissionMode, newId: nextId(), now: nextNow(), getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index f31f1f785d..c3fac8bd7a 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -5164,6 +5164,7 @@ describe('AiSdkBackend model history', () => { newId: idGenerator(), now: monotonicClock(), readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => 'ask', contextBudget: { name: 'malformed-summary-config-circuit-test', charsPerToken: 1, diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index 890e2167a0..936dfb8ef3 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -45,8 +45,11 @@ import type { ModelProjectionTransition } from '@maka/core/model-projection-tran export const readExternalExecutionBoundary: AiSdkBackendInput['readExecutionBoundary'] = async () => createExternalExecutionBoundary(); -type TestAiSdkBackendInput = Omit & - Partial> & { +type TestAiSdkBackendInput = Omit< + AiSdkBackendInput, + 'readExecutionBoundary' | 'readPermissionMode' +> & + Partial> & { testProjectionArtifacts?: boolean; /** * The transcript this backend's turn produces, row by row as it appears. @@ -100,6 +103,7 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke const transitions: ModelProjectionTransition[] = []; const backend = new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => input.header.permissionMode, loadModelProjectionTransitions: async () => ({ transitions: [...transitions], unreadableTargets: new Set(), @@ -158,8 +162,11 @@ export function testToolResultArchive( }); } -type TestToolRuntimeInput = Omit & - Partial> & { +type TestToolRuntimeInput = Omit< + ToolRuntimeInput, + 'readExecutionBoundary' | 'readPermissionMode' | 'turnId' +> & + Partial> & { /** The transcript rows this runtime's calls produce; see the backend helper. */ appendMessage?: (message: StoredMessage) => Promise; }; @@ -169,6 +176,7 @@ export function createTestToolRuntime(input: TestToolRuntimeInput): ToolRuntime const { appendMessage, ...runtimeInput } = input; const runtime = new ToolRuntime({ readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => input.header.permissionMode, turnId: 'turn-1', ...runtimeInput, }); diff --git a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts index d442a8d3f6..83cb2d82a4 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -23,8 +23,12 @@ import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; -import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { + createReadOnlyPermissionProfile, + createWorkspaceWritePermissionProfile, +} from '@maka/core/permission-profile'; +import { + applySandboxBoundaryExpansion, type ExecutionBoundary, type SandboxBoundaryRequest, type SandboxBoundarySettlement, @@ -56,6 +60,7 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', + readPermissionMode: async () => 'ask', readExecutionBoundary: async () => { reads += 1; return { @@ -113,12 +118,13 @@ describe('ToolRuntime session sandbox boundary', () => { test('reads the authoritative boundary for every tool invocation', async () => { const observed: ExecutionBoundary[] = []; let revision = 0; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', + readPermissionMode: async () => 'ask', readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -148,24 +154,25 @@ describe('ToolRuntime session sandbox boundary', () => { ); }); - // #3349: the header carries the mode the backend was built with. A picker - // switch to Bypass between two turns widens the boundary without rebuilding - // that header, so a dispatch that trusts the header keeps sandboxing and - // keeps prompting while the picker already reads Bypass. - test('reads the permission mode off the live boundary, not the header it was built with', async () => { + test('reads the selected mode live while letting a Bypass boundary override it', async () => { + let selectedMode: 'explore' | 'ask' = 'explore'; let boundary: ExecutionBoundary = { kind: 'managed', - profile: createWorkspaceWritePermissionProfile(), + profile: applySandboxBoundaryExpansion(createReadOnlyPermissionProfile(), { + filesystem: { + entries: [{ path: '/approved/output', access: 'write', scope: 'subtree' }], + }, + }), revision: 0, }; const observed: Array<{ kind: string; permissionMode: string | undefined }> = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, + readPermissionMode: async () => selectedMode, readExecutionBoundary: async () => boundary, newId: nextId(), now: () => 1, @@ -186,11 +193,14 @@ describe('ToolRuntime session sandbox boundary', () => { }; await settle(runtime, tool, 'tool-1'); - boundary = { kind: 'bypass', revision: 1 }; + selectedMode = 'ask'; await settle(runtime, tool, 'tool-2'); + boundary = { kind: 'bypass', revision: 1 }; + await settle(runtime, tool, 'tool-3'); assert.equal(header().permissionMode, 'ask'); assert.deepEqual(observed, [ + { kind: 'managed', permissionMode: 'explore' }, { kind: 'managed', permissionMode: 'ask' }, { kind: 'bypass', permissionMode: 'bypass' }, ]); @@ -198,13 +208,12 @@ describe('ToolRuntime session sandbox boundary', () => { test('holds Plan mode to read-only even when the live boundary allows writes', async () => { let observed: string | undefined; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: { ...header(), collaborationMode: 'plan' }, connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -240,7 +249,7 @@ describe('ToolRuntime session sandbox boundary', () => { revision: 0, }; let created: SandboxBoundaryRequest | undefined; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -360,7 +369,7 @@ describe('ToolRuntime session sandbox boundary', () => { await releaseAdmission.promise; }, }; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', hostedInteraction, sessionId: 'session-1', @@ -442,7 +451,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('rejects an invalid expansion before creating durable pending state', async () => { let createCalls = 0; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -499,7 +508,7 @@ describe('ToolRuntime session sandbox boundary', () => { const canonicalFile = await realpath(file); let created: SandboxBoundaryRequest | undefined; const events: SessionEvent[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(root), @@ -577,7 +586,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('rejects exact directory authority before creating durable pending state', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-boundary-directory-')); let createCalls = 0; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(root), @@ -636,7 +645,7 @@ describe('ToolRuntime session sandbox boundary', () => { }; let created: SandboxBoundaryRequest | undefined; const settlements: Array<{ requestId: string; decision: string }> = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -711,7 +720,7 @@ describe('ToolRuntime session sandbox boundary', () => { releaseCreate = resolve; }); const settlements: string[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -775,7 +784,7 @@ describe('ToolRuntime session sandbox boundary', () => { }); test('returns a structured boundary requirement to the agent', async () => { - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -837,7 +846,7 @@ describe('ToolRuntime session sandbox boundary', () => { }); test('counts one boundary correction per model step and keeps failure kinds independent', async () => { - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -910,7 +919,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('cancels a suspended nested boundary wait when its cell aborts', async () => { const events: SessionEvent[] = []; const settlements: string[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -991,7 +1000,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('keeps a durable deny failure attached to the aborted nested call', async () => { const events: SessionEvent[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -1046,7 +1055,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('returns structured requires_bypass without opening an interaction', async () => { const events: SessionEvent[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -1114,7 +1123,7 @@ describe('ToolRuntime session sandbox boundary', () => { // here: ToolRuntime injects that callback unconditionally. This is the // branch a model actually reaches, and it used to say something different // from the tool the model called. - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -1158,6 +1167,16 @@ describe('ToolRuntime session sandbox boundary', () => { }); }); +type SandboxToolRuntimeInput = Omit & + Partial>; + +function createRuntime(input: SandboxToolRuntimeInput): ToolRuntime { + return new ToolRuntime({ + readPermissionMode: async () => input.header.permissionMode, + ...input, + }); +} + async function settle(runtime: ToolRuntime, tool: MakaTool, toolCallId: string): Promise { const events: SessionEvent[] = []; await runtime.settleToolCall({ diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index c6d9c4be40..08d1a5a7cf 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -22,9 +22,11 @@ import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + applySandboxBoundaryExpansion, createBypassExecutionBoundary, createGenesisExecutionBoundary, } from '@maka/core/sandbox-boundary'; +import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import { type LlmConnection } from '@maka/core/llm-connections'; import type { SessionEvent } from '@maka/core/events'; import { type SessionHeader } from '@maka/core/session'; @@ -84,6 +86,56 @@ describe('ToolRuntime settlement', () => { ); }); + it('does not promote an expanded Explore boundary into Client Capability admission', async () => { + let preparationCalls = 0; + let implementationCalls = 0; + const clientTool: MakaTool = { + name: 'client_browser', + description: 'client browser', + parameters: {}, + categoryHint: 'custom_tool', + hostAdmission: 'client_capability', + prepareExecution: async () => { + preparationCalls += 1; + return { execute: async () => ({ ok: true }), cancel: () => undefined }; + }, + impl: () => { + implementationCalls += 1; + return { ok: true }; + }, + }; + const expandedProfile = applySandboxBoundaryExpansion(createReadOnlyPermissionProfile(), { + filesystem: { + entries: [{ path: '/approved/output', access: 'write', scope: 'subtree' }], + }, + }); + const runtime = makeRuntime({ + readPermissionMode: async () => 'explore', + readExecutionBoundary: async () => ({ + kind: 'managed', + profile: expandedProfile, + revision: 1, + }), + }); + + const settlement = await runtime.settleToolCall({ + tool: clientTool, + turnId: 'turn-1', + stepId: 'step-1', + toolCallId: 'call-expanded-explore', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: () => undefined, + pushAndWaitUntilConsumed: async () => undefined, + }, + }); + + assert.equal(preparationCalls, 0); + assert.equal(implementationCalls, 0); + assert.match(String((settlement.result as { error?: unknown }).error), /require the Bypass/u); + }); + it('prepares Bypass Client Capability work before T1 and admits only after T1', async () => { const order: string[] = []; const clientTool: MakaTool = { @@ -645,7 +697,12 @@ function makeRuntime( overrides: Partial< Pick< ToolRuntimeInput, - 'readExecutionBoundary' | 'spawnChildSession' | 'runId' | 'invocationId' | 'runtimeCommitSink' + | 'readExecutionBoundary' + | 'readPermissionMode' + | 'spawnChildSession' + | 'runId' + | 'invocationId' + | 'runtimeCommitSink' > > = {}, ): ToolRuntime { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 8a7c9d3aef..892a453e7f 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -124,6 +124,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { providerStateIdentity?: `sha256:${string}`; /** Reads the authoritative session boundary immediately before every local tool invocation. */ readExecutionBoundary: ToolRuntimeInput['readExecutionBoundary']; + /** Reads the user's current Session permission selection for each local tool invocation. */ + readPermissionMode: ToolRuntimeInput['readPermissionMode']; createSandboxBoundaryRequest?: ToolRuntimeInput['createSandboxBoundaryRequest']; settleSandboxBoundaryRequest?: ToolRuntimeInput['settleSandboxBoundaryRequest']; @@ -499,6 +501,7 @@ export class AiSdkBackend implements AgentBackend { connection: input.connection, modelId: input.modelId, readExecutionBoundary: input.readExecutionBoundary, + readPermissionMode: input.readPermissionMode, createSandboxBoundaryRequest: input.createSandboxBoundaryRequest, settleSandboxBoundaryRequest: input.settleSandboxBoundaryRequest, newId: this.newId, diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index d145aababb..74bc44a835 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -24,7 +24,6 @@ import { resolveCollaborationPermissionMode } from '@maka/core/collaboration'; import { type CreateSandboxBoundaryRequest, type ExecutionBoundary, - executionBoundaryDisplayMode, type SandboxBoundaryDecision, type SandboxBoundaryExpansion, type SandboxBoundaryRequest, @@ -376,6 +375,7 @@ export interface ToolRuntimeInput { connection: RuntimeExecutionConnection; modelId: string; readExecutionBoundary: () => Promise; + readPermissionMode: () => Promise; createSandboxBoundaryRequest?: ( input: CreateSandboxBoundaryRequest, ) => Promise; @@ -601,6 +601,7 @@ export class ToolRuntime { private readonly durableToolAttempts = new Map(); private readonly activeToolSettlements = new Set>(); private readonly readExecutionBoundary: NonNullable; + private readonly readPermissionMode: NonNullable; private readonly stepAdmissions = new Map< string, { callCount: number; exclusiveToolName?: string } @@ -609,6 +610,9 @@ export class ToolRuntime { if (!input.readExecutionBoundary) { throw new Error('ToolRuntime requires explicit execution boundary authority'); } + if (!input.readPermissionMode) { + throw new Error('ToolRuntime requires explicit permission mode authority'); + } const hosted = input.hostedInteraction; if (hosted && (hosted.sessionId !== input.sessionId || hosted.turnId !== input.turnId)) { throw new RuntimeInteractionInvariantError( @@ -619,23 +623,22 @@ export class ToolRuntime { this.hostedInteraction = hosted; this.readExecutionBoundary = input.readExecutionBoundary; this.sandboxBoundaryDenied = input.inheritedSandboxBoundaryDenied === true; + this.readPermissionMode = input.readPermissionMode; } /** * The permission mode in force for this dispatch. * - * The header carries the mode this backend was built with, which goes stale - * the moment the boundary widens under a live Session. The boundary is the - * authority, so read the mode off the boundary we are about to dispatch - * against; the header only answers for an externally isolated boundary, - * which projects to no local mode at all. + * A Bypass boundary is an unambiguous live grant. A managed boundary is not: + * an approved path or network expansion changes its structural display mode + * without changing the mode the user selected. Keep that selection live in + * its own authority, then apply the collaboration overlay for this backend. */ - private livePermissionMode(boundary: ExecutionBoundary): PermissionMode { - const displayed = executionBoundaryDisplayMode(boundary); - if (displayed === undefined) return this.input.header.permissionMode; + private async livePermissionMode(boundary: ExecutionBoundary): Promise { + const permissionMode = boundary.kind === 'bypass' ? 'bypass' : await this.readPermissionMode(); return resolveCollaborationPermissionMode({ collaborationMode: this.input.header.collaborationMode ?? 'agent', - permissionMode: displayed, + permissionMode, }); } @@ -1477,10 +1480,12 @@ export class ToolRuntime { } let clientCapabilityBoundary: ExecutionBoundary | undefined; + let clientCapabilityPermissionMode: PermissionMode | undefined; let preparedExecution: PreparedMakaToolExecution | undefined; if (tool.hostAdmission === 'client_capability') { try { clientCapabilityBoundary = await this.readExecutionBoundary(); + clientCapabilityPermissionMode = await this.livePermissionMode(clientCapabilityBoundary); } catch (error) { const reason = formatSyntheticToolErrorText(error); await refuseBeforeDispatch(reason); @@ -1495,8 +1500,7 @@ export class ToolRuntime { } const admissionFailure = !tool.prepareExecution ? CLIENT_CAPABILITY_PREPARATION_MESSAGE - : clientCapabilityBoundary.kind !== 'bypass' && - this.livePermissionMode(clientCapabilityBoundary) !== 'ask' + : clientCapabilityBoundary.kind !== 'bypass' && clientCapabilityPermissionMode !== 'ask' ? CLIENT_CAPABILITY_BOUNDARY_MESSAGE : undefined; if (admissionFailure) { @@ -1525,7 +1529,7 @@ export class ToolRuntime { ...(runId ? { runId } : {}), cwd: this.input.header.cwd, executionBoundary: clientCapabilityBoundary, - permissionMode: this.livePermissionMode(clientCapabilityBoundary), + permissionMode: clientCapabilityPermissionMode, toolCallId: toolUseId, abortSignal: ctx.abortSignal, }); @@ -1646,6 +1650,8 @@ export class ToolRuntime { try { const runId = this.input.runId; const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary()); + const permissionMode = + clientCapabilityPermissionMode ?? (await this.livePermissionMode(executionBoundary)); const toolContext: MakaToolContext = { sessionId: this.input.sessionId, turnId, @@ -1655,7 +1661,7 @@ export class ToolRuntime { : {}), cwd: this.input.header.cwd, executionBoundary, - permissionMode: this.livePermissionMode(executionBoundary), + permissionMode, toolCallId: toolUseId, // The id the call event actually carries, not the candidate: by here // `prepareDurableToolAttempt` has pushed it on the dispatch lane. diff --git a/scripts/computer-use/lab-root.test.mjs b/scripts/computer-use/lab-root.test.mjs index 945d5afd9a..bf37beea75 100644 --- a/scripts/computer-use/lab-root.test.mjs +++ b/scripts/computer-use/lab-root.test.mjs @@ -131,3 +131,10 @@ test('Lab-backed entry points require the configured root', async () => { ); } }); + +test('real AX harness supplies every explicit runtime permission authority', async () => { + const source = await readFile(new URL('real-ax-harness.mjs', import.meta.url), 'utf8'); + + assert.match(source, /readExecutionBoundary:\s*async \(\) =>/); + assert.match(source, /readPermissionMode:\s*async \(\) => 'bypass'/); +}); diff --git a/scripts/computer-use/real-ax-harness.mjs b/scripts/computer-use/real-ax-harness.mjs index ebdbe462d3..7825f33e42 100644 --- a/scripts/computer-use/real-ax-harness.mjs +++ b/scripts/computer-use/real-ax-harness.mjs @@ -550,6 +550,7 @@ const runtime = new AiSdkBackend({ apiKey, modelId, readExecutionBoundary: async () => ({ kind: 'bypass', revision: 0 }), + readPermissionMode: async () => 'bypass', modelFactory: (input) => getAIModel(input), tools: [computerTool], maxSteps: 8, From aa423c2ed5d2639b7ac6256019697af2c8638435 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 14:28:15 +0800 Subject: [PATCH 05/12] refactor(runtime-host): keep one permission resolver import path (#3349) resolveCollaborationPermissionMode belongs to @maka/core/collaboration, where runtime and runtime-host can share the rule without a package-layer shortcut. Drop the compatibility re-export from execution-model-composition and remove its now-unused test import so callers have one canonical module path. Generated-by: OpenAI Codex --- .../src/__tests__/execution-model-composition.test.ts | 1 - packages/runtime-host/src/server/execution-model-composition.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 558bf867d9..0da38670cd 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -104,7 +104,6 @@ import { import { createHostAiSdkBackend, prepareHostAiSdkBackend, - resolveCollaborationPermissionMode, type HostAiSdkBackendInput, } from '../server/execution-model-composition.js'; import { diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index f505837565..85f3f6f278 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -546,5 +546,3 @@ class HostAiSdkBackend extends AiSdkBackend { } } } - -export { resolveCollaborationPermissionMode }; From 7f0bda34f61b182f42cb13ea5f0f2c2e25fa2240 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 17:15:51 +0800 Subject: [PATCH 06/12] fix(runtime): preserve legacy permission store compatibility (#3349) Keep setPermissionMode working for SessionStore embeddings that do not yet expose the optional versioned configuration methods. The compatibility path reuses the canonical execution-boundary transition instead of creating a second widening or narrowing policy. This fallback is intentionally temporary redundancy. A follow-up PR will shortly remove setPermissionMode and this fallback after callers migrate to the configuration authority. Generated-by: OpenAI Codex --- .../src/__tests__/session-manager.test.ts | 17 ++++++ packages/runtime/src/session-manager.ts | 61 ++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 2b48c43509..83005bc202 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4873,6 +4873,23 @@ describe('SessionManager permission mode updates', () => { assert.deepStrictEqual((await store.readHeader(session.id)).labels, ['kept']); }); + 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'); + + assert.strictEqual(summary.permissionMode, 'bypass'); + assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'bypass'); + assert.strictEqual((await store.readExecutionBoundary(session.id)).kind, 'bypass'); + }); + test('starts a new turn without workspace identity when safety inspection fails', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index e8d1e8b989..12b090b204 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1625,8 +1625,16 @@ export class SessionManager { } async setPermissionMode(sessionId: string, mode: PermissionMode): Promise { - const store = this.requireSessionConfigurationStore(); - const current = await store.readHeaderRecordSnapshot(sessionId); + 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, @@ -1636,6 +1644,44 @@ export class SessionManager { 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', @@ -5125,6 +5171,17 @@ 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, From d3c1a1ada1b2b21f34ddff5e38d0f830b98304d1 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 17:28:59 +0800 Subject: [PATCH 07/12] test(runtime-host): cover live permission updates end to end (#3349) Exercise session.configuration.update through the production Host composition for both an active ordinary Turn and an active Goal continuation. Verify that the following tool dispatch observes the widened permission through a real Client Capability call. Generated-by: OpenAI Codex --- .../execution-model-composition.test.ts | 418 +++++++++++++++++- 1 file changed, 414 insertions(+), 4 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 0da38670cd..0841f7e989 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { deferred, waitFor } from '@maka/core/test-only/async-primitives'; +import { deferred, type Deferred, waitFor } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; @@ -522,6 +522,337 @@ test('production Host executes Bash against the current live sandbox boundary', } }); +test('permission widening through the Host reaches the next ordinary Turn tool call', async () => { + await runPermissionUpdateHostRegression('ordinary_session'); +}); + +test('permission widening through the Host reaches a tool call in an active Goal continuation', async () => { + await runPermissionUpdateHostRegression('active_goal'); +}); + +async function runPermissionUpdateHostRegression( + scenario: 'ordinary_session' | 'active_goal', +): Promise { + const scenarioSlug = scenario.replace('_', '-'); + const base = await mkdtemp(join(tmpdir(), `maka-host-permission-${scenario}-`)); + const root = join(base, 'interactive'); + const project = join(base, 'project'); + const provider = await startProvider(); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const context: ConnectionContext = { + hostEpoch: `permission-${scenario}-epoch`, + connectionId: `permission-${scenario}-client`, + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }; + const capabilityConnectionId = `permission-${scenario}-capability`; + const capabilityContext: ConnectionContext = { + ...context, + connectionId: capabilityConnectionId, + }; + const calls: Array> = []; + let admitted = 0; + let composition: Awaited> | undefined; + let capabilityConnection: + | ReturnType + | undefined; + let releaseActiveRequest: (() => void) | undefined; + try { + await mkdir(project); + const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const created = await policy.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: { + slug: `permission-${scenarioSlug}-provider`, + name: `Permission ${scenario} provider`, + providerType: 'moonshot', + baseUrl: provider.baseUrl, + enabled: true, + enabledModelIds: [MODEL_ID], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') return; + const modelConnection = created.snapshot.connections[0]; + assert.ok(modelConnection); + if (!modelConnection) return; + assert.equal( + ( + await policy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: modelConnection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: API_KEY, + }) + ).kind, + 'committed', + ); + await publishConnectionModel(policy, modelConnection.connectionId, MODEL_ID, 32_768); + + const execution = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await execution.sessionStore.create({ + cwd: project, + llmConnectionId: modelConnection.connectionId, + llmConnectionSlug: `permission-${scenarioSlug}-provider`, + model: MODEL_ID, + permissionMode: 'explore', + }); + composition = await createExecutionRuntimeHostComposition({ + owner, + hostEpoch: context.hostEpoch, + acquireResidency: context.acquireResidency, + retainUntilProcessExit: () => undefined, + requestDrain: () => undefined, + }); + await composition.recover(); + const clientCapabilities = composition.clientCapabilities as + | HostClientCapabilityCoordinator + | undefined; + assert.ok(clientCapabilities); + if (!clientCapabilities) return; + + capabilityConnection = clientCapabilities.attachConnection( + clientCapabilityConnectionIdentity(capabilityConnectionId), + { + send: async (frame) => { + if (frame.kind === 'client.capability.call') { + calls.push(frame); + queueMicrotask(() => { + capabilityConnection?.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + }); + } else if (frame.kind === 'client.capability.admitted') { + admitted += 1; + queueMicrotask(() => { + capabilityConnection?.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: { + content: [{ type: 'text', text: CLIENT_CAPABILITY_RESULT_TEXT }], + }, + }); + }); + } + }, + }, + ); + const registered = await composition.handlers['client.capability.replace']( + { + registrationId: `permission-${scenario}-registration`, + offers: [ + { + offerId: 'hosted-browser', + version: '0', + affinity: 'session', + hostPathAccess: 'cwd', + label: 'Hosted Browser', + tools: [ + { + serverId: 'hosted_browser', + name: 'navigate', + description: 'Navigate the hosted browser.', + inputSchema: { + type: 'object', + properties: { url: { type: 'string' } }, + required: ['url'], + additionalProperties: false, + }, + }, + ], + }, + ], + }, + capabilityContext, + ); + assert.equal(registered.ok, true); + assert.deepEqual(await clientCapabilities.bindSession(session.id, capabilityConnectionId), { + ok: true, + }); + const snapshot = clientCapabilities.snapshotForSession(session.id); + assert.ok(snapshot); + if (!snapshot) return; + const group = snapshot.groups[0]; + const tool = snapshot.tools[0]; + snapshot.release(); + assert.ok(group); + assert.ok(tool); + if (!group || !tool) return; + const providerControl = provider.configurePermissionUpdateFlow({ + scenario, + groupId: group.id, + toolName: tool.name, + }); + releaseActiveRequest = providerControl.releaseActiveRequest; + + let exercisedRunId: string; + if (scenario === 'ordinary_session') { + const firstTurnId = 'permission-ordinary-running-turn'; + const firstStarted = await startTurn( + composition, + session.id, + firstTurnId, + 'Keep this Turn active while permission changes.', + context, + ); + await settleWithin(providerControl.activeRequestStarted); + await commitBypassPermissionUpdate(composition, execution, session.id, context); + providerControl.releaseActiveRequest(); + const firstTerminal = await waitForTerminal( + composition, + session.id, + firstTurnId, + firstStarted, + context, + ); + assert.equal(firstTerminal.status, 'completed'); + + const nextTurnId = 'permission-ordinary-next-turn'; + const nextTerminal = await waitForTerminal( + composition, + session.id, + nextTurnId, + await startTurn( + composition, + session.id, + nextTurnId, + 'Use the connected browser capability.', + context, + ), + context, + ); + assert.equal(nextTerminal.status, 'completed'); + exercisedRunId = nextTerminal.runId; + } else { + const armed = await composition.handlers['goal.arm']( + { + sessionId: session.id, + condition: 'Use the connected browser capability once.', + maxIterations: 3, + tokenBudget: null, + }, + context, + ); + assert.equal(armed.ok, true); + if (!armed.ok) return; + const carryingTurnId = 'permission-goal-carrying-turn'; + const carryingStarted = await startTurn( + composition, + session.id, + carryingTurnId, + 'Begin the active Goal.', + context, + ); + const carryingTerminal = waitForTerminal( + composition, + session.id, + carryingTurnId, + carryingStarted, + context, + ); + await settleWithin(providerControl.activeRequestStarted); + assert.equal((await carryingTerminal).status, 'completed'); + const activeGoalRun = ( + await execution.runtimeEventStore.listSessionInvocations(session.id) + ).find( + (run) => + run.terminalEvent === undefined && + run.opening.root.kind === 'goal' && + run.opening.root.goalId === armed.result.goal.goalId, + ); + assert.ok(activeGoalRun, 'Goal continuation did not hold an active Run'); + if (!activeGoalRun) return; + assert.equal(activeGoalRun.opening.configuration.permissionMode, 'explore'); + exercisedRunId = activeGoalRun.runId; + + await commitBypassPermissionUpdate(composition, execution, session.id, context); + providerControl.releaseActiveRequest(); + await waitForGoalStatus(composition, session.id, 'achieved', context); + } + + assert.equal((await execution.sessionStore.readHeader(session.id)).permissionMode, 'bypass'); + assert.equal((await execution.sessionStore.readExecutionBoundary(session.id)).kind, 'bypass'); + assert.equal(admitted, 1); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0]?.arguments, { + url: 'https://example.test/permission-update', + }); + const events = await execution.runtimeEventStore.readRuntimeEvents(session.id, exercisedRunId); + assert.ok( + events.some( + (event) => + event.content?.kind === 'function_response' && + event.content.name === tool.name && + JSON.stringify(event.content.result).includes(CLIENT_CAPABILITY_RESULT_TEXT), + ), + ); + } finally { + releaseActiveRequest?.(); + try { + await capabilityConnection?.close(); + } finally { + try { + await composition?.close(); + } finally { + try { + await owner.close(); + } finally { + try { + await provider.close(); + } finally { + await rm(base, { recursive: true, force: true }); + } + } + } + } + } +} + +async function commitBypassPermissionUpdate( + composition: Awaited>, + execution: Awaited>, + sessionId: string, + context: ConnectionContext, +): Promise { + const current = await execution.sessionStore.readHeaderRecordSnapshot(sessionId); + const updated = await composition.handlers['session.configuration.update']( + { + sessionId, + expectedRevision: current.revision, + patch: { permissionMode: 'bypass' }, + }, + context, + ); + assert.equal(updated.ok, true, JSON.stringify(updated)); + if (!updated.ok) return; + assert.equal(updated.result.kind, 'committed'); + if (updated.result.kind !== 'committed' || 'kind' in updated.result.session) return; + assert.equal(updated.result.session.permissionMode, 'bypass'); +} + +async function waitForGoalStatus( + composition: Awaited>, + sessionId: string, + status: 'achieved', + context: ConnectionContext, +): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const queried = await composition.handlers['goal.query']({ sessionId }, context); + assert.equal(queried.ok, true); + if (queried.ok && queried.result.goal?.status === status) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Hosted Goal did not reach ${status}`); +} + test('backend creation admits the enabled bootstrap DeepSeek model before discovery', async () => { const modelId = 'deepseek-v4-flash'; const backend = await createHostAiSdkBackend( @@ -5082,6 +5413,15 @@ interface ManagedSandboxPaths { type ProviderFlow = | { readonly kind: 'default' } + | { + readonly kind: 'permission_update'; + readonly scenario: 'ordinary_session' | 'active_goal'; + readonly groupId: string; + readonly toolName: string; + readonly activeRequestStarted: Deferred; + readonly activeRequestRelease: Deferred; + goalEvaluationCount: number; + } | { readonly kind: 'managed_bash'; readonly sandboxPaths?: ManagedSandboxPaths; @@ -5103,6 +5443,14 @@ type ProviderFlow = async function startProvider(): Promise<{ readonly baseUrl: string; readonly requests: ProviderRequest[]; + configurePermissionUpdateFlow(input: { + scenario: 'ordinary_session' | 'active_goal'; + groupId: string; + toolName: string; + }): { + readonly activeRequestStarted: Promise; + releaseActiveRequest(): void; + }; configureManagedBashFlow(sandboxPaths?: ManagedSandboxPaths): void; configureClientCapability(input: { groupId: string; toolName: string }): void; configureProjectionImageFlow(toolName: string): void; @@ -5132,6 +5480,22 @@ async function startProvider(): Promise<{ return { baseUrl: `http://127.0.0.1:${address.port}/v1`, requests, + configurePermissionUpdateFlow: (input) => { + if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); + const activeRequestStarted = deferred(); + const activeRequestRelease = deferred(); + flow = { + kind: 'permission_update', + ...input, + activeRequestStarted, + activeRequestRelease, + goalEvaluationCount: 0, + }; + return { + activeRequestStarted: activeRequestStarted.promise, + releaseActiveRequest: () => activeRequestRelease.resolve(), + }; + }, configureManagedBashFlow: (sandboxPaths) => { if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); flow = { @@ -5208,6 +5572,11 @@ async function handleProviderRequest( serialized, ); const isHistoryCompaction = /context summarization assistant/.test(serialized); + const isGoalEvaluation = /goal evaluation judge/.test(serialized); + const goalEvaluation = + flow.kind === 'permission_update' && flow.scenario === 'active_goal' && isGoalEvaluation + ? ++flow.goalEvaluationCount + : 0; response.writeHead(200, { 'content-type': 'application/json' }); response.end( JSON.stringify({ @@ -5228,9 +5597,20 @@ async function handleProviderRequest( requestedItems: [], incidentalItems: [], }) - : isHistoryCompaction - ? COMPACT_SUMMARY_TEXT - : SUMMARY_TEXT, + : goalEvaluation > 0 + ? JSON.stringify({ + met: goalEvaluation > 1, + impossible: false, + progress: true, + waiting: false, + reason: + goalEvaluation > 1 + ? 'The permission update reached the continuation tool.' + : 'Continue with the permission-sensitive tool call.', + }) + : isHistoryCompaction + ? COMPACT_SUMMARY_TEXT + : SUMMARY_TEXT, }, finish_reason: 'stop', }, @@ -5241,6 +5621,36 @@ async function handleProviderRequest( return; } const streamRequestIndex = requests.filter((candidate) => candidate.body.stream === true).length; + if (flow.kind === 'permission_update' && streamRequestIndex === 1) { + if (flow.scenario === 'ordinary_session') { + flow.activeRequestStarted.resolve(); + await flow.activeRequestRelease.promise; + } + respondProviderText(response, RESPONSE_TEXT); + return; + } + if (flow.kind === 'permission_update' && streamRequestIndex === 2) { + if (flow.scenario === 'active_goal') { + flow.activeRequestStarted.resolve(); + await flow.activeRequestRelease.promise; + } + assert.ok(toolNames(body).includes('tool_search')); + respondProviderToolCall(response, streamRequestIndex, 'tool_search', { + query: flow.toolName, + }); + return; + } + if (flow.kind === 'permission_update' && streamRequestIndex === 3) { + assert.ok(toolNames(body).includes(flow.toolName)); + respondProviderToolCall(response, streamRequestIndex, flow.toolName, { + url: 'https://example.test/permission-update', + }); + return; + } + if (flow.kind === 'permission_update') { + respondProviderText(response, RESPONSE_TEXT); + return; + } if (flow.kind === 'projection_image' && streamRequestIndex === 1) { assert.ok(toolNames(body).includes(flow.toolName)); respondProviderToolCall(response, streamRequestIndex, flow.toolName, {}); From 3912567fbf978dbe0cc8e47ff988ec367d2bd81e Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 11 Sep 2026 11:15:54 +0800 Subject: [PATCH 08/12] fix(runtime): retain backend refreshes during activation (#3349) Track the full prepare/build/reservation interval in the existing backend invalidation lifecycle. A widening permission update can complete during a cold activation without losing its refresh or interrupting the admitted Run; dispose the stale generation after that Run exits, and settle invalidation if activation fails. Include in-flight activations in strict backend refreshes. Cover configuration updates blocked in both preparation and construction, verify the next Plan Turn receives its fresh tool catalog and prompt, and exercise failed-build cleanup. Generated-by: OpenAI Codex --- .../src/__tests__/session-manager.test.ts | 134 ++++++++++++++++++ packages/runtime/src/runtime-kernel.ts | 55 +++++-- 2 files changed, 179 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 83005bc202..9bef3feb9e 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -99,6 +99,7 @@ import { assertDoubleRunNotSealed } from './runtime-event-store-seal.js'; import type { LanguageModelV4StreamPart } from '@ai-sdk/provider'; import { z } from 'zod'; import { AiSdkBackend } from '../ai-sdk-backend.js'; +import { renderPlanModePrompt, selectCollaborationTools } from '../plan-mode.js'; import { BackendRegistry, SessionConfigurationRevisionConflictError, @@ -4557,6 +4558,139 @@ describe('SessionManager manual compaction and quiescent session changes', () => }); describe('SessionManager permission mode updates', () => { + for (const checkpoint of ['prepare', 'build'] as const) { + test(`retains a permission refresh during backend ${checkpoint} until the admitted turn exits`, async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const activationStarted = makeGate(); + const releaseActivation = makeGate(); + const sendGate = makeGate(); + const builds: PermissionMode[] = []; + const dispatched: Array<{ tools: string[]; prompt: string }> = []; + const instances: TestBackend[] = []; + const backends = new BackendRegistry(); + backends.register('ai-sdk', { + async prepare() { + if (checkpoint === 'prepare' && builds.length === 0) { + activationStarted.release(); + await releaseActivation.promise; + } + return { + async build(ctx) { + builds.push(ctx.header.permissionMode); + if (checkpoint === 'build' && builds.length === 1) { + activationStarted.release(); + await releaseActivation.promise; + } + const fullAccess = ctx.header.permissionMode === 'bypass'; + const composition = { + tools: selectCollaborationTools({ + mode: 'plan', + tools: [testTool('Read'), testTool('Write')], + hasActiveExecution: false, + fullAccess, + }).map((tool) => tool.name), + prompt: renderPlanModePrompt({ fullAccess }), + }; + const backend = new (class extends TestBackend { + override async *send(input: BackendSendInput) { + dispatched.push(composition); + yield* super.send(input); + } + })(ctx, sendGate); + instances.push(backend); + return backend; + }, + }; + }, + }); + const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(980) }); + const session = await manager.createSession( + makeInput({ permissionMode: 'ask', collaborationMode: 'plan' }), + ); + const firstTurn = manager + .sendMessage(session.id, { turnId: 'turn-building', text: 'plan' }) + [Symbol.asyncIterator](); + const firstEvent = firstTurn.next(); + await activationStarted.promise; + try { + const current = await store.readHeaderRecordSnapshot(session.id); + await manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'bypass' }), + }); + assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'bypass'); + assert.strictEqual(store.disposeCount, 0); + } finally { + releaseActivation.release(); + } + try { + await firstEvent; + assert.strictEqual(store.disposeCount, 0); + assert.strictEqual(instances[0]?.stopCalls, 0); + } finally { + sendGate.release(); + while (!(await firstTurn.next()).done) {} + } + assert.strictEqual(store.disposeCount, 1); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-fresh', text: 'write now' })); + assert.deepStrictEqual(builds, ['ask', 'bypass']); + assert.deepStrictEqual( + dispatched.map((input) => input.tools), + [['Read'], ['Read', 'Write']], + ); + assert.strictEqual(dispatched[0]?.prompt, renderPlanModePrompt()); + assert.strictEqual(dispatched[1]?.prompt, renderPlanModePrompt({ fullAccess: true })); + }); + } + + test('settles a backend refresh when an in-flight build fails', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const buildStarted = makeGate(); + const releaseBuild = makeGate(); + const builds: PermissionMode[] = []; + const backends = new BackendRegistry(); + backends.register('ai-sdk', async (ctx) => { + builds.push(ctx.header.permissionMode); + if (builds.length === 1) { + buildStarted.release(); + await releaseBuild.promise; + throw new Error('injected activation failure'); + } + return new TestBackend(ctx); + }); + const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(982) }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + const firstTurn = assert.rejects( + drain(manager.sendMessage(session.id, { turnId: 'turn-failing', text: 'start' })), + /injected activation failure/, + ); + await buildStarted.promise; + const current = await store.readHeaderRecordSnapshot(session.id); + await manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'bypass' }), + }); + let refreshed = false; + const refresh = manager.refreshIdleBackends().then(() => { + refreshed = true; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual(refreshed, false); + } finally { + releaseBuild.release(); + } + await firstTurn; + await refresh; + await drain(manager.sendMessage(session.id, { turnId: 'turn-retry', text: 'retry' })); + assert.deepStrictEqual(builds, ['ask', 'bypass']); + }); + test('revokes background shell authority before narrowing Auto to Explore', async () => { const store = new VersionedConfigurationMemorySessionStore(); const calls: string[] = []; diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index dfe788e54f..61b6ae5265 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -364,6 +364,7 @@ type BackendDisposalOutcome = { ok: true } | { ok: false; error: unknown }; interface BackendInvalidationState { readonly outcome: Promise; + readonly activations: Set; resolve(outcome: BackendDisposalOutcome): void; disposal?: Promise; failure?: Error; @@ -380,6 +381,7 @@ export class RuntimeKernel implements RuntimeKernelLike { private readonly active = new Map(); private readonly backendGenerations = new Map(); private readonly backendActivationBuilds = new Map>(); + private readonly backendActivations = new Set(); private readonly stopOperations = new Map(); private readonly stopAttempts = new Map>(); private readonly executionClaims = new Map>(); @@ -404,8 +406,21 @@ export class RuntimeKernel implements RuntimeKernelLike { this.historyCompactCoordinator = new HistoryCompactCheckpointCoordinator(deps); } - private async runBackendActivation(operation: () => Promise | T): Promise { - return await (this.deps.runBackendActivation?.(operation) ?? operation()); + private async runBackendActivation( + execution: PendingExecutionClaim, + operation: () => Promise | T, + ): Promise { + const activate = async () => { + this.backendActivations.add(execution); + try { + return await operation(); + } finally { + this.backendActivations.delete(execution); + this.backendInvalidations.get(execution.sessionId)?.activations.delete(execution); + await this.flushBackendInvalidation(execution.sessionId); + } + }; + return await (this.deps.runBackendActivation?.(activate) ?? activate()); } claimExecution(sessionId: string): RuntimeExecutionClaim { @@ -1171,7 +1186,7 @@ export class RuntimeKernel implements RuntimeKernelLike { runId: run.runId, }); } - begin = await this.runBackendActivation(async () => { + begin = await this.runBackendActivation(execution, async () => { run.bindProviderStateIdentity( await this.prepareBackendForExecution(sessionId, header, execution), ); @@ -1274,7 +1289,7 @@ export class RuntimeKernel implements RuntimeKernelLike { header: SessionHeader, execution: PendingExecutionClaim, ): Promise { - const active = await this.runBackendActivation(() => + const active = await this.runBackendActivation(execution, () => this.ensureActive(sessionId, header, execution), ); if (!active.backend.compactHistory) { @@ -1312,7 +1327,7 @@ export class RuntimeKernel implements RuntimeKernelLike { runId: run.runId, }); } - begin = await this.runBackendActivation(async () => { + begin = await this.runBackendActivation(execution, async () => { await prepareBackendActivation?.(); run.bindProviderStateIdentity( await this.prepareBackendForExecution(sessionId, run.headerSnapshot(), execution), @@ -1457,7 +1472,7 @@ export class RuntimeKernel implements RuntimeKernelLike { let begin: Awaited>; try { if (messageOwner) owners.bindMessage(this.deps.messageAuthority, messageOwner); - begin = await this.runBackendActivation(async () => { + begin = await this.runBackendActivation(execution, async () => { if (!revalidateSafety) { throw new Error('Durable continuation omitted final safety revalidation'); } @@ -2240,6 +2255,7 @@ export class RuntimeKernel implements RuntimeKernelLike { const sessionIds = new Set( [...this.backendGenerations.values()].map((generation) => generation.sessionId), ); + for (const execution of this.backendActivations) sessionIds.add(execution.sessionId); for (const sessionId of this.backendInvalidations.keys()) sessionIds.add(sessionId); await Promise.all( [...sessionIds].map(async (sessionId) => { @@ -2811,7 +2827,7 @@ export class RuntimeKernel implements RuntimeKernelLike { private async flushBackendInvalidation(sessionId: string): Promise { const invalidation = this.backendInvalidations.get(sessionId); - if (!invalidation || this.hasActiveRuns(sessionId)) return; + if (!invalidation || invalidation.activations.size > 0 || this.hasActiveRuns(sessionId)) return; await this.startBackendDisposal(sessionId, invalidation); } @@ -2858,8 +2874,12 @@ export class RuntimeKernel implements RuntimeKernelLike { const invalidation = this.backendInvalidations.get(sessionId); if (!invalidation) return; + // This activation was already admitted when the refresh arrived. Let it + // reserve its Run; invalidation must survive until that Run exits (or the + // activation fails), rather than disposing a not-yet-reserved generation. + if (invalidation.activations.has(execution)) return; await this.flushBackendInvalidation(sessionId); - if (this.hasActiveRuns(sessionId)) { + if (invalidation.activations.size > 0 || this.hasActiveRuns(sessionId)) { throw new Error(`Backend generation is quarantined for session ${sessionId}`); } await this.startBackendDisposal(sessionId, invalidation); @@ -2896,16 +2916,31 @@ export class RuntimeKernel implements RuntimeKernelLike { private ensureBackendInvalidation(sessionId: string): BackendInvalidationState { const existing = this.backendInvalidations.get(sessionId); - if (existing) return existing; + if (existing) { + if (!existing.disposal) this.retainBackendActivations(sessionId, existing); + return existing; + } let resolve!: (outcome: BackendDisposalOutcome) => void; const outcome = new Promise((resolvePromise) => { resolve = resolvePromise; }); - const invalidation = { outcome, resolve }; + const invalidation: BackendInvalidationState = { outcome, resolve, activations: new Set() }; + this.retainBackendActivations(sessionId, invalidation); this.backendInvalidations.set(sessionId, invalidation); return invalidation; } + private retainBackendActivations( + sessionId: string, + invalidation: BackendInvalidationState, + ): void { + // A cold backend has no generation or activeRuns yet. Retain the entire + // prepare/build/reservation interval, not just the shared factory promise. + for (const execution of this.backendActivations) { + if (execution.sessionId === sessionId) invalidation.activations.add(execution); + } + } + private async updateStatus( sessionId: string, status: SessionStatus, From ec0805e979fa680b34a614172a5ca115a8c77d66 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 11 Sep 2026 11:22:23 +0800 Subject: [PATCH 09/12] fix(runtime): fence Explore read-grant revocation as narrowing (#3349) A managed profile can remain read-only while granting reads outside the workspace. Restoring Explore removes those grants, so only the canonical Explore policy is safe to classify as non-narrowing. Share that name-independent policy check between Runtime and Storage. Keep the existing quiescence and shell-revocation path for expanded read authority. Reproduce an approved outside read followed by Explore-to-Auto-to-Explore transitions against the durable Session store, asserting that an active Turn blocks the reset and an idle reset revokes shell authority before removing the grant. Generated-by: OpenAI Codex --- .../src/__tests__/permission-profile.test.ts | 57 +++++++++ packages/core/src/permission-profile.ts | 20 +++ .../src/__tests__/session-manager.test.ts | 114 ++++++++++++++++++ packages/runtime/src/session-manager.ts | 6 +- .../src/sqlite-session-metadata-store.ts | 15 +-- 5 files changed, 198 insertions(+), 14 deletions(-) diff --git a/packages/core/src/__tests__/permission-profile.test.ts b/packages/core/src/__tests__/permission-profile.test.ts index f0b67af5e5..0e42f0644a 100644 --- a/packages/core/src/__tests__/permission-profile.test.ts +++ b/packages/core/src/__tests__/permission-profile.test.ts @@ -26,6 +26,7 @@ import { createDangerFullAccessPermissionProfile, createReadOnlyPermissionProfile, createWorkspaceWritePermissionProfile, + isCanonicalReadOnlyPermissionProfile, isDeniedPath, isProtectedMetadataPath, isReadOnlyPermissionProfile, @@ -193,6 +194,62 @@ describe('isReadOnlyPermissionProfile', () => { }); }); +describe('isCanonicalReadOnlyPermissionProfile', () => { + test('matches the canonical policy without relying on its display name', () => { + const { name: _name, ...policy } = createReadOnlyPermissionProfile(); + for (const profile of [ + policy, + { ...policy, name: 'custom' }, + { ...policy, name: 'read-only' }, + ]) { + assert.strictEqual(isCanonicalReadOnlyPermissionProfile(profile), true); + } + }); + + test('distinguishes extra read authority from the canonical Explore policy', () => { + const profile = createReadOnlyPermissionProfile(); + const extraRead: PermissionProfileManaged = { + ...profile, + fileSystem: { + ...profile.fileSystem, + entries: [ + ...profile.fileSystem.entries, + { kind: 'path', access: 'read', path: '/outside' }, + ], + }, + }; + assert.strictEqual(isReadOnlyPermissionProfile(extraRead), true); + assert.strictEqual(isCanonicalReadOnlyPermissionProfile(extraRead), false); + }); + + test('does not certify other managed policies as canonical Explore', () => { + const profile = createReadOnlyPermissionProfile(); + const nonCanonical: PermissionProfileManaged[] = [ + createWorkspaceWritePermissionProfile(), + createDangerFullAccessPermissionProfile(), + { ...profile, network: { kind: 'enabled' } }, + { ...profile, fileSystem: { kind: 'restricted', entries: [] } }, + { + ...profile, + fileSystem: { + ...profile.fileSystem, + entries: [{ kind: 'special', access: 'read', special: ':root' }], + }, + }, + { + ...profile, + fileSystem: { + ...profile.fileSystem, + protectedMetadata: { access: 'deny_write', names: ['.git'] }, + }, + }, + ]; + for (const candidate of nonCanonical) { + assert.strictEqual(isCanonicalReadOnlyPermissionProfile(candidate), false); + } + }); +}); + describe('PermissionProfile matcher rules', () => { test('deny entries take precedence over read and write entries', () => { const profile: PermissionProfile = { diff --git a/packages/core/src/permission-profile.ts b/packages/core/src/permission-profile.ts index c8f780ee3c..b92130114c 100644 --- a/packages/core/src/permission-profile.ts +++ b/packages/core/src/permission-profile.ts @@ -153,6 +153,26 @@ export function isReadOnlyPermissionProfile(profile: PermissionProfileManaged): ); } +/** + * True only for the canonical Explore policy, independently of its display name. + * A read-only profile may still grant extra read paths that restoring Explore + * would revoke. Treat every other policy conservatively as a possible narrowing. + * Storage and Runtime must agree on which policy an Explore reset preserves. + */ +export function isCanonicalReadOnlyPermissionProfile(profile: PermissionProfileManaged): boolean { + const { fileSystem, network } = profile; + const entry = fileSystem.entries[0]; + return ( + fileSystem.kind === 'restricted' && + fileSystem.protectedMetadata === undefined && + fileSystem.entries.length === 1 && + entry?.kind === 'special' && + entry.access === 'read' && + entry.special === ':workspace_roots' && + network.kind === 'restricted' + ); +} + export function createWorkspaceWritePermissionProfile(): PermissionProfileManaged { return { type: 'managed', diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 9bef3feb9e..0bd81269d0 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -22,6 +22,10 @@ import { sectionedSummary } from './history-compact-test-fixtures.js'; import { runtimeInvocationFailureClass } from '../runtime-event-read-model.js'; import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createSessionStore } from '@maka/storage/session-store'; import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; import { buildInvocationOpenedEvent, @@ -47,8 +51,10 @@ import { isSandboxBoundaryRestartClosure, } from '@maka/core/sandbox-boundary'; import { + canReadPath, createReadOnlyPermissionProfile, createWorkspaceWritePermissionProfile, + isReadOnlyPermissionProfile, } from '@maka/core/permission-profile'; import { DEEP_RESEARCH_SESSION_LABEL } from '@maka/core/deep-research'; import { RUNTIME_CONTINUATION_AUTHORITY_V1 } from '@maka/core/runtime-event-store'; @@ -4798,6 +4804,114 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'explore'); }); + test('restoring Explore revokes an approved outside read through the durable configuration 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: 'outside-read', + turnId: 'turn-approval', + runId: 'run-approval', + expansion: { + filesystem: { entries: [{ path: outsidePath, access: 'read', scope: 'exact' }] }, + }, + justification: 'Read the approved input outside the workspace.', + }); + const settlement = await store.settleSandboxBoundaryRequest({ + sessionId: session.id, + requestId: 'outside-read', + decision: 'allow', + }); + assert.strictEqual(settlement.request.status, 'approved'); + await updatePermissionMode('ask'); + 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), true); + assert.strictEqual( + canReadPath(expanded.profile, outsidePath, { workspaceRoots: [workspaceRoot] }), + true, + ); + 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(updatePermissionMode('explore'), (error: unknown) => { + 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, 'ask'); + assert.deepStrictEqual(calls, []); + } finally { + gate.release(); + while (!(await activeTurn.next()).done) {} + } + + await updatePermissionMode('explore'); + 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(); const calls: string[] = []; diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 12b090b204..fdc4231670 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -70,7 +70,7 @@ import type { import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; -import { isReadOnlyPermissionProfile } from '@maka/core/permission-profile'; +import { isCanonicalReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; import type { CreateSandboxBoundaryRequest, @@ -5188,7 +5188,9 @@ function narrowsExecutionAuthority( ): boolean { if (nextPermissionMode === 'bypass') return false; if (boundary.kind !== 'managed') return true; - return nextPermissionMode === 'explore' && !isReadOnlyPermissionProfile(boundary.profile); + return ( + nextPermissionMode === 'explore' && !isCanonicalReadOnlyPermissionProfile(boundary.profile) + ); } function agentRunStatusForSpawnResult( diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 2f338a5ca3..73d1194aa6 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -23,6 +23,7 @@ import { dirname, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { existsSync, mkdirSync } from 'node:fs'; import { isDeepStrictEqual } from 'node:util'; +import { isCanonicalReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import type { DatabaseSync } from 'node:sqlite'; import { AGENT_GRAPH_CLIENT_PROJECTION_SCHEMA_VERSION, @@ -4575,7 +4576,7 @@ export class SqliteSessionMetadataStore { `Managed sandbox boundary history is invalid: ${sessionId}`, ); } - if (!isCanonicalReadOnlySandboxProfile(boundary.profile)) return boundary.profile; + if (!isCanonicalReadOnlyPermissionProfile(boundary.profile)) return boundary.profile; } return requireManagedProfile(createGenesisExecutionBoundary('ask')); } @@ -4838,7 +4839,7 @@ export class SqliteSessionMetadataStore { kind === 'managed' ? projectedMode === 'explore' ? requireManagedProfile(createGenesisExecutionBoundary('explore')) - : current.kind === 'managed' && !isCanonicalReadOnlySandboxProfile(current.profile) + : current.kind === 'managed' && !isCanonicalReadOnlyPermissionProfile(current.profile) ? current.profile : this.readLatestAutoSandboxProfileSync(sessionId) : undefined; @@ -6264,16 +6265,6 @@ function requireManagedProfile( return boundary.profile; } -function isCanonicalReadOnlySandboxProfile( - profile: Extract['profile'], -): boolean { - const { name: _profileName, ...profilePolicy } = profile; - const { name: _canonicalName, ...canonicalPolicy } = requireManagedProfile( - createGenesisExecutionBoundary('explore'), - ); - return isDeepStrictEqual(profilePolicy, canonicalPolicy); -} - function assertGraphLookupIdentity(value: string, name: string): void { if ( typeof value !== 'string' || From 8e76dbb680b847b40e7d60b33ae3bf3a2959b6b5 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 11 Sep 2026 13:53:39 +0800 Subject: [PATCH 10/12] fix(runtime): retain refreshes across backend preflight snapshots (#3349) Mark backend header snapshots from the start of their store read, covering safety inspection, admission and policy-gate waits before prepare/build. Preserve invalidation on the execution claim and re-arm it inside activation after any previous disposal, so an old snapshot cannot leave a reusable stale backend. Do not make a policy mutation wait for preflight claims queued behind its own activation gate. Cover the earlier snapshot windows, strict refresh of a cold queued activation, and cancelled or failed admission cleanup. Generated-by: OpenAI Codex --- .../src/__tests__/session-manager.test.ts | 158 +++++++++++++++++- packages/runtime/src/runtime-kernel.ts | 35 +++- 2 files changed, 185 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 0bd81269d0..d903eddcba 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4564,12 +4564,29 @@ describe('SessionManager manual compaction and quiescent session changes', () => }); describe('SessionManager permission mode updates', () => { - for (const checkpoint of ['prepare', 'build'] as const) { - test(`retains a permission refresh during backend ${checkpoint} until the admitted turn exits`, async () => { + for (const checkpoint of [ + 'header', + 'safety', + 'admission', + 'activation_gate', + 'prepare', + 'build', + ] as const) { + test(`retains a permission refresh during backend ${checkpoint} until the admitted turn exits`, { + timeout: 10_000, + }, async (t) => { const store = new VersionedConfigurationMemorySessionStore(); const activationStarted = makeGate(); const releaseActivation = makeGate(); const sendGate = makeGate(); + t.after(() => { + releaseActivation.release(); + sendGate.release(); + }); + const pause = async () => { + activationStarted.release(); + await releaseActivation.promise; + }; const builds: PermissionMode[] = []; const dispatched: Array<{ tools: string[]; prompt: string }> = []; const instances: TestBackend[] = []; @@ -4609,12 +4626,51 @@ describe('SessionManager permission mode updates', () => { }; }, }); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(980) }); + const manager = new SessionManager({ + store, + backends, + newId: nextId(), + now: nextNow(980), + inspectContinuationSafety: async () => { + if (checkpoint === 'safety' && builds.length === 0) await pause(); + return { + workspaceIdentity: 'workspace', + workspacePath: '/tmp/cwd', + backgroundOperationsSettled: true, + availableToolNames: [], + }; + }, + runBackendActivation: async (operation) => { + if (checkpoint === 'activation_gate' && builds.length === 0) await pause(); + return operation(); + }, + }); const session = await manager.createSession( makeInput({ permissionMode: 'ask', collaborationMode: 'plan' }), ); + if (checkpoint === 'header') { + const readHeader = store.readHeader.bind(store); + let firstRead = true; + store.readHeader = async (id) => { + const header = await readHeader(id); + if (firstRead) { + firstRead = false; + await pause(); + } + return header; + }; + } const firstTurn = manager - .sendMessage(session.id, { turnId: 'turn-building', text: 'plan' }) + .sendMessage( + session.id, + { turnId: 'turn-building', text: 'plan' }, + { + admitTurn: async () => { + if (checkpoint === 'admission') await pause(); + return 'admitted'; + }, + }, + ) [Symbol.asyncIterator](); const firstEvent = firstTurn.next(); await activationStarted.promise; @@ -4628,6 +4684,11 @@ describe('SessionManager permission mode updates', () => { }); assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'bypass'); assert.strictEqual(store.disposeCount, 0); + if (checkpoint === 'activation_gate') { + // A policy mutation owns the gate and refreshes before releasing it. + // Waiting for the queued activation here would deadlock that mutation. + await manager.refreshIdleBackends(); + } } finally { releaseActivation.release(); } @@ -4652,6 +4713,95 @@ describe('SessionManager permission mode updates', () => { }); } + test('strict refresh marks cold snapshots without waiting for the policy gate', { + timeout: 10_000, + }, async (t) => { + const store = new MemorySessionStore(); + const queued = makeGate(); + const releasePolicyMutation = makeGate(); + t.after(() => releasePolicyMutation.release()); + let builds = 0; + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => { + builds += 1; + return new TestBackend(ctx); + }); + const manager = new SessionManager({ + store, + backends, + newId: nextId(), + now: nextNow(981), + runBackendActivation: async (operation) => { + queued.release(); + await releasePolicyMutation.promise; + return operation(); + }, + }); + const session = await manager.createSession(makeInput()); + const firstTurn = drain(manager.sendMessage(session.id, { turnId: 'queued', text: 'start' })); + await queued.promise; + // This is the policy mutation's final step before opening its gate again. + // There is no cached generation and no previous per-session invalidation. + await manager.refreshIdleBackends(); + assert.strictEqual(builds, 0); + releasePolicyMutation.release(); + await firstTurn; + assert.strictEqual(store.disposeCount, 1); + await drain(manager.sendMessage(session.id, { turnId: 'next', text: 'fresh' })); + assert.strictEqual(builds, 2); + }); + + for (const outcome of ['cancelled', 'failed'] as const) { + test(`a pre-activation refresh does not retain a ${outcome} admission`, async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const admissionStarted = makeGate(); + const releaseAdmission = makeGate(); + const builds: PermissionMode[] = []; + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => { + builds.push(ctx.header.permissionMode); + return new TestBackend(ctx); + }); + const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(981) }); + const session = await manager.createSession(makeInput()); + const firstTurn = assert.rejects( + drain( + manager.sendMessage( + session.id, + { turnId: 'cancelled', text: 'start' }, + { + admitTurn: async () => { + admissionStarted.release(); + await releaseAdmission.promise; + if (outcome === 'failed') throw new Error('injected admission failure'); + return 'cancelled'; + }, + }, + ), + ), + /cancelled before runtime admission|injected admission failure/, + ); + await admissionStarted.promise; + try { + const current = await store.readHeaderRecordSnapshot(session.id); + await manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'bypass' }), + }); + } finally { + releaseAdmission.release(); + } + await firstTurn; + await manager.refreshIdleBackends(); + await drain(manager.sendMessage(session.id, { turnId: 'retry', text: 'retry' })); + await drain(manager.sendMessage(session.id, { turnId: 'reuse', text: 'reuse' })); + assert.deepStrictEqual(builds, ['bypass']); + assert.strictEqual(store.disposeCount, 0); + }); + } + test('settles a backend refresh when an in-flight build fails', async () => { const store = new VersionedConfigurationMemorySessionStore(); const buildStarted = makeGate(); diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 61b6ae5265..83217db13c 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -355,6 +355,7 @@ interface PendingExecutionClaim { phase: 'pending' | 'attached' | 'reserved' | 'released' | 'failed'; run?: AgentRun; hostOperation?: true; + backendHeaderSnapshot?: { invalidated: boolean }; backendPreparation?: PreparedBackendActivation; stopIntent?: SessionStopIntent; finalization?: ExecutionClaimOutcome; @@ -423,6 +424,19 @@ export class RuntimeKernel implements RuntimeKernelLike { return await (this.deps.runBackendActivation?.(activate) ?? activate()); } + private readBackendHeader(execution: PendingExecutionClaim): Promise { + // Register before the read: even the store may suspend after taking its + // snapshot. This covers all preflight work before the policy activation gate. + execution.backendHeaderSnapshot = { invalidated: false }; + return this.deps.store.readHeader(execution.sessionId); + } + + private invalidateBackendHeaderSnapshots(sessionId: string): void { + for (const execution of this.executionClaims.get(sessionId) ?? []) { + if (execution.backendHeaderSnapshot) execution.backendHeaderSnapshot.invalidated = true; + } + } + claimExecution(sessionId: string): RuntimeExecutionClaim { if (this.stopIntents.has(sessionId)) { throw new Error(`Session ${sessionId} is stopping and cannot admit a new execution`); @@ -656,7 +670,7 @@ export class RuntimeKernel implements RuntimeKernelLike { const execution = this.takeExecutionClaim(sessionId, options.execution); try { await this.enterExecutionClaim(execution); - const header = await this.deps.store.readHeader(sessionId); + const header = await this.readBackendHeader(execution); let workspaceIdentity: string | undefined; if (this.deps.inspectContinuationSafety) { try { @@ -759,7 +773,7 @@ export class RuntimeKernel implements RuntimeKernelLike { throw new Error('Cannot continue while another run is active'); } - const header = await this.deps.store.readHeader(continuation.sessionId); + const header = await this.readBackendHeader(execution); const sessionRuns = await this.deps.runtimeEventStore.listSessionInvocations( continuation.sessionId, ); @@ -1114,7 +1128,7 @@ export class RuntimeKernel implements RuntimeKernelLike { 'Cannot compact while a Turn is running', ); } - const header = await this.deps.store.readHeader(sessionId); + const header = await this.readBackendHeader(execution); await this.requireContextCompactionBackend(sessionId, header, execution); } finally { this.releaseExecutionClaim(execution); @@ -1140,7 +1154,7 @@ export class RuntimeKernel implements RuntimeKernelLike { ); } - const header = await this.deps.store.readHeader(sessionId); + const header = await this.readBackendHeader(execution); const turnId = input.turnId ?? this.deps.newId(); const run = new AgentRun({ sessionId, @@ -2247,6 +2261,7 @@ export class RuntimeKernel implements RuntimeKernelLike { } async invalidateBackend(sessionId: string): Promise { + this.invalidateBackendHeaderSnapshots(sessionId); this.ensureBackendInvalidation(sessionId); await this.flushBackendInvalidation(sessionId); } @@ -2255,10 +2270,15 @@ export class RuntimeKernel implements RuntimeKernelLike { const sessionIds = new Set( [...this.backendGenerations.values()].map((generation) => generation.sessionId), ); + for (const [sessionId, claims] of this.executionClaims) { + if ([...claims].some((execution) => execution.backendHeaderSnapshot)) + sessionIds.add(sessionId); + } for (const execution of this.backendActivations) sessionIds.add(execution.sessionId); for (const sessionId of this.backendInvalidations.keys()) sessionIds.add(sessionId); await Promise.all( [...sessionIds].map(async (sessionId) => { + this.invalidateBackendHeaderSnapshots(sessionId); const failedGeneration = this.backendGenerationsFor(sessionId).find( (generation) => generation.phase === 'failed', ); @@ -2872,6 +2892,13 @@ export class RuntimeKernel implements RuntimeKernelLike { } } + await this.waitForBackendDisposal(sessionId); + if (execution.backendHeaderSnapshot?.invalidated) { + // A refresh must not wait for preflight claims that may themselves be + // waiting on the policy mutation gate. Remember their stale snapshots, + // then re-arm invalidation inside activation, after any old disposal. + this.ensureBackendInvalidation(sessionId); + } const invalidation = this.backendInvalidations.get(sessionId); if (!invalidation) return; // This activation was already admitted when the refresh arrived. Let it From cf4104da63b3fd65f4223bf0635b3d64cc2dc782 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 11 Sep 2026 13:59:05 +0800 Subject: [PATCH 11/12] fix(runtime): align direct boundary transitions with their mode projection (#3349) A managed boundary can still project Explore. Derive the direct API target from the current Session permission mode and pass that same projection to Storage, instead of classifying every managed transition as Auto. Restore quiescence and shell revocation when the direct API resets an expanded Explore boundary. Exercise both the configuration and direct boundary paths against SQLite for approved read, write and network grants, checking active-Turn rejection and idle revocation. Generated-by: OpenAI Codex --- .../src/__tests__/session-manager.test.ts | 235 ++++++++++-------- packages/runtime/src/session-manager.ts | 17 +- 2 files changed, 144 insertions(+), 108 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index d903eddcba..f6d161d587 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4954,113 +4954,140 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'explore'); }); - test('restoring Explore revokes an approved outside read through the durable configuration 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: 'outside-read', - turnId: 'turn-approval', - runId: 'run-approval', - expansion: { - filesystem: { entries: [{ path: outsidePath, access: 'read', scope: 'exact' }] }, - }, - justification: 'Read the approved input outside the workspace.', - }); - const settlement = await store.settleSandboxBoundaryRequest({ - sessionId: session.id, - requestId: 'outside-read', - decision: 'allow', - }); - assert.strictEqual(settlement.request.status, 'approved'); - await updatePermissionMode('ask'); - 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), true); - assert.strictEqual( - canReadPath(expanded.profile, outsidePath, { workspaceRoots: [workspaceRoot] }), - true, - ); - assert.deepStrictEqual(calls, []); + 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.', + }); + const settlement = await store.settleSandboxBoundaryRequest({ + sessionId: session.id, + requestId: 'approved-expansion', + decision: 'allow', + }); + 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(calls, []); - const activeTurn = manager - .sendMessage(session.id, { turnId: 'turn-expanded-read', text: 'keep reading' }) - [Symbol.asyncIterator](); - try { - await activeTurn.next(); - await assert.rejects(updatePermissionMode('explore'), (error: unknown) => { - assert.ok(error instanceof SessionConfigurationTransitionError); - assert.strictEqual(error.code, 'session_busy'); - return true; + 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 Error); + assert.match(error.message, /当前任务正在运行/); + 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'); }); - 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) {} } - - await updatePermissionMode('explore'); - 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(); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index fdc4231670..b4ba1ecffc 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1687,19 +1687,28 @@ export class SessionManager { kind: 'managed' | 'bypass', ): Promise { const current = await this.deps.store.readExecutionBoundary(sessionId); - const narrows = narrowsExecutionAuthority(current, kind === 'bypass' ? 'bypass' : 'ask'); + 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 Error('当前任务正在运行,等结束后再切换沙箱边界。'); } - const header = await this.deps.store.readHeader(sessionId); if (header.status === 'waiting_for_user') { throw new Error('当前有沙箱边界请求正在等待确认,处理后再切换。'); } const boundary = await this.commitExecutionBoundaryTransition( sessionId, current, - kind === 'bypass' ? 'bypass' : 'ask', - async () => () => this.deps.store.setExecutionBoundaryKind(sessionId, kind), + permissionMode, + async () => () => + this.deps.store.setExecutionBoundaryKind(sessionId, kind, { permissionMode }), ); return boundary; } From 9af9b8c3ab8aeae5bbe131a10c566b43cb02961a Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 11 Sep 2026 22:39:03 +0800 Subject: [PATCH 12/12] fix(runtime): serialize live boundary checks through commit (#3349) Reuse Runtime admission mutation authority for the widening revision check and commit, so concurrent direct or legacy writes cannot turn a stale non-narrowing classification into an unfenced narrowing. Keep backend invalidation outside the mutation and leave the quiescent narrowing path unchanged. Cover both unversioned entry points against SQLite with an active Turn: reject the stale request, preserve Bypass without stopping the Turn, and revoke shell authority on an idle retry. Fail closed before writing when a custom Kernel omits admission mutation authority. Generated-by: OpenAI Codex --- .../src/__tests__/session-manager.test.ts | 117 ++++++++++++++++++ packages/runtime/src/session-manager.ts | 15 ++- 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index f6d161d587..03389aa465 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4564,6 +4564,123 @@ 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, + }); + 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), /当前任务正在运行|linked Turn is active/); + } 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}`]); + }); + } + + test('rejects unprotected boundary commits when admission mutation authority is unavailable', async () => { + const store = new MemorySessionStore(); + const kernel = new DelegatingRuntimeKernel(); + Object.defineProperty(kernel, 'runSessionAdmissionMutation', { value: undefined }); + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + runtimeKernel: kernel, + newId: nextId(), + now: nextNow(979), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'explore' })); + await assert.rejects( + manager.setExecutionBoundaryKind(session.id, 'bypass'), + (error: unknown) => { + assert.ok(error instanceof SessionConfigurationTransitionError); + assert.strictEqual(error.code, 'operation_unavailable'); + return true; + }, + ); + assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'explore'); + assert.strictEqual((await store.readExecutionBoundary(session.id)).kind, 'managed'); + assert.deepStrictEqual(kernel.disposed, []); + }); + for (const checkpoint of [ 'header', 'safety', diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index b4ba1ecffc..a69e608fb0 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1735,8 +1735,19 @@ export class SessionManager { // check only gets easier — so the grant is just written. Waiting for the // Session to go idle is what let a running Turn, or a Goal's continuation // holding a claim near-continuously, keep the user's own grant out. - const commit = await prepareBoundaryCommit(); - const result = await commit(); + if (!this.runtimeKernel.runSessionAdmissionMutation) { + throw new SessionConfigurationTransitionError( + 'operation_unavailable', + 'Session boundary changes require Runtime admission mutation authority', + ); + } + // Serialize the revision check through commit without requiring an idle + // Turn. Otherwise two unversioned writes can both pass the check, then + // the later write can narrow the first grant using its stale classification. + const result = await this.runtimeKernel.runSessionAdmissionMutation([sessionId], async () => { + const commit = await prepareBoundaryCommit(); + return commit(); + }); // Not `disposeBackend`: disposing a live Turn's backend stops that Turn. // Invalidation refreshes it now when the Session is idle, and otherwise // defers to the next activation, which disposes before it starts.