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/__tests__/client-capability-admission-integration.test.ts b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts index a68e1d6d71..af88612d79 100644 --- a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts @@ -153,6 +153,7 @@ test('cancels managed approval owners and joiners with the canonical provider id appendMessage: async () => undefined, readExecutionBoundary: async () => createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + readPermissionMode: async () => 'ask', newId: nextId(), now: nextNow(), getPermissionPauseTarget: () => null, diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 67abcacd27..e93b1eaa93 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { deferred } from '@maka/core/test-only/async-primitives'; +import { deferred, type Deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; @@ -98,7 +98,6 @@ import { import { createHostAiSdkBackend, prepareHostAiSdkBackend, - resolveCollaborationPermissionMode, type HostAiSdkBackendInput, } from '../server/execution-model-composition.js'; import { @@ -515,6 +514,337 @@ test('production Host executes Bash against the current live sandbox boundary', } }); +test('permission widening through the Host reaches the next ordinary Turn tool call', async () => { + await runPermissionUpdateHostRegression('ordinary_session'); +}); + +test('permission widening through the Host reaches a tool call in an active Goal continuation', async () => { + await runPermissionUpdateHostRegression('active_goal'); +}); + +async function runPermissionUpdateHostRegression( + scenario: 'ordinary_session' | 'active_goal', +): Promise { + const scenarioSlug = scenario.replace('_', '-'); + const base = await mkdtemp(join(tmpdir(), `maka-host-permission-${scenario}-`)); + const root = join(base, 'interactive'); + const project = join(base, 'project'); + const provider = await startProvider(); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const context: ConnectionContext = { + hostEpoch: `permission-${scenario}-epoch`, + connectionId: `permission-${scenario}-client`, + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }; + const capabilityConnectionId = `permission-${scenario}-capability`; + const capabilityContext: ConnectionContext = { + ...context, + connectionId: capabilityConnectionId, + }; + const calls: Array> = []; + let admitted = 0; + let composition: Awaited> | undefined; + let capabilityConnection: + | ReturnType + | undefined; + let releaseActiveRequest: (() => void) | undefined; + try { + await mkdir(project); + const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const created = await policy.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: { + slug: `permission-${scenarioSlug}-provider`, + name: `Permission ${scenario} provider`, + providerType: 'moonshot', + baseUrl: provider.baseUrl, + enabled: true, + enabledModelIds: [MODEL_ID], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') return; + const modelConnection = created.snapshot.connections[0]; + assert.ok(modelConnection); + if (!modelConnection) return; + assert.equal( + ( + await policy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: modelConnection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: API_KEY, + }) + ).kind, + 'committed', + ); + await publishConnectionModel(policy, modelConnection.connectionId, MODEL_ID, 32_768); + + const execution = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await execution.sessionStore.create({ + cwd: project, + llmConnectionId: modelConnection.connectionId, + llmConnectionSlug: `permission-${scenarioSlug}-provider`, + model: MODEL_ID, + permissionMode: 'explore', + }); + composition = await createExecutionRuntimeHostComposition({ + owner, + hostEpoch: context.hostEpoch, + acquireResidency: context.acquireResidency, + retainUntilProcessExit: () => undefined, + requestDrain: () => undefined, + }); + await composition.recover(); + const clientCapabilities = composition.clientCapabilities as + | HostClientCapabilityCoordinator + | undefined; + assert.ok(clientCapabilities); + if (!clientCapabilities) return; + + capabilityConnection = clientCapabilities.attachConnection( + clientCapabilityConnectionIdentity(capabilityConnectionId), + { + send: async (frame) => { + if (frame.kind === 'client.capability.call') { + calls.push(frame); + queueMicrotask(() => { + capabilityConnection?.accept({ + kind: 'client.capability.accepted', + invocationId: frame.invocationId, + admissionEvidence: { kind: 'none' }, + }); + }); + } else if (frame.kind === 'client.capability.admitted') { + admitted += 1; + queueMicrotask(() => { + capabilityConnection?.accept({ + kind: 'client.capability.result', + invocationId: frame.invocationId, + result: { + content: [{ type: 'text', text: CLIENT_CAPABILITY_RESULT_TEXT }], + }, + }); + }); + } + }, + }, + ); + const registered = await composition.handlers['client.capability.replace']( + { + registrationId: `permission-${scenario}-registration`, + offers: [ + { + offerId: 'hosted-browser', + version: '0', + affinity: 'session', + hostPathAccess: 'cwd', + label: 'Hosted Browser', + tools: [ + { + serverId: 'hosted_browser', + name: 'navigate', + description: 'Navigate the hosted browser.', + inputSchema: { + type: 'object', + properties: { url: { type: 'string' } }, + required: ['url'], + additionalProperties: false, + }, + }, + ], + }, + ], + }, + capabilityContext, + ); + assert.equal(registered.ok, true); + assert.deepEqual(await clientCapabilities.bindSession(session.id, capabilityConnectionId), { + ok: true, + }); + const snapshot = clientCapabilities.snapshotForSession(session.id); + assert.ok(snapshot); + if (!snapshot) return; + const group = snapshot.groups[0]; + const tool = snapshot.tools[0]; + snapshot.release(); + assert.ok(group); + assert.ok(tool); + if (!group || !tool) return; + const providerControl = provider.configurePermissionUpdateFlow({ + scenario, + groupId: group.id, + toolName: tool.name, + }); + releaseActiveRequest = providerControl.releaseActiveRequest; + + let exercisedRunId: string; + if (scenario === 'ordinary_session') { + const firstTurnId = 'permission-ordinary-running-turn'; + const firstStarted = await startTurn( + composition, + session.id, + firstTurnId, + 'Keep this Turn active while permission changes.', + context, + ); + await settleWithin(providerControl.activeRequestStarted); + await commitBypassPermissionUpdate(composition, execution, session.id, context); + providerControl.releaseActiveRequest(); + const firstTerminal = await waitForTerminal( + composition, + session.id, + firstTurnId, + firstStarted, + context, + ); + assert.equal(firstTerminal.status, 'completed'); + + const nextTurnId = 'permission-ordinary-next-turn'; + const nextTerminal = await waitForTerminal( + composition, + session.id, + nextTurnId, + await startTurn( + composition, + session.id, + nextTurnId, + 'Use the connected browser capability.', + context, + ), + context, + ); + assert.equal(nextTerminal.status, 'completed'); + exercisedRunId = nextTerminal.runId; + } else { + const armed = await composition.handlers['goal.arm']( + { + sessionId: session.id, + condition: 'Use the connected browser capability once.', + maxIterations: 3, + tokenBudget: null, + }, + context, + ); + assert.equal(armed.ok, true); + if (!armed.ok) return; + const carryingTurnId = 'permission-goal-carrying-turn'; + const carryingStarted = await startTurn( + composition, + session.id, + carryingTurnId, + 'Begin the active Goal.', + context, + ); + const carryingTerminal = waitForTerminal( + composition, + session.id, + carryingTurnId, + carryingStarted, + context, + ); + await settleWithin(providerControl.activeRequestStarted); + assert.equal((await carryingTerminal).status, 'completed'); + const activeGoalRun = ( + await execution.runtimeEventStore.listSessionInvocations(session.id) + ).find( + (run) => + run.terminalEvent === undefined && + run.opening.root.kind === 'goal' && + run.opening.root.goalId === armed.result.goal.goalId, + ); + assert.ok(activeGoalRun, 'Goal continuation did not hold an active Run'); + if (!activeGoalRun) return; + assert.equal(activeGoalRun.opening.configuration.permissionMode, 'explore'); + exercisedRunId = activeGoalRun.runId; + + await commitBypassPermissionUpdate(composition, execution, session.id, context); + providerControl.releaseActiveRequest(); + await waitForGoalStatus(composition, session.id, 'achieved', context); + } + + assert.equal((await execution.sessionStore.readHeader(session.id)).permissionMode, 'bypass'); + assert.equal((await execution.sessionStore.readExecutionBoundary(session.id)).kind, 'bypass'); + assert.equal(admitted, 1); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0]?.arguments, { + url: 'https://example.test/permission-update', + }); + const events = await execution.runtimeEventStore.readRuntimeEvents(session.id, exercisedRunId); + assert.ok( + events.some( + (event) => + event.content?.kind === 'function_response' && + event.content.name === tool.name && + JSON.stringify(event.content.result).includes(CLIENT_CAPABILITY_RESULT_TEXT), + ), + ); + } finally { + releaseActiveRequest?.(); + try { + await capabilityConnection?.close(); + } finally { + try { + await composition?.close(); + } finally { + try { + await owner.close(); + } finally { + try { + await provider.close(); + } finally { + await rm(base, { recursive: true, force: true }); + } + } + } + } + } +} + +async function commitBypassPermissionUpdate( + composition: Awaited>, + execution: Awaited>, + sessionId: string, + context: ConnectionContext, +): Promise { + const current = await execution.sessionStore.readHeaderRecordSnapshot(sessionId); + const updated = await composition.handlers['session.configuration.update']( + { + sessionId, + expectedRevision: current.revision, + patch: { permissionMode: 'bypass' }, + }, + context, + ); + assert.equal(updated.ok, true, JSON.stringify(updated)); + if (!updated.ok) return; + assert.equal(updated.result.kind, 'committed'); + if (updated.result.kind !== 'committed' || 'kind' in updated.result.session) return; + assert.equal(updated.result.session.permissionMode, 'bypass'); +} + +async function waitForGoalStatus( + composition: Awaited>, + sessionId: string, + status: 'achieved', + context: ConnectionContext, +): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const queried = await composition.handlers['goal.query']({ sessionId }, context); + assert.equal(queried.ok, true); + if (queried.ok && queried.result.goal?.status === status) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Hosted Goal did not reach ${status}`); +} + test('backend creation admits the enabled bootstrap DeepSeek model before discovery', async () => { const modelId = 'deepseek-v4-flash'; const backend = await createHostAiSdkBackend( @@ -4388,6 +4718,15 @@ interface ManagedSandboxPaths { type ProviderFlow = | { readonly kind: 'default' } + | { + readonly kind: 'permission_update'; + readonly scenario: 'ordinary_session' | 'active_goal'; + readonly groupId: string; + readonly toolName: string; + readonly activeRequestStarted: Deferred; + readonly activeRequestRelease: Deferred; + goalEvaluationCount: number; + } | { readonly kind: 'managed_bash'; readonly sandboxPaths?: ManagedSandboxPaths; @@ -4409,6 +4748,14 @@ type ProviderFlow = async function startProvider(): Promise<{ readonly baseUrl: string; readonly requests: ProviderRequest[]; + configurePermissionUpdateFlow(input: { + scenario: 'ordinary_session' | 'active_goal'; + groupId: string; + toolName: string; + }): { + readonly activeRequestStarted: Promise; + releaseActiveRequest(): void; + }; configureManagedBashFlow(sandboxPaths?: ManagedSandboxPaths): void; configureClientCapability(input: { groupId: string; toolName: string }): void; configureProjectionImageFlow(toolName: string): void; @@ -4438,6 +4785,22 @@ async function startProvider(): Promise<{ return { baseUrl: `http://127.0.0.1:${address.port}/v1`, requests, + configurePermissionUpdateFlow: (input) => { + if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); + const activeRequestStarted = deferred(); + const activeRequestRelease = deferred(); + flow = { + kind: 'permission_update', + ...input, + activeRequestStarted, + activeRequestRelease, + goalEvaluationCount: 0, + }; + return { + activeRequestStarted: activeRequestStarted.promise, + releaseActiveRequest: () => activeRequestRelease.resolve(), + }; + }, configureManagedBashFlow: (sandboxPaths) => { if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); flow = { @@ -4501,6 +4864,11 @@ async function handleProviderRequest( serialized, ); const isHistoryCompaction = /context summarization assistant/.test(serialized); + const isGoalEvaluation = /goal evaluation judge/.test(serialized); + const goalEvaluation = + flow.kind === 'permission_update' && flow.scenario === 'active_goal' && isGoalEvaluation + ? ++flow.goalEvaluationCount + : 0; response.writeHead(200, { 'content-type': 'application/json' }); response.end( JSON.stringify({ @@ -4521,9 +4889,20 @@ async function handleProviderRequest( requestedItems: [], incidentalItems: [], }) - : isHistoryCompaction - ? COMPACT_SUMMARY_TEXT - : SUMMARY_TEXT, + : goalEvaluation > 0 + ? JSON.stringify({ + met: goalEvaluation > 1, + impossible: false, + progress: true, + waiting: false, + reason: + goalEvaluation > 1 + ? 'The permission update reached the continuation tool.' + : 'Continue with the permission-sensitive tool call.', + }) + : isHistoryCompaction + ? COMPACT_SUMMARY_TEXT + : SUMMARY_TEXT, }, finish_reason: 'stop', }, @@ -4534,6 +4913,36 @@ async function handleProviderRequest( return; } const streamRequestIndex = requests.filter((candidate) => candidate.body.stream === true).length; + if (flow.kind === 'permission_update' && streamRequestIndex === 1) { + if (flow.scenario === 'ordinary_session') { + flow.activeRequestStarted.resolve(); + await flow.activeRequestRelease.promise; + } + respondProviderText(response, RESPONSE_TEXT); + return; + } + if (flow.kind === 'permission_update' && streamRequestIndex === 2) { + if (flow.scenario === 'active_goal') { + flow.activeRequestStarted.resolve(); + await flow.activeRequestRelease.promise; + } + assert.ok(toolNames(body).includes('tool_search')); + respondProviderToolCall(response, streamRequestIndex, 'tool_search', { + query: flow.toolName, + }); + return; + } + if (flow.kind === 'permission_update' && streamRequestIndex === 3) { + assert.ok(toolNames(body).includes(flow.toolName)); + respondProviderToolCall(response, streamRequestIndex, flow.toolName, { + url: 'https://example.test/permission-update', + }); + return; + } + if (flow.kind === 'permission_update') { + respondProviderText(response, RESPONSE_TEXT); + return; + } if (flow.kind === 'projection_image' && streamRequestIndex === 1) { assert.ok(toolNames(body).includes(flow.toolName)); respondProviderToolCall(response, streamRequestIndex, flow.toolName, {}); diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 614998f366..b95b34dba1 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -981,6 +981,55 @@ test('configuration update admits Plan mode through Runtime authority', async () assert.equal(fixture.drainRequests(), 0); }); +test('permission-only Host updates select the live boundary transition path', async () => { + const observed: boolean[] = []; + const fixture = createFixture({ + manager: { + transitionSessionConfiguration: async (_sessionId, input) => { + observed.push(input.permissionModeOnly); + if (!input.permissionModeOnly) { + throw new SessionConfigurationTransitionError( + 'session_busy', + 'Session configuration cannot change while a linked Turn is active', + ); + } + return headerSnapshot( + { ...fixture.header(), permissionMode: input.configuration.permissionMode }, + fixture.revision() + 1, + ); + }, + }, + }); + + const widening = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { permissionMode: 'bypass' }, + }, + context, + ); + const mixed = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { permissionMode: 'bypass', collaborationMode: 'plan' }, + }, + context, + ); + + assert.equal(widening.ok, true); + assert.deepEqual(mixed, { + ok: false, + error: { + code: 'session_busy', + message: 'Session configuration cannot change while a linked Turn is active', + }, + }); + assert.deepEqual(observed, [true, false]); + assert.equal(fixture.drainRequests(), 0); +}); + test('configuration update never rebinds a bound Session through a reused slug', async () => { let observedRef: unknown; const fixture = createFixture({ diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 667b597af4..a4c9763dd1 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, @@ -369,6 +370,8 @@ async function buildHostAiSdkBackend( ((message) => input.context.store.appendMessage(input.context.sessionId, message)), readExecutionBoundary: () => input.context.store.readExecutionBoundary(input.context.sessionId), + readPermissionMode: async () => + (await input.context.store.readHeader(input.context.sessionId)).permissionMode, ...(input.context.store.createSandboxBoundaryRequest ? { createSandboxBoundaryRequest: (request) => @@ -535,12 +538,3 @@ 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; -} diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 3180a34226..dd44e1790d 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -608,6 +608,7 @@ export class HostSessionCatalogCoordinator { await this.#manager.transitionSessionConfiguration(input.sessionId, { expectedRevision: input.expectedRevision, clearConnectionBlock: input.patch.modelTarget !== undefined, + permissionModeOnly: isPermissionModeOnlyPatch(input.patch), configuration, }); return configurationSuccess(await this.#committedUpdate(input.sessionId, lease)); @@ -1018,6 +1019,16 @@ function sessionConfigurationMatches( ); } +function isPermissionModeOnlyPatch(patch: SessionConfigurationUpdateInput['patch']): boolean { + return ( + patch.permissionMode !== undefined && + patch.modelTarget === undefined && + patch.thinkingLevel === undefined && + patch.collaborationMode === undefined && + patch.orchestrationMode === undefined + ); +} + interface PreparedSessionCreate { readonly name: string; readonly labels: readonly string[]; diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 052516d52d..4895bb151d 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -5284,6 +5284,7 @@ describe('AiSdkBackend model history', () => { newId: idGenerator(), now: monotonicClock(), readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => 'ask', contextBudget: { name: 'malformed-summary-config-circuit-test', charsPerToken: 1, diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index b715b10c73..d948b88d8d 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -31,8 +31,11 @@ import type { ModelProjectionTransition } from '@maka/core/model-projection-tran export const readExternalExecutionBoundary: AiSdkBackendInput['readExecutionBoundary'] = async () => createExternalExecutionBoundary(); -type TestAiSdkBackendInput = Omit & - Partial> & { +type TestAiSdkBackendInput = Omit< + AiSdkBackendInput, + 'readExecutionBoundary' | 'readPermissionMode' +> & + Partial> & { testProjectionArtifacts?: boolean; }; @@ -47,6 +50,7 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke const transitions: ModelProjectionTransition[] = []; return new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => input.header.permissionMode, loadModelProjectionTransitions: async () => ({ transitions: [...transitions], unreadableTargets: new Set(), @@ -104,13 +108,17 @@ export function testToolResultArchive( }); } -type TestToolRuntimeInput = Omit & - Partial>; +type TestToolRuntimeInput = Omit< + ToolRuntimeInput, + 'readExecutionBoundary' | 'readPermissionMode' | 'turnId' +> & + Partial>; /** Defaults to the turn id nearly every ToolRuntime test already uses. */ export function createTestToolRuntime(input: TestToolRuntimeInput): ToolRuntime { return new ToolRuntime({ readExecutionBoundary: readExternalExecutionBoundary, + readPermissionMode: async () => input.header.permissionMode, turnId: 'turn-1', ...input, }); diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 3acbdb5837..1b17e969f8 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -45,7 +45,10 @@ import { createGenesisExecutionBoundary, isSandboxBoundaryRestartClosure, } from '@maka/core/sandbox-boundary'; -import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { + createReadOnlyPermissionProfile, + createWorkspaceWritePermissionProfile, +} from '@maka/core/permission-profile'; import { DEEP_RESEARCH_SESSION_LABEL } from '@maka/core/deep-research'; import { RUNTIME_CONTINUATION_AUTHORITY_V1 } from '@maka/core/runtime-event-store'; import { deriveTurnRecords } from '@maka/core/session'; @@ -102,6 +105,7 @@ import { SessionManager, headerToSummary, type BackendFactoryContext, + type SessionConfigurationTransitionRequest, type SessionConfigurationStoreUpdate, type SessionStore, type VersionedSessionHeader, @@ -484,6 +488,7 @@ describe('SessionManager Plan control boundaries', () => { manager.transitionSessionConfiguration(child.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: child.backend, llmConnectionId: 'test-connection-id', @@ -635,6 +640,7 @@ describe('SessionManager graph operator provisioning', () => { .transitionSessionConfiguration(parent.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: parent.backend, llmConnectionId: 'test-connection-id', @@ -2383,8 +2389,6 @@ describe('SessionManager child-session runtime primitive', () => { ), true, ); - await manager.setPermissionMode(result.childSessionId, 'bypass'); - assert.strictEqual((await store.readHeader(result.childSessionId)).permissionMode, 'bypass'); const projection = await manager.listChildAgents(parent.id); assert.deepStrictEqual(projection.runs, []); assert.strictEqual(projection.executions.length, 1); @@ -3898,6 +3902,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: baseConfiguration, }), (error: unknown) => { @@ -3912,16 +3917,33 @@ describe('SessionManager manual compaction and quiescent session changes', () => const committed = await manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: baseConfiguration, }); assert.equal(committed.revision, 2); assert.equal(committed.header.orchestrationMode, 'graph'); assert.deepEqual(kernel.disposed, [session.id]); + await assert.rejects( + manager.transitionSessionConfiguration(session.id, { + expectedRevision: 1, + clearConnectionBlock: false, + permissionModeOnly: false, + configuration: baseConfiguration, + }), + (error: unknown) => { + assert.ok(error instanceof SessionConfigurationRevisionConflictError); + assert.equal(error.expectedRevision, 1); + assert.equal(error.actualRevision, 2); + return true; + }, + ); + await assert.rejects( manager.transitionSessionConfiguration(session.id, { expectedRevision: 2, clearConnectionBlock: false, + permissionModeOnly: true, configuration: { ...baseConfiguration, permissionMode: 'explore', @@ -3964,6 +3986,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => const preserved = await manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration, }); assert.equal(preserved.header.blockedReason, 'NO_REAL_CONNECTION'); @@ -3972,6 +3995,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => const recovered = await manager.transitionSessionConfiguration(session.id, { expectedRevision: 2, clearConnectionBlock: true, + permissionModeOnly: false, configuration, }); assert.equal(recovered.header.blockedReason, undefined); @@ -4066,6 +4090,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => .transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: session.backend, llmConnectionId: 'test-connection-id', @@ -4119,6 +4144,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => const transition = manager.transitionSessionConfiguration(session.id, { expectedRevision: 1, clearConnectionBlock: false, + permissionModeOnly: false, configuration: { backend: session.backend, llmConnectionId: 'test-connection-id', @@ -4346,7 +4372,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => describe('SessionManager permission mode updates', () => { test('revokes background shell authority before narrowing Auto to Explore', async () => { - const store = new AtomicBoundaryMemorySessionStore(); + const store = new VersionedConfigurationMemorySessionStore(); const calls: string[] = []; const manager = new SessionManager({ store, @@ -4370,8 +4396,14 @@ describe('SessionManager permission mode updates', () => { } as never, }); const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + const current = await store.readHeaderRecordSnapshot(session.id); - await manager.setPermissionMode(session.id, 'explore'); + await manager.transitionSessionConfiguration(session.id, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'explore' }), + }); assert.deepStrictEqual(calls, [`terminate:${session.id}`, 'commit', `resume:${session.id}`]); const boundary = await store.readExecutionBoundary(session.id); @@ -4379,6 +4411,73 @@ describe('SessionManager permission mode updates', () => { if (boundary.kind === 'managed') assert.strictEqual(boundary.profile.name, 'read-only'); }); + test('treats an expanded Explore profile as narrowing before restoring Explore', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const gate = makeGate(); + const calls: string[] = []; + const backends = new BackendRegistry(); + const runStore = new MemoryAgentRunStore(); + backends.register('ai-sdk', (ctx) => new TestBackend(ctx, gate)); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(987), + shellRuns: { + async terminateSession(sessionId: string) { + calls.push(`terminate:${sessionId}`); + return { sessionId, token: Symbol('test') }; + }, + async commitSessionClose() { + calls.push('commit'); + }, + rollbackSessionClose() { + calls.push('rollback'); + }, + resumeSession(sessionId: string) { + calls.push(`resume:${sessionId}`); + }, + } as never, + }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + store.forceBoundary(session.id, { + kind: 'managed', + profile: applySandboxBoundaryExpansion(createReadOnlyPermissionProfile(), { + filesystem: { + entries: [{ path: '/approved/output', access: 'write', scope: 'subtree' }], + }, + }), + revision: 1, + }); + const activeTurn = manager + .sendMessage(session.id, { turnId: 'turn-expanded-explore', text: 'keep running' }) + [Symbol.asyncIterator](); + await activeTurn.next(); + + const current = await store.readHeaderRecordSnapshot(session.id); + const narrowing = { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: configurationForHeader(current.header, { permissionMode: 'explore' }), + } as const; + await expectRejects( + manager.transitionSessionConfiguration(session.id, narrowing), + /linked Turn is active/, + ); + assert.deepStrictEqual(calls, []); + assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'ask'); + + gate.release(); + while (!(await activeTurn.next()).done) {} + + await manager.transitionSessionConfiguration(session.id, narrowing); + assert.deepStrictEqual(calls, [`terminate:${session.id}`, 'commit', `resume:${session.id}`]); + assert.strictEqual((await store.readHeader(session.id)).permissionMode, 'explore'); + }); + test('revokes descendant background shell authority through the direct boundary API', async () => { const store = new AtomicBoundaryMemorySessionStore(); const calls: string[] = []; @@ -4490,8 +4589,8 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(store.disposeCount, 3); }); - test('keeps mode changes blocked until all overlapping turns finish', async () => { - const store = new MemorySessionStore(); + test('keeps narrowing blocked until all overlapping turns finish', async () => { + const store = new VersionedConfigurationMemorySessionStore(); const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const firstGate = makeGate(); @@ -4530,7 +4629,28 @@ 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 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.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(); @@ -4544,13 +4664,12 @@ describe('SessionManager permission mode updates', () => { ['turn-2', 'completed'], ], ); - const summary = await manager.setPermissionMode(session.id, 'bypass'); assert.strictEqual(summary.permissionMode, 'bypass'); }); - test('leaving explore clears the deep research label so visible read-only copy stays truthful', async () => { - const store = new MemorySessionStore(); + test('the setPermissionMode wrapper delegates deep research cleanup to configuration authority', async () => { + const store = new VersionedConfigurationMemorySessionStore(); const backends = new BackendRegistry(); backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(6_000) }); @@ -4566,13 +4685,56 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(summary.permissionMode, 'ask'); assert.deepStrictEqual(summary.labels, ['kept']); assert.deepStrictEqual((await store.readHeader(session.id)).labels, ['kept']); + assert.deepStrictEqual(await store.readMessages(session.id), [ + { + type: 'system_note', + id: 'id-1', + ts: 6_001, + kind: 'mode_change', + data: { from: 'explore', to: 'ask' }, + }, + ]); + }); - const messages = await store.readMessages(session.id); - const modeNote = messages.find( - (message) => message.type === 'system_note' && message.kind === 'mode_change', - ); - if (modeNote?.type !== 'system_note') throw new Error('mode_change note was not written'); - assert.deepStrictEqual(modeNote.data, { from: 'explore', to: 'ask' }); + test('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'); + assert.deepStrictEqual(await store.readMessages(session.id), [ + { + type: 'system_note', + id: 'id-1', + ts: 6_101, + kind: 'mode_change', + data: { from: 'ask', to: 'bypass' }, + }, + ]); + }); + + test('does not append a permission audit note when configuration is unchanged', async () => { + const store = new VersionedConfigurationMemorySessionStore(); + const manager = new SessionManager({ + store, + backends: new BackendRegistry(), + newId: nextId(), + now: nextNow(6_200), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); + + await manager.setPermissionMode(session.id, 'ask'); + + assert.deepStrictEqual(await store.readMessages(session.id), []); }); test('starts a new turn without workspace identity when safety inspection fails', async () => { @@ -10227,7 +10389,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; @@ -10260,7 +10422,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, { @@ -12956,8 +13118,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, @@ -13008,6 +13179,7 @@ class VersionedConfigurationMemorySessionStore extends MemorySessionStore { } : {}), }); + this.forcedBoundaries.delete(sessionId); this.revisions.set(sessionId, revision + 1); return { header, revision: revision + 1, committedAt: revision + 1 }; } @@ -13725,6 +13897,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/__tests__/tool-runtime-sandbox-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts index e4a71162ac..7e197724f6 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -23,8 +23,12 @@ import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; -import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { + createReadOnlyPermissionProfile, + createWorkspaceWritePermissionProfile, +} from '@maka/core/permission-profile'; +import { + applySandboxBoundaryExpansion, type ExecutionBoundary, type SandboxBoundaryRequest, type SandboxBoundarySettlement, @@ -67,13 +71,14 @@ describe('ToolRuntime session sandbox boundary', () => { test('reads the authoritative boundary for every tool invocation', async () => { const observed: ExecutionBoundary[] = []; let revision = 0; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', appendMessage: async () => {}, + readPermissionMode: async () => 'ask', readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -103,6 +108,95 @@ describe('ToolRuntime session sandbox boundary', () => { ); }); + 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: applySandboxBoundaryExpansion(createReadOnlyPermissionProfile(), { + filesystem: { + entries: [{ path: '/approved/output', access: 'write', scope: 'subtree' }], + }, + }), + revision: 0, + }; + const observed: Array<{ kind: string; permissionMode: string | undefined }> = []; + 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, + 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'); + 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' }, + ]); + }); + + test('holds Plan mode to read-only even when the live boundary allows writes', async () => { + let observed: string | undefined; + 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(), + 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 = { @@ -111,7 +205,7 @@ describe('ToolRuntime session sandbox boundary', () => { revision: 0, }; let created: SandboxBoundaryRequest | undefined; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -232,7 +326,7 @@ describe('ToolRuntime session sandbox boundary', () => { await releaseAdmission.promise; }, }; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', hostedInteraction, sessionId: 'session-1', @@ -315,7 +409,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('rejects an invalid expansion before creating durable pending state', async () => { let createCalls = 0; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -373,7 +467,7 @@ describe('ToolRuntime session sandbox boundary', () => { const canonicalFile = await realpath(file); let created: SandboxBoundaryRequest | undefined; const events: SessionEvent[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(root), @@ -452,7 +546,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('rejects exact directory authority before creating durable pending state', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-boundary-directory-')); let createCalls = 0; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(root), @@ -512,7 +606,7 @@ describe('ToolRuntime session sandbox boundary', () => { }; let created: SandboxBoundaryRequest | undefined; const settlements: Array<{ requestId: string; decision: string }> = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -588,7 +682,7 @@ describe('ToolRuntime session sandbox boundary', () => { releaseCreate = resolve; }); const settlements: string[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -653,7 +747,7 @@ describe('ToolRuntime session sandbox boundary', () => { }); test('returns a structured boundary requirement to the agent', async () => { - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -716,7 +810,7 @@ describe('ToolRuntime session sandbox boundary', () => { }); test('counts one boundary correction per model step and keeps failure kinds independent', async () => { - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -790,7 +884,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('cancels a suspended nested boundary wait when its cell aborts', async () => { const events: SessionEvent[] = []; const settlements: string[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -871,7 +965,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('keeps a durable deny failure attached to the aborted nested call', async () => { const events: SessionEvent[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -927,7 +1021,7 @@ describe('ToolRuntime session sandbox boundary', () => { test('returns structured requires_bypass without opening an interaction', async () => { const events: SessionEvent[] = []; - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -996,7 +1090,7 @@ describe('ToolRuntime session sandbox boundary', () => { // here: ToolRuntime injects that callback unconditionally. This is the // branch a model actually reaches, and it used to say something different // from the tool the model called. - const runtime = new ToolRuntime({ + const runtime = createRuntime({ turnId: 'turn-1', sessionId: 'session-1', header: header(), @@ -1041,6 +1135,16 @@ describe('ToolRuntime session sandbox boundary', () => { }); }); +type SandboxToolRuntimeInput = Omit & + Partial>; + +function createRuntime(input: SandboxToolRuntimeInput): ToolRuntime { + return new ToolRuntime({ + readPermissionMode: async () => input.header.permissionMode, + ...input, + }); +} + async function settle(runtime: ToolRuntime, tool: MakaTool, toolCallId: string): Promise { const events: SessionEvent[] = []; await runtime.settleToolCall({ diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index 2540f554b2..552eddd106 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -22,9 +22,11 @@ import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + applySandboxBoundaryExpansion, createBypassExecutionBoundary, createGenesisExecutionBoundary, } from '@maka/core/sandbox-boundary'; +import { createReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import { type LlmConnection } from '@maka/core/llm-connections'; import type { SessionEvent } from '@maka/core/events'; import { type SessionHeader } from '@maka/core/session'; @@ -84,6 +86,56 @@ describe('ToolRuntime settlement', () => { ); }); + it('does not promote an expanded Explore boundary into Client Capability admission', async () => { + let preparationCalls = 0; + let implementationCalls = 0; + const clientTool: MakaTool = { + name: 'client_browser', + description: 'client browser', + parameters: {}, + categoryHint: 'custom_tool', + hostAdmission: 'client_capability', + prepareExecution: async () => { + preparationCalls += 1; + return { execute: async () => ({ ok: true }), cancel: () => undefined }; + }, + impl: () => { + implementationCalls += 1; + return { ok: true }; + }, + }; + const expandedProfile = applySandboxBoundaryExpansion(createReadOnlyPermissionProfile(), { + filesystem: { + entries: [{ path: '/approved/output', access: 'write', scope: 'subtree' }], + }, + }); + const runtime = makeRuntime({ + readPermissionMode: async () => 'explore', + readExecutionBoundary: async () => ({ + kind: 'managed', + profile: expandedProfile, + revision: 1, + }), + }); + + const settlement = await runtime.settleToolCall({ + tool: clientTool, + turnId: 'turn-1', + stepId: 'step-1', + toolCallId: 'call-expanded-explore', + input: {}, + abortSignal: new AbortController().signal, + eventSink: { + push: () => undefined, + pushAndWaitUntilConsumed: async () => undefined, + }, + }); + + assert.equal(preparationCalls, 0); + assert.equal(implementationCalls, 0); + assert.match(String((settlement.result as { error?: unknown }).error), /require the Bypass/u); + }); + it('prepares Bypass Client Capability work before T1 and admits only after T1', async () => { const order: string[] = []; const clientTool: MakaTool = { @@ -645,7 +697,12 @@ function makeRuntime( overrides: Partial< Pick< ToolRuntimeInput, - 'readExecutionBoundary' | 'spawnChildSession' | 'runId' | 'invocationId' | 'runtimeCommitSink' + | 'readExecutionBoundary' + | 'readPermissionMode' + | 'spawnChildSession' + | 'runId' + | 'invocationId' + | 'runtimeCommitSink' > > = {}, ): ToolRuntime { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 8fc7daf013..0e51471b6c 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -703,6 +703,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { appendMessage: AppendMessageFn; /** Reads the authoritative session boundary immediately before every local tool invocation. */ readExecutionBoundary: ToolRuntimeInput['readExecutionBoundary']; + /** Reads the user's current Session permission selection for each local tool invocation. */ + readPermissionMode: ToolRuntimeInput['readPermissionMode']; createSandboxBoundaryRequest?: ToolRuntimeInput['createSandboxBoundaryRequest']; settleSandboxBoundaryRequest?: ToolRuntimeInput['settleSandboxBoundaryRequest']; @@ -1348,6 +1350,7 @@ export class AiSdkBackend implements AgentBackend { modelId: input.modelId, appendMessage: input.appendMessage, readExecutionBoundary: input.readExecutionBoundary, + readPermissionMode: input.readPermissionMode, createSandboxBoundaryRequest: input.createSandboxBoundaryRequest, settleSandboxBoundaryRequest: input.settleSandboxBoundaryRequest, newId: this.newId, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 91bd2d1021..3efe18629a 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -68,6 +68,7 @@ import type { import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; +import { isReadOnlyPermissionProfile } from '@maka/core/permission-profile'; import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; import type { CreateSandboxBoundaryRequest, @@ -540,6 +541,7 @@ export interface SessionConfigurationStoreUpdate { export interface SessionConfigurationTransitionRequest { readonly expectedRevision: number; readonly clearConnectionBlock: boolean; + readonly permissionModeOnly: boolean; readonly configuration: Omit; } @@ -1125,57 +1127,86 @@ 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); + await this.appendPermissionModeChangeNote( + sessionId, + observed.header.permissionMode, + next.header.permissionMode, + ); return next; } @@ -1609,6 +1640,29 @@ export class SessionManager { } async setPermissionMode(sessionId: string, mode: PermissionMode): Promise { + const readHeaderRecordSnapshot = this.deps.store.readHeaderRecordSnapshot?.bind( + this.deps.store, + ); + if (!readHeaderRecordSnapshot || !this.deps.store.updateSessionConfiguration) { + // Temporary compatibility bridge for SessionStore embeddings that predate + // versioned configuration authority. A follow-up PR will shortly remove + // setPermissionMode and this redundant fallback after callers migrate. + return this.setPermissionModeWithLegacyStore(sessionId, mode); + } + const current = await readHeaderRecordSnapshot(sessionId); + const next = await this.transitionSessionConfiguration(sessionId, { + expectedRevision: current.revision, + clearConnectionBlock: false, + permissionModeOnly: true, + configuration: sessionConfigurationWithPermissionMode(current.header, mode), + }); + return headerToSummary(next.header); + } + + private async setPermissionModeWithLegacyStore( + sessionId: string, + mode: PermissionMode, + ): Promise { const previous = await this.deps.store.readHeader(sessionId); const boundary = await this.deps.store.readExecutionBoundary(sessionId); const leavingDeepResearch = isDeepResearchSession(previous.labels) && mode !== 'explore'; @@ -1620,62 +1674,80 @@ export class SessionManager { return headerToSummary(previous); } - if (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 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); + await this.appendPermissionModeChangeNote( + sessionId, + previous.permissionMode, + next.permissionMode, + ); + return headerToSummary(next); + } + + private async appendPermissionModeChangeNote( + sessionId: string, + from: PermissionMode, + to: PermissionMode, + ): Promise { + if (from === to) return; await this.deps.store .appendMessage(sessionId, { type: 'system_note', id: this.deps.newId(), ts: this.deps.now(), kind: 'mode_change', - data: { from: previous.permissionMode, to: mode }, + data: { from, to }, } satisfies SystemNoteMessage) .catch(() => undefined); - return headerToSummary(next); } async setExecutionBoundaryKind( 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); + 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'); - return this.commitExecutionResourceTransition(sessionId, nextPermissionMode, async () => { + 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( @@ -1683,8 +1755,27 @@ 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 + // 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 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 result; + } + return this.commitExecutionResourceTransition( + sessionId, + nextPermissionMode, + prepareBoundaryCommit, + ); } private async commitExecutionResourceTransition( @@ -5084,6 +5175,49 @@ function claimedAgentGraphIntentResult( }; } +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 { + return ( + header.permissionMode === configuration.permissionMode && + sessionConfigurationMatchesExceptPermissionMode(header, configuration) + ); +} + function executionBoundaryMatchesPermissionMode( boundary: ExecutionBoundary, mode: PermissionMode, @@ -5101,7 +5235,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( diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index a12fdce73e..a8e75eff3a 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -20,6 +20,7 @@ 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, @@ -375,6 +376,7 @@ export interface ToolRuntimeInput { modelId: string; appendMessage: AppendMessageFn; readExecutionBoundary: () => Promise; + readPermissionMode: () => Promise; createSandboxBoundaryRequest?: ( input: CreateSandboxBoundaryRequest, ) => Promise; @@ -600,6 +602,7 @@ export class ToolRuntime { private readonly durableToolAttempts = new Map(); private readonly activeToolSettlements = new Set>(); private readonly readExecutionBoundary: NonNullable; + private readonly readPermissionMode: NonNullable; private readonly stepAdmissions = new Map< string, { callCount: number; exclusiveToolName?: string } @@ -608,6 +611,9 @@ export class ToolRuntime { if (!input.readExecutionBoundary) { throw new Error('ToolRuntime requires explicit execution boundary authority'); } + if (!input.readPermissionMode) { + throw new Error('ToolRuntime requires explicit permission mode authority'); + } const hosted = input.hostedInteraction; if (hosted && (hosted.sessionId !== input.sessionId || hosted.turnId !== input.turnId)) { throw new RuntimeInteractionInvariantError( @@ -617,6 +623,23 @@ export class ToolRuntime { this.turnId = input.turnId; this.hostedInteraction = hosted; this.readExecutionBoundary = input.readExecutionBoundary; + this.readPermissionMode = input.readPermissionMode; + } + + /** + * The permission mode in force for this dispatch. + * + * 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 async livePermissionMode(boundary: ExecutionBoundary): Promise { + const permissionMode = boundary.kind === 'bypass' ? 'bypass' : await this.readPermissionMode(); + return resolveCollaborationPermissionMode({ + collaborationMode: this.input.header.collaborationMode ?? 'agent', + permissionMode, + }); } async endTurn(reason: 'completed' | 'aborted' = 'completed'): Promise { @@ -1491,10 +1514,12 @@ export class ToolRuntime { } let clientCapabilityBoundary: ExecutionBoundary | undefined; + let clientCapabilityPermissionMode: PermissionMode | undefined; let preparedExecution: PreparedMakaToolExecution | undefined; if (tool.hostAdmission === 'client_capability') { try { clientCapabilityBoundary = await this.readExecutionBoundary(); + clientCapabilityPermissionMode = await this.livePermissionMode(clientCapabilityBoundary); } catch (error) { const reason = formatSyntheticToolErrorText(error); await refuseBeforeDispatch(reason); @@ -1509,7 +1534,7 @@ export class ToolRuntime { } const admissionFailure = !tool.prepareExecution ? CLIENT_CAPABILITY_PREPARATION_MESSAGE - : clientCapabilityBoundary.kind !== 'bypass' && this.input.header.permissionMode !== 'ask' + : clientCapabilityBoundary.kind !== 'bypass' && clientCapabilityPermissionMode !== 'ask' ? CLIENT_CAPABILITY_BOUNDARY_MESSAGE : undefined; if (admissionFailure) { @@ -1538,7 +1563,7 @@ export class ToolRuntime { ...(runId ? { runId } : {}), cwd: this.input.header.cwd, executionBoundary: clientCapabilityBoundary, - permissionMode: this.input.header.permissionMode, + permissionMode: clientCapabilityPermissionMode, toolCallId: toolUseId, abortSignal: ctx.abortSignal, }); @@ -1660,6 +1685,8 @@ export class ToolRuntime { try { const runId = this.input.runId; const executionBoundary = clientCapabilityBoundary ?? (await this.readExecutionBoundary()); + const permissionMode = + clientCapabilityPermissionMode ?? (await this.livePermissionMode(executionBoundary)); const toolContext: MakaToolContext = { sessionId: this.input.sessionId, turnId, @@ -1669,7 +1696,7 @@ export class ToolRuntime { : {}), cwd: this.input.header.cwd, executionBoundary, - permissionMode: this.input.header.permissionMode, + 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,