From 9a6d4a750d618f5535cc4663b99bbcdf7c067865 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Thu, 3 Sep 2026 11:32:40 +0800 Subject: [PATCH 1/8] 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 667b597af4..ed18906df3 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, @@ -536,11 +537,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 e4a71162ac..1f7c4e998c 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -103,6 +103,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 a12fdce73e..feca4115ca 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.readExecutionBoundary = input.readExecutionBoundary; } + /** + * 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(); @@ -1509,7 +1529,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) { @@ -1538,7 +1559,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, }); @@ -1669,7 +1690,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 507bf6ae299864e77c6f3caa6aaebf44eb293430 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Thu, 3 Sep 2026 11:32:40 +0800 Subject: [PATCH 2/8] 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 3acbdb5837..60ae1a643b 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4490,7 +4490,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(); @@ -4530,7 +4530,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(); @@ -10260,7 +10267,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 91bd2d1021..a550f89a84 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1620,7 +1620,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') { @@ -1653,14 +1653,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; } @@ -1675,7 +1676,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( @@ -1684,7 +1685,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 f973c8e39d5a49a65033bc70850f78bcb86602b3 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 14:18:29 +0800 Subject: [PATCH 3/8] 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 + .../src/__tests__/session-manager.test.ts | 175 ++++++++++-- packages/runtime/src/session-manager.ts | 254 ++++++++++-------- 4 files changed, 358 insertions(+), 131 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 614998f366..b95b34dba1 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -981,6 +981,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 3180a34226..dd44e1790d 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -608,6 +608,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(await this.#committedUpdate(input.sessionId, lease)); @@ -1018,6 +1019,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/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 60ae1a643b..c7a6fa6ba2 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -45,7 +45,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'; @@ -102,6 +105,7 @@ import { SessionManager, headerToSummary, type BackendFactoryContext, + type SessionConfigurationTransitionRequest, type SessionConfigurationStoreUpdate, type SessionStore, type VersionedSessionHeader, @@ -484,6 +488,7 @@ describe('SessionManager Plan control boundaries', () => { manager.transitionSessionConfiguration(child.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: child.backend, llmConnectionId: 'test-connection-id', @@ -635,6 +640,7 @@ describe('SessionManager graph operator provisioning', () => { .transitionSessionConfiguration(parent.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: parent.backend, llmConnectionId: 'test-connection-id', @@ -2383,8 +2389,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); @@ -3898,6 +3902,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: baseConfiguration, }), (error: unknown) => { @@ -3912,16 +3917,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', @@ -3964,6 +3986,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'); @@ -3972,6 +3995,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); @@ -4066,6 +4090,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', @@ -4119,6 +4144,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', @@ -4346,7 +4372,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, @@ -4370,8 +4396,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); @@ -4379,6 +4411,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[] = []; @@ -4491,7 +4590,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(); @@ -4532,12 +4631,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(); @@ -4551,13 +4664,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) }); @@ -4573,13 +4685,6 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(summary.permissionMode, 'ask'); assert.deepStrictEqual(summary.labels, ['kept']); assert.deepStrictEqual((await store.readHeader(session.id)).labels, ['kept']); - - const messages = await store.readMessages(session.id); - const modeNote = messages.find( - (message) => message.type === 'system_note' && message.kind === 'mode_change', - ); - if (modeNote?.type !== 'system_note') throw new Error('mode_change note was not written'); - assert.deepStrictEqual(modeNote.data, { from: 'explore', to: 'ask' }); }); test('starts a new turn without workspace identity when safety inspection fails', async () => { @@ -10234,7 +10339,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; @@ -10267,7 +10372,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, { @@ -12963,8 +13068,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, @@ -13015,6 +13129,7 @@ class VersionedConfigurationMemorySessionStore extends MemorySessionStore { } : {}), }); + this.forcedBoundaries.delete(sessionId); this.revisions.set(sessionId, revision + 1); return { header, revision: revision + 1, committedAt: revision + 1 }; } @@ -13732,6 +13847,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 a550f89a84..bc28f76bda 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -68,6 +68,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, @@ -540,6 +541,7 @@ export interface SessionConfigurationStoreUpdate { export interface SessionConfigurationTransitionRequest { readonly expectedRevision: number; readonly clearConnectionBlock: boolean; + readonly permissionModeOnly: boolean; readonly configuration: Omit; } @@ -1125,56 +1127,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; } @@ -1609,44 +1635,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); - await this.deps.store - .appendMessage(sessionId, { - type: 'system_note', - id: this.deps.newId(), - ts: this.deps.now(), - kind: 'mode_change', - data: { from: previous.permissionMode, to: mode }, - } satisfies SystemNoteMessage) - .catch(() => undefined); - return headerToSummary(next); + return headerToSummary(next.header); } async setExecutionBoundaryKind( @@ -1662,21 +1659,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( @@ -1684,7 +1682,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 @@ -1692,15 +1690,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( @@ -5100,15 +5102,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( @@ -5117,7 +5151,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 f6ee552721afdbd22d3a605687ea72f31cb8a631 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 14:26:32 +0800 Subject: [PATCH 4/8] 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 + .../src/__tests__/ai-sdk-backend.test.ts | 1 + .../execution-boundary-test-helpers.ts | 16 +++-- .../tool-runtime-sandbox-boundary.test.ts | 68 ++++++++++++------- .../__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 + 10 files changed, 149 insertions(+), 43 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 a68e1d6d71..af88612d79 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 @@ -153,6 +153,7 @@ test('cancels managed approval owners and joiners with the canonical provider id appendMessage: async () => undefined, 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 ed18906df3..09917b6ebd 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -370,6 +370,8 @@ async function buildHostAiSdkBackend( ((message) => input.context.store.appendMessage(input.context.sessionId, message)), 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/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 052516d52d..4895bb151d 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -5284,6 +5284,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 b715b10c73..d948b88d8d 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -31,8 +31,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; }; @@ -47,6 +50,7 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke const transitions: ModelProjectionTransition[] = []; return new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => input.header.permissionMode, loadModelProjectionTransitions: async () => ({ transitions: [...transitions], unreadableTargets: new Set(), @@ -104,13 +108,17 @@ export function testToolResultArchive( }); } -type TestToolRuntimeInput = Omit & - Partial>; +type TestToolRuntimeInput = Omit< + ToolRuntimeInput, + 'readExecutionBoundary' | 'readPermissionMode' | 'turnId' +> & + Partial>; /** Defaults to the turn id nearly every ToolRuntime test already uses. */ export function createTestToolRuntime(input: TestToolRuntimeInput): ToolRuntime { return new ToolRuntime({ readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => input.header.permissionMode, turnId: 'turn-1', ...input, }); 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 1f7c4e998c..7e197724f6 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, @@ -67,13 +71,14 @@ 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', appendMessage: async () => {}, + readPermissionMode: async () => 'ask', readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -103,24 +108,26 @@ 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, @@ -141,11 +148,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' }, ]); @@ -153,7 +163,7 @@ 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' }, @@ -195,7 +205,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(), @@ -316,7 +326,7 @@ describe('ToolRuntime session sandbox boundary', () => { await releaseAdmission.promise; }, }; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', hostedInteraction, sessionId: 'session-1', @@ -399,7 +409,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(), @@ -457,7 +467,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), @@ -536,7 +546,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), @@ -596,7 +606,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(), @@ -672,7 +682,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(), @@ -737,7 +747,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(), @@ -800,7 +810,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(), @@ -874,7 +884,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(), @@ -955,7 +965,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(), @@ -1011,7 +1021,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(), @@ -1080,7 +1090,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(), @@ -1125,6 +1135,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 2540f554b2..552eddd106 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 8fc7daf013..0e51471b6c 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -703,6 +703,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { appendMessage: AppendMessageFn; /** 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']; @@ -1348,6 +1350,7 @@ export class AiSdkBackend implements AgentBackend { modelId: input.modelId, appendMessage: input.appendMessage, 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 feca4115ca..a8e75eff3a 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, @@ -377,6 +376,7 @@ export interface ToolRuntimeInput { modelId: string; appendMessage: AppendMessageFn; readExecutionBoundary: () => Promise; + readPermissionMode: () => Promise; createSandboxBoundaryRequest?: ( input: CreateSandboxBoundaryRequest, ) => Promise; @@ -602,6 +602,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 } @@ -610,6 +611,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.turnId = input.turnId; this.hostedInteraction = hosted; this.readExecutionBoundary = input.readExecutionBoundary; + 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, }); } @@ -1511,10 +1514,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); @@ -1529,8 +1534,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) { @@ -1559,7 +1563,7 @@ export class ToolRuntime { ...(runId ? { runId } : {}), cwd: this.input.header.cwd, executionBoundary: clientCapabilityBoundary, - permissionMode: this.livePermissionMode(clientCapabilityBoundary), + permissionMode: clientCapabilityPermissionMode, toolCallId: toolUseId, abortSignal: ctx.abortSignal, }); @@ -1681,6 +1685,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, @@ -1690,7 +1696,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 97768e83cb3bf55cc70ef732ae4b11523e0853d9 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 14:28:15 +0800 Subject: [PATCH 5/8] 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 67abcacd27..0214b11983 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -98,7 +98,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 09917b6ebd..a4c9763dd1 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -538,5 +538,3 @@ class HostAiSdkBackend extends AiSdkBackend { } } } - -export { resolveCollaborationPermissionMode }; From c5a4622a86469f2ff04215ecb49f8e5709cff5cc Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 17:15:51 +0800 Subject: [PATCH 6/8] 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 c7a6fa6ba2..56cf9ef5a5 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4687,6 +4687,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 bc28f76bda..359e84c731 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1635,8 +1635,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, @@ -1646,6 +1654,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', @@ -5145,6 +5191,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 2d63ec6eea20cb1d87a8e7a86101e84b0a56eb51 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 17:17:24 +0800 Subject: [PATCH 7/8] fix(runtime): preserve permission mode audit notes (#3349) Append the existing mode_change system note after a committed permission transition through either the configuration authority or the temporary legacy fallback. Skip no-op updates and retain the audit write's best-effort behavior. Generated-by: OpenAI Codex --- .../src/__tests__/session-manager.test.ts | 33 +++++++++++++++++++ packages/runtime/src/session-manager.ts | 27 +++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 56cf9ef5a5..1b17e969f8 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4685,6 +4685,15 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(summary.permissionMode, 'ask'); assert.deepStrictEqual(summary.labels, ['kept']); assert.deepStrictEqual((await store.readHeader(session.id)).labels, ['kept']); + assert.deepStrictEqual(await store.readMessages(session.id), [ + { + type: 'system_note', + id: 'id-1', + ts: 6_001, + kind: 'mode_change', + data: { from: 'explore', to: 'ask' }, + }, + ]); }); test('temporarily preserves setPermissionMode for legacy SessionStore implementations', async () => { @@ -4702,6 +4711,30 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(summary.permissionMode, 'bypass'); assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'bypass'); assert.strictEqual((await store.readExecutionBoundary(session.id)).kind, 'bypass'); + assert.deepStrictEqual(await store.readMessages(session.id), [ + { + type: 'system_note', + id: 'id-1', + ts: 6_101, + kind: 'mode_change', + data: { from: 'ask', to: 'bypass' }, + }, + ]); + }); + + test('does not append a permission audit note when configuration is unchanged', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(6_200), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + + await manager.setPermissionMode(session.id, 'ask'); + + assert.deepStrictEqual(await store.readMessages(session.id), []); }); test('starts a new turn without workspace identity when safety inspection fails', async () => { diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 359e84c731..3efe18629a 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1202,6 +1202,11 @@ export class SessionManager { prepareCommit, ); this.runtimeKernel.updateCachedHeader(sessionId, next.header); + await this.appendPermissionModeChangeNote( + sessionId, + observed.header.permissionMode, + next.header.permissionMode, + ); return next; } @@ -1689,9 +1694,31 @@ export class SessionManager { }); const next = await this.deps.store.readHeader(sessionId); this.runtimeKernel.updateCachedHeader(sessionId, next); + await this.appendPermissionModeChangeNote( + sessionId, + previous.permissionMode, + next.permissionMode, + ); return headerToSummary(next); } + private async appendPermissionModeChangeNote( + sessionId: string, + from: PermissionMode, + to: PermissionMode, + ): Promise { + if (from === to) return; + await this.deps.store + .appendMessage(sessionId, { + type: 'system_note', + id: this.deps.newId(), + ts: this.deps.now(), + kind: 'mode_change', + data: { from, to }, + } satisfies SystemNoteMessage) + .catch(() => undefined); + } + async setExecutionBoundaryKind( sessionId: string, kind: 'managed' | 'bypass', From 04097cbd27a6a2fbfa7b348f10c90220ebe98570 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 17:28:59 +0800 Subject: [PATCH 8/8] 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 0214b11983..e93b1eaa93 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 } from '@maka/core/test-only/async-primitives'; +import { deferred, type Deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; @@ -514,6 +514,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( @@ -4387,6 +4718,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; @@ -4408,6 +4748,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; @@ -4437,6 +4785,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 = { @@ -4500,6 +4864,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({ @@ -4520,9 +4889,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', }, @@ -4533,6 +4913,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, {});