From 35e23820c868ac9cfd2e0f7a6c97fb33697fe0e0 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Thu, 3 Sep 2026 11:32:40 +0800 Subject: [PATCH 1/7] fix(runtime): derive the tool permission mode from the live boundary (#3349) The header carries the permission mode the backend was composed with, and a backend generation outlives many turns. A permission change does not recompose it, so `ctx.permissionMode` stayed at whatever the mode was when the backend was built while the boundary the same dispatch reads for sandboxing had already moved. The picker said Bypass, Bash stayed sandboxed, and approvals kept prompting. The boundary is the authority, so the mode is read off the boundary this dispatch is about to run against. The header answers only for an externally isolated boundary, which projects to no local mode at all. Plan mode writes only the header, never the boundary, so the collaboration overlay still has to apply on top; deriving purely from the boundary would turn plan+managed from explore into ask and open the client-capability gate. That rule now lives in @maka/core because both the composer and tool dispatch have to reach the same answer, and packages/runtime cannot reach runtime-host. Generated-by: OpenAI Codex --- packages/core/src/collaboration.ts | 18 ++++ .../src/server/execution-model-composition.ts | 10 +-- .../tool-runtime-sandbox-boundary.test.ts | 84 +++++++++++++++++++ packages/runtime/src/tool-runtime.ts | 27 +++++- 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/packages/core/src/collaboration.ts b/packages/core/src/collaboration.ts index a6294ec2e8..993f1fe514 100644 --- a/packages/core/src/collaboration.ts +++ b/packages/core/src/collaboration.ts @@ -17,6 +17,8 @@ * under the License. */ +import type { PermissionMode } from './permission.js'; + export const COLLABORATION_MODES = ['agent', 'plan'] as const; export type CollaborationMode = (typeof COLLABORATION_MODES)[number]; @@ -24,3 +26,19 @@ export type CollaborationMode = (typeof COLLABORATION_MODES)[number]; export function isCollaborationMode(value: unknown): value is CollaborationMode { return typeof value === 'string' && (COLLABORATION_MODES as readonly string[]).includes(value); } + +/** + * The permission mode a session runs under once its collaboration mode is + * applied: Plan mode holds the session to read-only unless it is on Bypass. + * + * Lives here because both the model composer and tool dispatch have to reach + * the same answer; a second copy of the rule is a second authority. + */ +export function resolveCollaborationPermissionMode(input: { + readonly collaborationMode: CollaborationMode; + readonly permissionMode: PermissionMode; +}): PermissionMode { + return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass' + ? 'explore' + : input.permissionMode; +} diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 628a82ad86..6ea3b0e6cf 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -24,6 +24,7 @@ import { relayModelProfile } from '@maka/core/model-thinking'; import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; import type { ModelCallCommit } from '@maka/core/agent-run'; import type { PermissionMode } from '@maka/core/permission'; +import { resolveCollaborationPermissionMode } from '@maka/core/collaboration'; import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend'; import { buildDefaultContextBudgetPolicy, @@ -544,11 +545,4 @@ class HostAiSdkBackend extends AiSdkBackend { } } -export function resolveCollaborationPermissionMode(input: { - readonly collaborationMode: 'agent' | 'plan'; - readonly permissionMode: PermissionMode; -}): PermissionMode { - return input.collaborationMode === 'plan' && input.permissionMode !== 'bypass' - ? 'explore' - : input.permissionMode; -} +export { resolveCollaborationPermissionMode }; diff --git a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts index 4ce516d68a..d442a8d3f6 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -148,6 +148,90 @@ describe('ToolRuntime session sandbox boundary', () => { ); }); + // #3349: the header carries the mode the backend was built with. A picker + // switch to Bypass between two turns widens the boundary without rebuilding + // that header, so a dispatch that trusts the header keeps sandboxing and + // keeps prompting while the picker already reads Bypass. + test('reads the permission mode off the live boundary, not the header it was built with', async () => { + let boundary: ExecutionBoundary = { + kind: 'managed', + profile: createWorkspaceWritePermissionProfile(), + revision: 0, + }; + const observed: Array<{ kind: string; permissionMode: string | undefined }> = []; + const runtime = new ToolRuntime({ + turnId: 'turn-1', + sessionId: 'session-1', + header: header(), + connection: { providerType: 'openai', slug: 'test' } as never, + modelId: 'test', + appendMessage: async () => {}, + readExecutionBoundary: async () => boundary, + newId: nextId(), + now: () => 1, + getPermissionPauseTarget: () => null, + }); + const tool: MakaTool = { + name: 'Bash', + description: 'test', + parameters: {}, + impl: (_args, context) => { + assert.ok(context.executionBoundary); + observed.push({ + kind: context.executionBoundary.kind, + permissionMode: context.permissionMode, + }); + return { ok: true }; + }, + }; + + await settle(runtime, tool, 'tool-1'); + boundary = { kind: 'bypass', revision: 1 }; + await settle(runtime, tool, 'tool-2'); + + assert.equal(header().permissionMode, 'ask'); + assert.deepEqual(observed, [ + { kind: 'managed', permissionMode: 'ask' }, + { kind: 'bypass', permissionMode: 'bypass' }, + ]); + }); + + test('holds Plan mode to read-only even when the live boundary allows writes', async () => { + let observed: string | undefined; + const runtime = new ToolRuntime({ + turnId: 'turn-1', + sessionId: 'session-1', + header: { ...header(), collaborationMode: 'plan' }, + connection: { providerType: 'openai', slug: 'test' } as never, + modelId: 'test', + appendMessage: async () => {}, + readExecutionBoundary: async () => ({ + kind: 'managed', + profile: createWorkspaceWritePermissionProfile(), + revision: 0, + }), + newId: nextId(), + now: () => 1, + getPermissionPauseTarget: () => null, + }); + + await settle( + runtime, + { + name: 'Bash', + description: 'test', + parameters: {}, + impl: (_args, context) => { + observed = context.permissionMode; + return { ok: true }; + }, + }, + 'tool-1', + ); + + assert.equal(observed, 'explore'); + }); + test('parks the dedicated tool and admits only one boundary request at a time', async () => { const events: SessionEvent[] = []; const managed: ExecutionBoundary = { diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index f435aade4e..d145aababb 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -20,9 +20,11 @@ import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { projectAgentSwarmResult } from '@maka/core/agent-swarm'; import { projectToolActivityArgs } from '@maka/core/tool-activity-args'; +import { resolveCollaborationPermissionMode } from '@maka/core/collaboration'; import { type CreateSandboxBoundaryRequest, type ExecutionBoundary, + executionBoundaryDisplayMode, type SandboxBoundaryDecision, type SandboxBoundaryExpansion, type SandboxBoundaryRequest, @@ -619,6 +621,24 @@ export class ToolRuntime { this.sandboxBoundaryDenied = input.inheritedSandboxBoundaryDenied === true; } + /** + * The permission mode in force for this dispatch. + * + * The header carries the mode this backend was built with, which goes stale + * the moment the boundary widens under a live Session. The boundary is the + * authority, so read the mode off the boundary we are about to dispatch + * against; the header only answers for an externally isolated boundary, + * which projects to no local mode at all. + */ + private livePermissionMode(boundary: ExecutionBoundary): PermissionMode { + const displayed = executionBoundaryDisplayMode(boundary); + if (displayed === undefined) return this.input.header.permissionMode; + return resolveCollaborationPermissionMode({ + collaborationMode: this.input.header.collaborationMode ?? 'agent', + permissionMode: displayed, + }); + } + async endTurn(reason: 'completed' | 'aborted' = 'completed'): Promise { const turnId = this.turnId; const boundaryRequests = this.sandboxBoundaryRequests.entries(); @@ -1475,7 +1495,8 @@ export class ToolRuntime { } const admissionFailure = !tool.prepareExecution ? CLIENT_CAPABILITY_PREPARATION_MESSAGE - : clientCapabilityBoundary.kind !== 'bypass' && this.input.header.permissionMode !== 'ask' + : clientCapabilityBoundary.kind !== 'bypass' && + this.livePermissionMode(clientCapabilityBoundary) !== 'ask' ? CLIENT_CAPABILITY_BOUNDARY_MESSAGE : undefined; if (admissionFailure) { @@ -1504,7 +1525,7 @@ export class ToolRuntime { ...(runId ? { runId } : {}), cwd: this.input.header.cwd, executionBoundary: clientCapabilityBoundary, - permissionMode: this.input.header.permissionMode, + permissionMode: this.livePermissionMode(clientCapabilityBoundary), toolCallId: toolUseId, abortSignal: ctx.abortSignal, }); @@ -1634,7 +1655,7 @@ export class ToolRuntime { : {}), cwd: this.input.header.cwd, executionBoundary, - permissionMode: this.input.header.permissionMode, + permissionMode: this.livePermissionMode(executionBoundary), toolCallId: toolUseId, // The id the call event actually carries, not the candidate: by here // `prepareDurableToolAttempt` has pushed it on the dispatch lane. From bb75c9e8cd7864e0023f3e2987bfed486e6ae766 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Thu, 3 Sep 2026 11:32:40 +0800 Subject: [PATCH 2/7] 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 124d79ff28..ce13961867 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4609,7 +4609,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(); @@ -4649,7 +4649,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(); @@ -10463,7 +10470,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 40000d95d0..bd7c20646c 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1609,7 +1609,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') { @@ -1633,14 +1633,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; } @@ -1655,7 +1656,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( @@ -1664,7 +1665,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 f5c3d792f3b340d2754afe9f780d79bd4fc6a33d Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 14:18:29 +0800 Subject: [PATCH 3/7] 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 | 168 +++++++++++- packages/runtime/src/session-manager.ts | 245 ++++++++++-------- 4 files changed, 358 insertions(+), 115 deletions(-) diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 9b191e3280..4df6854e70 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -1085,6 +1085,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 138b6e2d4e..462f482bd9 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -733,6 +733,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)); @@ -1235,6 +1236,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 ce13961867..d3f90fa6ab 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -46,7 +46,10 @@ import { createGenesisExecutionBoundary, isSandboxBoundaryRestartClosure, } from '@maka/core/sandbox-boundary'; -import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { + createReadOnlyPermissionProfile, + createWorkspaceWritePermissionProfile, +} from '@maka/core/permission-profile'; import { DEEP_RESEARCH_SESSION_LABEL } from '@maka/core/deep-research'; import { RUNTIME_CONTINUATION_AUTHORITY_V1 } from '@maka/core/runtime-event-store'; import { deriveTurnRecords } from '@maka/core/session'; @@ -103,6 +106,7 @@ import { SessionManager, headerToSummary, type BackendFactoryContext, + type SessionConfigurationTransitionRequest, type SessionConfigurationStoreUpdate, type SessionStore, type VersionedSessionHeader, @@ -485,6 +489,7 @@ describe('SessionManager Plan control boundaries', () => { manager.transitionSessionConfiguration(child.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: child.backend, llmConnectionId: 'test-connection-id', @@ -636,6 +641,7 @@ describe('SessionManager graph operator provisioning', () => { .transitionSessionConfiguration(parent.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: parent.backend, llmConnectionId: 'test-connection-id', @@ -2408,8 +2414,6 @@ describe('SessionManager child-session runtime primitive', () => { ), true, ); - await manager.setPermissionMode(result.childSessionId, 'bypass'); - assert.strictEqual((await store.readHeader(result.childSessionId)).permissionMode, 'bypass'); const projection = await manager.listChildAgents(parent.id); assert.deepStrictEqual(projection.runs, []); assert.strictEqual(projection.executions.length, 1); @@ -4019,6 +4023,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: baseConfiguration, }), (error: unknown) => { @@ -4033,16 +4038,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', @@ -4085,6 +4107,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'); @@ -4093,6 +4116,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); @@ -4187,6 +4211,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', @@ -4240,6 +4265,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', @@ -4465,7 +4491,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, @@ -4489,8 +4515,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); @@ -4498,6 +4530,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[] = []; @@ -4610,7 +4709,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(); @@ -4651,12 +4750,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(); @@ -4670,13 +4783,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) }); @@ -10437,7 +10549,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; @@ -10470,7 +10582,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, { @@ -13214,8 +13326,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, @@ -13266,6 +13387,7 @@ class VersionedConfigurationMemorySessionStore extends MemorySessionStore { } : {}), }); + this.forcedBoundaries.delete(sessionId); this.revisions.set(sessionId, revision + 1); return { header, revision: revision + 1, committedAt: revision + 1 }; } @@ -13995,6 +14117,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 bd7c20646c..e141d1ec3c 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -69,6 +69,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; } @@ -1143,56 +1145,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; } @@ -1598,35 +1624,15 @@ export class SessionManager { } async setPermissionMode(sessionId: string, mode: PermissionMode): Promise { - const previous = await this.deps.store.readHeader(sessionId); - const boundary = await this.deps.store.readExecutionBoundary(sessionId); - const leavingDeepResearch = isDeepResearchSession(previous.labels) && mode !== 'explore'; - if ( - previous.permissionMode === mode && - executionBoundaryMatchesPermissionMode(boundary, mode) && - !leavingDeepResearch - ) { - return headerToSummary(previous); - } - - if (narrowsExecutionAuthority(boundary, mode) && this.runtimeKernel.hasActiveRuns(sessionId)) { - throw new Error('当前任务正在运行,等结束后再切换权限模式。'); - } - if (previous.status === 'waiting_for_user') { - throw new Error('当前有工具调用正在等待确认,处理后再切换权限模式。'); - } - - const labels = leavingDeepResearch - ? previous.labels.filter((label) => label !== DEEP_RESEARCH_SESSION_LABEL) - : previous.labels; - const nextKind = mode === 'bypass' ? 'bypass' : 'managed'; - await this.commitExecutionBoundaryTransition(sessionId, boundary, nextKind, { - permissionMode: mode, - labels, + const store = this.requireSessionConfigurationStore(); + const current = await store.readHeaderRecordSnapshot(sessionId); + const next = await this.transitionSessionConfiguration(sessionId, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: sessionConfigurationWithPermissionMode(current.header, mode), }); - const next = await this.deps.store.readHeader(sessionId); - this.runtimeKernel.updateCachedHeader(sessionId, next); - return headerToSummary(next); + return headerToSummary(next.header); } async setExecutionBoundaryKind( @@ -1642,21 +1648,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( @@ -1664,7 +1671,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 @@ -1672,15 +1679,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( @@ -5058,15 +5069,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( @@ -5075,7 +5118,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 69dc7ceec2791e8f207baf5cbf783b4f5212ad14 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 14:26:32 +0800 Subject: [PATCH 4/7] 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 | 71 ++++++++++++------- .../__tests__/tool-runtime-settlement.test.ts | 59 ++++++++++++++- packages/runtime/src/ai-sdk-backend.ts | 3 + packages/runtime/src/tool-runtime.ts | 34 +++++---- scripts/computer-use/lab-root.test.mjs | 7 ++ scripts/computer-use/real-ax-harness.mjs | 1 + 10 files changed, 150 insertions(+), 45 deletions(-) diff --git a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts index 9f9f40e286..2dfece5ff8 100644 --- a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts @@ -152,6 +152,7 @@ test('cancels managed approval owners and joiners with the canonical provider id modelId: 'model-1', readExecutionBoundary: async () => createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + readPermissionMode: async () => 'ask', newId: nextId(), now: nextNow(), getPermissionPauseTarget: () => null, diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 6ea3b0e6cf..f505837565 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -372,6 +372,8 @@ async function buildHostAiSdkBackend( : {}), readExecutionBoundary: () => input.context.store.readExecutionBoundary(input.context.sessionId), + readPermissionMode: async () => + (await input.context.store.readHeader(input.context.sessionId)).permissionMode, ...(input.context.store.createSandboxBoundaryRequest ? { createSandboxBoundaryRequest: (request) => diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 9e9f82bcdc..504d5d12c0 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -5164,6 +5164,7 @@ describe('AiSdkBackend model history', () => { newId: idGenerator(), now: monotonicClock(), readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => 'ask', contextBudget: { name: 'malformed-summary-config-circuit-test', charsPerToken: 1, diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index 890e2167a0..936dfb8ef3 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -45,8 +45,11 @@ import type { ModelProjectionTransition } from '@maka/core/model-projection-tran export const readExternalExecutionBoundary: AiSdkBackendInput['readExecutionBoundary'] = async () => createExternalExecutionBoundary(); -type TestAiSdkBackendInput = Omit & - Partial> & { +type TestAiSdkBackendInput = Omit< + AiSdkBackendInput, + 'readExecutionBoundary' | 'readPermissionMode' +> & + Partial> & { testProjectionArtifacts?: boolean; /** * The transcript this backend's turn produces, row by row as it appears. @@ -100,6 +103,7 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke const transitions: ModelProjectionTransition[] = []; const backend = new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => input.header.permissionMode, loadModelProjectionTransitions: async () => ({ transitions: [...transitions], unreadableTargets: new Set(), @@ -158,8 +162,11 @@ export function testToolResultArchive( }); } -type TestToolRuntimeInput = Omit & - Partial> & { +type TestToolRuntimeInput = Omit< + ToolRuntimeInput, + 'readExecutionBoundary' | 'readPermissionMode' | 'turnId' +> & + Partial> & { /** The transcript rows this runtime's calls produce; see the backend helper. */ appendMessage?: (message: StoredMessage) => Promise; }; @@ -169,6 +176,7 @@ export function createTestToolRuntime(input: TestToolRuntimeInput): ToolRuntime const { appendMessage, ...runtimeInput } = input; const runtime = new ToolRuntime({ readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => input.header.permissionMode, turnId: 'turn-1', ...runtimeInput, }); diff --git a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts index d442a8d3f6..83cb2d82a4 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -23,8 +23,12 @@ import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; -import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { + createReadOnlyPermissionProfile, + createWorkspaceWritePermissionProfile, +} from '@maka/core/permission-profile'; +import { + applySandboxBoundaryExpansion, type ExecutionBoundary, type SandboxBoundaryRequest, type SandboxBoundarySettlement, @@ -56,6 +60,7 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', + readPermissionMode: async () => 'ask', readExecutionBoundary: async () => { reads += 1; return { @@ -113,12 +118,13 @@ describe('ToolRuntime session sandbox boundary', () => { test('reads the authoritative boundary for every tool invocation', async () => { const observed: ExecutionBoundary[] = []; let revision = 0; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', + readPermissionMode: async () => 'ask', readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -148,24 +154,25 @@ describe('ToolRuntime session sandbox boundary', () => { ); }); - // #3349: the header carries the mode the backend was built with. A picker - // switch to Bypass between two turns widens the boundary without rebuilding - // that header, so a dispatch that trusts the header keeps sandboxing and - // keeps prompting while the picker already reads Bypass. - test('reads the permission mode off the live boundary, not the header it was built with', async () => { + test('reads the selected mode live while letting a Bypass boundary override it', async () => { + let selectedMode: 'explore' | 'ask' = 'explore'; let boundary: ExecutionBoundary = { kind: 'managed', - profile: createWorkspaceWritePermissionProfile(), + profile: applySandboxBoundaryExpansion(createReadOnlyPermissionProfile(), { + filesystem: { + entries: [{ path: '/approved/output', access: 'write', scope: 'subtree' }], + }, + }), revision: 0, }; const observed: Array<{ kind: string; permissionMode: string | undefined }> = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, + readPermissionMode: async () => selectedMode, readExecutionBoundary: async () => boundary, newId: nextId(), now: () => 1, @@ -186,11 +193,14 @@ describe('ToolRuntime session sandbox boundary', () => { }; await settle(runtime, tool, 'tool-1'); - boundary = { kind: 'bypass', revision: 1 }; + selectedMode = 'ask'; await settle(runtime, tool, 'tool-2'); + boundary = { kind: 'bypass', revision: 1 }; + await settle(runtime, tool, 'tool-3'); assert.equal(header().permissionMode, 'ask'); assert.deepEqual(observed, [ + { kind: 'managed', permissionMode: 'explore' }, { kind: 'managed', permissionMode: 'ask' }, { kind: 'bypass', permissionMode: 'bypass' }, ]); @@ -198,13 +208,12 @@ describe('ToolRuntime session sandbox boundary', () => { test('holds Plan mode to read-only even when the live boundary allows writes', async () => { let observed: string | undefined; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: { ...header(), collaborationMode: 'plan' }, connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -240,7 +249,7 @@ describe('ToolRuntime session sandbox boundary', () => { revision: 0, }; let created: SandboxBoundaryRequest | undefined; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -360,7 +369,7 @@ describe('ToolRuntime session sandbox boundary', () => { await releaseAdmission.promise; }, }; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', hostedInteraction, sessionId: 'session-1', @@ -442,7 +451,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('rejects an invalid expansion before creating durable pending state', async () => { let createCalls = 0; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -499,7 +508,7 @@ describe('ToolRuntime session sandbox boundary', () => { const canonicalFile = await realpath(file); let created: SandboxBoundaryRequest | undefined; const events: SessionEvent[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(root), @@ -577,7 +586,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('rejects exact directory authority before creating durable pending state', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-boundary-directory-')); let createCalls = 0; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(root), @@ -636,7 +645,7 @@ describe('ToolRuntime session sandbox boundary', () => { }; let created: SandboxBoundaryRequest | undefined; const settlements: Array<{ requestId: string; decision: string }> = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -711,7 +720,7 @@ describe('ToolRuntime session sandbox boundary', () => { releaseCreate = resolve; }); const settlements: string[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -775,7 +784,7 @@ describe('ToolRuntime session sandbox boundary', () => { }); test('returns a structured boundary requirement to the agent', async () => { - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -837,7 +846,7 @@ describe('ToolRuntime session sandbox boundary', () => { }); test('counts one boundary correction per model step and keeps failure kinds independent', async () => { - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -910,7 +919,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('cancels a suspended nested boundary wait when its cell aborts', async () => { const events: SessionEvent[] = []; const settlements: string[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -991,7 +1000,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('keeps a durable deny failure attached to the aborted nested call', async () => { const events: SessionEvent[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -1046,7 +1055,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('returns structured requires_bypass without opening an interaction', async () => { const events: SessionEvent[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -1114,7 +1123,7 @@ describe('ToolRuntime session sandbox boundary', () => { // here: ToolRuntime injects that callback unconditionally. This is the // branch a model actually reaches, and it used to say something different // from the tool the model called. - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -1158,6 +1167,16 @@ describe('ToolRuntime session sandbox boundary', () => { }); }); +type SandboxToolRuntimeInput = Omit & + Partial>; + +function createRuntime(input: SandboxToolRuntimeInput): ToolRuntime { + return new ToolRuntime({ + readPermissionMode: async () => input.header.permissionMode, + ...input, + }); +} + async function settle(runtime: ToolRuntime, tool: MakaTool, toolCallId: string): Promise { const events: SessionEvent[] = []; await runtime.settleToolCall({ diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index c6d9c4be40..08d1a5a7cf 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -22,9 +22,11 @@ import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + applySandboxBoundaryExpansion, createBypassExecutionBoundary, createGenesisExecutionBoundary, } from '@maka/core/sandbox-boundary'; +import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import { type LlmConnection } from '@maka/core/llm-connections'; import type { SessionEvent } from '@maka/core/events'; import { type SessionHeader } from '@maka/core/session'; @@ -84,6 +86,56 @@ describe('ToolRuntime settlement', () => { ); }); + it('does not promote an expanded Explore boundary into Client Capability admission', async () => { + let preparationCalls = 0; + let implementationCalls = 0; + const clientTool: MakaTool = { + name: 'client_browser', + description: 'client browser', + parameters: {}, + categoryHint: 'custom_tool', + hostAdmission: 'client_capability', + prepareExecution: async () => { + preparationCalls += 1; + return { execute: async () => ({ ok: true }), cancel: () => undefined }; + }, + impl: () => { + implementationCalls += 1; + return { ok: true }; + }, + }; + const expandedProfile = applySandboxBoundaryExpansion(createReadOnlyPermissionProfile(), { + filesystem: { + entries: [{ path: '/approved/output', access: 'write', scope: 'subtree' }], + }, + }); + const runtime = makeRuntime({ + readPermissionMode: async () => 'explore', + readExecutionBoundary: async () => ({ + kind: 'managed', + profile: expandedProfile, + revision: 1, + }), + }); + + const settlement = await runtime.settleToolCall({ + tool: clientTool, + turnId: 'turn-1', + stepId: 'step-1', + toolCallId: 'call-expanded-explore', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: () => undefined, + pushAndWaitUntilConsumed: async () => undefined, + }, + }); + + assert.equal(preparationCalls, 0); + assert.equal(implementationCalls, 0); + assert.match(String((settlement.result as { error?: unknown }).error), /require the Bypass/u); + }); + it('prepares Bypass Client Capability work before T1 and admits only after T1', async () => { const order: string[] = []; const clientTool: MakaTool = { @@ -645,7 +697,12 @@ function makeRuntime( overrides: Partial< Pick< ToolRuntimeInput, - 'readExecutionBoundary' | 'spawnChildSession' | 'runId' | 'invocationId' | 'runtimeCommitSink' + | 'readExecutionBoundary' + | 'readPermissionMode' + | 'spawnChildSession' + | 'runId' + | 'invocationId' + | 'runtimeCommitSink' > > = {}, ): ToolRuntime { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 235e1fdf0a..bc3a1a5221 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -124,6 +124,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { providerStateIdentity?: `sha256:${string}`; /** Reads the authoritative session boundary immediately before every local tool invocation. */ readExecutionBoundary: ToolRuntimeInput['readExecutionBoundary']; + /** Reads the user's current Session permission selection for each local tool invocation. */ + readPermissionMode: ToolRuntimeInput['readPermissionMode']; createSandboxBoundaryRequest?: ToolRuntimeInput['createSandboxBoundaryRequest']; settleSandboxBoundaryRequest?: ToolRuntimeInput['settleSandboxBoundaryRequest']; @@ -497,6 +499,7 @@ export class AiSdkBackend implements AgentBackend { connection: input.connection, modelId: input.modelId, readExecutionBoundary: input.readExecutionBoundary, + readPermissionMode: input.readPermissionMode, createSandboxBoundaryRequest: input.createSandboxBoundaryRequest, settleSandboxBoundaryRequest: input.settleSandboxBoundaryRequest, newId: this.newId, diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index d145aababb..74bc44a835 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -24,7 +24,6 @@ import { resolveCollaborationPermissionMode } from '@maka/core/collaboration'; import { type CreateSandboxBoundaryRequest, type ExecutionBoundary, - executionBoundaryDisplayMode, type SandboxBoundaryDecision, type SandboxBoundaryExpansion, type SandboxBoundaryRequest, @@ -376,6 +375,7 @@ export interface ToolRuntimeInput { connection: RuntimeExecutionConnection; modelId: string; readExecutionBoundary: () => Promise; + readPermissionMode: () => Promise; createSandboxBoundaryRequest?: ( input: CreateSandboxBoundaryRequest, ) => Promise; @@ -601,6 +601,7 @@ export class ToolRuntime { private readonly durableToolAttempts = new Map(); private readonly activeToolSettlements = new Set>(); private readonly readExecutionBoundary: NonNullable; + private readonly readPermissionMode: NonNullable; private readonly stepAdmissions = new Map< string, { callCount: number; exclusiveToolName?: string } @@ -609,6 +610,9 @@ export class ToolRuntime { if (!input.readExecutionBoundary) { throw new Error('ToolRuntime requires explicit execution boundary authority'); } + if (!input.readPermissionMode) { + throw new Error('ToolRuntime requires explicit permission mode authority'); + } const hosted = input.hostedInteraction; if (hosted && (hosted.sessionId !== input.sessionId || hosted.turnId !== input.turnId)) { throw new RuntimeInteractionInvariantError( @@ -619,23 +623,22 @@ export class ToolRuntime { this.hostedInteraction = hosted; this.readExecutionBoundary = input.readExecutionBoundary; this.sandboxBoundaryDenied = input.inheritedSandboxBoundaryDenied === true; + this.readPermissionMode = input.readPermissionMode; } /** * The permission mode in force for this dispatch. * - * The header carries the mode this backend was built with, which goes stale - * the moment the boundary widens under a live Session. The boundary is the - * authority, so read the mode off the boundary we are about to dispatch - * against; the header only answers for an externally isolated boundary, - * which projects to no local mode at all. + * A Bypass boundary is an unambiguous live grant. A managed boundary is not: + * an approved path or network expansion changes its structural display mode + * without changing the mode the user selected. Keep that selection live in + * its own authority, then apply the collaboration overlay for this backend. */ - private livePermissionMode(boundary: ExecutionBoundary): PermissionMode { - const displayed = executionBoundaryDisplayMode(boundary); - if (displayed === undefined) return this.input.header.permissionMode; + private async livePermissionMode(boundary: ExecutionBoundary): Promise { + const permissionMode = boundary.kind === 'bypass' ? 'bypass' : await this.readPermissionMode(); return resolveCollaborationPermissionMode({ collaborationMode: this.input.header.collaborationMode ?? 'agent', - permissionMode: displayed, + permissionMode, }); } @@ -1477,10 +1480,12 @@ export class ToolRuntime { } let clientCapabilityBoundary: ExecutionBoundary | undefined; + let clientCapabilityPermissionMode: PermissionMode | undefined; let preparedExecution: PreparedMakaToolExecution | undefined; if (tool.hostAdmission === 'client_capability') { try { clientCapabilityBoundary = await this.readExecutionBoundary(); + clientCapabilityPermissionMode = await this.livePermissionMode(clientCapabilityBoundary); } catch (error) { const reason = formatSyntheticToolErrorText(error); await refuseBeforeDispatch(reason); @@ -1495,8 +1500,7 @@ export class ToolRuntime { } const admissionFailure = !tool.prepareExecution ? CLIENT_CAPABILITY_PREPARATION_MESSAGE - : clientCapabilityBoundary.kind !== 'bypass' && - this.livePermissionMode(clientCapabilityBoundary) !== 'ask' + : clientCapabilityBoundary.kind !== 'bypass' && clientCapabilityPermissionMode !== 'ask' ? CLIENT_CAPABILITY_BOUNDARY_MESSAGE : undefined; if (admissionFailure) { @@ -1525,7 +1529,7 @@ export class ToolRuntime { ...(runId ? { runId } : {}), cwd: this.input.header.cwd, executionBoundary: clientCapabilityBoundary, - permissionMode: this.livePermissionMode(clientCapabilityBoundary), + permissionMode: clientCapabilityPermissionMode, toolCallId: toolUseId, abortSignal: ctx.abortSignal, }); @@ -1646,6 +1650,8 @@ export class ToolRuntime { try { const runId = this.input.runId; const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary()); + const permissionMode = + clientCapabilityPermissionMode ?? (await this.livePermissionMode(executionBoundary)); const toolContext: MakaToolContext = { sessionId: this.input.sessionId, turnId, @@ -1655,7 +1661,7 @@ export class ToolRuntime { : {}), cwd: this.input.header.cwd, executionBoundary, - permissionMode: this.livePermissionMode(executionBoundary), + permissionMode, toolCallId: toolUseId, // The id the call event actually carries, not the candidate: by here // `prepareDurableToolAttempt` has pushed it on the dispatch lane. diff --git a/scripts/computer-use/lab-root.test.mjs b/scripts/computer-use/lab-root.test.mjs index 945d5afd9a..bf37beea75 100644 --- a/scripts/computer-use/lab-root.test.mjs +++ b/scripts/computer-use/lab-root.test.mjs @@ -131,3 +131,10 @@ test('Lab-backed entry points require the configured root', async () => { ); } }); + +test('real AX harness supplies every explicit runtime permission authority', async () => { + const source = await readFile(new URL('real-ax-harness.mjs', import.meta.url), 'utf8'); + + assert.match(source, /readExecutionBoundary:\s*async \(\) =>/); + assert.match(source, /readPermissionMode:\s*async \(\) => 'bypass'/); +}); diff --git a/scripts/computer-use/real-ax-harness.mjs b/scripts/computer-use/real-ax-harness.mjs index ebdbe462d3..7825f33e42 100644 --- a/scripts/computer-use/real-ax-harness.mjs +++ b/scripts/computer-use/real-ax-harness.mjs @@ -550,6 +550,7 @@ const runtime = new AiSdkBackend({ apiKey, modelId, readExecutionBoundary: async () => ({ kind: 'bypass', revision: 0 }), + readPermissionMode: async () => 'bypass', modelFactory: (input) => getAIModel(input), tools: [computerTool], maxSteps: 8, From 5d159b3bffbe9b8d0e91563e67d39ec2cbfb9560 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 14:28:15 +0800 Subject: [PATCH 5/7] 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 915dbe5803..9cfba0a0d0 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -101,7 +101,6 @@ import { import { createHostAiSdkBackend, prepareHostAiSdkBackend, - resolveCollaborationPermissionMode, type HostAiSdkBackendInput, } from '../server/execution-model-composition.js'; import { diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index f505837565..85f3f6f278 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -546,5 +546,3 @@ class HostAiSdkBackend extends AiSdkBackend { } } } - -export { resolveCollaborationPermissionMode }; From e7ccbc539dd528cd89ae9a6516b9e6dacf498109 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 17:15:51 +0800 Subject: [PATCH 6/7] 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 d3f90fa6ab..f944c40328 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4806,6 +4806,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 e141d1ec3c..c667d2a73c 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -1624,8 +1624,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, @@ -1635,6 +1643,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', @@ -5112,6 +5158,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 e62a36896e05af50ec8c55fefb34514bc0b14076 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Fri, 4 Sep 2026 17:28:59 +0800 Subject: [PATCH 7/7] 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 9cfba0a0d0..d58f95556e 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { deferred, waitFor } from '@maka/core/test-only/async-primitives'; +import { deferred, type Deferred, waitFor } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; @@ -519,6 +519,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( @@ -4657,6 +4988,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; @@ -4678,6 +5018,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; @@ -4707,6 +5055,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 = { @@ -4770,6 +5134,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({ @@ -4790,9 +5159,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', }, @@ -4803,6 +5183,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, {});