diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index 30fc6ea0c8..631b6b355f 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -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; @@ -76,6 +78,7 @@ 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' }, @@ -83,14 +86,14 @@ test('non-interactive CLI reports how to retire an incompatible Runtime Host', a 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', @@ -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( diff --git a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts index fd1ac64a4b..641199278b 100644 --- a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts +++ b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts @@ -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, @@ -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( @@ -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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Invalid ${label}`); + } + return value as Record; +} + +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, run: (result: ConnectRuntimeHostResult) => Promise, + options: { readonly registrationCompatibilityEpoch?: number } = {}, ): Promise { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-handshake-')); const rootPath = join(base, 'root'); @@ -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', diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index 4e4ab01f05..d303ff973b 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -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'; }