Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 6 additions & 3 deletions packages/cli/src/__tests__/runtime-host-cli-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
shouldRetryRuntimeHostConflict,
} from '../runtime-host-cli-context.js';

const LEGACY_SURFACE_COMPATIBILITY_EPOCH = 27;

test('CLI Runtime Host bootstrap launches the execution composition', async () => {
let candidateEntrypoint: string | URL | undefined;
let clientInstanceId: string | undefined;
Expand Down Expand Up @@ -76,21 +78,22 @@ test('CLI Runtime Host bootstrap launches the execution composition', async () =
});

test('non-interactive CLI reports how to retire an incompatible Runtime Host', async () => {
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > LEGACY_SURFACE_COMPATIBILITY_EPOCH);
await assert.rejects(
connectRuntimeHostCli(
{ rootPath: '/runtime-host-root' },
{
connectOrSpawn: async () => ({
kind: 'incompatible',
registration: hostRegistration({
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compatibilityEpoch: LEGACY_SURFACE_COMPATIBILITY_EPOCH,
}),
handshake: {
kind: 'incompatible',
hostEpoch: 'host-old',
protocolMin: 0,
protocolMax: 0,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compatibilityEpoch: LEGACY_SURFACE_COMPATIBILITY_EPOCH,
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
compositionRevision: 'legacy',
state: 'ready',
Expand All @@ -105,7 +108,7 @@ test('non-interactive CLI reports how to retire an incompatible Runtime Host', a
assert.match(
error.message,
new RegExp(
`PID 42; lifecycle ephemeral; compatibility epoch ${RUNTIME_HOST_COMPATIBILITY_EPOCH - 1}`,
`PID 42; lifecycle ephemeral; compatibility epoch ${LEGACY_SURFACE_COMPATIBILITY_EPOCH}`,
),
);
assert.match(
Expand Down
141 changes: 138 additions & 3 deletions packages/runtime-host/src/__tests__/handshake-compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,24 @@ import {
RUNTIME_HOST_PROTOCOL_VERSION,
RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
type HostFrame,
type HostHandshakeResult,
type RequestFrame,
} from '../protocol/index.js';
import { FramedTransport, RuntimeHostTransportError } from '../transport/framed-transport.js';

const EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH = 27;
const EPOCH_27_TOLERANT_HOST_REVISION = 'a6f33c9522ee2d4366046b84e5ed442aa1aaafe2';
const PROTOCOL = {
min: RUNTIME_HOST_PROTOCOL_VERSION,
max: RUNTIME_HOST_PROTOCOL_VERSION,
} as const;

test('emits the legacy desktop surface shim in the raw Client hello', async () => {
test('omits the legacy surface identity from the raw Client hello', async () => {
await withForgedHandshakePeer(
async (transport, hostEpoch, rootId) => {
const rawHello = await transport.read(2_000);
assert.ok(rawHello && typeof rawHello === 'object');
assert.equal((rawHello as Record<string, unknown>).surface, 'desktop');
assert.equal('surface' in rawHello, false);
const hello = decodeClientFrame(rawHello);
assert.ok('kind' in hello && hello.kind === 'hello');
await writeProtocolFrame(transport, {
Expand All @@ -56,6 +59,40 @@ test('emits the legacy desktop surface shim in the raw Client hello', async () =
);
});

test('receives structured incompatibility guidance from a tolerant epoch-27 Host', async () => {
await withForgedHandshakePeer(
async (transport, hostEpoch, rootId) => {
const rawHello = await transport.read(2_000);
assert.ok(rawHello && typeof rawHello === 'object');
assert.equal('surface' in rawHello, false);
const { hello, response } = await admitEpoch27TolerantClientHello({
rawHello,
transport,
hostEpoch,
rootId,
});
assert.equal(response.kind, 'incompatible');
assert.equal(response.compositionRevision, EPOCH_27_TOLERANT_HOST_REVISION);
assert.ok(hello.compatibilityEpoch > EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH);
assert.equal(hello.protocolMin, RUNTIME_HOST_PROTOCOL_VERSION + 1);
assert.equal(hello.protocolMax, RUNTIME_HOST_PROTOCOL_VERSION + 1);
await transport.closed;
},
async (result) => {
assert.equal(result.kind, 'incompatible');
if (result.kind === 'incompatible') {
assert.equal(
result.handshake.compatibilityEpoch,
EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH,
);
assert.equal(result.handshake.compositionRevision, EPOCH_27_TOLERANT_HOST_REVISION);
assert.equal(result.handshake.replacement, 'blocked_by_residency');
}
},
{ registrationCompatibilityEpoch: EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH },
);
});

test('rejects an epoch-23 Host before any domain command', async () => {
let admittedRequest: RequestFrame | undefined;
await withForgedHandshakePeer(
Expand Down Expand Up @@ -93,9 +130,106 @@ test('rejects an epoch-23 Host before any domain command', async () => {
assert.equal(admittedRequest, undefined);
});

interface Epoch27TolerantClientHello {
readonly kind: 'hello';
readonly clientInstanceId: string;
readonly protocolMin: number;
readonly protocolMax: number;
readonly compatibilityEpoch: number;
readonly compositionId: string;
}

/**
* Minimal historical fixture for the no-generation/no-takeover admission path
* in the post-#3277 epoch-27 Host at EPOCH_27_TOLERANT_HOST_REVISION. Keep its
* decoder and compatibility value independent of the current implementation.
*/
async function admitEpoch27TolerantClientHello(input: {
readonly rawHello: unknown;
readonly transport: FramedTransport;
readonly hostEpoch: string;
readonly rootId: string;
}): Promise<{
readonly hello: Epoch27TolerantClientHello;
readonly response: HostHandshakeResult;
}> {
const hello = decodeEpoch27TolerantClientHello(input.rawHello);
const selectedProtocol = negotiateEpoch27Protocol(hello.protocolMin, hello.protocolMax);
const incompatible =
selectedProtocol === undefined ||
hello.compatibilityEpoch !== EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH ||
hello.compositionId !== 'maka.interactive';
const response: HostHandshakeResult = incompatible
? {
kind: 'incompatible',
hostEpoch: input.hostEpoch,
protocolMin: 0,
protocolMax: 0,
compatibilityEpoch: EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH,
compositionId: 'maka.interactive',
compositionRevision: EPOCH_27_TOLERANT_HOST_REVISION,
state: 'ready',
replacement: 'blocked_by_residency',
}
: {
kind: 'accepted',
rootId: input.rootId,
hostEpoch: input.hostEpoch,
connectionId: 'epoch-27-tolerant-connection',
selectedProtocol,
compatibilityEpoch: EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH,
compositionId: 'maka.interactive',
compositionRevision: EPOCH_27_TOLERANT_HOST_REVISION,
state: 'ready',
};
await input.transport.write(encodeProtocolMessage(response));
return { hello, response };
}

function decodeEpoch27TolerantClientHello(value: unknown): Epoch27TolerantClientHello {
const frame = requireRecord(value, 'epoch-27 Client hello');
if (frame.kind !== 'hello') throw new Error('Expected an epoch-27 Client hello');
const protocolMin = requireProtocolVersion(frame.protocolMin, 'protocolMin');
const protocolMax = requireProtocolVersion(frame.protocolMax, 'protocolMax');
if (protocolMax < protocolMin) throw new Error('Invalid epoch-27 Client protocol range');
return {
kind: 'hello',
clientInstanceId: requireString(frame.clientInstanceId, 'clientInstanceId'),
protocolMin,
protocolMax,
compatibilityEpoch: requireProtocolVersion(frame.compatibilityEpoch, 'compatibilityEpoch'),
compositionId: requireString(frame.compositionId, 'compositionId'),
};
}

function negotiateEpoch27Protocol(protocolMin: number, protocolMax: number): number | undefined {
const selected = Math.min(protocolMax, 0);
return selected >= Math.max(protocolMin, 0) ? selected : undefined;
}

function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`Invalid ${label}`);
}
return value as Record<string, unknown>;
}

function requireProtocolVersion(value: unknown, label: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new Error(`Invalid ${label}`);
}
return value as number;
}

function requireString(value: unknown, label: string): string {
if (typeof value !== 'string' || value.length === 0) throw new Error(`Invalid ${label}`);
return value;
}

async function withForgedHandshakePeer(
serve: (transport: FramedTransport, hostEpoch: string, rootId: string) => Promise<void>,
run: (result: ConnectRuntimeHostResult) => Promise<void>,
options: { readonly registrationCompatibilityEpoch?: number } = {},
): Promise<void> {
const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-handshake-'));
const rootPath = join(base, 'root');
Expand Down Expand Up @@ -142,7 +276,8 @@ async function withForgedHandshakePeer(
endpoint: endpoint.path,
protocolMin: RUNTIME_HOST_PROTOCOL_VERSION,
protocolMax: RUNTIME_HOST_PROTOCOL_VERSION,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
compatibilityEpoch:
options.registrationCompatibilityEpoch ?? RUNTIME_HOST_COMPATIBILITY_EPOCH,
compositionId: 'maka.interactive',
compositionRevision: '1',
state: 'ready',
Expand Down
14 changes: 1 addition & 13 deletions packages/runtime-host/src/client/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1386,17 +1386,6 @@ interface ExchangeRuntimeHostHandshakeInput {
readonly connectionResource?: RuntimeHostConnectionResource;
}

interface LegacySurfaceClientHello extends ClientHello {
/**
* Hosts from compatibility epoch 27 may require this field while decoding
* the bootstrap hello. Keep the sentinel private until the minimum supported
* compatibility epoch is greater than 27; the removal change must bump the
* epoch so old Hosts take the structured incompatibility path. Tracked by
* #3297. This is not part of the Client identity seen by new Hosts.
*/
readonly surface: 'desktop';
}

async function exchangeRuntimeHostHandshake(
input: ExchangeRuntimeHostHandshakeInput,
): Promise<
Expand All @@ -1405,10 +1394,9 @@ async function exchangeRuntimeHostHandshake(
| { kind: 'draining' }
> {
const helloProtocol = input.helloProtocol ?? input.protocol;
const hello: LegacySurfaceClientHello = {
const hello: ClientHello = {
Comment thread
Sun-GLiang marked this conversation as resolved.
Outdated
kind: 'hello',
clientInstanceId: input.clientInstanceId,
surface: 'desktop',
protocolMin: helloProtocol.min,
protocolMax: helloProtocol.max,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH,
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ 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 = 27 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const;
Comment thread
Sun-GLiang marked this conversation as resolved.
Outdated
// 28: Clients stop sending the retired surface identity in their bootstrap
Comment thread
Sun-GLiang marked this conversation as resolved.
Outdated
// hello. Epoch-27 Hosts from the tolerant rollout reject the new Client with
// a structured incompatibility response instead of admitting domain work.
// 27: Runtime Policy carries the Host-owned shell preference used by tool,
// PTY, and prompt composition. Older peers cannot safely preserve that field.
// Transcript pages amortize storage and network round trips with a 512 KiB raw
Expand Down