Skip to content

Commit abcfda7

Browse files
author
testikun
committed
fix(runtime): fail closed on ambiguous sandbox settlement order
Generated-by: OpenAI Codex
1 parent a2799db commit abcfda7

4 files changed

Lines changed: 78 additions & 23 deletions

File tree

packages/core/src/__tests__/sandbox-boundary.test.ts

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,23 @@ describe('projectSandboxBoundaryNegotiation', () => {
440440
},
441441
});
442442

443+
const durableRequest = (
444+
requestId: string,
445+
status: SandboxBoundaryRequest['status'],
446+
): SandboxBoundaryRequest => ({
447+
sessionId: 'session-1',
448+
requestId,
449+
status,
450+
baseRevision: 0,
451+
expansion: { network: { enabled: true } },
452+
justification: 'Need network access.',
453+
createdAt: 1,
454+
settledAt: 2,
455+
...(status === 'approved' ? { appliedRevision: 1 } : {}),
456+
turnId: 'turn-1',
457+
runId: 'run-1',
458+
});
459+
443460
const failurePair = (
444461
id: string,
445462
toolName: string,
@@ -665,12 +682,7 @@ describe('projectSandboxBoundaryNegotiation', () => {
665682
assert.equal(projectSandboxBoundaryNegotiation([call, response]).kind, 'invalid');
666683

667684
const originalRequest = request('request-2', 'boundary-2', 'tool-2');
668-
const mismatchedIdentityDecision = decision(
669-
'decision-2',
670-
'boundary-2',
671-
'tool-2',
672-
'denied',
673-
);
685+
const mismatchedIdentityDecision = decision('decision-2', 'boundary-2', 'tool-2', 'denied');
674686
mismatchedIdentityDecision.invocationId = 'other-invocation';
675687
assert.equal(
676688
projectSandboxBoundaryNegotiation([originalRequest, mismatchedIdentityDecision]).kind,
@@ -715,22 +727,11 @@ describe('projectSandboxBoundaryNegotiation', () => {
715727
});
716728

717729
test('restores a durable denial when the RuntimeEvent ack was lost', () => {
718-
const durableRequest: SandboxBoundaryRequest = {
719-
sessionId: 'session-1',
720-
requestId: 'boundary-1',
721-
status: 'denied',
722-
baseRevision: 0,
723-
expansion: { network: { enabled: true } },
724-
justification: 'Need network access.',
725-
createdAt: 1,
726-
settledAt: 2,
727-
turnId: 'turn-1',
728-
runId: 'run-1',
729-
};
730+
const durable = durableRequest('boundary-1', 'denied');
730731
assert.deepEqual(
731732
projectSandboxBoundaryNegotiation(
732733
[base('source-event', { turnId: 'turn-1', runId: 'run-1' })],
733-
[durableRequest],
734+
[durable],
734735
),
735736
{
736737
kind: 'valid',
@@ -743,6 +744,38 @@ describe('projectSandboxBoundaryNegotiation', () => {
743744
},
744745
);
745746
});
747+
748+
test('fails closed when durable settlement order is unavailable', () => {
749+
const approved = durableRequest('boundary-1', 'approved');
750+
assert.equal(
751+
projectSandboxBoundaryNegotiation(
752+
[
753+
request('request-1', 'boundary-1', 'tool-1'),
754+
...failurePair(
755+
'invalid-1',
756+
'request_sandbox_boundary',
757+
'tool-2',
758+
'invalid_boundary_declaration',
759+
),
760+
],
761+
[approved],
762+
).kind,
763+
'invalid',
764+
);
765+
766+
const denied = durableRequest('boundary-1', 'denied');
767+
assert.equal(
768+
projectSandboxBoundaryNegotiation(
769+
[
770+
request('request-1', 'boundary-1', 'tool-1'),
771+
request('request-2', 'boundary-2', 'tool-2'),
772+
decision('decision-2', 'boundary-2', 'tool-2', 'approved'),
773+
],
774+
[denied],
775+
).kind,
776+
'invalid',
777+
);
778+
});
746779
});
747780

748781
describe('ExecutionBoundary', () => {

packages/core/src/sandbox-boundary.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ export function projectSandboxBoundaryNegotiation(
205205
let invalidRounds = 0;
206206
let unresolvedRounds = 0;
207207
let finalizationRequested = false;
208+
// RuntimeEvent order is authoritative inside the immutable ledger, but the
209+
// durable interaction rows live in a separate append/settlement path. When
210+
// a settlement has no matching decision ack, there is no shared sequence
211+
// number that can place it relative to later failures or decisions.
212+
let hasStatefulEvent = false;
208213
const invalidSteps = new Set<string>();
209214
const unresolvedSteps = new Set<string>();
210215
const requests = new Set<string>();
@@ -225,6 +230,7 @@ export function projectSandboxBoundaryNegotiation(
225230
reason,
226231
});
227232
const addFailure = (kind: 'invalid' | 'unresolved', step: string): void => {
233+
hasStatefulEvent = true;
228234
const steps = kind === 'invalid' ? invalidSteps : unresolvedSteps;
229235
if (steps.has(step)) return;
230236
steps.add(step);
@@ -315,6 +321,7 @@ export function projectSandboxBoundaryNegotiation(
315321
}
316322
settledRequests.add(decision.requestId);
317323
decisionEvents.set(decision.requestId, { status: decision.status });
324+
hasStatefulEvent = true;
318325
if (decision.status === 'denied') {
319326
denied = true;
320327
} else if (decision.status === 'approved') {
@@ -413,6 +420,7 @@ export function projectSandboxBoundaryNegotiation(
413420
}
414421

415422
const durableById = new Map<string, SandboxBoundaryRequest>();
423+
const durableSettlementsWithoutDecision: SandboxBoundaryRequest[] = [];
416424
for (const request of durableRequests) {
417425
const hasProvenance = request.turnId !== undefined || request.runId !== undefined;
418426
const attributable = hasProvenance
@@ -449,6 +457,7 @@ export function projectSandboxBoundaryNegotiation(
449457
return invalid(`sandbox boundary durable request ${request.requestId} is unresolved`);
450458
}
451459
if (!eventDecision) {
460+
durableSettlementsWithoutDecision.push(request);
452461
settledRequests.add(request.requestId);
453462
if (request.status === 'denied') {
454463
denied = true;
@@ -470,6 +479,19 @@ export function projectSandboxBoundaryNegotiation(
470479
}
471480
}
472481

482+
// Do not guess at the order between an interaction-row settlement and
483+
// RuntimeEvent facts from the same source run. An approved durable row
484+
// applied after the event projection could erase later failure budget, and
485+
// a denied row could overwrite a later approval. The only safe exception is
486+
// the ack-loss recovery case where the durable row is the sole stateful fact
487+
// and can be applied without crossing another state transition.
488+
if (
489+
durableSettlementsWithoutDecision.length > 1 ||
490+
(durableSettlementsWithoutDecision.length > 0 && hasStatefulEvent)
491+
) {
492+
return invalid('sandbox boundary durable settlement ordering is unavailable');
493+
}
494+
473495
for (const requestId of requests) {
474496
if (!settledRequests.has(requestId)) {
475497
return invalid(`sandbox boundary request ${requestId} has no durable decision`);

packages/runtime-host/src/protocol/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,13 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
100100
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
101101
// Increment when the same protocol version no longer guarantees safe Client-Host
102102
// interoperability. Mismatches are rejected before domain commands are admitted.
103-
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 93 as const;
103+
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 94 as const;
104104
// 93: Configuration credential transfer binds proxy destinations and
105105
// Connection credentials to exact Host-owned targets before secret access.
106106
// Proxy policy and credentials commit through one recoverable Host command;
107107
// older peers can split the writes and violate the shared credential basis.
108+
// 94: Session continuity carries authenticated sandbox-boundary negotiation
109+
// facts; older Clients cannot safely preserve the new fail-closed contract.
108110
// 92: Owners can query their complete pending Session Turn-request inbox.
109111
// 91: Host status publishes the live Direct peer endpoint so newly issued
110112
// connection invitations do not preserve stale startup routes.

packages/runtime/src/runtime-resume.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ import type {
3434
} from '@maka/core/runtime-boundary';
3535
import type { AgentRunHeader } from '@maka/core/agent-run';
3636
import type { ContinuationClaimStateV1 } from '@maka/core/runtime-event-store';
37-
import {
38-
type SandboxBoundaryRequest,
39-
} from '@maka/core/sandbox-boundary';
37+
import { type SandboxBoundaryRequest } from '@maka/core/sandbox-boundary';
4038
import { isDeepStrictEqual } from 'node:util';
4139
import {
4240
buildContinuationReplayPlan,

0 commit comments

Comments
 (0)