Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/core/src/__tests__/runtime-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import assert from 'node:assert/strict';
import { expect } from './test-helpers.js';
import {
decodeMessageContent,
isCanonicalStorageRef,
messageContentsEqual,
normalizeMessageContent,
type SessionEvent,
Expand Down Expand Up @@ -173,6 +174,33 @@ describe('continuation-start protocol', () => {
});

describe('RuntimeEvent content variants', () => {
test('recognizes canonical durable Session context references', () => {
assert.equal(
isCanonicalStorageRef({
kind: 'session_context',
sessionId: 'session-1',
refId: 'read-image:owner-1',
}),
true,
);
assert.equal(
isCanonicalStorageRef({
kind: 'session_context',
sessionId: 'session-1',
refId: '😀'.repeat(512),
}),
true,
);
for (const ref of [
{ kind: 'session_context', sessionId: 'bad/session', refId: 'ref-1' },
{ kind: 'session_context', sessionId: 'session-1', refId: '' },
{ kind: 'session_context', sessionId: 'session-1', refId: '😀'.repeat(513) },
{ kind: 'session_context', sessionId: 'session-1', refId: 'ref-1', extra: true },
]) {
assert.equal(isCanonicalStorageRef(ref), false);
}
});

test('preserves sent inline references as message identity', () => {
const inlineReferences = [
{ kind: 'skill', value: '/skill:writer', label: 'Writer', start: 8 },
Expand Down Expand Up @@ -324,6 +352,17 @@ describe('RuntimeEvent content variants', () => {
bytes: 1,
ref: { kind: 'workspace_file' as const, relativePath: 'a.ts' },
},
{
kind: 'image' as const,
name: 'snapshot.png',
mimeType: 'image/png',
bytes: 8,
ref: {
kind: 'session_context' as const,
sessionId: 'session-1',
refId: 'read-image:owner-1',
},
},
];
const quotes = [
{ text: 'first', label: 'Assistant', sourceTurnId: 'turn-1' },
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/context-offload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export interface SessionContextRef {
readonly refId: string;
}

/** Maximum Unicode code points accepted for durable context-offload identities. */
export const CONTEXT_OFFLOAD_ID_MAX_CODE_POINTS = 512;

export type ContextOffloadOwner =
| {
readonly kind: 'read_image_snapshot';
Expand Down Expand Up @@ -115,15 +118,17 @@ export class ReadImageSnapshotStoreError extends Error {
}
}

export interface ReadImageSnapshotStore {
export interface ReadImageSnapshotReader {
read(input: SessionContextRef): Promise<ContextOffloadReadResult>;
}

export interface ReadImageSnapshotStore extends ReadImageSnapshotReader {
snapshot(input: {
/** Stable identity of the Read result within its Session. */
readonly ownerId: string;
readonly bytes: Uint8Array;
readonly mimeType: string;
}): Promise<SessionContextRef>;

read(input: SessionContextRef): Promise<ContextOffloadReadResult>;
}

/**
Expand Down
26 changes: 25 additions & 1 deletion packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
*/

import * as nodeCrypto from 'node:crypto';
import { CONTEXT_OFFLOAD_ID_MAX_CODE_POINTS, type SessionContextRef } from './context-offload.js';
import type {
AdditionalPermissionRequest,
PermissionMode,
Expand Down Expand Up @@ -74,6 +75,7 @@ type TerminalToolResultStatus = Exclude<ShellRunTerminalStatus, 'orphaned'>;
// ============================================================================

export type StorageRef =
| SessionContextRef

@zhiiw zhiiw Aug 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Adding session_context to the global StorageRef union makes it flow through conversation copy, but rewriteStorageRef only rewrites session_file. A copied message therefore retains the source sessionId, and the target Session later fails to hydrate it with session_mismatch; Session retirement also does not release these references. Please add lifecycle handling or fail these operations explicitly until that owner exists.

@likun666661 likun666661 Aug 29, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 91817d6 for the reader-only slice. Exact conversation copy now rejects source-owned session_context refs, and Session removal checks context usage under admission and fails before the tombstone if any refs exist or if the Store is unavailable. The stacked writer PR will replace these guards with copy and retire lifecycle handling.

| { kind: 'session_file'; sessionId: string; relativePath: string }
| { kind: 'workspace_file'; relativePath: string }
| { kind: 'external_file'; absolutePath: string };
Expand Down Expand Up @@ -154,6 +156,9 @@ const SESSION_FILE_REF_SHAPE = defineObjectShape<Extract<StorageRef, { kind: 'se
['kind', 'sessionId', 'relativePath'],
[],
);
const SESSION_CONTEXT_REF_SHAPE = defineObjectShape<
Extract<StorageRef, { kind: 'session_context' }>
>()(['kind', 'sessionId', 'refId'], []);
const WORKSPACE_FILE_REF_SHAPE = defineObjectShape<
Extract<StorageRef, { kind: 'workspace_file' }>
>()(['kind', 'relativePath'], []);
Expand Down Expand Up @@ -328,6 +333,13 @@ export function isStorageRef(value: unknown): value is StorageRef {
typeof value.relativePath === 'string'
);
}
if (value.kind === 'session_context') {
return (
hasExactShape(value, SESSION_CONTEXT_REF_SHAPE) &&
typeof value.sessionId === 'string' &&
typeof value.refId === 'string'
);
}
if (value.kind === 'workspace_file') {
return hasExactShape(value, WORKSPACE_FILE_REF_SHAPE) && typeof value.relativePath === 'string';
}
Expand All @@ -341,9 +353,15 @@ export function isStorageRef(value: unknown): value is StorageRef {
export function isCanonicalStorageRef(value: unknown): value is StorageRef {
if (!isStorageRef(value)) return false;
if (value.kind === 'external_file') return isCanonicalAbsolutePath(value.absolutePath);
if (value.kind === 'session_file' && !/^[A-Za-z0-9_-]{1,128}$/.test(value.sessionId)) {
if (
(value.kind === 'session_file' || value.kind === 'session_context') &&
!/^[A-Za-z0-9_-]{1,128}$/.test(value.sessionId)
) {
return false;
}
if (value.kind === 'session_context') {
return value.refId.length > 0 && [...value.refId].length <= CONTEXT_OFFLOAD_ID_MAX_CODE_POINTS;
}
return isCanonicalRelativePath(value.relativePath);
}

Expand Down Expand Up @@ -445,6 +463,12 @@ function attachmentRefsEqual(left: AttachmentRef, right: AttachmentRef): boolean
return false;
}
switch (left.ref.kind) {
case 'session_context':
return (
right.ref.kind === 'session_context' &&
left.ref.sessionId === right.ref.sessionId &&
left.ref.refId === right.ref.refId
);
case 'session_file':
return (
right.ref.kind === 'session_file' &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"epoch": 67,
"files": [
"packages/runtime-host/src/protocol/hosted-execution.ts",
"packages/runtime-host/src/protocol/message.ts",
"packages/runtime-host/src/protocol/turn.ts"
],
"reason": "Widens Host result decoding for a durable context reference variant while client admission rejects Host-owned refs; this reader-only slice emits no new wire frames"
}
22 changes: 22 additions & 0 deletions packages/runtime-host/src/__tests__/execution-composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {

const require = createRequire(import.meta.url);
const FAKE_CONNECTION_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
const CONTEXT_OFFLOAD_DATABASE_NAME = 'context-offload.sqlite';

test('filesystem worker follows the candidate executable runtime', () => {
assert.equal(runtimeHostFilesystemWorkerRuntime({ electron: '43.1.1' }), 'electron');
Expand Down Expand Up @@ -97,6 +98,27 @@ test('production composition owns the long-term memory database lifecycle', asyn
});
});

test('production composition reaches Ready when the optional context reader cannot open', async () => {
await withCompositionRoot(async ({ root, owner }) => {
await mkdir(join(root, CONTEXT_OFFLOAD_DATABASE_NAME));
const originalConsoleError = console.error;
const diagnostics: string[] = [];
console.error = (...values: unknown[]) => diagnostics.push(values.map(String).join(' '));
let composition: Awaited<ReturnType<typeof createExecutionRuntimeHostComposition>> | undefined;
try {
composition = await createExecutionRuntimeHostComposition(compositionContext(owner));
assert.equal(composition.workspaceExecution.state, 'ready');
assert.equal(
diagnostics.some((message) => message.includes('optional context-offload reader')),
true,
);
} finally {
console.error = originalConsoleError;
await composition?.close();
}
});
});

test('production composition closes long-term memory after a later startup failure', async () => {
await withCompositionRoot(async ({ root, owner }) => {
const stores = await openInteractiveExecutionStoresForWrite(owner.lease);
Expand Down
16 changes: 16 additions & 0 deletions packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
TURN_MESSAGE_QUOTE_MAX_COUNT,
TURN_MESSAGE_QUOTE_TEXT_MAX_LENGTH,
TURN_FAILURE_MESSAGE_MAX_BYTES,
decodeMessageContent,
TURN_SKILL_ID_MAX_COUNT,
TURN_SKILL_ID_MAX_LENGTH,
} from '../protocol/turn.js';
Expand Down Expand Up @@ -1546,6 +1547,18 @@ describe('Runtime Host bootstrap protocol', () => {
),
}),
);
const contextContent = {
text: 'valid context ref',
attachments: [
attachmentRef({
kind: 'session_context' as const,
sessionId: 'session-1',
refId: 'read-image:owner-1',
}),
],
};
assert.throws(() => submit(contextContent), isInvalidFrame);
assert.deepEqual(decodeMessageContent(contextContent), contextContent);
assert.throws(
() =>
submit({
Expand All @@ -1566,6 +1579,8 @@ describe('Runtime Host bootstrap protocol', () => {
{ ...attachmentRef({ kind: 'workspace_file', relativePath: 'a.ts' }), mimeType: '' },
attachmentRef({ kind: 'workspace_file', relativePath: 'a'.repeat(4097) }),
attachmentRef({ kind: 'session_file', sessionId: 'bad/id', relativePath: 'a.ts' }),
attachmentRef({ kind: 'session_context', sessionId: 'session-1', refId: '' }),
attachmentRef({ kind: 'session_context', sessionId: 'session-1', refId: 'a'.repeat(513) }),
attachmentRef({ kind: 'workspace_file', relativePath: '../secret' }),
attachmentRef({ kind: 'workspace_file', relativePath: 'src//a.ts' }),
attachmentRef({ kind: 'external_file', absolutePath: 'relative/a.ts' }),
Expand Down Expand Up @@ -2046,6 +2061,7 @@ function retractedMessage(text = 'do this next') {
function attachmentRef(
ref:
| { kind: 'session_file'; sessionId: string; relativePath: string }
| { kind: 'session_context'; sessionId: string; refId: string }
| { kind: 'workspace_file'; relativePath: string }
| { kind: 'external_file'; absolutePath: string },
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ describe('Host Session retirement coordinator', () => {
'parent retirement cleanup did not converge',
);
assert.deepEqual(new Set(harness.actions.purgedArtifacts), new Set(harness.familyIds));
assert.deepEqual(new Set(harness.actions.checkedContext), new Set(harness.familyIds));
});
});

Expand Down Expand Up @@ -1008,6 +1009,7 @@ interface RetirementActions {
readonly retiredCapabilities: string[];
readonly retiredMessages: string[];
readonly purgedArtifacts: string[];
readonly checkedContext: string[];
readonly purgedTasks: string[];
readonly purgedOperationalState: string[];
readonly purgedAgentGraphs: string[];
Expand Down Expand Up @@ -1046,6 +1048,7 @@ async function withHarness(
retiredCapabilities: [],
retiredMessages: [],
purgedArtifacts: [],
checkedContext: [],
purgedTasks: [],
purgedOperationalState: [],
purgedAgentGraphs: [],
Expand Down Expand Up @@ -1211,6 +1214,9 @@ async function withHarness(
actions.purgedTasks.push(sessionId);
},
},
assertNoContextOffloadReferences: async (sessionIds) => {
actions.checkedContext.push(...sessionIds);
},
purgeOperationalState: async (sessionId) => {
actions.purgedOperationalState.push(sessionId);
},
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-host/src/protocol/hosted-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
import { decodeSessionCreateInput, type SessionCreateInput } from './session-catalog.js';
import { decodeMessageContent } from './turn.js';
import { decodeMessageAdmissionContent, decodeMessageContent } from './turn.js';

const ERRORS = [
'host_not_ready',
Expand Down Expand Up @@ -124,7 +124,7 @@ export function decodeHostedExecutionStartInput(value: unknown): HostedExecution
return {
executionId,
session,
content: decodeMessageContent(input.content),
content: decodeMessageAdmissionContent(input.content),
...(input.maxSteps === undefined
? {}
: { maxSteps: requirePositiveCount(input.maxSteps, 'maxSteps') }),
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime-host/src/protocol/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { defineOperation } from './operation-spec.js';
import {
decodeMessageContent,
decodeMessageAdmissionContent,
decodeSkillIds,
decodeTurnOrchestration,
decodeTurnSnapshot,
Expand Down Expand Up @@ -328,7 +329,7 @@ function decodeTurnMessageSubmitInput(value: unknown): TurnMessageSubmitInput {
originHostEpoch: requireId(record.originHostEpoch, 'originHostEpoch'),
sessionId: requireEntityId(record.sessionId, 'sessionId'),
messageId: requireEntityId(record.messageId, 'messageId'),
content: decodeMessageContent(record.content, skillIds.length > 0),
content: decodeMessageAdmissionContent(record.content, skillIds.length > 0),
placement,
...(skillIds.length > 0 ? { skillIds } : {}),
...(turnOrchestration !== undefined ? { turnOrchestration } : {}),
Expand Down
24 changes: 19 additions & 5 deletions packages/runtime-host/src/protocol/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ function decodeTurnStartInput(value: unknown): TurnStartInput {
return {
sessionId: requireEntityId(record.sessionId, 'sessionId'),
turnId: requireEntityId(record.turnId, 'turnId'),
content: decodeMessageContent(record.content, skillIds.length > 0),
content: decodeMessageAdmissionContent(record.content, skillIds.length > 0),
...(skillIds.length > 0 ? { skillIds } : {}),
...(record.turnOrchestration !== undefined
? { turnOrchestration: decodeTurnOrchestration(record.turnOrchestration) }
Expand Down Expand Up @@ -431,14 +431,16 @@ export function decodeMessageContent(value: unknown, allowEmptyText = false): Me
if (attachment.bytes > MAX_ATTACHMENT_BYTES) {
throw invalidProtocolFrame('Invalid AttachmentRef bytes');
}
if (attachment.ref.kind === 'session_file') {
if (attachment.ref.kind === 'session_file' || attachment.ref.kind === 'session_context') {

@zhiiw zhiiw Aug 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This decoder is also used by turn.message.submit, so the reader-first widening lets a peer persist arbitrary or dangling session_context references before the claimed writer cutover. Please use a direction-specific decoder or admission rule, or validate the active capability and referenced record before accepting this durable input.

@likun666661 likun666661 Aug 29, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 91817d6. Client admission now uses a direction-specific decoder that rejects Host-owned session_context attachments for turn.start, turn.message.submit, and hosted execution, while snapshot and result decoding continues to accept the ref kind.

requireEntityId(attachment.ref.sessionId, 'AttachmentRef sessionId');
}
const path =
const identity =
attachment.ref.kind === 'external_file'
? attachment.ref.absolutePath
: attachment.ref.relativePath;
requireUtf8String(path, 'AttachmentRef path', ATTACHMENT_PATH_MAX_BYTES, false);
: attachment.ref.kind === 'session_context'
? attachment.ref.refId
: attachment.ref.relativePath;
requireUtf8String(identity, 'AttachmentRef identity', ATTACHMENT_PATH_MAX_BYTES, false);
}
if ((content.quotes?.length ?? 0) > TURN_MESSAGE_QUOTE_MAX_COUNT) {
throw invalidProtocolFrame('Invalid Message quotes');
Expand All @@ -456,6 +458,18 @@ export function decodeMessageContent(value: unknown, allowEmptyText = false): Me
return content;
}

/** Client-authored Messages cannot claim Host-owned Session context references. */
export function decodeMessageAdmissionContent(
value: unknown,
allowEmptyText = false,
): MessageContent {
const content = decodeMessageContent(value, allowEmptyText);
if (content.attachments?.some((attachment) => attachment.ref.kind === 'session_context')) {
throw invalidProtocolFrame('Session context references are Host-owned');
}
return content;
}

function requireUtf8String(
value: unknown,
label: string,
Expand Down
Loading
Loading