Skip to content
Open
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
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 V0_1_11_HOST_COMPATIBILITY_EPOCH = 25;

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 > V0_1_11_HOST_COMPATIBILITY_EPOCH);
await assert.rejects(
connectRuntimeHostCli(
{ rootPath: '/runtime-host-root' },
{
connectOrSpawn: async () => ({
kind: 'incompatible',
registration: hostRegistration({
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compatibilityEpoch: V0_1_11_HOST_COMPATIBILITY_EPOCH,
}),
handshake: {
kind: 'incompatible',
hostEpoch: 'host-old',
protocolMin: 0,
protocolMax: 0,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compatibilityEpoch: V0_1_11_HOST_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 ${V0_1_11_HOST_COMPATIBILITY_EPOCH}`,
),
);
assert.match(
Expand Down
160 changes: 159 additions & 1 deletion packages/runtime-host/src/__tests__/handshake-compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ 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 V0_1_11_HOST_COMPATIBILITY_EPOCH = 25;
const V0_1_11_HOST_REVISION = 'a3c4d0b2a6ca0c87bebebff135d40017558ae5b8';
const PROTOCOL = {
min: RUNTIME_HOST_PROTOCOL_VERSION,
max: RUNTIME_HOST_PROTOCOL_VERSION,
Expand Down Expand Up @@ -56,6 +59,36 @@ test('emits the legacy desktop surface shim in the raw Client hello', async () =
);
});

test('receives structured incompatibility guidance from the released v0.1.11 Host', async () => {
await withForgedHandshakePeer(
async (transport, hostEpoch, rootId) => {
const rawHello = await transport.read(2_000);
assert.ok(rawHello && typeof rawHello === 'object');
const { hello, response } = await admitV0_1_11ClientHello({
rawHello,
transport,
hostEpoch,
rootId,
});
assert.equal(hello.surface, 'desktop');
assert.ok(hello.compatibilityEpoch > V0_1_11_HOST_COMPATIBILITY_EPOCH);
assert.equal(hello.protocolMin, RUNTIME_HOST_PROTOCOL_VERSION + 1);
assert.equal(hello.protocolMax, RUNTIME_HOST_PROTOCOL_VERSION + 1);
assert.equal(response.kind, 'incompatible');
await transport.closed;
},
async (result) => {
assert.equal(result.kind, 'incompatible');
if (result.kind === 'incompatible') {
assert.equal(result.handshake.compatibilityEpoch, V0_1_11_HOST_COMPATIBILITY_EPOCH);
assert.equal(result.handshake.compositionRevision, V0_1_11_HOST_REVISION);
assert.equal(result.handshake.replacement, 'blocked_by_residency');
}
},
{ registrationCompatibilityEpoch: V0_1_11_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 +126,133 @@ test('rejects an epoch-23 Host before any domain command', async () => {
assert.equal(admittedRequest, undefined);
});

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

/**
* Minimal no-generation/no-takeover fixture copied from the released v0.1.11
* Host at V0_1_11_HOST_REVISION. Keep its decoder, negotiation, and admission
* independent of the current implementation so removing the private bootstrap
* shim reproduces that Host's pre-admission transport abort.
*/
async function admitV0_1_11ClientHello(input: {
readonly rawHello: unknown;
readonly transport: FramedTransport;
readonly hostEpoch: string;
readonly rootId: string;
}): Promise<{
readonly hello: V0_1_11ClientHello;
readonly response: HostHandshakeResult;
}> {
const hello = decodeV0_1_11ClientHello(input.rawHello);
const selectedProtocol = negotiateV0_1_11Protocol(hello.protocolMin, hello.protocolMax);
const incompatible =
selectedProtocol === undefined ||
hello.compatibilityEpoch !== V0_1_11_HOST_COMPATIBILITY_EPOCH ||
hello.compositionId !== 'maka.interactive';
const response: HostHandshakeResult = incompatible
? {
kind: 'incompatible',
hostEpoch: input.hostEpoch,
protocolMin: 0,
protocolMax: 0,
compatibilityEpoch: V0_1_11_HOST_COMPATIBILITY_EPOCH,
compositionId: 'maka.interactive',
compositionRevision: V0_1_11_HOST_REVISION,
state: 'ready',
replacement: 'blocked_by_residency',
}
: {
kind: 'accepted',
rootId: input.rootId,
hostEpoch: input.hostEpoch,
connectionId: 'v0.1.11-connection',
selectedProtocol,
compatibilityEpoch: V0_1_11_HOST_COMPATIBILITY_EPOCH,
compositionId: 'maka.interactive',
compositionRevision: V0_1_11_HOST_REVISION,
state: 'ready',
};
await input.transport.write(encodeProtocolMessage(response));
if (response.kind !== 'accepted') input.transport.closeAfterFlush();
return { hello, response };
}

function decodeV0_1_11ClientHello(value: unknown): V0_1_11ClientHello {
const frame = requireRecord(value, 'v0.1.11 Client hello');
if (frame.kind !== 'hello') throw new Error('Expected a v0.1.11 Client hello');
const protocolMin = requireProtocolVersion(frame.protocolMin, 'protocolMin');
const protocolMax = requireProtocolVersion(frame.protocolMax, 'protocolMax');
if (protocolMax < protocolMin) throw new Error('Invalid v0.1.11 Client protocol range');
return {
kind: 'hello',
clientInstanceId: requireString(frame.clientInstanceId, 'clientInstanceId'),
surface: requireV0_1_11Surface(frame.surface),
protocolMin,
protocolMax,
compatibilityEpoch: requireProtocolVersion(frame.compatibilityEpoch, 'compatibilityEpoch'),
compositionId: requireString(frame.compositionId, 'compositionId'),
};
}

type V0_1_11ClientSurface =
| 'desktop'
| 'tui'
| 'run'
| 'activation'
| 'bot'
| 'inspect'
| 'capability-provider';

function requireV0_1_11Surface(value: unknown): V0_1_11ClientSurface {
if (
value === 'desktop' ||
value === 'tui' ||
value === 'run' ||
value === 'activation' ||
value === 'bot' ||
value === 'inspect' ||
value === 'capability-provider'
)
return value;
throw new Error('Invalid surface');
}

function negotiateV0_1_11Protocol(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 +299,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
10 changes: 5 additions & 5 deletions packages/runtime-host/src/client/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1388,11 +1388,11 @@ interface ExchangeRuntimeHostHandshakeInput {

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.
* Released Hosts through v0.1.11 require this field while decoding the
* bootstrap hello, before compatibility negotiation can run. Keep the
* sentinel private until the minimum supported Host release has a tolerant
* decoder. Tracked by #3297. This is not part of the Client identity seen by
* current Hosts.
*/
readonly surface: 'desktop';
}
Expand Down