* 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)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
82
src/handshake-serialized.cts
Normal file
82
src/handshake-serialized.cts
Normal file
@@ -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<HostIntegrationAxes> }
|
||||
* 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<string, unknown> } {
|
||||
if (!hostDescriptor || typeof hostDescriptor !== 'object') {
|
||||
throw new TypeError('buildHandshakeRequest: host descriptor (object) is required');
|
||||
}
|
||||
const desc = hostDescriptor as Record<string, unknown>;
|
||||
const hasAxes = Object.prototype.hasOwnProperty.call(desc, 'axes');
|
||||
const axes = (hasAxes ? desc.axes : desc) as Record<string, unknown>;
|
||||
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<string, unknown> };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
const protocolVersion = req.protocolVersion;
|
||||
const result = hostIntegration.negotiateHostCapabilities(
|
||||
{ ...axes, ...(typeof protocolVersion === 'number' && Number.isFinite(protocolVersion) ? { protocolVersion } : {}) },
|
||||
engine as Parameters<typeof hostIntegration.negotiateHostCapabilities>[1],
|
||||
);
|
||||
// Force a wire round-trip: the response must be strictly JSON-serializable.
|
||||
return JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export = {
|
||||
HANDSHAKE_METHOD,
|
||||
buildHandshakeRequest,
|
||||
handleHandshakeRequest,
|
||||
};
|
||||
77
tests/handshake-serialized.test.cjs
Normal file
77
tests/handshake-serialized.test.cjs
Normal file
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user