From a4e747d684ae5c505aff76fe099a6e575015511f Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:47:43 +0800 Subject: [PATCH 1/3] chore(runtime-host): retire legacy surface hello shim Generated-by: Codex --- .../runtime-host-cli-context.test.ts | 9 +- .../__tests__/handshake-compatibility.test.ts | 141 +++++++++++++++++- .../runtime-host/src/client/connection.ts | 14 +- packages/runtime-host/src/protocol/index.ts | 5 +- 4 files changed, 149 insertions(+), 20 deletions(-) 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..80009a10c4 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 LEGACY_SURFACE_COMPATIBILITY_EPOCH = 27; + 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 > LEGACY_SURFACE_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: 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', @@ -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( diff --git a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts index fd1ac64a4b..6ae45fdb5a 100644 --- a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts +++ b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts @@ -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).surface, 'desktop'); + assert.equal('surface' in rawHello, false); const hello = decodeClientFrame(rawHello); assert.ok('kind' in hello && hello.kind === 'hello'); await writeProtocolFrame(transport, { @@ -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( @@ -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 { + 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 +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', diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index 4e4ab01f05..7d92e3e9ea 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -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< @@ -1405,10 +1394,9 @@ async function exchangeRuntimeHostHandshake( | { kind: 'draining' } > { const helloProtocol = input.helloProtocol ?? input.protocol; - const hello: LegacySurfaceClientHello = { + const hello: ClientHello = { kind: 'hello', clientInstanceId: input.clientInstanceId, - surface: 'desktop', protocolMin: helloProtocol.min, protocolMax: helloProtocol.max, compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 54bc6f661b..fd010c0e47 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -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; +// 28: Clients stop sending the retired surface identity in their bootstrap +// 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 From ecc20880e816747d1c2a150508ee7b9082ae34e2 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:50:34 +0800 Subject: [PATCH 2/3] test(runtime-host): pin the pre-surface epoch boundary Generated-by: Codex --- .../__tests__/handshake-compatibility.test.ts | 64 +++++++++++++++++++ packages/runtime-host/src/protocol/index.ts | 3 + 2 files changed, 67 insertions(+) diff --git a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts index 6ae45fdb5a..94bb0fcd91 100644 --- a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts +++ b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts @@ -27,6 +27,7 @@ import { FramedTransport, RuntimeHostTransportError } from '../transport/framed- const EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH = 27; const EPOCH_27_TOLERANT_HOST_REVISION = 'a6f33c9522ee2d4366046b84e5ed442aa1aaafe2'; +const EPOCH_27_INTOLERANT_HOST_REVISION = '0d7d174be5e7c26f9397e20d26ea322b55956c15'; const PROTOCOL = { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION, @@ -93,6 +94,38 @@ test('receives structured incompatibility guidance from a tolerant epoch-27 Host ); }); +test('documents the unshipped pre-#3277 epoch-27 Host abort before admission', async () => { + await withForgedHandshakePeer( + async (transport, hostEpoch) => { + const rawHello = await transport.read(2_000); + assert.ok(rawHello && typeof rawHello === 'object'); + assert.equal('surface' in rawHello, false); + try { + decodeEpoch27IntolerantClientHello(rawHello); + await writeProtocolFrame(transport, { + kind: 'incompatible', + hostEpoch, + protocolMin: 0, + protocolMax: 0, + compatibilityEpoch: EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH, + compositionId: 'maka.interactive', + compositionRevision: EPOCH_27_INTOLERANT_HOST_REVISION, + state: 'ready', + replacement: 'blocked_by_residency', + }); + transport.closeAfterFlush(); + } catch { + transport.abort(); + } + }, + async (result) => { + assert.equal(result.kind, 'unavailable'); + if (result.kind === 'unavailable') assert.equal(result.reason, 'handshake_failed'); + }, + { registrationCompatibilityEpoch: EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH }, + ); +}); + test('rejects an epoch-23 Host before any domain command', async () => { let admittedRequest: RequestFrame | undefined; await withForgedHandshakePeer( @@ -202,6 +235,37 @@ function decodeEpoch27TolerantClientHello(value: unknown): Epoch27TolerantClient }; } +/** + * Minimal historical decoder for the no-generation/no-takeover hello path at + * EPOCH_27_INTOLERANT_HOST_REVISION, immediately before #3277 made surface + * optional. The historical Host aborted the transport when this decoder threw. + */ +function decodeEpoch27IntolerantClientHello(value: unknown): void { + const frame = requireRecord(value, 'pre-#3277 epoch-27 Client hello'); + if (frame.kind !== 'hello') throw new Error('Expected a pre-#3277 epoch-27 Client hello'); + const protocolMin = requireProtocolVersion(frame.protocolMin, 'protocolMin'); + const protocolMax = requireProtocolVersion(frame.protocolMax, 'protocolMax'); + if (protocolMax < protocolMin) throw new Error('Invalid pre-#3277 epoch-27 protocol range'); + requireString(frame.clientInstanceId, 'clientInstanceId'); + requireEpoch27Surface(frame.surface); + requireProtocolVersion(frame.compatibilityEpoch, 'compatibilityEpoch'); + requireString(frame.compositionId, 'compositionId'); +} + +function requireEpoch27Surface(value: unknown): void { + if ( + value === 'desktop' || + value === 'tui' || + value === 'run' || + value === 'activation' || + value === 'bot' || + value === 'inspect' || + value === 'capability-provider' + ) + return; + throw new Error('Invalid surface'); +} + function negotiateEpoch27Protocol(protocolMin: number, protocolMax: number): number | undefined { const selected = Math.min(protocolMax, 0); return selected >= Math.max(protocolMin, 0) ? selected : undefined; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index fd010c0e47..86301a6332 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -76,6 +76,9 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const; // 28: Clients stop sending the retired surface identity in their bootstrap // hello. Epoch-27 Hosts from the tolerant rollout reject the new Client with // a structured incompatibility response instead of admitting domain work. +// No tagged release carried the surface-requiring epoch-27 decoder: the latest +// release predated epoch 27, and #3277 made decoding tolerant before the next +// release. A pinned regression documents the bare abort in that unreleased gap. // 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 From 66442257908355e3efe8f2ef6be675a16218087c Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:04:31 +0800 Subject: [PATCH 3/3] test(runtime-host): pin released Host handshake compatibility --- .../runtime-host-cli-context.test.ts | 10 +- .../__tests__/handshake-compatibility.test.ts | 131 ++++++------------ .../runtime-host/src/client/connection.ts | 14 +- packages/runtime-host/src/protocol/index.ts | 8 +- 4 files changed, 64 insertions(+), 99 deletions(-) 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 80009a10c4..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,7 +26,7 @@ import { shouldRetryRuntimeHostConflict, } from '../runtime-host-cli-context.js'; -const LEGACY_SURFACE_COMPATIBILITY_EPOCH = 27; +const V0_1_11_HOST_COMPATIBILITY_EPOCH = 25; test('CLI Runtime Host bootstrap launches the execution composition', async () => { let candidateEntrypoint: string | URL | undefined; @@ -78,7 +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 > LEGACY_SURFACE_COMPATIBILITY_EPOCH); + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > V0_1_11_HOST_COMPATIBILITY_EPOCH); await assert.rejects( connectRuntimeHostCli( { rootPath: '/runtime-host-root' }, @@ -86,14 +86,14 @@ test('non-interactive CLI reports how to retire an incompatible Runtime Host', a connectOrSpawn: async () => ({ kind: 'incompatible', registration: hostRegistration({ - compatibilityEpoch: LEGACY_SURFACE_COMPATIBILITY_EPOCH, + compatibilityEpoch: V0_1_11_HOST_COMPATIBILITY_EPOCH, }), handshake: { kind: 'incompatible', hostEpoch: 'host-old', protocolMin: 0, protocolMax: 0, - compatibilityEpoch: LEGACY_SURFACE_COMPATIBILITY_EPOCH, + compatibilityEpoch: V0_1_11_HOST_COMPATIBILITY_EPOCH, compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, compositionRevision: 'legacy', state: 'ready', @@ -108,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 ${LEGACY_SURFACE_COMPATIBILITY_EPOCH}`, + `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 94bb0fcd91..641199278b 100644 --- a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts +++ b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts @@ -25,20 +25,19 @@ import { } 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 EPOCH_27_INTOLERANT_HOST_REVISION = '0d7d174be5e7c26f9397e20d26ea322b55956c15'; +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, } as const; -test('omits the legacy surface identity from the raw Client hello', async () => { +test('emits the legacy desktop surface shim in 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('surface' in rawHello, false); + assert.equal((rawHello as Record).surface, 'desktop'); const hello = decodeClientFrame(rawHello); assert.ok('kind' in hello && hello.kind === 'hello'); await writeProtocolFrame(transport, { @@ -60,69 +59,33 @@ test('omits the legacy surface identity from the raw Client hello', async () => ); }); -test('receives structured incompatibility guidance from a tolerant epoch-27 Host', 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'); - assert.equal('surface' in rawHello, false); - const { hello, response } = await admitEpoch27TolerantClientHello({ + const { hello, response } = await admitV0_1_11ClientHello({ 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.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, - EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH, - ); - assert.equal(result.handshake.compositionRevision, EPOCH_27_TOLERANT_HOST_REVISION); + 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: EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH }, - ); -}); - -test('documents the unshipped pre-#3277 epoch-27 Host abort before admission', async () => { - await withForgedHandshakePeer( - async (transport, hostEpoch) => { - const rawHello = await transport.read(2_000); - assert.ok(rawHello && typeof rawHello === 'object'); - assert.equal('surface' in rawHello, false); - try { - decodeEpoch27IntolerantClientHello(rawHello); - await writeProtocolFrame(transport, { - kind: 'incompatible', - hostEpoch, - protocolMin: 0, - protocolMax: 0, - compatibilityEpoch: EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH, - compositionId: 'maka.interactive', - compositionRevision: EPOCH_27_INTOLERANT_HOST_REVISION, - state: 'ready', - replacement: 'blocked_by_residency', - }); - transport.closeAfterFlush(); - } catch { - transport.abort(); - } - }, - async (result) => { - assert.equal(result.kind, 'unavailable'); - if (result.kind === 'unavailable') assert.equal(result.reason, 'handshake_failed'); - }, - { registrationCompatibilityEpoch: EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH }, + { registrationCompatibilityEpoch: V0_1_11_HOST_COMPATIBILITY_EPOCH }, ); }); @@ -163,9 +126,10 @@ test('rejects an epoch-23 Host before any domain command', async () => { assert.equal(admittedRequest, undefined); }); -interface Epoch27TolerantClientHello { +interface V0_1_11ClientHello { readonly kind: 'hello'; readonly clientInstanceId: string; + readonly surface: V0_1_11ClientSurface; readonly protocolMin: number; readonly protocolMax: number; readonly compatibilityEpoch: number; @@ -173,24 +137,25 @@ interface Epoch27TolerantClientHello { } /** - * 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. + * 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 admitEpoch27TolerantClientHello(input: { +async function admitV0_1_11ClientHello(input: { readonly rawHello: unknown; readonly transport: FramedTransport; readonly hostEpoch: string; readonly rootId: string; }): Promise<{ - readonly hello: Epoch27TolerantClientHello; + readonly hello: V0_1_11ClientHello; readonly response: HostHandshakeResult; }> { - const hello = decodeEpoch27TolerantClientHello(input.rawHello); - const selectedProtocol = negotiateEpoch27Protocol(hello.protocolMin, hello.protocolMax); + const hello = decodeV0_1_11ClientHello(input.rawHello); + const selectedProtocol = negotiateV0_1_11Protocol(hello.protocolMin, hello.protocolMax); const incompatible = selectedProtocol === undefined || - hello.compatibilityEpoch !== EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH || + hello.compatibilityEpoch !== V0_1_11_HOST_COMPATIBILITY_EPOCH || hello.compositionId !== 'maka.interactive'; const response: HostHandshakeResult = incompatible ? { @@ -198,9 +163,9 @@ async function admitEpoch27TolerantClientHello(input: { hostEpoch: input.hostEpoch, protocolMin: 0, protocolMax: 0, - compatibilityEpoch: EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH, + compatibilityEpoch: V0_1_11_HOST_COMPATIBILITY_EPOCH, compositionId: 'maka.interactive', - compositionRevision: EPOCH_27_TOLERANT_HOST_REVISION, + compositionRevision: V0_1_11_HOST_REVISION, state: 'ready', replacement: 'blocked_by_residency', } @@ -208,26 +173,28 @@ async function admitEpoch27TolerantClientHello(input: { kind: 'accepted', rootId: input.rootId, hostEpoch: input.hostEpoch, - connectionId: 'epoch-27-tolerant-connection', + connectionId: 'v0.1.11-connection', selectedProtocol, - compatibilityEpoch: EPOCH_27_TOLERANT_HOST_COMPATIBILITY_EPOCH, + compatibilityEpoch: V0_1_11_HOST_COMPATIBILITY_EPOCH, compositionId: 'maka.interactive', - compositionRevision: EPOCH_27_TOLERANT_HOST_REVISION, + 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 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'); +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 epoch-27 Client protocol range'); + 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'), @@ -235,24 +202,16 @@ function decodeEpoch27TolerantClientHello(value: unknown): Epoch27TolerantClient }; } -/** - * Minimal historical decoder for the no-generation/no-takeover hello path at - * EPOCH_27_INTOLERANT_HOST_REVISION, immediately before #3277 made surface - * optional. The historical Host aborted the transport when this decoder threw. - */ -function decodeEpoch27IntolerantClientHello(value: unknown): void { - const frame = requireRecord(value, 'pre-#3277 epoch-27 Client hello'); - if (frame.kind !== 'hello') throw new Error('Expected a pre-#3277 epoch-27 Client hello'); - const protocolMin = requireProtocolVersion(frame.protocolMin, 'protocolMin'); - const protocolMax = requireProtocolVersion(frame.protocolMax, 'protocolMax'); - if (protocolMax < protocolMin) throw new Error('Invalid pre-#3277 epoch-27 protocol range'); - requireString(frame.clientInstanceId, 'clientInstanceId'); - requireEpoch27Surface(frame.surface); - requireProtocolVersion(frame.compatibilityEpoch, 'compatibilityEpoch'); - requireString(frame.compositionId, 'compositionId'); -} +type V0_1_11ClientSurface = + | 'desktop' + | 'tui' + | 'run' + | 'activation' + | 'bot' + | 'inspect' + | 'capability-provider'; -function requireEpoch27Surface(value: unknown): void { +function requireV0_1_11Surface(value: unknown): V0_1_11ClientSurface { if ( value === 'desktop' || value === 'tui' || @@ -262,11 +221,11 @@ function requireEpoch27Surface(value: unknown): void { value === 'inspect' || value === 'capability-provider' ) - return; + return value; throw new Error('Invalid surface'); } -function negotiateEpoch27Protocol(protocolMin: number, protocolMax: number): number | undefined { +function negotiateV0_1_11Protocol(protocolMin: number, protocolMax: number): number | undefined { const selected = Math.min(protocolMax, 0); return selected >= Math.max(protocolMin, 0) ? selected : undefined; } diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index 7d92e3e9ea..d303ff973b 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -1386,6 +1386,17 @@ interface ExchangeRuntimeHostHandshakeInput { readonly connectionResource?: RuntimeHostConnectionResource; } +interface LegacySurfaceClientHello extends ClientHello { + /** + * 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'; +} + async function exchangeRuntimeHostHandshake( input: ExchangeRuntimeHostHandshakeInput, ): Promise< @@ -1394,9 +1405,10 @@ async function exchangeRuntimeHostHandshake( | { kind: 'draining' } > { const helloProtocol = input.helloProtocol ?? input.protocol; - const hello: ClientHello = { + const hello: LegacySurfaceClientHello = { kind: 'hello', clientInstanceId: input.clientInstanceId, + surface: 'desktop', protocolMin: helloProtocol.min, protocolMax: helloProtocol.max, compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index a0917f9a22..9ff215cea8 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -72,13 +72,7 @@ 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 = 30 as const; -// 30: Clients stop sending the retired surface identity in their bootstrap -// hello. Epoch-27 Hosts from the tolerant rollout reject the new Client with -// a structured incompatibility response instead of admitting domain work. -// No tagged release carried the surface-requiring epoch-27 decoder: the latest -// release predated epoch 27, and #3277 made decoding tolerant before the next -// release. A pinned regression documents the bare abort in that unreleased gap. +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 29 as const; // 29: `goal.arm` is a new wire operation. An older Host decodes it as unknown // and tears the connection down, so the pair must be refused up front. // 28: Relay model profiles carry the Fast service-tier declaration. Older