From 47ecf997d623e5fab44db611d00107255c23d5f6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 2 Jul 2026 18:08:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(#1683):=20serialized=20capability-exchange?= =?UTF-8?q?=20handshake=20=E2=80=94=20Phase=206=20Slice=201=20(#1937)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#1683): serialized capability-exchange handshake — Phase 6 Slice 1 The out-of-process wire form of Phase 1's negotiateHostCapabilities: SDK hosts (pi, VS Code) that cannot share object refs exchange a JSON capability set over a wire boundary (MCP-style initialize). src/handshake-serialized.cts provides buildHandshakeRequest (host side) + handleHandshakeRequest (engine side, delegates to negotiateHostCapabilities), both JSON-round-trip-safe. CONSISTENCY is the contract: a serialized request yields the same NegotiationResult as the in-process call for the same axes (asserted in the test). Pure + additive; the companion MCP server or an SDK host binds it to a real transport. * fix(#1683): ignore tsc-emitted handshake-serialized.cjs (ADR-457) + refresh inventory manifest * fix(#1683): correct inventory manifest — drop junk smart-entry.cjs, add handshake-serialized.cjs The local gen-inventory-manifest scan captured a stale untracked smart-entry.cjs (not on next, no src/*.cts) and missed the freshly-built handshake-serialized.cjs. Aligned to the canonical clean-build report: + handshake-serialized.cjs, - smart-entry.cjs. * fix(#1683): type the JSON wire round-trips (no-unsafe-return) --- docs/INVENTORY-MANIFEST.json | 1 + eslint.config.mjs | 1 + src/handshake-serialized.cts | 82 +++++++++++++++++++++++++++++ tests/handshake-serialized.test.cjs | 77 +++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 src/handshake-serialized.cts create mode 100644 tests/handshake-serialized.test.cjs diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 93c5e7f5b..dd3fa08fb 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -338,6 +338,7 @@ "graphify-command-router.cjs", "graphify.cjs", "gsd2-import.cjs", + "handshake-serialized.cjs", "hook-bus.cjs", "host-integration.cjs", "init-command-router.cjs", diff --git a/eslint.config.mjs b/eslint.config.mjs index 1ef8c1083..06038d37f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -58,6 +58,7 @@ export default tseslint.config( // ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs. 'gsd-core/bin/lib/semver-compare.cjs', 'gsd-core/bin/lib/host-integration.cjs', + 'gsd-core/bin/lib/handshake-serialized.cjs', 'gsd-core/bin/lib/install-engine.cjs', 'gsd-core/bin/lib/capability-loader.cjs', 'gsd-core/bin/lib/capability-source.cjs', diff --git a/src/handshake-serialized.cts b/src/handshake-serialized.cts new file mode 100644 index 000000000..12beca65d --- /dev/null +++ b/src/handshake-serialized.cts @@ -0,0 +1,82 @@ +/** + * Serialized (out-of-process) capability-exchange handshake (ADR-1239 Phase E / #1683). + * + * Phase 1's `negotiateHostCapabilities` is IN-PROCESS (a host descriptor merged + * directly into the engine). Out-of-process SDK hosts (pi, VS Code) cannot share + * object references with the engine — they exchange a SERIALIZED capability set + * over a wire boundary (an MCP-style `initialize`). This module is the wire form + * of that handshake, kept CONSISTENT with the in-process negotiation: a request + * built + serialized here MUST yield the same NegotiationResult the in-process + * call produces for the same axes (asserted in tests/handshake-serialized.test.cjs). + * + * Wire shape (JSON — no object refs, safe across a process/IPC boundary): + * + * request = { protocolVersion: number, axes: Partial } + * response = NegotiationResult = { protocolVersion, effective, points, warnings } + * + * The engine side delegates to negotiateHostCapabilities; the host side builds + * the request from a descriptor. Both round-trip through JSON so the exchange is + * strictly serializable (a non-JSON-safe value would break the wire contract). + * + * Pure + additive: no I/O, no global state. The companion MCP server (Phase 4) + * or an SDK host binds this to a real transport. + */ +'use strict'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import hostIntegration = require('./host-integration.cjs'); + +/** The wire method name for the initialize-style exchange. */ +const HANDSHAKE_METHOD = 'gsd/host-initialize'; + +/** + * Host side: build a JSON-serializable handshake request from a host descriptor. + * Accepts either `{ protocolVersion?, axes }` or a bare axes object. + * Defaults protocolVersion to the engine's current PROTOCOL_VERSION. + */ +function buildHandshakeRequest(hostDescriptor: unknown): { protocolVersion: number; axes: Record } { + if (!hostDescriptor || typeof hostDescriptor !== 'object') { + throw new TypeError('buildHandshakeRequest: host descriptor (object) is required'); + } + const desc = hostDescriptor as Record; + const hasAxes = Object.prototype.hasOwnProperty.call(desc, 'axes'); + const axes = (hasAxes ? desc.axes : desc) as Record; + if (!axes || typeof axes !== 'object') { + throw new TypeError('buildHandshakeRequest: descriptor.axes (object) is required'); + } + const protocolVersion = + typeof desc.protocolVersion === 'number' && Number.isFinite(desc.protocolVersion) + ? desc.protocolVersion + : hostIntegration.PROTOCOL_VERSION; + // Force a wire round-trip so a non-JSON-safe descriptor fails HERE, not later. + return JSON.parse(JSON.stringify({ protocolVersion, axes })) as { protocolVersion: number; axes: Record }; +} + +/** + * Engine side: handle a serialized handshake request → a JSON-serializable + * NegotiationResult. Delegates to negotiateHostCapabilities, so the result is + * identical to the in-process negotiation for the same axes. + */ +function handleHandshakeRequest( + request: unknown, + engine: unknown = hostIntegration.DEFAULT_ENGINE, +): Record { + if (!request || typeof request !== 'object') { + throw new TypeError('handleHandshakeRequest: request (object) is required'); + } + const req = request as { protocolVersion?: unknown; axes?: unknown }; + const axes = (req.axes && typeof req.axes === 'object' ? req.axes : {}) as Record; + const protocolVersion = req.protocolVersion; + const result = hostIntegration.negotiateHostCapabilities( + { ...axes, ...(typeof protocolVersion === 'number' && Number.isFinite(protocolVersion) ? { protocolVersion } : {}) }, + engine as Parameters[1], + ); + // Force a wire round-trip: the response must be strictly JSON-serializable. + return JSON.parse(JSON.stringify(result)) as Record; +} + +export = { + HANDSHAKE_METHOD, + buildHandshakeRequest, + handleHandshakeRequest, +}; diff --git a/tests/handshake-serialized.test.cjs b/tests/handshake-serialized.test.cjs new file mode 100644 index 000000000..eb430f7c9 --- /dev/null +++ b/tests/handshake-serialized.test.cjs @@ -0,0 +1,77 @@ +'use strict'; + +/** + * handshake-serialized.test.cjs — ADR-1239 Phase E / #1683 Slice 1. + * + * The wire-form capability-exchange handshake MUST be consistent with the + * in-process negotiateHostCapabilities: a request built + serialized here yields + * the same NegotiationResult the in-process call produces for the same axes. + * That consistency is what makes the out-of-process SDK handshake safe. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + negotiateHostCapabilities, + PROTOCOL_VERSION, +} = require('../gsd-core/bin/lib/host-integration.cjs'); +const { + buildHandshakeRequest, + handleHandshakeRequest, + HANDSHAKE_METHOD, +} = require('../gsd-core/bin/lib/handshake-serialized.cjs'); + +test('HANDSHAKE_METHOD is a non-empty initialize-style wire method name', () => { + assert.equal(typeof HANDSHAKE_METHOD, 'string'); + assert.ok(HANDSHAKE_METHOD.length > 0); +}); + +test('buildHandshakeRequest: bare-axes form → {protocolVersion, axes}, JSON-safe', () => { + const req = buildHandshakeRequest({ embeddingMode: 'declarative', commandSurface: 'slash-file' }); + assert.equal(req.protocolVersion, PROTOCOL_VERSION); + assert.equal(req.axes.embeddingMode, 'declarative'); + assert.deepEqual(req, JSON.parse(JSON.stringify(req))); +}); + +test('buildHandshakeRequest: {protocolVersion, axes} form honored', () => { + const req = buildHandshakeRequest({ protocolVersion: 999, axes: { embeddingMode: 'imperative' } }); + assert.equal(req.protocolVersion, 999); + assert.equal(req.axes.embeddingMode, 'imperative'); +}); + +test('buildHandshakeRequest throws on non-object descriptor', () => { + assert.throws(() => buildHandshakeRequest(null), /host descriptor/); + assert.throws(() => buildHandshakeRequest('x'), /host descriptor/); +}); + +test('handleHandshakeRequest is CONSISTENT with in-process negotiateHostCapabilities', () => { + // A full declarative-cli descriptor. + const axes = { + embeddingMode: 'declarative', commandSurface: 'slash-file', modelMode: 'passive', + hookBus: 'host', stateIO: 'filesystem', transport: 'mcp', runtime: 'node', + }; + const inProcess = negotiateHostCapabilities(axes); + const wire = handleHandshakeRequest(buildHandshakeRequest(axes)); + // The wire result MUST equal the in-process result for the same axes. + assert.deepEqual(wire.effective, inProcess.effective); + assert.deepEqual(wire.protocolVersion, inProcess.protocolVersion); + assert.deepEqual(wire.points, inProcess.points); +}); + +test('handleHandshakeRequest returns a strictly JSON-serializable NegotiationResult', () => { + const wire = handleHandshakeRequest({ protocolVersion: 1, axes: { embeddingMode: 'declarative' } }); + assert.deepEqual(wire, JSON.parse(JSON.stringify(wire))); +}); + +test('full round-trip: host builds → engine handles → negotiated IDE profile', () => { + const req = buildHandshakeRequest({ embeddingMode: 'imperative', runtime: 'sandboxed-web' }); + const result = handleHandshakeRequest(req); + assert.ok(result.effective && typeof result.effective === 'object'); + assert.ok(result.points && typeof result.points === 'object'); + assert.ok(Array.isArray(result.warnings)); +}); + +test('handleHandshakeRequest throws on non-object request', () => { + assert.throws(() => handleHandshakeRequest(null), /request/); +});