From ac04d65e19e869490c0d61fa5a7329bc04fb5eb6 Mon Sep 17 00:00:00 2001 From: testikun Date: Mon, 31 Aug 2026 11:22:16 +0800 Subject: [PATCH 01/12] fix(runtime): preserve sandbox negotiation across continuations (#3731) Generated-by: OpenAI Codex --- .../src/__tests__/sandbox-boundary.test.ts | 247 ++++++++++++ packages/core/src/backend-types.ts | 8 +- packages/core/src/events.ts | 2 +- packages/core/src/sandbox-boundary.ts | 352 ++++++++++++++++++ .../core/src/tool-result-record-schema.ts | 16 +- packages/runtime-host/src/protocol/index.ts | 2 + .../src/protocol/session-continuity.ts | 8 +- .../src/__tests__/ai-sdk-backend.test.ts | 287 ++++++++++++++ .../__tests__/runtime-continuation.test.ts | 58 +++ packages/runtime/src/ai-sdk-backend.ts | 23 ++ packages/runtime/src/runtime-kernel.ts | 59 ++- packages/runtime/src/runtime-resume.ts | 55 +++ packages/runtime/src/session-manager.ts | 3 + packages/runtime/src/tool-runtime.ts | 35 +- packages/storage/src/execution-stores.ts | 2 + packages/storage/src/session-store.ts | 6 + .../src/sqlite-session-metadata-store.ts | 21 ++ 17 files changed, 1166 insertions(+), 18 deletions(-) diff --git a/packages/core/src/__tests__/sandbox-boundary.test.ts b/packages/core/src/__tests__/sandbox-boundary.test.ts index 500fbe00d1..9ae0ab2ca0 100644 --- a/packages/core/src/__tests__/sandbox-boundary.test.ts +++ b/packages/core/src/__tests__/sandbox-boundary.test.ts @@ -26,8 +26,11 @@ import { decodeExecutionBoundary, executionBoundaryContains, executionBoundaryDisplayMode, + projectSandboxBoundaryNegotiation, + type SandboxBoundaryRequest, validateSandboxBoundaryExpansion, } from '../sandbox-boundary.js'; +import type { RuntimeEvent } from '../runtime-event.js'; import { canReadPath, canWritePath, @@ -387,6 +390,250 @@ describe('SandboxBoundaryExpansion', () => { }); }); +describe('projectSandboxBoundaryNegotiation', () => { + const base = (id: string, partial: Partial): RuntimeEvent => ({ + id, + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'system', + author: 'system', + ...partial, + }); + + const request = (id: string, requestId: string, toolUseId: string): RuntimeEvent => + base(id, { + refs: { toolCallId: toolUseId }, + actions: { + stateDelta: { + sandboxBoundaryRequest: { + requestId, + toolUseId, + justification: 'Need the smallest boundary expansion.', + expansion: { network: { enabled: true } }, + }, + }, + }, + }); + + const decision = ( + id: string, + requestId: string, + toolUseId: string, + status: 'approved' | 'denied' | 'conflict', + ): RuntimeEvent => + base(id, { + author: 'user', + refs: { toolCallId: toolUseId }, + actions: { + stateDelta: { + sandboxBoundaryDecision: { + requestId, + decision: status === 'denied' ? 'deny' : 'allow', + status, + revision: status === 'approved' ? 1 : 0, + }, + }, + }, + }); + + const failurePair = ( + id: string, + toolName: string, + toolCallId: string, + reason: 'invalid_boundary_declaration' | 'sandbox_boundary_required', + hidden = false, + ): RuntimeEvent[] => [ + base(`${id}-call`, { + role: 'model', + author: 'agent', + ...(hidden ? { modelVisibility: 'hidden' as const } : {}), + refs: { toolCallId, stepId: `${id}-step` }, + content: { + kind: 'function_call', + id: toolCallId, + name: toolName, + args: toolName === 'Bash' ? { boundary_intent: 'expand' } : {}, + }, + }), + base(`${id}-response`, { + role: 'tool', + author: 'tool', + ...(hidden ? { modelVisibility: 'hidden' as const } : {}), + refs: { toolCallId, stepId: `${id}-step` }, + content: { + kind: 'function_response', + id: toolCallId, + name: toolName, + isError: true, + result: { + kind: 'text', + text: 'Sandbox boundary correction failed.', + sandboxFailure: { reason }, + }, + }, + }), + ]; + + test('restores denial and both correction budgets, including hidden Code Mode calls', () => { + const events = [ + request('request-1', 'boundary-1', 'tool-1'), + decision('decision-1', 'boundary-1', 'tool-1', 'denied'), + ...failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-2', + 'invalid_boundary_declaration', + ), + ...failurePair('unresolved-1', 'Bash', 'tool-3', 'sandbox_boundary_required', true), + ]; + + assert.deepEqual(projectSandboxBoundaryNegotiation(events), { + kind: 'valid', + state: { + denied: true, + invalidRounds: 1, + unresolvedRounds: 1, + finalizationRequested: false, + }, + }); + }); + + test('approved requests reset prior correction state', () => { + const events = [ + ...failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-1', + 'invalid_boundary_declaration', + ), + request('request-1', 'boundary-1', 'tool-2'), + decision('decision-1', 'boundary-1', 'tool-2', 'approved'), + ]; + assert.deepEqual(projectSandboxBoundaryNegotiation(events), { + kind: 'valid', + state: { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }); + }); + + test('fails closed for malformed or legacy boundary facts', () => { + const malformed = request('request-1', 'boundary-1', 'tool-1'); + malformed.actions!.stateDelta!.sandboxBoundaryRequest = { + requestId: 'boundary-1', + toolUseId: 'tool-1', + justification: 'missing expansion', + }; + assert.equal(projectSandboxBoundaryNegotiation([malformed]).kind, 'invalid'); + + const [call, response] = failurePair( + 'legacy-1', + 'request_sandbox_boundary', + 'tool-1', + 'invalid_boundary_declaration', + ); + (response.content as Extract).result = { + kind: 'text', + text: 'Tool arguments failed validation', + }; + assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid'); + }); + + test('fails closed when boundary facts do not preserve call identity', () => { + const mismatchedDecision = decision('decision-1', 'boundary-1', 'other-tool', 'denied'); + assert.equal( + projectSandboxBoundaryNegotiation([ + request('request-1', 'boundary-1', 'tool-1'), + mismatchedDecision, + ]).kind, + 'invalid', + ); + + const [call, response] = failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-1', + 'invalid_boundary_declaration', + ); + (response.content as Extract).name = + 'Bash'; + assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid'); + }); + + test('fails closed when a boundary failure marker is attached to a non-boundary tool', () => { + const [call, response] = failurePair( + 'forged-1', + 'Read', + 'tool-1', + 'invalid_boundary_declaration', + ); + assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid'); + }); + + test('requests finalization after the bounded correction budget', () => { + const events = [ + ...failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-1', + 'invalid_boundary_declaration', + ), + ...failurePair( + 'invalid-2', + 'request_sandbox_boundary', + 'tool-2', + 'invalid_boundary_declaration', + ), + ...failurePair( + 'invalid-3', + 'request_sandbox_boundary', + 'tool-3', + 'invalid_boundary_declaration', + ), + ]; + const result = projectSandboxBoundaryNegotiation(events); + assert.equal(result.kind, 'valid'); + if (result.kind === 'valid') assert.equal(result.state.finalizationRequested, true); + }); + + test('restores a durable denial when the RuntimeEvent ack was lost', () => { + const durableRequest: SandboxBoundaryRequest = { + sessionId: 'session-1', + requestId: 'boundary-1', + status: 'denied', + baseRevision: 0, + expansion: { network: { enabled: true } }, + justification: 'Need network access.', + createdAt: 1, + settledAt: 2, + turnId: 'turn-1', + runId: 'run-1', + }; + assert.deepEqual( + projectSandboxBoundaryNegotiation( + [base('source-event', { turnId: 'turn-1', runId: 'run-1' })], + [durableRequest], + ), + { + kind: 'valid', + state: { + denied: true, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }, + ); + }); +}); + describe('ExecutionBoundary', () => { test('decodes only a complete full boundary snapshot', () => { const managed = createGenesisExecutionBoundary('ask'); diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 9f326ca20f..49b8524d5e 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -39,7 +39,11 @@ import type { } from './events.js'; import type { InteractionClosureReason } from './interaction.js'; import type { RuntimeEvent } from './runtime-event.js'; -import type { SandboxBoundaryResponse, SandboxBoundarySettlement } from './sandbox-boundary.js'; +import type { + SandboxBoundaryNegotiationState, + SandboxBoundaryResponse, + SandboxBoundarySettlement, +} from './sandbox-boundary.js'; import type { StoredMessage, PersistedBackendKind } from './session.js'; import type { AgentRunHeader } from './agent-run.js'; import type { UserQuestionResponse } from './user-question.js'; @@ -52,6 +56,8 @@ export interface RuntimeContinuationMetadata { sourceRunId: string; sourceTurnId: string; sourceRuntimeEventHighWater: number; + /** Authenticated negotiation projection; never grants execution authority. */ + sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState; } export interface BackendSendInput { diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 52af0d0727..d427751d4d 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -775,7 +775,7 @@ export interface SandboxDenialRecovery extends SandboxDenialSignal { } export interface SandboxBoundaryFailureSignal { - reason: 'sandbox_boundary_required' | 'requires_bypass'; + reason: 'invalid_boundary_declaration' | 'sandbox_boundary_required' | 'requires_bypass'; requiredExpansion?: SandboxBoundaryExpansion; source?: 'client_capability'; } diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index 208fd1f2b3..e617b54777 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -18,6 +18,7 @@ */ import type { PermissionMode } from './permission.js'; +import type { RuntimeEvent } from './runtime-event.js'; import { isNormalizedAbsolutePath, pathWithinRoot, @@ -45,6 +46,38 @@ export type SandboxBoundaryAccess = (typeof SANDBOX_BOUNDARY_ACCESS_MODES)[numbe export const SANDBOX_BOUNDARY_SCOPES = ['exact', 'subtree'] as const; export type SandboxBoundaryScope = (typeof SANDBOX_BOUNDARY_SCOPES)[number]; +/** Maximum number of distinct correction rounds allowed for one negotiation kind. */ +export const SANDBOX_BOUNDARY_FAILURE_ROUND_LIMIT = 3; + +/** + * The small amount of sandbox negotiation state that may cross a safe + * continuation boundary. It is deliberately not an execution authority: the + * live ExecutionBoundary remains the only source that can grant capability. + */ +export interface SandboxBoundaryNegotiationState { + readonly denied: boolean; + readonly invalidRounds: number; + readonly unresolvedRounds: number; + readonly finalizationRequested: boolean; +} + +/** + * State used when a continuation's boundary projection cannot be trusted. + * Callers may report the blocked Turn, but must not reopen negotiation. + */ +export function createSandboxBoundaryFinalizationState(): SandboxBoundaryNegotiationState { + return { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: true, + }; +} + +export type SandboxBoundaryNegotiationProjection = + | { readonly kind: 'valid'; readonly state: SandboxBoundaryNegotiationState } + | { readonly kind: 'invalid'; readonly reason: string }; + export const MAX_SANDBOX_BOUNDARY_FILESYSTEM_ENTRIES = 32; export const MAX_SANDBOX_BOUNDARY_PATH_CHARS = 4096; export const MAX_SANDBOX_BOUNDARY_SERIALIZED_BYTES = 64 * 1024; @@ -157,6 +190,325 @@ export interface SandboxBoundarySettlement { readonly changed: boolean; } +/** + * Rebuild negotiation control state from an authenticated RuntimeEvent + * projection. Only canonical sandbox request/decision events and structured + * boundary failures participate; prompt text and unstructured error strings do + * not. A malformed or incomplete relevant fact returns `invalid`, allowing a + * caller to fail closed without guessing state. + */ +export function projectSandboxBoundaryNegotiation( + events: readonly RuntimeEvent[], + durableRequests: readonly SandboxBoundaryRequest[] = [], +): SandboxBoundaryNegotiationProjection { + let denied = false; + let invalidRounds = 0; + let unresolvedRounds = 0; + let finalizationRequested = false; + const invalidSteps = new Set(); + const unresolvedSteps = new Set(); + const requests = new Set(); + const requestToolUseIds = new Map(); + const settledRequests = new Set(); + const requestEvents = new Set(); + const decisionEvents = new Map(); + const eventTurnIds = new Set(); + const eventRunIds = new Set(); + const eventIdentityPairs = new Set(); + const toolCalls = new Map(); + const boundaryCalls = new Map(); + const boundaryResponses = new Set(); + + const invalid = (reason: string): SandboxBoundaryNegotiationProjection => ({ + kind: 'invalid', + reason, + }); + const addFailure = (kind: 'invalid' | 'unresolved', step: string): void => { + const steps = kind === 'invalid' ? invalidSteps : unresolvedSteps; + if (steps.has(step)) return; + steps.add(step); + if (kind === 'invalid') invalidRounds += 1; + else unresolvedRounds += 1; + if ( + invalidRounds >= SANDBOX_BOUNDARY_FAILURE_ROUND_LIMIT || + unresolvedRounds >= SANDBOX_BOUNDARY_FAILURE_ROUND_LIMIT + ) { + finalizationRequested = true; + } + }; + + for (const event of events) { + eventTurnIds.add(event.turnId); + eventRunIds.add(event.runId); + eventIdentityPairs.add(`${event.runId}\u0000${event.turnId}`); + const delta = event.actions?.stateDelta; + const request = delta?.sandboxBoundaryRequest; + const decision = delta?.sandboxBoundaryDecision; + if (request !== undefined || decision !== undefined) { + if (request !== undefined && decision !== undefined) { + return invalid(`sandbox boundary event ${event.id} contains request and decision facts`); + } + if ( + event.role !== 'system' || + typeof event.refs?.toolCallId !== 'string' || + event.refs.toolCallId.length === 0 + ) { + return invalid(`sandbox boundary event ${event.id} has non-canonical identity`); + } + if (request !== undefined) { + if ( + event.author !== 'system' || + !isRecord(request) || + !hasExactKeys(request, ['requestId', 'toolUseId', 'justification', 'expansion']) || + !nonEmptyString(request.requestId) || + !nonEmptyString(request.toolUseId) || + typeof request.justification !== 'string' || + !validateSandboxBoundaryExpansion(request.expansion).ok || + request.toolUseId !== event.refs.toolCallId + ) { + return invalid(`sandbox boundary request ${event.id} is incomplete`); + } + if (requests.has(request.requestId) || settledRequests.has(request.requestId)) { + return invalid(`sandbox boundary request ${request.requestId} is duplicated`); + } + requests.add(request.requestId); + requestToolUseIds.set(request.requestId, request.toolUseId); + requestEvents.add(request.requestId); + continue; + } + const requestToolUseId = + isRecord(decision) && nonEmptyString(decision.requestId) + ? requestToolUseIds.get(decision.requestId) + : undefined; + if ( + event.author !== 'user' || + !isRecord(decision) || + !hasExactKeys(decision, ['requestId', 'decision', 'status', 'revision']) || + !nonEmptyString(decision.requestId) || + (decision.decision !== 'allow' && decision.decision !== 'deny') || + (decision.status !== 'approved' && + decision.status !== 'denied' && + decision.status !== 'conflict') || + typeof decision.revision !== 'number' || + !Number.isFinite(decision.revision) || + requestToolUseId === undefined || + event.refs.toolCallId !== requestToolUseId || + settledRequests.has(decision.requestId) || + (decision.status === 'approved' && decision.decision !== 'allow') || + (decision.status === 'denied' && decision.decision !== 'deny') || + (decision.status === 'conflict' && decision.decision !== 'allow') + ) { + return invalid(`sandbox boundary decision ${event.id} is incomplete`); + } + settledRequests.add(decision.requestId); + decisionEvents.set(decision.requestId, { status: decision.status }); + if (decision.status === 'denied') { + denied = true; + } else if (decision.status === 'approved') { + denied = false; + invalidRounds = 0; + unresolvedRounds = 0; + invalidSteps.clear(); + unresolvedSteps.clear(); + finalizationRequested = false; + } else { + addFailure('unresolved', `request:${decision.requestId}`); + } + continue; + } + + const content = event.content; + if (content?.kind === 'function_call') { + const isBoundaryCall = isBoundaryAuthorityCall(content.name, content.args); + if ( + isBoundaryCall && + (event.role !== 'model' || + event.author !== 'agent' || + event.refs?.toolCallId !== content.id || + !nonEmptyString(content.name)) + ) { + return invalid(`sandbox boundary call ${event.id} has non-canonical identity`); + } + const call = { + name: content.name, + step: event.refs?.stepId ?? event.refs?.toolCallId ?? content.id, + }; + if (toolCalls.has(content.id)) { + return invalid(`tool call ${content.id} is duplicated`); + } + toolCalls.set(content.id, call); + if (isBoundaryCall) { + if (boundaryCalls.has(content.id)) { + return invalid(`sandbox boundary call ${content.id} is duplicated`); + } + boundaryCalls.set(content.id, { step: call.step }); + } + continue; + } + if (content?.kind !== 'function_response') continue; + const call = toolCalls.get(content.id); + const boundaryCall = boundaryCalls.get(content.id); + const result = content.result; + if ( + boundaryCall && + (event.role !== 'tool' || + event.author !== 'tool' || + event.refs?.toolCallId !== content.id || + content.name !== call?.name) + ) { + return invalid(`sandbox boundary response ${event.id} has non-canonical identity`); + } + const failure = readBoundaryFailure(result); + if (failure === 'malformed') { + return invalid(`sandbox boundary failure on ${event.id} is malformed`); + } + if (failure !== undefined) { + if (!boundaryCall || !call || content.isError !== true) { + return invalid(`sandbox boundary failure on ${event.id} has no canonical call`); + } + const step = event.refs?.stepId ?? boundaryCall?.step ?? call.step; + addFailure(failure, step); + } else if (content.isError === true && boundaryCall) { + // Older ledgers did not carry a structured invalid-boundary marker. Do + // not infer a count from their text; the relevant lineage is incomplete + // and must be rejected closed by the continuation caller. + return invalid(`sandbox boundary call ${content.id} lacks a canonical failure marker`); + } + if (boundaryCall) { + if (boundaryResponses.has(content.id)) { + return invalid(`sandbox boundary response ${content.id} is duplicated`); + } + boundaryResponses.add(content.id); + } + continue; + } + + for (const callId of boundaryCalls.keys()) { + if (!boundaryResponses.has(callId)) { + return invalid(`sandbox boundary call ${callId} has no durable response`); + } + } + + const durableById = new Map(); + for (const request of durableRequests) { + const hasProvenance = request.turnId !== undefined || request.runId !== undefined; + const attributable = hasProvenance + ? request.turnId !== undefined && request.runId !== undefined + ? eventIdentityPairs.has(`${request.runId}\u0000${request.turnId}`) + : (request.turnId === undefined || eventTurnIds.has(request.turnId)) && + (request.runId === undefined || eventRunIds.has(request.runId)) + : requestEvents.has(request.requestId) || decisionEvents.has(request.requestId); + if (!attributable) continue; + if ( + !nonEmptyString(request.requestId) || + !SANDBOX_BOUNDARY_REQUEST_STATUSES.includes(request.status) || + !validateSandboxBoundaryExpansion(request.expansion).ok || + typeof request.justification !== 'string' || + request.justification.trim().length === 0 || + !Number.isSafeInteger(request.createdAt) || + request.createdAt < 0 || + (request.turnId !== undefined && !nonEmptyString(request.turnId)) || + (request.runId !== undefined && !nonEmptyString(request.runId)) + ) { + return invalid(`sandbox boundary durable request ${String(request.requestId)} is malformed`); + } + if (durableById.has(request.requestId)) { + return invalid(`sandbox boundary durable request ${request.requestId} is duplicated`); + } + durableById.set(request.requestId, request); + const eventDecision = decisionEvents.get(request.requestId); + if (eventDecision && eventDecision.status !== request.status) { + return invalid( + `sandbox boundary durable request ${request.requestId} changed decision status`, + ); + } + if (request.status === 'pending') { + return invalid(`sandbox boundary durable request ${request.requestId} is unresolved`); + } + if (!eventDecision) { + settledRequests.add(request.requestId); + if (request.status === 'denied') { + denied = true; + } else if (request.status === 'approved') { + denied = false; + invalidRounds = 0; + unresolvedRounds = 0; + invalidSteps.clear(); + unresolvedSteps.clear(); + finalizationRequested = false; + } else { + addFailure('unresolved', `request:${request.requestId}`); + } + } + } + + for (const requestId of requests) { + if (!settledRequests.has(requestId)) { + return invalid(`sandbox boundary request ${requestId} has no durable decision`); + } + } + return { + kind: 'valid', + state: { + denied, + invalidRounds, + unresolvedRounds, + finalizationRequested, + }, + }; +} + +function isBoundaryAuthorityCall(toolName: string, args: unknown): boolean { + if (toolName === 'request_sandbox_boundary') return true; + if (toolName !== 'Bash' || !isRecord(args)) return false; + return args.boundary_intent !== undefined && args.boundary_intent !== 'current'; +} + +function readBoundaryFailure(result: unknown): 'invalid' | 'unresolved' | 'malformed' | undefined { + if (!isRecord(result) || result.kind !== 'text') return undefined; + const failure = result.sandboxFailure; + if (failure === undefined) return undefined; + if (!isRecord(failure) || !hasOnlyKeys(failure, ['reason', 'requiredExpansion', 'source'])) { + return 'malformed'; + } + if ( + failure.reason === 'invalid_boundary_declaration' && + (failure.source !== undefined || failure.requiredExpansion !== undefined) + ) { + return 'malformed'; + } + if (failure.reason === 'sandbox_boundary_required' || failure.reason === 'requires_bypass') { + if ( + failure.source !== undefined && + !(failure.reason === 'requires_bypass' && failure.source === 'client_capability') + ) { + return 'malformed'; + } + if ( + failure.requiredExpansion !== undefined && + !validateSandboxBoundaryExpansion(failure.requiredExpansion).ok + ) { + return 'malformed'; + } + return 'unresolved'; + } + if (failure.reason === 'invalid_boundary_declaration') return 'invalid'; + return 'malformed'; +} + +function hasExactKeys(value: Record, required: readonly string[]): boolean { + const keys = Object.keys(value).sort(); + return keys.length === required.length && required.every((key) => keys.includes(key)); +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + export type SandboxProfile = PermissionProfileManaged; export type ExecutionBoundary = diff --git a/packages/core/src/tool-result-record-schema.ts b/packages/core/src/tool-result-record-schema.ts index dc0b090285..b0d77a0890 100644 --- a/packages/core/src/tool-result-record-schema.ts +++ b/packages/core/src/tool-result-record-schema.ts @@ -207,13 +207,17 @@ function isNonShellToolResultContent(value: unknown): value is ToolResultContent (value.sandboxFailure === undefined || (isRecord(value.sandboxFailure) && hasExactShape(value.sandboxFailure, SANDBOX_FAILURE_SHAPE) && - (value.sandboxFailure.reason === 'sandbox_boundary_required' || + (value.sandboxFailure.reason === 'invalid_boundary_declaration' || + value.sandboxFailure.reason === 'sandbox_boundary_required' || value.sandboxFailure.reason === 'requires_bypass') && - (value.sandboxFailure.source === undefined || - (value.sandboxFailure.reason === 'requires_bypass' && - value.sandboxFailure.source === 'client_capability')) && - (value.sandboxFailure.requiredExpansion === undefined || - validateSandboxBoundaryExpansion(value.sandboxFailure.requiredExpansion).ok))) && + (value.sandboxFailure.reason === 'invalid_boundary_declaration' + ? value.sandboxFailure.source === undefined && + value.sandboxFailure.requiredExpansion === undefined + : (value.sandboxFailure.source === undefined || + (value.sandboxFailure.reason === 'requires_bypass' && + value.sandboxFailure.source === 'client_capability')) && + (value.sandboxFailure.requiredExpansion === undefined || + validateSandboxBoundaryExpansion(value.sandboxFailure.requiredExpansion).ok)))) && (value.uncertainOutcome === undefined || (isRecord(value.uncertainOutcome) && hasExactShape(value.uncertainOutcome, UNCERTAIN_OUTCOME_SHAPE) && diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 83883daec7..1ca055cae8 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -150,6 +150,8 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 94 as const; // 79: Every `turn.message.submit` disposition carries the exact Skill // invocation outcome. Durable queued replays may omit the previous Host // Epoch's transient queue revision; older strict peers reject either shape. +======= +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 79 as const; // 78: OAuth login targets explicit create/existing Connection entities and // returns their canonical identity. Older peers reject both closed wire shapes. // 77: LLM and tool usage-log projections carry an optional `sessionTitle` (the diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 4219b6ab34..5f2db1f825 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -986,7 +986,13 @@ function decodeSessionToolEvent(value: unknown): SessionToolEvent { } function requireSandboxFailureReason(value: unknown): SandboxBoundaryFailureSignal['reason'] { - if (value === 'sandbox_boundary_required' || value === 'requires_bypass') return value; + if ( + value === 'invalid_boundary_declaration' || + value === 'sandbox_boundary_required' || + value === 'requires_bypass' + ) { + return value; + } throw invalidProtocolFrame('Invalid Session tool result sandbox failure reason'); } diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index a021546196..b0d8281ef3 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -1241,6 +1241,293 @@ describe('AiSdkBackend Memory Extraction triggers', () => { }); describe('AiSdkBackend sandbox boundary convergence', () => { + test('restores a denied negotiation on a fresh continuation segment', async () => { + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'continuation-boundary-retry', + toolName: 'request_sandbox_boundary', + input: JSON.stringify({ + expansion: { network: { enabled: true } }, + justification: 'Try the denied expansion again.', + }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'continuation-final' }, + { + type: 'text-delta', + id: 'continuation-final', + delta: 'The prior denial remains in force.', + }, + { type: 'text-end', id: 'continuation-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const sourceTurnId = 'turn-source'; + const sourceRuntimeContext: RuntimeEvent[] = [ + runtimeTextEvent({ + id: 'source-user', + turnId: sourceTurnId, + role: 'user', + author: 'user', + text: 'Need network access.', + }), + runtimeEvent({ + id: 'source-boundary-request', + turnId: sourceTurnId, + role: 'system', + author: 'system', + refs: { toolCallId: 'source-boundary-call' }, + actions: { + stateDelta: { + sandboxBoundaryRequest: { + requestId: 'source-boundary', + toolUseId: 'source-boundary-call', + justification: 'Need network access.', + expansion: { network: { enabled: true } }, + }, + }, + }, + }), + runtimeEvent({ + id: 'source-boundary-decision', + turnId: sourceTurnId, + role: 'system', + author: 'user', + refs: { toolCallId: 'source-boundary-call' }, + actions: { + stateDelta: { + sandboxBoundaryDecision: { + requestId: 'source-boundary', + decision: 'deny', + status: 'denied', + revision: 0, + }, + }, + }, + }), + ]; + let createCalls = 0; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildRequestSandboxBoundaryTool()], + readExecutionBoundary: async () => + createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), + createSandboxBoundaryRequest: async () => { + createCalls += 1; + throw new Error('a denied continuation must not reopen the request'); + }, + settleSandboxBoundaryRequest: async () => { + throw new Error('a denied continuation must not settle a new request'); + }, + loadTurnRuntimeEvents: async () => [], + maxSteps: 3, + newId: idGenerator(), + now: monotonicClock(), + }); + const events: SessionEvent[] = []; + await collectEvents( + backend.send({ + turnId: 'turn-continuation', + text: '', + context: [], + runtimeContext: sourceRuntimeContext, + continuation: { + sourceInvocationId: 'inv-1', + sourceRunId: 'run-prev', + sourceTurnId, + sourceRuntimeEventHighWater: sourceRuntimeContext.length, + }, + }), + events, + ); + + assert.equal(createCalls, 0); + assert.equal(streamCalls, 2); + assert.equal(events.filter((event) => event.type === 'sandbox_boundary_request').length, 0); + assert.equal( + events.find((event) => event.type === 'complete')?.stopReason, + 'permission_handoff', + ); + await backend.dispose(); + }); + + test('starts a genuinely new user Turn with a clean negotiation state', async () => { + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks: LanguageModelV4StreamPart[] = + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'new-turn-boundary-request', + toolName: 'request_sandbox_boundary', + input: JSON.stringify({ + expansion: { network: { enabled: true } }, + justification: 'This is a new user Turn.', + }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'new-turn-final' }, + { + type: 'text-delta', + id: 'new-turn-final', + delta: 'The new Turn handled its own boundary decision.', + }, + { type: 'text-end', id: 'new-turn-final' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const managed = createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0); + let pendingRequest: + | Awaited>> + | undefined; + let createCalls = 0; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildRequestSandboxBoundaryTool()], + readExecutionBoundary: async () => managed, + createSandboxBoundaryRequest: async (input) => { + createCalls += 1; + pendingRequest = { ...input, status: 'pending', baseRevision: 0, createdAt: 1 }; + return pendingRequest; + }, + settleSandboxBoundaryRequest: async () => { + assert.ok(pendingRequest); + pendingRequest = { ...pendingRequest, status: 'denied', settledAt: 2 }; + return { request: pendingRequest, boundary: managed, changed: false }; + }, + loadTurnRuntimeEvents: async () => [], + maxSteps: 3, + newId: idGenerator(), + now: monotonicClock(), + }); + const priorDeniedRuntimeContext: RuntimeEvent[] = [ + runtimeTextEvent({ + id: 'old-user', + turnId: 'turn-old', + role: 'user', + author: 'user', + text: 'Old Turn.', + }), + runtimeEvent({ + id: 'old-boundary-request', + turnId: 'turn-old', + role: 'system', + author: 'system', + refs: { toolCallId: 'old-boundary-call' }, + actions: { + stateDelta: { + sandboxBoundaryRequest: { + requestId: 'old-boundary', + toolUseId: 'old-boundary-call', + justification: 'Old request.', + expansion: { network: { enabled: true } }, + }, + }, + }, + }), + runtimeEvent({ + id: 'old-boundary-decision', + turnId: 'turn-old', + role: 'system', + author: 'user', + refs: { toolCallId: 'old-boundary-call' }, + actions: { + stateDelta: { + sandboxBoundaryDecision: { + requestId: 'old-boundary', + decision: 'deny', + status: 'denied', + revision: 0, + }, + }, + }, + }), + ]; + const events: SessionEvent[] = []; + const consuming = collectEvents( + backend.send({ + turnId: 'turn-new', + text: 'Start fresh.', + context: [], + runtimeContext: priorDeniedRuntimeContext, + }), + events, + ); + + await waitFor(() => events.some((event) => event.type === 'sandbox_boundary_request')); + const request = events.find((event) => event.type === 'sandbox_boundary_request'); + assert.ok(request?.type === 'sandbox_boundary_request'); + await backend.respondToSandboxBoundary({ requestId: request.requestId, decision: 'deny' }); + await consuming; + + assert.equal(createCalls, 1); + assert.equal(streamCalls, 2); + assert.equal(events.filter((event) => event.type === 'sandbox_boundary_request').length, 1); + await backend.dispose(); + }); + test('bounds an expansion retry after denial with one tool-free final step', async () => { const cwd = process.cwd(); const calls = [ diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index 678a0f96fe..bdfae19df3 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -129,6 +129,64 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates }); }); +test('RuntimeContinuationPlanner restores a durable denial when its RuntimeEvent ack was lost', async () => { + const sourceEvents = [ + event({ + id: 'source-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'Need network access.' }, + }), + event({ + id: 'source-terminal', + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true }, + }), + ]; + const planner = new RuntimeContinuationPlanner({ + readSourceRun: async () => runHeader('run-1'), + readImmutableRuntimePrefix: async () => immutablePrefix(sourceEvents), + readSandboxBoundaryRequests: async () => [ + { + sessionId: 'session-1', + requestId: 'boundary-1', + status: 'denied', + baseRevision: 0, + expansion: { network: { enabled: true } }, + justification: 'Need network access.', + createdAt: 1, + settledAt: 2, + turnId: 'turn-1', + runId: 'run-1', + }, + ], + newId: (() => { + const ids = ['invocation-2', 'run-2', 'turn-2', 'claim-2']; + return () => ids.shift() ?? 'unexpected-id'; + })(), + }); + + const plan = await planner.plan({ + sessionId: 'session-1', + sourceRunId: 'run-1', + currentCwd: '/workspace/repo', + sourceWorkspaceIdentity: 'workspace-1', + currentWorkspaceIdentity: 'workspace-1', + backgroundOperationsSettled: true, + availableToolNames: [], + }); + + assert.equal(plan.disposition, 'continue'); + assert.deepEqual(plan.continuation?.sandboxBoundaryNegotiationState, { + denied: true, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }); +}); + test('RuntimeContinuationPlanner parks with a stable reason when the ledger cannot be read', async () => { const planner = new RuntimeContinuationPlanner({ readSourceRun: async () => runHeader('run-1'), diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index fe67a5fffb..299ac68e5e 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -78,6 +78,11 @@ import type { HostedInteractionBridge, } from '@maka/core/backend-types'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { + createSandboxBoundaryFinalizationState, + projectSandboxBoundaryNegotiation, + type SandboxBoundaryNegotiationState, +} from '@maka/core/sandbox-boundary'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from '@maka/core/tool-mode'; @@ -1325,6 +1330,7 @@ export class AiSdkBackend implements AgentBackend { invocationId: string | undefined; hostedInteraction: HostedInteractionBridge | undefined; orchestrationMode: EffectiveOrchestration['mode']; + sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState; scope: () => TurnScope; }): ToolRuntime { const input = this.input; @@ -1345,6 +1351,9 @@ export class AiSdkBackend implements AgentBackend { ...(identity.runId ? { runId: identity.runId } : {}), orchestrationMode: identity.orchestrationMode, ...(identity.invocationId ? { invocationId: identity.invocationId } : {}), + ...(identity.sandboxBoundaryNegotiationState + ? { sandboxBoundaryNegotiationState: identity.sandboxBoundaryNegotiationState } + : {}), prepareDurableProjectionArtifact: input.prepareDurableProjectionArtifact, spawnChildSession: input.spawnChildSession, listChildAgents: input.listChildAgents, @@ -1401,6 +1410,19 @@ export class AiSdkBackend implements AgentBackend { input.orchestration ?? resolveEffectiveOrchestration(this.input.header.orchestrationMode, undefined); let scope: TurnScope; + const negotiationProjection = input.continuation?.sandboxBoundaryNegotiationState + ? { kind: 'valid' as const, state: input.continuation.sandboxBoundaryNegotiationState } + : input.continuation + ? projectSandboxBoundaryNegotiation(input.runtimeContext ?? []) + : undefined; + const sandboxBoundaryNegotiationState = + negotiationProjection === undefined + ? undefined + : negotiationProjection.kind === 'valid' + ? negotiationProjection.state + : // A continuation with an incomplete or non-canonical boundary + // projection must not guess at prior state or reopen negotiation. + createSandboxBoundaryFinalizationState(); scope = new TurnScope( input.turnId, input.runId, @@ -1411,6 +1433,7 @@ export class AiSdkBackend implements AgentBackend { invocationId: input.invocationId ?? input.runId, hostedInteraction: input.hostedInteraction, orchestrationMode: orchestration.mode, + ...(sandboxBoundaryNegotiationState ? { sandboxBoundaryNegotiationState } : {}), scope: () => scope, }), ); diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 1da029c273..a0a3d23d62 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -49,6 +49,11 @@ import type { TurnStateMessage, } from '@maka/core/session'; import { isDeepStrictEqual } from 'node:util'; +import { + createSandboxBoundaryFinalizationState, + projectSandboxBoundaryNegotiation, + type SandboxBoundaryRequest, +} from '@maka/core/sandbox-boundary'; import type { UserMessageInput } from '@maka/core/runtime-inputs'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import { @@ -760,11 +765,13 @@ export class RuntimeKernel implements RuntimeKernelLike { targetProviderStateIdentity, targetModelId: header.model, }; - const sourceEvents = await revalidateContinuationBoundary( + const revalidatedBoundary = await revalidateContinuationBoundary( continuationAuthority, continuation, admissionRoute, + (await this.deps.store.listSandboxBoundaryRequests?.(continuation.sessionId)) ?? [], ); + const sourceEvents = revalidatedBoundary.events; assertContinuationSourceUnchanged(continuation, sourceRun, sourceEvents); await this.revalidateContinuationSafety(continuation); @@ -918,6 +925,7 @@ export class RuntimeKernel implements RuntimeKernelLike { }, options.onRunStarted, () => this.revalidateContinuationSafety(continuation), + revalidatedBoundary.sandboxBoundaryNegotiationState, ); } @@ -1291,6 +1299,9 @@ export class RuntimeKernel implements RuntimeKernelLike { messageOwner?: RuntimeMessageRunIdentity, onRunStarted?: () => void | Promise, revalidateSafety?: () => Promise, + authenticatedSandboxBoundaryNegotiationState?: NonNullable< + RuntimeContinuation['sandboxBoundaryNegotiationState'] + >, ): AsyncIterable { const sessionEvents = new DeliveryAckQueue(); const { abortController, release: releaseExecutionAbort } = @@ -1345,6 +1356,11 @@ export class RuntimeKernel implements RuntimeKernelLike { throw new Error('Durable continuation is missing its start admission'); })(), ...(run.toolBoundaryProtocol ? { toolBoundaryProtocol: run.toolBoundaryProtocol } : {}), + authenticatedSandboxBoundaryNegotiationState: + authenticatedSandboxBoundaryNegotiationState ?? + (() => { + throw new Error('Durable continuation is missing sandbox negotiation state'); + })(), }); } catch (error) { releaseExecutionAbort(); @@ -2778,7 +2794,13 @@ async function revalidateContinuationBoundary( store: RuntimeContinuationAuthorityStore, continuation: RuntimeContinuation, admissionRoute: ContinuationReplayAdmissionRoute, -): Promise { + durableSandboxBoundaryRequests: readonly SandboxBoundaryRequest[] = [], +): Promise<{ + events: RuntimeEvent[]; + sandboxBoundaryNegotiationState: NonNullable< + RuntimeContinuation['sandboxBoundaryNegotiationState'] + >; +}> { if ( !continuation.boundary || !continuation.providerReplayDigest || @@ -2828,7 +2850,25 @@ async function revalidateContinuationBoundary( 'Runtime continuation replay changed after planning', ); } - return [...prefixes.at(-1)!.events]; + const negotiation = projectSandboxBoundaryNegotiation( + prefixes.flatMap((prefix) => prefix.events), + durableSandboxBoundaryRequests, + ); + const sandboxBoundaryNegotiationState = + negotiation.kind === 'valid' ? negotiation.state : createSandboxBoundaryFinalizationState(); + if ( + continuation.sandboxBoundaryNegotiationState !== undefined && + !isDeepStrictEqual( + continuation.sandboxBoundaryNegotiationState, + sandboxBoundaryNegotiationState, + ) + ) { + throw new RuntimeContinuationRevalidationError( + 'source_replay_changed', + 'Runtime continuation sandbox negotiation projection changed after planning', + ); + } + return { events: [...prefixes.at(-1)!.events], sandboxBoundaryNegotiationState }; } function continuationClaimForExecution( @@ -2944,6 +2984,9 @@ function consumeAdmittedRuntimeContinuation(input: { admissionRoute: ContinuationReplayAdmissionRoute; startAdmission: RuntimeContinuationStartAdmissionProof; toolBoundaryProtocol?: ToolBoundaryProtocol; + authenticatedSandboxBoundaryNegotiationState: NonNullable< + RuntimeContinuation['sandboxBoundaryNegotiationState'] + >; }): RuntimeContinuationMetadata { const { continuation } = input; assertRuntimeContinuationEnvelope(continuation); @@ -3008,11 +3051,21 @@ function consumeAdmittedRuntimeContinuation(input: { ) { throw new Error('Runtime continuation provider replay identity changed after admission'); } + if ( + continuation.sandboxBoundaryNegotiationState !== undefined && + !isDeepStrictEqual( + continuation.sandboxBoundaryNegotiationState, + input.authenticatedSandboxBoundaryNegotiationState, + ) + ) { + throw new Error('Runtime continuation sandbox negotiation projection is not authenticated'); + } return { sourceInvocationId: continuation.sourceInvocationId, sourceRunId: continuation.sourceRunId, sourceTurnId: continuation.sourceTurnId, sourceRuntimeEventHighWater: continuation.sourceRuntimeEventHighWater, + sandboxBoundaryNegotiationState: input.authenticatedSandboxBoundaryNegotiationState, }; } diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index 849972c74a..42b0fb11fd 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -34,6 +34,12 @@ import type { } from '@maka/core/runtime-boundary'; import type { AgentRunHeader } from '@maka/core/agent-run'; import type { ContinuationClaimStateV1 } from '@maka/core/runtime-event-store'; +import { + createSandboxBoundaryFinalizationState, + projectSandboxBoundaryNegotiation, + type SandboxBoundaryRequest, + type SandboxBoundaryNegotiationState, +} from '@maka/core/sandbox-boundary'; import { isDeepStrictEqual } from 'node:util'; import { buildContinuationReplayPlan, @@ -295,6 +301,8 @@ export interface SafeBoundaryContinuationFacts { priorRuntimeContext?: readonly RuntimeEvent[]; /** Versioned, segment-scoped provider replay built from immutable prefixes. */ continuationReplayPlan?: ContinuationReplayPlanV1; + /** Projection from the complete immutable lineage, including hidden calls. */ + sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState; expectedRuntimeEventHighWater?: number; workspaceCheckpoint?: { ref?: string; @@ -320,6 +328,8 @@ export interface RuntimeContinuation { runtimeContext: RuntimeEvent[]; /** Composite immutable ledger boundary used to build runtimeContext. */ boundary?: RuntimeBoundaryCursorV1; + /** Authenticated negotiation projection, including hidden Code Mode calls. */ + sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState; /** Identity of the exact provider-facing replay projection. */ providerReplayDigest?: RuntimeBoundaryDigest; providerProjectionVersion?: typeof PROVIDER_REPLAY_PROJECTION_VERSION; @@ -376,6 +386,8 @@ export interface RuntimeContinuationPlannerDeps { runId: string; upToEventSeq?: number; }): Promise; + /** Optional authoritative interaction log used to cover event/row crash gaps. */ + readSandboxBoundaryRequests?(sessionId: string): Promise; readContinuationClaimStateByBoundary?( boundaryDigest: RuntimeBoundaryDigest, ): Promise; @@ -387,6 +399,17 @@ export interface RuntimeContinuationPlannerDeps { newId(): string; } +function hasNonEmptySandboxBoundaryNegotiationState( + state: SandboxBoundaryNegotiationState, +): boolean { + return ( + state.denied || + state.invalidRounds > 0 || + state.unresolvedRounds > 0 || + state.finalizationRequested + ); +} + export class RuntimeContinuationPlanner { constructor(private readonly deps: RuntimeContinuationPlannerDeps) {} @@ -443,6 +466,26 @@ export class RuntimeContinuationPlanner { `continuation replay segment ${replay.segmentIndex} is not replayable: ${replay.reason}`, ); } + let durableSandboxBoundaryRequests: readonly SandboxBoundaryRequest[] = []; + try { + durableSandboxBoundaryRequests = + (await this.deps.readSandboxBoundaryRequests?.(input.sessionId)) ?? []; + } catch { + return parkedPlan( + 'continuation_authority_unavailable', + 'sandbox boundary interaction log is unavailable', + ); + } + const negotiationProjection = projectSandboxBoundaryNegotiation( + prefixes.flatMap((prefix) => prefix.events), + durableSandboxBoundaryRequests, + ); + const sandboxBoundaryNegotiationState = + negotiationProjection.kind === 'valid' + ? negotiationProjection.state + : // Do not guess from a malformed or legacy lineage. The resumed + // segment will be tool-free and can report the blocked state. + createSandboxBoundaryFinalizationState(); let durableClaimState: ContinuationClaimStateV1 | undefined; try { durableClaimState = await this.deps.readContinuationClaimStateByBoundary?.( @@ -502,6 +545,9 @@ export class RuntimeContinuationPlanner { }, continuationClaimId: this.deps.newId(), continuationReplayPlan: replay.plan, + ...(hasNonEmptySandboxBoundaryNegotiationState(sandboxBoundaryNegotiationState) + ? { sandboxBoundaryNegotiationState } + : {}), ...(input.expectedRuntimeEventHighWater !== undefined ? { expectedRuntimeEventHighWater: input.expectedRuntimeEventHighWater } : {}), @@ -1093,6 +1139,12 @@ export function buildSafeBoundaryContinuationPlan( ...(facts.priorRuntimeContext ?? []), ...sourceReplayRuntimeEvents, ]; + const sandboxBoundaryNegotiationState = facts.sandboxBoundaryNegotiationState ?? { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }; const availableToolNames = new Set(facts.availableToolNames); const unavailableToolNames = [ ...new Set( @@ -1196,6 +1248,9 @@ export function buildSafeBoundaryContinuationPlan( providerProjectionVersion: compositeReplay.providerProjectionVersion, } : {}), + ...(hasNonEmptySandboxBoundaryNegotiationState(sandboxBoundaryNegotiationState) + ? { sandboxBoundaryNegotiationState } + : {}), safetySnapshot: { workspaceIdentity: facts.currentWorkspaceIdentity, backgroundOperationsSettled: true, diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index fe98e096f7..e758b6f1ba 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -574,6 +574,7 @@ export interface SessionStore { createSandboxBoundaryRequest?( input: CreateSandboxBoundaryRequest, ): Promise; + listSandboxBoundaryRequests?(sessionId: string): Promise; listPendingSandboxBoundaryRequests?(sessionId: string): Promise; listSandboxBoundaryRestartClosures?(sessionId: string): Promise; settleSandboxBoundaryRequest?( @@ -2060,6 +2061,8 @@ export class SessionManager { } return authority.readImmutableRuntimePrefix(prefixInput); }, + readSandboxBoundaryRequests: async (targetSessionId) => + (await this.deps.store.listSandboxBoundaryRequests?.(targetSessionId)) ?? [], readContinuationClaimStateByBoundary: async (boundaryDigest) => { const authority = runtimeContinuationAuthority(this.deps.runtimeEventStore); if (!authority) throw new Error('Continuation authority is not configured'); diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 5214ffda49..5bde8dcded 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -28,6 +28,8 @@ import { type SandboxBoundaryRequest, type SandboxBoundarySettlement, type SettleSandboxBoundaryRequest, + SANDBOX_BOUNDARY_FAILURE_ROUND_LIMIT, + type SandboxBoundaryNegotiationState, } from '@maka/core/sandbox-boundary'; import { serializedByteLength } from '@maka/core/serialized-byte-length'; import { encodeToolStepProgress, ToolOutcomeUnknownError } from '@maka/core/events'; @@ -326,7 +328,6 @@ export const DEFAULT_PERMISSION_TIMEOUT_MS = 300_000; * identical *failures* is. */ export const LOOP_GATE_IDENTICAL_THRESHOLD = 3; -const SANDBOX_BOUNDARY_FAILURE_ROUND_LIMIT = 3; type SandboxBoundaryFailureKind = 'invalid' | 'unresolved'; type SandboxBoundaryFailureDetails = Extract['sandboxFailure']; @@ -426,6 +427,8 @@ export interface ToolRuntimeInput { recordToolArtifacts?: ToolArtifactRecorder; /** Optional Phase 2 T1/T2 commit boundary for hosts that persist RuntimeEvents. */ runtimeCommitSink?: RuntimeCommitSink; + /** Authenticated continuation projection; never grants execution authority. */ + sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState; /** Host-owned managed mutation admission. It may never fall back after returning a profile. */ admitManagedMutation?: (input: { readonly operationId: string; @@ -590,6 +593,13 @@ export class ToolRuntime { this.turnId = input.turnId; this.hostedInteraction = hosted; this.readExecutionBoundary = input.readExecutionBoundary; + const negotiation = input.sandboxBoundaryNegotiationState; + if (negotiation) { + this.sandboxBoundaryDenied = negotiation.denied; + this.sandboxBoundaryInvalidRounds = negotiation.invalidRounds; + this.sandboxBoundaryUnresolvedRounds = negotiation.unresolvedRounds; + this.sandboxBoundaryFinalizationRequested = negotiation.finalizationRequested; + } } async endTurn(reason: 'completed' | 'aborted' = 'completed'): Promise { @@ -1255,6 +1265,9 @@ export class ToolRuntime { }; if (admissionFailure) { const boundaryKind = boundaryAuthorityAttempt ? ('invalid' as const) : undefined; + const boundaryFailure = boundaryKind + ? ({ reason: 'invalid_boundary_declaration' } as const) + : undefined; if (boundaryKind) { this.recordSandboxBoundaryFailure( boundaryKind, @@ -1262,7 +1275,7 @@ export class ToolRuntime { sandboxBoundaryDecisionGeneration, ); } - await refuseBeforeDispatch(admissionFailure); + await refuseBeforeDispatch(admissionFailure, boundaryFailure); trace?.emit('tool', 'tool_failed', 'Tool rejected by exclusive-step admission', { toolUseId, toolName: tool.name, @@ -1270,11 +1283,14 @@ export class ToolRuntime { status: 'error', errorClass: 'ExclusiveStepConflict', }); - this.recordLoopGateOutcome(callSignature, true, boundaryKind); + this.recordLoopGateOutcome(callSignature, true, boundaryKind, boundaryFailure); return this.errorReturn(admissionFailure); } if (permissionArgsError !== undefined) { const boundaryKind = boundaryAuthorityAttempt ? ('invalid' as const) : undefined; + const boundaryFailure = boundaryKind + ? ({ reason: 'invalid_boundary_declaration' } as const) + : undefined; if (boundaryKind) { this.recordSandboxBoundaryFailure( boundaryKind, @@ -1305,7 +1321,7 @@ export class ToolRuntime { args: executionArgs, error: permissionArgsError, }); - await refuseBeforeDispatch(msg); + await refuseBeforeDispatch(msg, boundaryFailure); this.input.recordToolInvocation?.({ sessionId: this.input.sessionId, turnId, @@ -1341,7 +1357,7 @@ export class ToolRuntime { status: 'error', errorClass: 'InvalidArguments', }); - this.recordLoopGateOutcome(callSignature, true, boundaryKind); + this.recordLoopGateOutcome(callSignature, true, boundaryKind, boundaryFailure); return this.errorReturn(msg); } @@ -3320,9 +3336,16 @@ export function formatToolArgsViolationText(input: { function sandboxBoundaryFailureSignal( metadata: ReturnType, ): Extract['sandboxFailure'] { - if (metadata?.reason !== 'sandbox_boundary_required' && metadata?.reason !== 'requires_bypass') { + if ( + metadata?.reason !== 'invalid_boundary_declaration' && + metadata?.reason !== 'sandbox_boundary_required' && + metadata?.reason !== 'requires_bypass' + ) { return undefined; } + if (metadata.reason === 'invalid_boundary_declaration') { + return { reason: 'invalid_boundary_declaration' }; + } return { reason: metadata.reason, ...(metadata.requiredExpansion diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index dc62748e68..5772859cc6 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -381,6 +381,8 @@ async function createExecutionStoresForWrite sessionStore.readExecutionBoundary(sessionId)), createSandboxBoundaryRequest: (input) => run(() => sessionStore.createSandboxBoundaryRequest(input)), + listSandboxBoundaryRequests: (sessionId) => + run(() => sessionStore.listSandboxBoundaryRequests(sessionId)), readSandboxBoundaryRequest: (sessionId, requestId) => run(() => sessionStore.readSandboxBoundaryRequest(sessionId, requestId)), listPendingSandboxBoundaryRequests: (sessionId) => diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 1831eb21b9..34fd0587b9 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -390,6 +390,7 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto createSandboxBoundaryRequest( input: CreateSandboxBoundaryRequest, ): Promise; + listSandboxBoundaryRequests(sessionId: string): Promise; readSandboxBoundaryRequest( sessionId: string, requestId: string, @@ -812,6 +813,11 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readSandboxBoundaryRequest(sessionId, requestId); } + async listSandboxBoundaryRequests(sessionId: string): Promise { + await this.ensureReady(); + return this.metadata.listSandboxBoundaryRequests(sessionId); + } + async listPendingSandboxBoundaryRequests(sessionId: string): Promise { await this.ensureReady(); return this.metadata.listPendingSandboxBoundaryRequests(sessionId); diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 770147b5d3..8e21323c87 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -626,6 +626,27 @@ export class SqliteSessionMetadataStore { }); } + async listSandboxBoundaryRequests(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.transaction(() => { + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + this.ensureGenesisExecutionBoundary(record.header); + const rows = this.db + .prepare( + ` + SELECT ${SANDBOX_BOUNDARY_REQUEST_COLUMNS} + FROM sandbox_boundary_log + WHERE session_id = ? AND entry_kind = 'expansion_request' + ORDER BY created_at, entry_id + `, + ) + .all(sessionId) as unknown as SandboxBoundaryRequestRow[]; + return rows.map(decodeSandboxBoundaryRequestRow); + }); + } + async listPendingSandboxBoundaryRequests(sessionId: string): Promise { this.assertOpen(); assertSafeSessionId(sessionId); From f24b12c8a6d8663195955d4774b74425d0dd26ea Mon Sep 17 00:00:00 2001 From: testikun Date: Tue, 1 Sep 2026 09:53:27 +0800 Subject: [PATCH 02/12] fix(runtime): finish sandbox continuation merge Generated-by: OpenAI Codex --- packages/runtime/src/__tests__/runtime-continuation.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index bdfae19df3..af45a5ef37 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -92,6 +92,7 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', backgroundOperationsSettled: true, + admissionRoute: sameRouteAdmission(), availableToolNames: [], }); From 69f1341a98e2dde133cc697f002f3330e20a3652 Mon Sep 17 00:00:00 2001 From: testikun Date: Tue, 1 Sep 2026 09:54:58 +0800 Subject: [PATCH 03/12] test(runtime): align continuation fixtures with admission route Generated-by: OpenAI Codex --- packages/runtime-host/src/protocol/index.ts | 2 -- packages/runtime/src/__tests__/runtime-continuation.test.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 1ca055cae8..83883daec7 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -150,8 +150,6 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 94 as const; // 79: Every `turn.message.submit` disposition carries the exact Skill // invocation outcome. Durable queued replays may omit the previous Host // Epoch's transient queue revision; older strict peers reject either shape. -======= -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 79 as const; // 78: OAuth login targets explicit create/existing Connection entities and // returns their canonical identity. Older peers reject both closed wire shapes. // 77: LLM and tool usage-log projections carry an optional `sessionTitle` (the diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index af45a5ef37..6a2d758f98 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -92,7 +92,6 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', backgroundOperationsSettled: true, - admissionRoute: sameRouteAdmission(), availableToolNames: [], }); @@ -176,6 +175,7 @@ test('RuntimeContinuationPlanner restores a durable denial when its RuntimeEvent sourceWorkspaceIdentity: 'workspace-1', currentWorkspaceIdentity: 'workspace-1', backgroundOperationsSettled: true, + admissionRoute: sameRouteAdmission(), availableToolNames: [], }); From 4e0b7bcefaae4df0054a0a81a7f96f73abff7ce2 Mon Sep 17 00:00:00 2001 From: testikun Date: Tue, 1 Sep 2026 22:14:37 +0800 Subject: [PATCH 04/12] fix(runtime): derive sandbox negotiation at continuation admission Generated-by: OpenAI Codex --- .../__tests__/runtime-continuation.test.ts | 9 +--- packages/runtime/src/runtime-kernel.ts | 34 ++------------ packages/runtime/src/runtime-resume.ts | 44 +------------------ 3 files changed, 7 insertions(+), 80 deletions(-) diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index 6a2d758f98..453335c26c 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -129,7 +129,7 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates }); }); -test('RuntimeContinuationPlanner restores a durable denial when its RuntimeEvent ack was lost', async () => { +test('RuntimeContinuationPlanner does not carry the durable negotiation projection', async () => { const sourceEvents = [ event({ id: 'source-user', @@ -180,12 +180,7 @@ test('RuntimeContinuationPlanner restores a durable denial when its RuntimeEvent }); assert.equal(plan.disposition, 'continue'); - assert.deepEqual(plan.continuation?.sandboxBoundaryNegotiationState, { - denied: true, - invalidRounds: 0, - unresolvedRounds: 0, - finalizationRequested: false, - }); + assert.equal('sandboxBoundaryNegotiationState' in (plan.continuation ?? {}), false); }); test('RuntimeContinuationPlanner parks with a stable reason when the ledger cannot be read', async () => { diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index a0a3d23d62..79f28360e9 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -53,6 +53,7 @@ import { createSandboxBoundaryFinalizationState, projectSandboxBoundaryNegotiation, type SandboxBoundaryRequest, + type SandboxBoundaryNegotiationState, } from '@maka/core/sandbox-boundary'; import type { UserMessageInput } from '@maka/core/runtime-inputs'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -1299,9 +1300,7 @@ export class RuntimeKernel implements RuntimeKernelLike { messageOwner?: RuntimeMessageRunIdentity, onRunStarted?: () => void | Promise, revalidateSafety?: () => Promise, - authenticatedSandboxBoundaryNegotiationState?: NonNullable< - RuntimeContinuation['sandboxBoundaryNegotiationState'] - >, + authenticatedSandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState, ): AsyncIterable { const sessionEvents = new DeliveryAckQueue(); const { abortController, release: releaseExecutionAbort } = @@ -2797,9 +2796,7 @@ async function revalidateContinuationBoundary( durableSandboxBoundaryRequests: readonly SandboxBoundaryRequest[] = [], ): Promise<{ events: RuntimeEvent[]; - sandboxBoundaryNegotiationState: NonNullable< - RuntimeContinuation['sandboxBoundaryNegotiationState'] - >; + sandboxBoundaryNegotiationState: SandboxBoundaryNegotiationState; }> { if ( !continuation.boundary || @@ -2856,18 +2853,6 @@ async function revalidateContinuationBoundary( ); const sandboxBoundaryNegotiationState = negotiation.kind === 'valid' ? negotiation.state : createSandboxBoundaryFinalizationState(); - if ( - continuation.sandboxBoundaryNegotiationState !== undefined && - !isDeepStrictEqual( - continuation.sandboxBoundaryNegotiationState, - sandboxBoundaryNegotiationState, - ) - ) { - throw new RuntimeContinuationRevalidationError( - 'source_replay_changed', - 'Runtime continuation sandbox negotiation projection changed after planning', - ); - } return { events: [...prefixes.at(-1)!.events], sandboxBoundaryNegotiationState }; } @@ -2984,9 +2969,7 @@ function consumeAdmittedRuntimeContinuation(input: { admissionRoute: ContinuationReplayAdmissionRoute; startAdmission: RuntimeContinuationStartAdmissionProof; toolBoundaryProtocol?: ToolBoundaryProtocol; - authenticatedSandboxBoundaryNegotiationState: NonNullable< - RuntimeContinuation['sandboxBoundaryNegotiationState'] - >; + authenticatedSandboxBoundaryNegotiationState: SandboxBoundaryNegotiationState; }): RuntimeContinuationMetadata { const { continuation } = input; assertRuntimeContinuationEnvelope(continuation); @@ -3051,15 +3034,6 @@ function consumeAdmittedRuntimeContinuation(input: { ) { throw new Error('Runtime continuation provider replay identity changed after admission'); } - if ( - continuation.sandboxBoundaryNegotiationState !== undefined && - !isDeepStrictEqual( - continuation.sandboxBoundaryNegotiationState, - input.authenticatedSandboxBoundaryNegotiationState, - ) - ) { - throw new Error('Runtime continuation sandbox negotiation projection is not authenticated'); - } return { sourceInvocationId: continuation.sourceInvocationId, sourceRunId: continuation.sourceRunId, diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index 42b0fb11fd..5d8f1bd1d1 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -35,10 +35,7 @@ import type { import type { AgentRunHeader } from '@maka/core/agent-run'; import type { ContinuationClaimStateV1 } from '@maka/core/runtime-event-store'; import { - createSandboxBoundaryFinalizationState, - projectSandboxBoundaryNegotiation, type SandboxBoundaryRequest, - type SandboxBoundaryNegotiationState, } from '@maka/core/sandbox-boundary'; import { isDeepStrictEqual } from 'node:util'; import { @@ -301,8 +298,6 @@ export interface SafeBoundaryContinuationFacts { priorRuntimeContext?: readonly RuntimeEvent[]; /** Versioned, segment-scoped provider replay built from immutable prefixes. */ continuationReplayPlan?: ContinuationReplayPlanV1; - /** Projection from the complete immutable lineage, including hidden calls. */ - sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState; expectedRuntimeEventHighWater?: number; workspaceCheckpoint?: { ref?: string; @@ -328,8 +323,6 @@ export interface RuntimeContinuation { runtimeContext: RuntimeEvent[]; /** Composite immutable ledger boundary used to build runtimeContext. */ boundary?: RuntimeBoundaryCursorV1; - /** Authenticated negotiation projection, including hidden Code Mode calls. */ - sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState; /** Identity of the exact provider-facing replay projection. */ providerReplayDigest?: RuntimeBoundaryDigest; providerProjectionVersion?: typeof PROVIDER_REPLAY_PROJECTION_VERSION; @@ -399,17 +392,6 @@ export interface RuntimeContinuationPlannerDeps { newId(): string; } -function hasNonEmptySandboxBoundaryNegotiationState( - state: SandboxBoundaryNegotiationState, -): boolean { - return ( - state.denied || - state.invalidRounds > 0 || - state.unresolvedRounds > 0 || - state.finalizationRequested - ); -} - export class RuntimeContinuationPlanner { constructor(private readonly deps: RuntimeContinuationPlannerDeps) {} @@ -466,26 +448,14 @@ export class RuntimeContinuationPlanner { `continuation replay segment ${replay.segmentIndex} is not replayable: ${replay.reason}`, ); } - let durableSandboxBoundaryRequests: readonly SandboxBoundaryRequest[] = []; try { - durableSandboxBoundaryRequests = - (await this.deps.readSandboxBoundaryRequests?.(input.sessionId)) ?? []; + await this.deps.readSandboxBoundaryRequests?.(input.sessionId); } catch { return parkedPlan( 'continuation_authority_unavailable', 'sandbox boundary interaction log is unavailable', ); } - const negotiationProjection = projectSandboxBoundaryNegotiation( - prefixes.flatMap((prefix) => prefix.events), - durableSandboxBoundaryRequests, - ); - const sandboxBoundaryNegotiationState = - negotiationProjection.kind === 'valid' - ? negotiationProjection.state - : // Do not guess from a malformed or legacy lineage. The resumed - // segment will be tool-free and can report the blocked state. - createSandboxBoundaryFinalizationState(); let durableClaimState: ContinuationClaimStateV1 | undefined; try { durableClaimState = await this.deps.readContinuationClaimStateByBoundary?.( @@ -545,9 +515,6 @@ export class RuntimeContinuationPlanner { }, continuationClaimId: this.deps.newId(), continuationReplayPlan: replay.plan, - ...(hasNonEmptySandboxBoundaryNegotiationState(sandboxBoundaryNegotiationState) - ? { sandboxBoundaryNegotiationState } - : {}), ...(input.expectedRuntimeEventHighWater !== undefined ? { expectedRuntimeEventHighWater: input.expectedRuntimeEventHighWater } : {}), @@ -1139,12 +1106,6 @@ export function buildSafeBoundaryContinuationPlan( ...(facts.priorRuntimeContext ?? []), ...sourceReplayRuntimeEvents, ]; - const sandboxBoundaryNegotiationState = facts.sandboxBoundaryNegotiationState ?? { - denied: false, - invalidRounds: 0, - unresolvedRounds: 0, - finalizationRequested: false, - }; const availableToolNames = new Set(facts.availableToolNames); const unavailableToolNames = [ ...new Set( @@ -1248,9 +1209,6 @@ export function buildSafeBoundaryContinuationPlan( providerProjectionVersion: compositeReplay.providerProjectionVersion, } : {}), - ...(hasNonEmptySandboxBoundaryNegotiationState(sandboxBoundaryNegotiationState) - ? { sandboxBoundaryNegotiationState } - : {}), safetySnapshot: { workspaceIdentity: facts.currentWorkspaceIdentity, backgroundOperationsSettled: true, From c721467ebbe9f7e6eef722ced53ea0ef334ab019 Mon Sep 17 00:00:00 2001 From: testikun Date: Tue, 1 Sep 2026 22:33:17 +0800 Subject: [PATCH 05/12] fix(runtime): harden sandbox negotiation projection Generated-by: OpenAI Codex --- .../src/__tests__/sandbox-boundary.test.ts | 82 +++++++++++++++++ packages/core/src/sandbox-boundary.ts | 17 +++- .../runtime-continuation-crash.test.ts | 89 +++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/sandbox-boundary.test.ts b/packages/core/src/__tests__/sandbox-boundary.test.ts index 9ae0ab2ca0..547500a68d 100644 --- a/packages/core/src/__tests__/sandbox-boundary.test.ts +++ b/packages/core/src/__tests__/sandbox-boundary.test.ts @@ -502,6 +502,88 @@ describe('projectSandboxBoundaryNegotiation', () => { }); }); + test('counts nested Code Mode boundary failures once per parent tool step', () => { + const events = ['one', 'two', 'three'].flatMap((id) => { + const toolCallId = `nested-${id}`; + const refs = { toolCallId, parentToolCallId: 'code-cell-1' }; + return [ + base(`${id}-call`, { + role: 'model', + author: 'agent', + modelVisibility: 'hidden', + refs, + content: { + kind: 'function_call', + id: toolCallId, + name: 'Bash', + args: { boundary_intent: 'expand' }, + }, + }), + base(`${id}-response`, { + role: 'tool', + author: 'tool', + modelVisibility: 'hidden', + refs, + content: { + kind: 'function_response', + id: toolCallId, + name: 'Bash', + isError: true, + result: { + kind: 'text', + text: 'Sandbox boundary correction failed.', + sandboxFailure: { reason: 'invalid_boundary_declaration' }, + }, + }, + }), + ]; + }); + + assert.deepEqual(projectSandboxBoundaryNegotiation(events), { + kind: 'valid', + state: { + denied: false, + invalidRounds: 1, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }); + }); + + test('fails closed when approval reopens a denied or finalized negotiation', () => { + const deniedThenApproved = [ + request('request-1', 'boundary-1', 'tool-1'), + decision('decision-1', 'boundary-1', 'tool-1', 'denied'), + request('request-2', 'boundary-2', 'tool-2'), + decision('decision-2', 'boundary-2', 'tool-2', 'approved'), + ]; + assert.equal(projectSandboxBoundaryNegotiation(deniedThenApproved).kind, 'invalid'); + + const finalizedThenApproved = [ + ...failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-1', + 'invalid_boundary_declaration', + ), + ...failurePair( + 'invalid-2', + 'request_sandbox_boundary', + 'tool-2', + 'invalid_boundary_declaration', + ), + ...failurePair( + 'invalid-3', + 'request_sandbox_boundary', + 'tool-3', + 'invalid_boundary_declaration', + ), + request('request-1', 'boundary-1', 'tool-4'), + decision('decision-1', 'boundary-1', 'tool-4', 'approved'), + ]; + assert.equal(projectSandboxBoundaryNegotiation(finalizedThenApproved).kind, 'invalid'); + }); + test('approved requests reset prior correction state', () => { const events = [ ...failurePair( diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index e617b54777..3c7ce9a6c2 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -305,6 +305,11 @@ export function projectSandboxBoundaryNegotiation( if (decision.status === 'denied') { denied = true; } else if (decision.status === 'approved') { + if (denied || finalizationRequested) { + return invalid( + `sandbox boundary approval ${decision.requestId} reopens a closed negotiation`, + ); + } denied = false; invalidRounds = 0; unresolvedRounds = 0; @@ -331,7 +336,12 @@ export function projectSandboxBoundaryNegotiation( } const call = { name: content.name, - step: event.refs?.stepId ?? event.refs?.toolCallId ?? content.id, + step: + event.refs?.stepId ?? + event.refs?.parentToolCallId ?? + event.refs?.parentOperationId ?? + event.refs?.toolCallId ?? + content.id, }; if (toolCalls.has(content.id)) { return invalid(`tool call ${content.id} is duplicated`); @@ -430,6 +440,11 @@ export function projectSandboxBoundaryNegotiation( if (request.status === 'denied') { denied = true; } else if (request.status === 'approved') { + if (denied || finalizationRequested) { + return invalid( + `sandbox boundary approval ${request.requestId} reopens a closed negotiation`, + ); + } denied = false; invalidRounds = 0; unresolvedRounds = 0; diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index 62c815dae5..b49e3815cd 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -29,6 +29,10 @@ import { describe, test } from 'node:test'; import type { AgentRunHeader } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { + projectSandboxBoundaryNegotiation, + type SandboxBoundaryRequest, +} from '@maka/core/sandbox-boundary'; import { createSessionStore } from '@maka/storage/session-store'; import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; @@ -53,6 +57,91 @@ if (process.env[CRASH_CHILD_ENV] === '1') { await runCrashChild(); } else { describe('runtime resume phase 1 process crash harness', () => { + test('reopens and reconstructs a denied sandbox negotiation from the durable ledger', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-boundary-restart-')); + const sessionId = 'session-1'; + const runId = 'source-run'; + const turnId = 'source-turn'; + const runtimeEventStore = createCrashRuntimeStore(root); + try { + const identity = { + sessionId, + invocationId: 'source-invocation', + runId, + turnId, + }; + await runtimeEventStore.appendRuntimeEvent(sessionId, runId, { + ...identity, + id: 'boundary-request-event', + ts: 1, + partial: false, + role: 'system', + author: 'system', + refs: { toolCallId: 'boundary-tool' }, + actions: { + stateDelta: { + sandboxBoundaryRequest: { + requestId: 'boundary-1', + toolUseId: 'boundary-tool', + justification: 'Need network access.', + expansion: { network: { enabled: true } }, + }, + }, + }, + }); + await runtimeEventStore.appendRuntimeEvent(sessionId, runId, { + ...identity, + id: 'boundary-decision-event', + ts: 2, + partial: false, + role: 'system', + author: 'user', + refs: { toolCallId: 'boundary-tool' }, + actions: { + stateDelta: { + sandboxBoundaryDecision: { + requestId: 'boundary-1', + decision: 'deny', + status: 'denied', + revision: 0, + }, + }, + }, + }); + const durableRequest: SandboxBoundaryRequest = { + sessionId, + requestId: 'boundary-1', + status: 'denied', + baseRevision: 0, + expansion: { network: { enabled: true } }, + justification: 'Need network access.', + createdAt: 1, + settledAt: 2, + turnId, + runId, + }; + + runtimeEventStore.close(); + const reopened = createCrashRuntimeStore(root); + try { + const events = await reopened.readRuntimeEvents(sessionId, runId); + assert.deepEqual(projectSandboxBoundaryNegotiation(events, [durableRequest]), { + kind: 'valid', + state: { + denied: true, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }); + } finally { + reopened.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test('reopens and repairs every committed continuation prefix after SIGKILL', { timeout: CRASH_HARNESS_TIMEOUT_MS, }, async () => { From 56cb9b64721a73c279e793d4161eeea2abfa4dce Mon Sep 17 00:00:00 2001 From: testikun Date: Tue, 1 Sep 2026 22:43:05 +0800 Subject: [PATCH 06/12] test(runtime): cover sandbox projection restart boundaries Generated-by: OpenAI Codex --- .../src/__tests__/sandbox-boundary.test.ts | 29 ++++ packages/core/src/sandbox-boundary.ts | 16 +- .../runtime-continuation-crash.test.ts | 152 ++++++++++++++---- 3 files changed, 160 insertions(+), 37 deletions(-) diff --git a/packages/core/src/__tests__/sandbox-boundary.test.ts b/packages/core/src/__tests__/sandbox-boundary.test.ts index 547500a68d..15f64609ee 100644 --- a/packages/core/src/__tests__/sandbox-boundary.test.ts +++ b/packages/core/src/__tests__/sandbox-boundary.test.ts @@ -626,6 +626,22 @@ describe('projectSandboxBoundaryNegotiation', () => { text: 'Tool arguments failed validation', }; assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid'); + + const blankJustification = request('request-2', 'boundary-2', 'tool-2'); + ( + blankJustification.actions!.stateDelta!.sandboxBoundaryRequest as { justification: string } + ).justification = ' '; + assert.equal(projectSandboxBoundaryNegotiation([blankJustification]).kind, 'invalid'); + + const validRequest = request('request-3', 'boundary-3', 'tool-3'); + const malformedRevision = decision('decision-3', 'boundary-3', 'tool-3', 'denied'); + ( + malformedRevision.actions!.stateDelta!.sandboxBoundaryDecision as { revision: number } + ).revision = 1.5; + assert.equal( + projectSandboxBoundaryNegotiation([validRequest, malformedRevision]).kind, + 'invalid', + ); }); test('fails closed when boundary facts do not preserve call identity', () => { @@ -647,6 +663,19 @@ describe('projectSandboxBoundaryNegotiation', () => { (response.content as Extract).name = 'Bash'; assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid'); + + const originalRequest = request('request-2', 'boundary-2', 'tool-2'); + const mismatchedIdentityDecision = decision( + 'decision-2', + 'boundary-2', + 'tool-2', + 'denied', + ); + mismatchedIdentityDecision.invocationId = 'other-invocation'; + assert.equal( + projectSandboxBoundaryNegotiation([originalRequest, mismatchedIdentityDecision]).kind, + 'invalid', + ); }); test('fails closed when a boundary failure marker is attached to a non-boundary tool', () => { diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index 3c7ce9a6c2..44e5f74206 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -209,6 +209,7 @@ export function projectSandboxBoundaryNegotiation( const unresolvedSteps = new Set(); const requests = new Set(); const requestToolUseIds = new Map(); + const requestIdentityKeys = new Map(); const settledRequests = new Set(); const requestEvents = new Set(); const decisionEvents = new Map(); @@ -263,6 +264,7 @@ export function projectSandboxBoundaryNegotiation( !nonEmptyString(request.requestId) || !nonEmptyString(request.toolUseId) || typeof request.justification !== 'string' || + request.justification.trim().length === 0 || !validateSandboxBoundaryExpansion(request.expansion).ok || request.toolUseId !== event.refs.toolCallId ) { @@ -273,6 +275,10 @@ export function projectSandboxBoundaryNegotiation( } requests.add(request.requestId); requestToolUseIds.set(request.requestId, request.toolUseId); + requestIdentityKeys.set( + request.requestId, + `${event.sessionId}\u0000${event.invocationId}\u0000${event.runId}\u0000${event.turnId}`, + ); requestEvents.add(request.requestId); continue; } @@ -280,6 +286,11 @@ export function projectSandboxBoundaryNegotiation( isRecord(decision) && nonEmptyString(decision.requestId) ? requestToolUseIds.get(decision.requestId) : undefined; + const requestIdentityKey = + isRecord(decision) && nonEmptyString(decision.requestId) + ? requestIdentityKeys.get(decision.requestId) + : undefined; + const decisionIdentityKey = `${event.sessionId}\u0000${event.invocationId}\u0000${event.runId}\u0000${event.turnId}`; if ( event.author !== 'user' || !isRecord(decision) || @@ -289,9 +300,10 @@ export function projectSandboxBoundaryNegotiation( (decision.status !== 'approved' && decision.status !== 'denied' && decision.status !== 'conflict') || - typeof decision.revision !== 'number' || - !Number.isFinite(decision.revision) || + !Number.isSafeInteger(decision.revision) || + decision.revision < 0 || requestToolUseId === undefined || + requestIdentityKey !== decisionIdentityKey || event.refs.toolCallId !== requestToolUseId || settledRequests.has(decision.requestId) || (decision.status === 'approved' && decision.decision !== 'allow') || diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index b49e3815cd..50fee41cb6 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -27,12 +27,10 @@ import { fileURLToPath } from 'node:url'; import { describe, test } from 'node:test'; import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { BackendSendInput } from '@maka/core/backend-types'; +import type { SessionEvent } from '@maka/core/events'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { - projectSandboxBoundaryNegotiation, - type SandboxBoundaryRequest, -} from '@maka/core/sandbox-boundary'; import { createSessionStore } from '@maka/storage/session-store'; import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; @@ -57,20 +55,80 @@ if (process.env[CRASH_CHILD_ENV] === '1') { await runCrashChild(); } else { describe('runtime resume phase 1 process crash harness', () => { - test('reopens and reconstructs a denied sandbox negotiation from the durable ledger', async () => { + test('reopens the boundary log and authenticates denial through continuation admission', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-boundary-restart-')); - const sessionId = 'session-1'; - const runId = 'source-run'; - const turnId = 'source-turn'; - const runtimeEventStore = createCrashRuntimeStore(root); + let observedContinuation: BackendSendInput | undefined; + const createHarness = () => { + const store = createSessionStore(root); + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createCrashRuntimeStore(root); + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => { + const backend = new FakeBackend({ + sessionId: ctx.sessionId, + header: ctx.header, + store: ctx.store, + appendMessage: ctx.appendMessage, + }); + return { + kind: backend.kind, + sessionId: backend.sessionId, + async *send(input: BackendSendInput): AsyncIterable { + if (input.continuation) observedContinuation = input; + yield* backend.send(input); + }, + stop: () => backend.stop(), + respondToSandboxBoundary: (response) => backend.respondToSandboxBoundary(response), + respondToUserQuestion: (response) => backend.respondToUserQuestion(response), + dispose: () => backend.dispose(), + }; + }); + return { + store, + runStore, + runtimeEventStore, + manager: new SessionManager({ + store, + runStore, + runtimeEventStore, + backends, + safeBoundaryResumeEnabled: true, + inspectContinuationSafety: async () => stableSafetyObservation(), + newId: (() => { + let id = 0; + return () => `restart-id-${++id}`; + })(), + now: Date.now, + }), + }; + }; + + const first = createHarness(); try { + const session = await first.manager.createSession({ + cwd: root, + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name: 'boundary restart authentication', + }); const identity = { - sessionId, + sessionId: session.id, invocationId: 'source-invocation', - runId, - turnId, + runId: 'source-run', + turnId: 'source-turn', }; - await runtimeEventStore.appendRuntimeEvent(sessionId, runId, { + await first.runStore.createRun(sourceHeader(session.id, root)); + await first.runtimeEventStore.appendRuntimeEvent(session.id, 'source-run', { + ...identity, + id: 'source-user', + ts: 0, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'continue after a denied boundary request' }, + }); + await first.runtimeEventStore.appendRuntimeEvent(session.id, 'source-run', { ...identity, id: 'boundary-request-event', ts: 1, @@ -89,7 +147,7 @@ if (process.env[CRASH_CHILD_ENV] === '1') { }, }, }); - await runtimeEventStore.appendRuntimeEvent(sessionId, runId, { + await first.runtimeEventStore.appendRuntimeEvent(session.id, 'source-run', { ...identity, id: 'boundary-decision-event', ts: 2, @@ -108,36 +166,60 @@ if (process.env[CRASH_CHILD_ENV] === '1') { }, }, }); - const durableRequest: SandboxBoundaryRequest = { - sessionId, + await first.runtimeEventStore.appendRuntimeEvent(session.id, 'source-run', { + ...identity, + id: 'source-terminal', + ts: 2, + partial: false, + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true, stateDelta: { failureClass: 'app_restarted' } }, + }); + await first.store.createSandboxBoundaryRequest({ + sessionId: session.id, requestId: 'boundary-1', - status: 'denied', - baseRevision: 0, + turnId: 'source-turn', + runId: 'source-run', expansion: { network: { enabled: true } }, justification: 'Need network access.', - createdAt: 1, - settledAt: 2, - turnId, - runId, - }; + }); + await first.store.settleSandboxBoundaryRequest({ + sessionId: session.id, + requestId: 'boundary-1', + decision: 'deny', + }); + await first.store.close?.(); + first.runStore.close?.(); + first.runtimeEventStore.close(); - runtimeEventStore.close(); - const reopened = createCrashRuntimeStore(root); + const reopened = createHarness(); try { - const events = await reopened.readRuntimeEvents(sessionId, runId); - assert.deepEqual(projectSandboxBoundaryNegotiation(events, [durableRequest]), { - kind: 'valid', - state: { - denied: true, - invalidRounds: 0, - unresolvedRounds: 0, - finalizationRequested: false, + const durableRequests = await reopened.store.listSandboxBoundaryRequests(session.id); + assert.equal(durableRequests[0]?.status, 'denied'); + const plan = await reopened.manager.planAuthoritativeSafeBoundaryContinuation( + session.id, + { + sourceRunId: 'source-run', }, - }); + ); + assert.equal(plan.disposition, 'continue'); + if (!plan.continuation) throw new Error('expected a continuation plan'); + const resumed = reopened.manager.resumeSafeBoundaryContinuation(plan.continuation); + for await (const _event of resumed) { + // Drain the restarted continuation so backend admission completes. + } + assert.equal( + observedContinuation?.continuation?.sandboxBoundaryNegotiationState?.denied, + true, + ); } finally { - reopened.close(); + await reopened.store.close?.(); + reopened.runStore.close?.(); + reopened.runtimeEventStore.close(); } } finally { + first.runtimeEventStore.close(); await rm(root, { recursive: true, force: true }); } }); From 826a2855032e9e8e4360132b7f07d9a6ffb3b1fc Mon Sep 17 00:00:00 2001 From: testikun Date: Tue, 1 Sep 2026 22:50:06 +0800 Subject: [PATCH 07/12] fix(core): narrow sandbox decision revision Generated-by: OpenAI Codex --- packages/core/src/sandbox-boundary.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index 44e5f74206..a2b3818992 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -300,6 +300,7 @@ export function projectSandboxBoundaryNegotiation( (decision.status !== 'approved' && decision.status !== 'denied' && decision.status !== 'conflict') || + typeof decision.revision !== 'number' || !Number.isSafeInteger(decision.revision) || decision.revision < 0 || requestToolUseId === undefined || From 581719cd4b439af56773a0f08ed80bcb54a2c3a7 Mon Sep 17 00:00:00 2001 From: testikun Date: Wed, 2 Sep 2026 09:44:33 +0800 Subject: [PATCH 08/12] fix(runtime): fail closed on ambiguous sandbox settlement order Generated-by: OpenAI Codex --- .../src/__tests__/sandbox-boundary.test.ts | 71 ++++++++++++++----- packages/core/src/sandbox-boundary.ts | 22 ++++++ packages/runtime-host/src/protocol/index.ts | 10 +-- packages/runtime/src/runtime-resume.ts | 4 +- 4 files changed, 81 insertions(+), 26 deletions(-) diff --git a/packages/core/src/__tests__/sandbox-boundary.test.ts b/packages/core/src/__tests__/sandbox-boundary.test.ts index 15f64609ee..e4080601d7 100644 --- a/packages/core/src/__tests__/sandbox-boundary.test.ts +++ b/packages/core/src/__tests__/sandbox-boundary.test.ts @@ -440,6 +440,23 @@ describe('projectSandboxBoundaryNegotiation', () => { }, }); + const durableRequest = ( + requestId: string, + status: SandboxBoundaryRequest['status'], + ): SandboxBoundaryRequest => ({ + sessionId: 'session-1', + requestId, + status, + baseRevision: 0, + expansion: { network: { enabled: true } }, + justification: 'Need network access.', + createdAt: 1, + settledAt: 2, + ...(status === 'approved' ? { appliedRevision: 1 } : {}), + turnId: 'turn-1', + runId: 'run-1', + }); + const failurePair = ( id: string, toolName: string, @@ -665,12 +682,7 @@ describe('projectSandboxBoundaryNegotiation', () => { assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid'); const originalRequest = request('request-2', 'boundary-2', 'tool-2'); - const mismatchedIdentityDecision = decision( - 'decision-2', - 'boundary-2', - 'tool-2', - 'denied', - ); + const mismatchedIdentityDecision = decision('decision-2', 'boundary-2', 'tool-2', 'denied'); mismatchedIdentityDecision.invocationId = 'other-invocation'; assert.equal( projectSandboxBoundaryNegotiation([originalRequest, mismatchedIdentityDecision]).kind, @@ -715,22 +727,11 @@ describe('projectSandboxBoundaryNegotiation', () => { }); test('restores a durable denial when the RuntimeEvent ack was lost', () => { - const durableRequest: SandboxBoundaryRequest = { - sessionId: 'session-1', - requestId: 'boundary-1', - status: 'denied', - baseRevision: 0, - expansion: { network: { enabled: true } }, - justification: 'Need network access.', - createdAt: 1, - settledAt: 2, - turnId: 'turn-1', - runId: 'run-1', - }; + const durable = durableRequest('boundary-1', 'denied'); assert.deepEqual( projectSandboxBoundaryNegotiation( [base('source-event', { turnId: 'turn-1', runId: 'run-1' })], - [durableRequest], + [durable], ), { kind: 'valid', @@ -743,6 +744,38 @@ describe('projectSandboxBoundaryNegotiation', () => { }, ); }); + + test('fails closed when durable settlement order is unavailable', () => { + const approved = durableRequest('boundary-1', 'approved'); + assert.equal( + projectSandboxBoundaryNegotiation( + [ + request('request-1', 'boundary-1', 'tool-1'), + ...failurePair( + 'invalid-1', + 'request_sandbox_boundary', + 'tool-2', + 'invalid_boundary_declaration', + ), + ], + [approved], + ).kind, + 'invalid', + ); + + const denied = durableRequest('boundary-1', 'denied'); + assert.equal( + projectSandboxBoundaryNegotiation( + [ + request('request-1', 'boundary-1', 'tool-1'), + request('request-2', 'boundary-2', 'tool-2'), + decision('decision-2', 'boundary-2', 'tool-2', 'approved'), + ], + [denied], + ).kind, + 'invalid', + ); + }); }); describe('ExecutionBoundary', () => { diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index a2b3818992..5925f48fec 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -205,6 +205,11 @@ export function projectSandboxBoundaryNegotiation( let invalidRounds = 0; let unresolvedRounds = 0; let finalizationRequested = false; + // RuntimeEvent order is authoritative inside the immutable ledger, but the + // durable interaction rows live in a separate append/settlement path. When + // a settlement has no matching decision ack, there is no shared sequence + // number that can place it relative to later failures or decisions. + let hasStatefulEvent = false; const invalidSteps = new Set(); const unresolvedSteps = new Set(); const requests = new Set(); @@ -225,6 +230,7 @@ export function projectSandboxBoundaryNegotiation( reason, }); const addFailure = (kind: 'invalid' | 'unresolved', step: string): void => { + hasStatefulEvent = true; const steps = kind === 'invalid' ? invalidSteps : unresolvedSteps; if (steps.has(step)) return; steps.add(step); @@ -315,6 +321,7 @@ export function projectSandboxBoundaryNegotiation( } settledRequests.add(decision.requestId); decisionEvents.set(decision.requestId, { status: decision.status }); + hasStatefulEvent = true; if (decision.status === 'denied') { denied = true; } else if (decision.status === 'approved') { @@ -413,6 +420,7 @@ export function projectSandboxBoundaryNegotiation( } const durableById = new Map(); + const durableSettlementsWithoutDecision: SandboxBoundaryRequest[] = []; for (const request of durableRequests) { const hasProvenance = request.turnId !== undefined || request.runId !== undefined; const attributable = hasProvenance @@ -449,6 +457,7 @@ export function projectSandboxBoundaryNegotiation( return invalid(`sandbox boundary durable request ${request.requestId} is unresolved`); } if (!eventDecision) { + durableSettlementsWithoutDecision.push(request); settledRequests.add(request.requestId); if (request.status === 'denied') { denied = true; @@ -470,6 +479,19 @@ export function projectSandboxBoundaryNegotiation( } } + // Do not guess at the order between an interaction-row settlement and + // RuntimeEvent facts from the same source run. An approved durable row + // applied after the event projection could erase later failure budget, and + // a denied row could overwrite a later approval. The only safe exception is + // the ack-loss recovery case where the durable row is the sole stateful fact + // and can be applied without crossing another state transition. + if ( + durableSettlementsWithoutDecision.length > 1 || + (durableSettlementsWithoutDecision.length > 0 && hasStatefulEvent) + ) { + return invalid('sandbox boundary durable settlement ordering is unavailable'); + } + for (const requestId of requests) { if (!settledRequests.has(requestId)) { return invalid(`sandbox boundary request ${requestId} has no durable decision`); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 83883daec7..b522a4bdf0 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,14 +100,16 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 94 as const; -// 94: A failed Turn snapshot no longer carries contextBudgetExhaustedDetail; the -// retired outcome reads as context_overflow at the ledger boundary, and an older -// Host still sending the field fails a newer client's closed snapshot decode. +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 95 as const; +// 95: Session continuity carries authenticated sandbox-boundary negotiation +// facts; older Clients cannot safely preserve the new fail-closed contract. // 93: Configuration credential transfer binds proxy destinations and // Connection credentials to exact Host-owned targets before secret access. // Proxy policy and credentials commit through one recoverable Host command; // older peers can split the writes and violate the shared credential basis. +// 94: A failed Turn snapshot no longer carries contextBudgetExhaustedDetail; the +// retired outcome reads as context_overflow at the ledger boundary, and an older +// Host still sending the field fails a newer client's closed snapshot decode. // 92: Owners can query their complete pending Session Turn-request inbox. // 91: Host status publishes the live Direct peer endpoint so newly issued // connection invitations do not preserve stale startup routes. diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index 5d8f1bd1d1..9917537cde 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -34,9 +34,7 @@ import type { } from '@maka/core/runtime-boundary'; import type { AgentRunHeader } from '@maka/core/agent-run'; import type { ContinuationClaimStateV1 } from '@maka/core/runtime-event-store'; -import { - type SandboxBoundaryRequest, -} from '@maka/core/sandbox-boundary'; +import { type SandboxBoundaryRequest } from '@maka/core/sandbox-boundary'; import { isDeepStrictEqual } from 'node:util'; import { buildContinuationReplayPlan, From 181087c1dbaa8377c601310be3a1938f9f30f120 Mon Sep 17 00:00:00 2001 From: testikun Date: Wed, 2 Sep 2026 16:53:25 +0800 Subject: [PATCH 09/12] fix(runtime): require authoritative sandbox continuation state Generated-by: OpenAI Codex --- packages/core/src/backend-types.ts | 2 +- .../src/__tests__/ai-sdk-backend.test.ts | 79 ++++++++++++++++++- .../__tests__/runtime-continuation.test.ts | 42 ++++++++++ .../src/__tests__/runtime-resume.test.ts | 1 + .../src/__tests__/session-manager.test.ts | 6 ++ packages/runtime/src/ai-sdk-backend.ts | 23 ++---- packages/runtime/src/runtime-kernel.ts | 15 +++- packages/runtime/src/runtime-resume.ts | 10 ++- packages/runtime/src/session-manager.ts | 9 ++- 9 files changed, 161 insertions(+), 26 deletions(-) diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 49b8524d5e..5bf7733de7 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -57,7 +57,7 @@ export interface RuntimeContinuationMetadata { sourceTurnId: string; sourceRuntimeEventHighWater: number; /** Authenticated negotiation projection; never grants execution authority. */ - sandboxBoundaryNegotiationState?: SandboxBoundaryNegotiationState; + sandboxBoundaryNegotiationState: SandboxBoundaryNegotiationState; } export interface BackendSendInput { diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index b0d8281ef3..da12261653 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -31,7 +31,10 @@ import type { AttachmentByteReader } from '@maka/core/attachments'; import type { BackendSendInput } from '@maka/core/backend-types'; import type { LlmConnection } from '@maka/core/llm-connections'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; -import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; +import { + createManagedExecutionBoundary, + type SandboxBoundaryNegotiationState, +} from '@maka/core/sandbox-boundary'; import type { SessionHeader } from '@maka/core/session'; import type { StorageRef } from '@maka/core/events'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; @@ -1369,6 +1372,12 @@ describe('AiSdkBackend sandbox boundary convergence', () => { sourceRunId: 'run-prev', sourceTurnId, sourceRuntimeEventHighWater: sourceRuntimeContext.length, + sandboxBoundaryNegotiationState: { + denied: true, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, }, }), events, @@ -1384,6 +1393,55 @@ describe('AiSdkBackend sandbox boundary convergence', () => { await backend.dispose(); }); + test('rejects a continuation that omits its authenticated negotiation state', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const incompleteContinuation = { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-source', + sourceRuntimeEventHighWater: 1, + } as unknown as NonNullable; + + try { + await assert.rejects( + collectEvents( + backend.send({ + turnId: 'turn-continuation', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'source-user', + turnId: 'turn-source', + role: 'user', + author: 'user', + text: 'continue', + }), + ], + continuation: incompleteContinuation, + }), + [], + ), + /missing authenticated sandbox negotiation state/, + ); + assert.equal(model.doStreamCalls.length, 0); + } finally { + await backend.dispose(); + } + }); + test('starts a genuinely new user Turn with a clean negotiation state', async () => { let streamCalls = 0; const model = new MockLanguageModelV4({ @@ -2274,6 +2332,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 1, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -2328,6 +2387,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 2, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -2375,6 +2435,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 1, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, })) { events.push(event); @@ -2439,6 +2500,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 2, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -2532,6 +2594,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 3, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -2582,6 +2645,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 2, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -2650,6 +2714,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 3, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -3934,6 +3999,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-prev', sourceRuntimeEventHighWater: 4, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -4044,6 +4110,7 @@ describe('AiSdkBackend model history', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-prev', sourceRuntimeEventHighWater: 6, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -11076,6 +11143,7 @@ describe('AiSdkBackend RunTrace', () => { sourceRunId: 'run-source', sourceTurnId: 'turn-source', sourceRuntimeEventHighWater: 2, + sandboxBoundaryNegotiationState: cleanSandboxBoundaryNegotiationState(), }, }), ); @@ -16405,6 +16473,15 @@ function sameRouteReplayProvenance( }; } +function cleanSandboxBoundaryNegotiationState(): SandboxBoundaryNegotiationState { + return { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }; +} + function connection(): LlmConnection { return { slug: 'anthropic-main', diff --git a/packages/runtime/src/__tests__/runtime-continuation.test.ts b/packages/runtime/src/__tests__/runtime-continuation.test.ts index 453335c26c..aaa39b5000 100644 --- a/packages/runtime/src/__tests__/runtime-continuation.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation.test.ts @@ -81,6 +81,7 @@ test('RuntimeContinuationPlanner reads the durable source boundary and allocates const planner = new RuntimeContinuationPlanner({ readSourceRun: async () => runHeader('run-1'), readImmutableRuntimePrefix: async () => sourcePrefix, + readSandboxBoundaryRequests: async () => [], newId: () => ids.shift() ?? 'unexpected-id', }); @@ -183,6 +184,43 @@ test('RuntimeContinuationPlanner does not carry the durable negotiation projecti assert.equal('sandboxBoundaryNegotiationState' in (plan.continuation ?? {}), false); }); +test('RuntimeContinuationPlanner parks when the durable boundary reader is absent', async () => { + const sourceEvents = [ + event({ + id: 'source-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'continue' }, + }), + event({ + id: 'source-terminal', + role: 'system', + author: 'system', + status: 'failed', + actions: { endInvocation: true }, + }), + ]; + const planner = new RuntimeContinuationPlanner({ + readSourceRun: async () => runHeader('run-1'), + readImmutableRuntimePrefix: async () => immutablePrefix(sourceEvents), + newId: () => 'unused', + }); + + const plan = await planner.plan({ + sessionId: 'session-1', + sourceRunId: 'run-1', + admissionRoute: sameRouteAdmission(), + currentCwd: '/workspace/repo', + sourceWorkspaceIdentity: 'workspace-1', + currentWorkspaceIdentity: 'workspace-1', + backgroundOperationsSettled: true, + availableToolNames: [], + }); + + assert.equal(plan.disposition, 'park'); + assert.deepEqual(plan.rejectionReasons, ['continuation_authority_unavailable']); +}); + test('RuntimeContinuationPlanner parks with a stable reason when the ledger cannot be read', async () => { const planner = new RuntimeContinuationPlanner({ readSourceRun: async () => runHeader('run-1'), @@ -219,6 +257,7 @@ test('RuntimeContinuationPlanner derives terminal repair from durable run and ev content: { kind: 'text', text: 'continue' }, }), ]), + readSandboxBoundaryRequests: async () => [], newId: () => 'fresh-id', }); @@ -256,6 +295,7 @@ test('RuntimeContinuationPlanner parks when the terminal run header disagrees wi actions: { endInvocation: true }, }), ]), + readSandboxBoundaryRequests: async () => [], newId: () => 'fresh-id', }); @@ -300,6 +340,7 @@ test('RuntimeContinuationPlanner rejects immutable output after the source termi content: { kind: 'text', text: 'must invalidate the boundary' }, }), ]), + readSandboxBoundaryRequests: async () => [], newId: () => 'fresh-id', }); @@ -346,6 +387,7 @@ test('RuntimeContinuationPlanner uses canonical provider items for composite hea actions: { endInvocation: true }, }), ]), + readSandboxBoundaryRequests: async () => [], newId: () => `fresh-id-${++nextId}`, }); diff --git a/packages/runtime/src/__tests__/runtime-resume.test.ts b/packages/runtime/src/__tests__/runtime-resume.test.ts index a751d8d894..0a3a2375dc 100644 --- a/packages/runtime/src/__tests__/runtime-resume.test.ts +++ b/packages/runtime/src/__tests__/runtime-resume.test.ts @@ -307,6 +307,7 @@ describe('runtime resume phase 1 safe-boundary continuation', () => { const events = runId === 'run-2' ? childEvents : rootEvents; return immutablePrefix(upToEventSeq === undefined ? events : events.slice(0, upToEventSeq)); }, + readSandboxBoundaryRequests: async () => [], newId: (() => { let next = 2; return () => `generated-${++next}`; diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 4d77f850d0..34cdf294bc 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -13257,6 +13257,12 @@ class MemorySessionStore implements SessionStore { return request; } + async listSandboxBoundaryRequests(sessionId: string): Promise { + return [...this.sandboxBoundaryRequests.values()].filter( + (request) => request.sessionId === sessionId, + ); + } + async listPendingSandboxBoundaryRequests(sessionId: string): Promise { return [...this.sandboxBoundaryRequests.values()].filter( (request) => request.sessionId === sessionId && request.status === 'pending', diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 299ac68e5e..3d87af07f5 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -78,11 +78,7 @@ import type { HostedInteractionBridge, } from '@maka/core/backend-types'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { - createSandboxBoundaryFinalizationState, - projectSandboxBoundaryNegotiation, - type SandboxBoundaryNegotiationState, -} from '@maka/core/sandbox-boundary'; +import type { SandboxBoundaryNegotiationState } from '@maka/core/sandbox-boundary'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { UserQuestionResponse } from '@maka/core/user-question'; import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from '@maka/core/tool-mode'; @@ -1409,20 +1405,11 @@ export class AiSdkBackend implements AgentBackend { const orchestration = input.orchestration ?? resolveEffectiveOrchestration(this.input.header.orchestrationMode, undefined); + if (input.continuation && input.continuation.sandboxBoundaryNegotiationState === undefined) { + throw new Error('Runtime continuation is missing authenticated sandbox negotiation state'); + } let scope: TurnScope; - const negotiationProjection = input.continuation?.sandboxBoundaryNegotiationState - ? { kind: 'valid' as const, state: input.continuation.sandboxBoundaryNegotiationState } - : input.continuation - ? projectSandboxBoundaryNegotiation(input.runtimeContext ?? []) - : undefined; - const sandboxBoundaryNegotiationState = - negotiationProjection === undefined - ? undefined - : negotiationProjection.kind === 'valid' - ? negotiationProjection.state - : // A continuation with an incomplete or non-canonical boundary - // projection must not guess at prior state or reopen negotiation. - createSandboxBoundaryFinalizationState(); + const sandboxBoundaryNegotiationState = input.continuation?.sandboxBoundaryNegotiationState; scope = new TurnScope( input.turnId, input.runId, diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 79f28360e9..68361ee695 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -770,7 +770,7 @@ export class RuntimeKernel implements RuntimeKernelLike { continuationAuthority, continuation, admissionRoute, - (await this.deps.store.listSandboxBoundaryRequests?.(continuation.sessionId)) ?? [], + await readRequiredSandboxBoundaryRequests(this.deps.store, continuation.sessionId), ); const sourceEvents = revalidatedBoundary.events; assertContinuationSourceUnchanged(continuation, sourceRun, sourceEvents); @@ -2793,7 +2793,7 @@ async function revalidateContinuationBoundary( store: RuntimeContinuationAuthorityStore, continuation: RuntimeContinuation, admissionRoute: ContinuationReplayAdmissionRoute, - durableSandboxBoundaryRequests: readonly SandboxBoundaryRequest[] = [], + durableSandboxBoundaryRequests: readonly SandboxBoundaryRequest[], ): Promise<{ events: RuntimeEvent[]; sandboxBoundaryNegotiationState: SandboxBoundaryNegotiationState; @@ -2856,6 +2856,17 @@ async function revalidateContinuationBoundary( return { events: [...prefixes.at(-1)!.events], sandboxBoundaryNegotiationState }; } +async function readRequiredSandboxBoundaryRequests( + store: SessionStore, + sessionId: string, +): Promise { + const reader = store.listSandboxBoundaryRequests; + if (typeof reader !== 'function') { + throw new Error('Runtime continuation requires a durable sandbox boundary request reader'); + } + return reader.call(store, sessionId); +} + function continuationClaimForExecution( continuation: RuntimeContinuation, claimedAt: number, diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index 9917537cde..68206b3cbf 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -377,7 +377,7 @@ export interface RuntimeContinuationPlannerDeps { runId: string; upToEventSeq?: number; }): Promise; - /** Optional authoritative interaction log used to cover event/row crash gaps. */ + /** Authoritative interaction log used to cover event/row crash gaps; absence parks admission. */ readSandboxBoundaryRequests?(sessionId: string): Promise; readContinuationClaimStateByBoundary?( boundaryDigest: RuntimeBoundaryDigest, @@ -446,8 +446,14 @@ export class RuntimeContinuationPlanner { `continuation replay segment ${replay.segmentIndex} is not replayable: ${replay.reason}`, ); } + if (!this.deps.readSandboxBoundaryRequests) { + return parkedPlan( + 'continuation_authority_unavailable', + 'sandbox boundary interaction log is unavailable', + ); + } try { - await this.deps.readSandboxBoundaryRequests?.(input.sessionId); + await this.deps.readSandboxBoundaryRequests(input.sessionId); } catch { return parkedPlan( 'continuation_authority_unavailable', diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index e758b6f1ba..ffa5f47c0a 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -2061,8 +2061,13 @@ export class SessionManager { } return authority.readImmutableRuntimePrefix(prefixInput); }, - readSandboxBoundaryRequests: async (targetSessionId) => - (await this.deps.store.listSandboxBoundaryRequests?.(targetSessionId)) ?? [], + readSandboxBoundaryRequests: async (targetSessionId) => { + const reader = this.deps.store.listSandboxBoundaryRequests; + if (typeof reader !== 'function') { + throw new Error('sandbox boundary interaction log is unavailable'); + } + return reader.call(this.deps.store, targetSessionId); + }, readContinuationClaimStateByBoundary: async (boundaryDigest) => { const authority = runtimeContinuationAuthority(this.deps.runtimeEventStore); if (!authority) throw new Error('Continuation authority is not configured'); From 5b2de2e4907a1ba55286a47123ee2ce0ebcdc8c5 Mon Sep 17 00:00:00 2001 From: testikun Date: Wed, 2 Sep 2026 17:12:21 +0800 Subject: [PATCH 10/12] fix(runtime): close sandbox negotiation retries safely Generated-by: OpenAI Codex --- .../src/__tests__/sandbox-boundary.test.ts | 69 +++++++++++++++++-- packages/core/src/sandbox-boundary.ts | 57 +++++++-------- packages/runtime/src/runtime-kernel.ts | 17 ++++- packages/runtime/src/runtime-resume.ts | 8 --- 4 files changed, 107 insertions(+), 44 deletions(-) diff --git a/packages/core/src/__tests__/sandbox-boundary.test.ts b/packages/core/src/__tests__/sandbox-boundary.test.ts index e4080601d7..477f3ff928 100644 --- a/packages/core/src/__tests__/sandbox-boundary.test.ts +++ b/packages/core/src/__tests__/sandbox-boundary.test.ts @@ -26,7 +26,7 @@ import { decodeExecutionBoundary, executionBoundaryContains, executionBoundaryDisplayMode, - projectSandboxBoundaryNegotiation, + projectSandboxBoundaryNegotiation as projectSandboxBoundaryNegotiationImpl, type SandboxBoundaryRequest, validateSandboxBoundaryExpansion, } from '../sandbox-boundary.js'; @@ -40,6 +40,11 @@ import { type PermissionProfileManaged, } from '../permission-profile.js'; +const projectSandboxBoundaryNegotiation = ( + events: readonly RuntimeEvent[], + durableRequests: readonly SandboxBoundaryRequest[] = [], +) => projectSandboxBoundaryNegotiationImpl(events, durableRequests); + describe('executionBoundaryDisplayMode', () => { test('keeps the read-only/writable distinction the boundary carries (#1611)', () => { assert.strictEqual( @@ -512,9 +517,9 @@ describe('projectSandboxBoundaryNegotiation', () => { kind: 'valid', state: { denied: true, - invalidRounds: 1, - unresolvedRounds: 1, - finalizationRequested: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: true, }, }); }); @@ -642,7 +647,15 @@ describe('projectSandboxBoundaryNegotiation', () => { kind: 'text', text: 'Tool arguments failed validation', }; - assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid'); + assert.deepEqual(projectSandboxBoundaryNegotiation([call, response]), { + kind: 'valid', + state: { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }); const blankJustification = request('request-2', 'boundary-2', 'tool-2'); ( @@ -700,6 +713,52 @@ describe('projectSandboxBoundaryNegotiation', () => { assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid'); }); + test('counts internal invalid repair calls as boundary attempts', () => { + const events = [ + base('repair-call', { + role: 'model', + author: 'agent', + refs: { toolCallId: 'repair-tool' }, + content: { + kind: 'function_call', + id: 'repair-tool', + name: 'invalid', + args: { + tool: 'request_sandbox_boundary', + error: 'boundary was denied', + sandboxBoundaryAttempt: true, + }, + }, + }), + base('repair-response', { + role: 'tool', + author: 'tool', + refs: { toolCallId: 'repair-tool' }, + content: { + kind: 'function_response', + id: 'repair-tool', + name: 'invalid', + isError: true, + result: { + kind: 'text', + text: 'Sandbox boundary correction failed.', + sandboxFailure: { reason: 'invalid_boundary_declaration' }, + }, + }, + }), + ]; + + assert.deepEqual(projectSandboxBoundaryNegotiation(events), { + kind: 'valid', + state: { + denied: false, + invalidRounds: 1, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }); + }); + test('requests finalization after the bounded correction budget', () => { const events = [ ...failurePair( diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index 5925f48fec..061eb523a3 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -199,7 +199,7 @@ export interface SandboxBoundarySettlement { */ export function projectSandboxBoundaryNegotiation( events: readonly RuntimeEvent[], - durableRequests: readonly SandboxBoundaryRequest[] = [], + durableRequests: readonly SandboxBoundaryRequest[], ): SandboxBoundaryNegotiationProjection { let denied = false; let invalidRounds = 0; @@ -229,8 +229,25 @@ export function projectSandboxBoundaryNegotiation( kind: 'invalid', reason, }); + const applyApproval = (requestId: string): SandboxBoundaryNegotiationProjection | undefined => { + if (denied || finalizationRequested) { + return invalid(`sandbox boundary approval ${requestId} reopens a closed negotiation`); + } + denied = false; + invalidRounds = 0; + unresolvedRounds = 0; + invalidSteps.clear(); + unresolvedSteps.clear(); + finalizationRequested = false; + return undefined; + }; const addFailure = (kind: 'invalid' | 'unresolved', step: string): void => { hasStatefulEvent = true; + if (denied) { + finalizationRequested = true; + return; + } + if (finalizationRequested) return; const steps = kind === 'invalid' ? invalidSteps : unresolvedSteps; if (steps.has(step)) return; steps.add(step); @@ -325,17 +342,8 @@ export function projectSandboxBoundaryNegotiation( if (decision.status === 'denied') { denied = true; } else if (decision.status === 'approved') { - if (denied || finalizationRequested) { - return invalid( - `sandbox boundary approval ${decision.requestId} reopens a closed negotiation`, - ); - } - denied = false; - invalidRounds = 0; - unresolvedRounds = 0; - invalidSteps.clear(); - unresolvedSteps.clear(); - finalizationRequested = false; + const approvalError = applyApproval(decision.requestId); + if (approvalError) return approvalError; } else { addFailure('unresolved', `request:${decision.requestId}`); } @@ -398,11 +406,6 @@ export function projectSandboxBoundaryNegotiation( } const step = event.refs?.stepId ?? boundaryCall?.step ?? call.step; addFailure(failure, step); - } else if (content.isError === true && boundaryCall) { - // Older ledgers did not carry a structured invalid-boundary marker. Do - // not infer a count from their text; the relevant lineage is incomplete - // and must be rejected closed by the continuation caller. - return invalid(`sandbox boundary call ${content.id} lacks a canonical failure marker`); } if (boundaryCall) { if (boundaryResponses.has(content.id)) { @@ -462,17 +465,8 @@ export function projectSandboxBoundaryNegotiation( if (request.status === 'denied') { denied = true; } else if (request.status === 'approved') { - if (denied || finalizationRequested) { - return invalid( - `sandbox boundary approval ${request.requestId} reopens a closed negotiation`, - ); - } - denied = false; - invalidRounds = 0; - unresolvedRounds = 0; - invalidSteps.clear(); - unresolvedSteps.clear(); - finalizationRequested = false; + const approvalError = applyApproval(request.requestId); + if (approvalError) return approvalError; } else { addFailure('unresolved', `request:${request.requestId}`); } @@ -510,6 +504,13 @@ export function projectSandboxBoundaryNegotiation( function isBoundaryAuthorityCall(toolName: string, args: unknown): boolean { if (toolName === 'request_sandbox_boundary') return true; + if ( + toolName === 'invalid' && + isRecord(args) && + args.sandboxBoundaryAttempt === true + ) { + return true; + } if (toolName !== 'Bash' || !isRecord(args)) return false; return args.boundary_intent !== undefined && args.boundary_intent !== 'current'; } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 68361ee695..de9f0546fe 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -2847,12 +2847,23 @@ async function revalidateContinuationBoundary( 'Runtime continuation replay changed after planning', ); } + const trimmedSuffixEventIds = new Set( + replay.plan.segments.flatMap((segment) => segment.trimmedSuffixEventIds), + ); + const negotiationEvents = prefixes + .flatMap((prefix) => prefix.events) + .filter((event) => !trimmedSuffixEventIds.has(event.id)); const negotiation = projectSandboxBoundaryNegotiation( - prefixes.flatMap((prefix) => prefix.events), + negotiationEvents, durableSandboxBoundaryRequests, ); - const sandboxBoundaryNegotiationState = - negotiation.kind === 'valid' ? negotiation.state : createSandboxBoundaryFinalizationState(); + if (negotiation.kind !== 'valid') { + throw new RuntimeContinuationRevalidationError( + 'source_replay_changed', + `Runtime continuation sandbox negotiation is invalid: ${negotiation.reason}`, + ); + } + const sandboxBoundaryNegotiationState = negotiation.state; return { events: [...prefixes.at(-1)!.events], sandboxBoundaryNegotiationState }; } diff --git a/packages/runtime/src/runtime-resume.ts b/packages/runtime/src/runtime-resume.ts index 68206b3cbf..63cc199da8 100644 --- a/packages/runtime/src/runtime-resume.ts +++ b/packages/runtime/src/runtime-resume.ts @@ -452,14 +452,6 @@ export class RuntimeContinuationPlanner { 'sandbox boundary interaction log is unavailable', ); } - try { - await this.deps.readSandboxBoundaryRequests(input.sessionId); - } catch { - return parkedPlan( - 'continuation_authority_unavailable', - 'sandbox boundary interaction log is unavailable', - ); - } let durableClaimState: ContinuationClaimStateV1 | undefined; try { durableClaimState = await this.deps.readContinuationClaimStateByBoundary?.( From 9b3c8104f43d51af9f9d9a22eff636bea6cbd112 Mon Sep 17 00:00:00 2001 From: testikun Date: Wed, 2 Sep 2026 17:17:36 +0800 Subject: [PATCH 11/12] style(core): apply biome formatting Generated-by: OpenAI Codex --- packages/core/src/sandbox-boundary.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index 061eb523a3..00935ccc25 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -504,11 +504,7 @@ export function projectSandboxBoundaryNegotiation( function isBoundaryAuthorityCall(toolName: string, args: unknown): boolean { if (toolName === 'request_sandbox_boundary') return true; - if ( - toolName === 'invalid' && - isRecord(args) && - args.sandboxBoundaryAttempt === true - ) { + if (toolName === 'invalid' && isRecord(args) && args.sandboxBoundaryAttempt === true) { return true; } if (toolName !== 'Bash' || !isRecord(args)) return false; From 1ea1942ed6aa401caf6dadb9ba1199321196f586 Mon Sep 17 00:00:00 2001 From: testikun Date: Wed, 2 Sep 2026 18:22:06 +0800 Subject: [PATCH 12/12] fix(core): ignore host restart sandbox closures --- .../src/__tests__/sandbox-boundary.test.ts | 42 +++++++++++++++++++ packages/core/src/sandbox-boundary.ts | 9 +++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/sandbox-boundary.test.ts b/packages/core/src/__tests__/sandbox-boundary.test.ts index 477f3ff928..9207cee161 100644 --- a/packages/core/src/__tests__/sandbox-boundary.test.ts +++ b/packages/core/src/__tests__/sandbox-boundary.test.ts @@ -835,6 +835,48 @@ describe('projectSandboxBoundaryNegotiation', () => { 'invalid', ); }); + + test('does not treat a host-restart closure as a user denial', () => { + const restartClosed = { + ...durableRequest('boundary-1', 'denied'), + outcomeReason: 'host_restarted', + }; + assert.deepEqual( + projectSandboxBoundaryNegotiation( + [request('request-1', 'boundary-1', 'tool-1')], + [restartClosed], + ), + { + kind: 'valid', + state: { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }, + ); + + assert.deepEqual( + projectSandboxBoundaryNegotiation( + [ + request('request-1', 'boundary-1', 'tool-1'), + decision('decision-1', 'boundary-1', 'tool-1', 'approved'), + request('request-2', 'boundary-2', 'tool-2'), + ], + [{ ...restartClosed, requestId: 'boundary-2' }], + ), + { + kind: 'valid', + state: { + denied: false, + invalidRounds: 0, + unresolvedRounds: 0, + finalizationRequested: false, + }, + }, + ); + }); }); describe('ExecutionBoundary', () => { diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index 00935ccc25..5447af369c 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -460,8 +460,15 @@ export function projectSandboxBoundaryNegotiation( return invalid(`sandbox boundary durable request ${request.requestId} is unresolved`); } if (!eventDecision) { - durableSettlementsWithoutDecision.push(request); + // A host-restart closure is a lifecycle cleanup, not a user decision. + // It closes the old request id, but must not turn a request the user + // never saw into a permanent denial or participate in the ordering + // guard as if it were an approval/denial transition. settledRequests.add(request.requestId); + if (isSandboxBoundaryRestartClosure(request)) { + continue; + } + durableSettlementsWithoutDecision.push(request); if (request.status === 'denied') { denied = true; } else if (request.status === 'approved') {