Merge TS Phase 0 remainder (72842e70b) into the 0.10.1 integration

Generated host protocol TypeScript, the core-only authority lint,
per-method cancel deadlines and a visible host-down reason, reviewed in the
extension-host lane.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-09-30 09:58:07 -07:00
commit ce1ecc8dc2
18 files changed
+1749 -608

No files matched your search

+148 -106
View File
@@ -4192,32 +4192,133 @@ function installResolveHooks(modules) {
});
}
// src/protocol.ts
// src/protocol.generated.ts
var PROTOCOL_VERSION = 1;
var MAGIC = Buffer.from("CWX1", "ascii");
var MAX_FRAME = 32 * 1024 * 1024;
var MAGIC_ASCII = "CWX1";
var HEADER_LEN = 8;
var MAX_FRAME = 33554432;
var MAX_INFLIGHT = 256;
var ErrorCode = {
ParseError: -32700,
InvalidRequest: -32600,
MethodNotFound: -32601,
InvalidParams: -32602,
Internal: -32603,
/** The tool body threw. */
ExecutionFailed: -32e3,
/** The handle is unknown, revoked, or its owner is not active. */
NotAvailable: -32001,
/** Cancelled by `$/cancel` (LSP's RequestCancelled). */
Cancelled: -32800
};
var CORE_TO_HOST = {
requests: ["host/initialize", "host/shutdown", "host/ping", "ext/activate", "ext/deactivate", "tool/call"],
notifications: ["$/cancel"]
};
var HOST_TO_CORE = {
requests: ["registry/register", "registry/unregister"],
notifications: ["host/hello", "host/ready", "ext/faulted", "log", "$/cancel"]
var METHODS = [
{ name: "host/initialize", direction: "core_to_host", request: true, params: "InitializeParams" },
{ name: "host/ping", direction: "core_to_host", request: true, params: "EmptyParams" },
{ name: "host/shutdown", direction: "core_to_host", request: true, params: "EmptyParams" },
{ name: "ext/activate", direction: "core_to_host", request: true, params: "ActivateParams" },
{ name: "ext/deactivate", direction: "core_to_host", request: true, params: "DeactivateParams" },
{ name: "tool/call", direction: "core_to_host", request: true, params: "ToolCallParams" },
{ name: "$/cancel", direction: "core_to_host", request: false, params: "CancelParams" },
{ name: "host/hello", direction: "host_to_core", request: false, params: "HelloParams" },
{ name: "host/ready", direction: "host_to_core", request: false, params: "EmptyParams" },
{ name: "registry/register", direction: "host_to_core", request: true, params: "RegisterParams" },
{ name: "registry/unregister", direction: "host_to_core", request: true, params: "UnregisterParams" },
{ name: "ext/faulted", direction: "host_to_core", request: false, params: "FaultedParams" },
{ name: "log", direction: "host_to_core", request: false, params: "LogParams" },
{ name: "$/cancel", direction: "host_to_core", request: false, params: "CancelParams" }
];
var SHAPES = {
ActivateParams: {
strict: false,
required: { owner: { ref: "OwnerRef" }, plugin_name: "string", entry: { ref: "EntryRef" } },
optional: { config: "json" }
},
CancelParams: {
strict: true,
required: { id: "uint" },
optional: {}
},
DeactivateParams: {
strict: false,
required: { owner: { ref: "OwnerRef" } },
optional: {}
},
EmptyParams: {
strict: true,
required: {},
optional: {}
},
EntryRef: {
strict: false,
required: { path: "string", sha256: "string" },
optional: {}
},
FaultedParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, error: "string" },
optional: {}
},
HelloParams: {
strict: true,
required: { protocol: { ref: "ProtocolRange" }, host_version: "string", bundle_sha256: "string", runtime: { ref: "HelloRuntime" } },
optional: { memory_limit_mib: "uint" }
},
HelloRuntime: {
strict: true,
required: { name: "string", version: "string" },
optional: {}
},
HostLimits: {
strict: false,
required: { max_frame: "uint", max_inflight: "uint", dispose_deadline_ms: "uint", activate_deadline_ms: "uint" },
optional: {}
},
InitializeParams: {
strict: false,
required: { protocol: "uint", limits: { ref: "HostLimits" } },
optional: {}
},
LogParams: {
strict: true,
required: { level: "string", msg: "string" },
optional: { plugin_id: "string" }
},
OwnerRef: {
strict: true,
required: { plugin_id: "string", generation: "uint", owner_token: "string" },
optional: {}
},
ProtocolRange: {
strict: true,
required: { min: "uint", max: "uint" },
optional: {}
},
RegisterParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool"] }, spec: { ref: "ToolSpecWire" } },
optional: {}
},
RpcErrorWire: {
strict: true,
required: { code: "integer", message: "string" },
optional: { data: "json" }
},
ToolCallParams: {
strict: false,
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
optional: {}
},
ToolSpecWire: {
strict: true,
required: { name: "string", description: "string", input_schema: "object" },
optional: {}
},
UnregisterParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, handle: "uint" },
optional: {}
}
};
// src/protocol.ts
var MAGIC = Buffer.from(MAGIC_ASCII, "ascii");
var FrameError = class extends Error {
constructor(message) {
super(message);
@@ -4268,144 +4369,86 @@ var FrameDecoder = class {
function isObject2(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function checkShape(where, value, required, optional = {}, strict = false) {
function checkShape(where, value, shape) {
if (!isObject2(value)) throw new ProtocolError(`${where}: expected an object`);
for (const [key, kind] of Object.entries(required)) {
for (const [key, kind] of Object.entries(shape.required)) {
if (!(key in value)) throw new ProtocolError(`${where}: missing field \`${key}\``);
checkKind(`${where}.${key}`, value[key], kind);
}
for (const [key, kind] of Object.entries(optional)) {
if (key in value && value[key] !== void 0) checkKind(`${where}.${key}`, value[key], kind);
for (const [key, kind] of Object.entries(shape.optional)) {
if (value[key] !== void 0 && value[key] !== null) checkKind(`${where}.${key}`, value[key], kind);
}
if (strict) {
if (shape.strict) {
for (const key of Object.keys(value)) {
if (!(key in required) && !(key in optional)) {
if (!(key in shape.required) && !(key in shape.optional)) {
throw new ProtocolError(`${where}: unknown field \`${key}\``);
}
}
}
}
function checkKind(where, value, kind) {
if (typeof kind === "object") {
if ("ref" in kind) return checkShape(where, value, SHAPES[kind.ref]);
if ("enum" in kind) {
if (typeof value !== "string" || !kind.enum.includes(value)) {
throw new ProtocolError(`${where}: expected one of ${kind.enum.map((v) => `\`${v}\``).join(", ")}`);
}
return;
}
if (!Array.isArray(value)) throw new ProtocolError(`${where}: expected an array`);
value.forEach((item, index) => checkKind(`${where}[${index}]`, item, kind.items));
return;
}
switch (kind) {
case "string":
if (typeof value !== "string") throw new ProtocolError(`${where}: expected a string`);
return;
case "number":
if (typeof value !== "number" || !Number.isFinite(value)) throw new ProtocolError(`${where}: expected a number`);
return;
case "u64":
case "uint":
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new ProtocolError(`${where}: expected an unsigned integer`);
}
return;
case "integer":
if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new ProtocolError(`${where}: expected an integer`);
return;
case "boolean":
if (typeof value !== "boolean") throw new ProtocolError(`${where}: expected a boolean`);
return;
case "object":
if (!isObject2(value)) throw new ProtocolError(`${where}: expected an object`);
return;
case "array":
if (!Array.isArray(value)) throw new ProtocolError(`${where}: expected an array`);
return;
case "owner":
checkShape(where, value, { plugin_id: "string", generation: "u64", owner_token: "string" }, {}, true);
return;
case "json":
return;
}
}
var PARAMS = {
// host → core
"host/hello": {
dir: "host",
required: { protocol: "object", host_version: "string", bundle_sha256: "string", runtime: "object" },
// A kernel memory limit the host applied to itself (macOS + Bun), in MiB.
optional: { memory_limit_mib: "u64" },
check: (p, strict) => {
checkShape("host/hello.protocol", p.protocol, { min: "u64", max: "u64" }, {}, strict);
checkShape("host/hello.runtime", p.runtime, { name: "string", version: "string" }, {}, strict);
if (p.runtime.name !== "bun" && p.runtime.name !== "node") {
throw new ProtocolError(`host/hello.runtime.name: unknown runtime \`${p.runtime.name}\``);
}
}
},
"host/ready": { dir: "host", required: {} },
"registry/register": {
dir: "host",
required: { owner: "owner", kind: "string", spec: "object" },
check: (p, strict) => {
if (p.kind !== "tool") throw new ProtocolError(`registry/register: unsupported kind \`${p.kind}\``);
checkShape("registry/register.spec", p.spec, { name: "string", description: "string", input_schema: "object" }, {}, strict);
}
},
"registry/unregister": { dir: "host", required: { owner: "owner", handle: "u64" } },
"ext/faulted": { dir: "host", required: { owner: "owner", error: "string" } },
log: { dir: "host", required: { level: "string", msg: "string" }, optional: { plugin_id: "string" } },
// core → host
"host/initialize": {
dir: "core",
required: { protocol: "u64", limits: "object" },
check: (p, strict) => checkShape(
"host/initialize.limits",
p.limits,
{ max_frame: "u64", max_inflight: "u64", dispose_deadline_ms: "u64", activate_deadline_ms: "u64" },
{},
strict
)
},
"host/shutdown": { dir: "core", required: {} },
"host/ping": { dir: "core", required: {} },
"ext/activate": {
dir: "core",
required: { owner: "owner", plugin_name: "string", entry: "object" },
optional: { config: "json" },
check: (p, strict) => checkShape("ext/activate.entry", p.entry, { path: "string", sha256: "string" }, {}, strict)
},
"ext/deactivate": { dir: "core", required: { owner: "owner" } },
"tool/call": {
dir: "core",
required: { handle: "u64", call_id: "string", input: "json", deadline_ms: "u64" }
}
};
var CANCEL_SHAPE = { id: "u64" };
function validateMessage(value, direction) {
const strict = direction === "host_to_core";
if (!isObject2(value)) throw new ProtocolError("message: expected an object");
if (value.jsonrpc !== "2.0") throw new ProtocolError('message: jsonrpc must be "2.0"');
const hasId = "id" in value;
if (hasId) checkKind("message.id", value.id, "u64");
if (hasId) checkKind("message.id", value.id, "uint");
if ("method" in value) {
checkShape("message", value, { jsonrpc: "string", method: "string" }, { id: "u64", params: "json" }, strict);
checkShape("message", value, { strict, required: { jsonrpc: "string", method: "string" }, optional: { id: "uint", params: "json" } });
const method = value.method;
const params = value.params ?? {};
if (method === "$/cancel") {
if (hasId) throw new ProtocolError("$/cancel must be a notification");
checkShape("$/cancel", params, CANCEL_SHAPE, {}, strict);
return value;
const spec = METHODS.find((entry) => entry.name === method && entry.direction === direction);
if (!spec) throw new ProtocolError(`unknown ${direction} method \`${method}\``);
if (spec.request !== hasId) {
throw new ProtocolError(`\`${method}\` must be ${spec.request ? "a request (with id)" : "a notification (no id)"}`);
}
const spec = PARAMS[method];
const expectedDir = direction === "host_to_core" ? "host" : "core";
if (!spec || spec.dir !== expectedDir) {
throw new ProtocolError(`unknown ${direction} method \`${method}\``);
const params = "params" in value ? value.params : {};
checkShape(method, params, SHAPES[spec.params]);
if (method === "host/hello" && params.runtime.name !== "bun" && params.runtime.name !== "node") {
throw new ProtocolError(`host/hello.runtime.name: unknown runtime \`${params.runtime.name}\``);
}
const isRequest = (direction === "host_to_core" ? HOST_TO_CORE.requests : CORE_TO_HOST.requests).includes(
method
);
if (isRequest !== hasId) {
throw new ProtocolError(`\`${method}\` must be ${isRequest ? "a request (with id)" : "a notification (no id)"}`);
}
checkShape(method, params, spec.required, spec.optional ?? {}, strict);
spec.check?.(params, strict);
return value;
}
if (!hasId) throw new ProtocolError("response: missing id");
if ("error" in value) {
checkShape("message", value, { jsonrpc: "string", id: "u64", error: "object" }, {}, strict);
checkShape("error", value.error, { code: "number", message: "string" }, { data: "json" }, strict);
checkShape("message", value, { strict, required: { jsonrpc: "string", id: "uint", error: { ref: "RpcErrorWire" } }, optional: {} });
return value;
}
if (!("result" in value)) throw new ProtocolError("response: needs result or error");
checkShape("message", value, { jsonrpc: "string", id: "u64", result: "json" }, {}, strict);
checkShape("message", value, { strict, required: { jsonrpc: "string", id: "uint", result: "json" }, optional: {} });
return value;
}
@@ -4423,7 +4466,6 @@ var RpcError = class extends Error {
return this.data === void 0 ? { code: this.code, message: this.message } : { code: this.code, message: this.message, data: this.data };
}
};
var MAX_INFLIGHT = 256;
var RpcPeer = class {
constructor(send) {
this.send = send;
+152 -107
View File
@@ -1,31 +1,132 @@
// generated by crates/tui/extension-host/build.mjs from src/protocol.ts; do not edit.
// src/protocol.ts
// src/protocol.generated.ts
var PROTOCOL_VERSION = 1;
var MAGIC = Buffer.from("CWX1", "ascii");
var MAX_FRAME = 32 * 1024 * 1024;
var MAGIC_ASCII = "CWX1";
var HEADER_LEN = 8;
var MAX_FRAME = 33554432;
var MAX_INFLIGHT = 256;
var ErrorCode = {
ParseError: -32700,
InvalidRequest: -32600,
MethodNotFound: -32601,
InvalidParams: -32602,
Internal: -32603,
/** The tool body threw. */
ExecutionFailed: -32e3,
/** The handle is unknown, revoked, or its owner is not active. */
NotAvailable: -32001,
/** Cancelled by `$/cancel` (LSP's RequestCancelled). */
Cancelled: -32800
};
var CORE_TO_HOST = {
requests: ["host/initialize", "host/shutdown", "host/ping", "ext/activate", "ext/deactivate", "tool/call"],
notifications: ["$/cancel"]
};
var HOST_TO_CORE = {
requests: ["registry/register", "registry/unregister"],
notifications: ["host/hello", "host/ready", "ext/faulted", "log", "$/cancel"]
var METHODS = [
{ name: "host/initialize", direction: "core_to_host", request: true, params: "InitializeParams" },
{ name: "host/ping", direction: "core_to_host", request: true, params: "EmptyParams" },
{ name: "host/shutdown", direction: "core_to_host", request: true, params: "EmptyParams" },
{ name: "ext/activate", direction: "core_to_host", request: true, params: "ActivateParams" },
{ name: "ext/deactivate", direction: "core_to_host", request: true, params: "DeactivateParams" },
{ name: "tool/call", direction: "core_to_host", request: true, params: "ToolCallParams" },
{ name: "$/cancel", direction: "core_to_host", request: false, params: "CancelParams" },
{ name: "host/hello", direction: "host_to_core", request: false, params: "HelloParams" },
{ name: "host/ready", direction: "host_to_core", request: false, params: "EmptyParams" },
{ name: "registry/register", direction: "host_to_core", request: true, params: "RegisterParams" },
{ name: "registry/unregister", direction: "host_to_core", request: true, params: "UnregisterParams" },
{ name: "ext/faulted", direction: "host_to_core", request: false, params: "FaultedParams" },
{ name: "log", direction: "host_to_core", request: false, params: "LogParams" },
{ name: "$/cancel", direction: "host_to_core", request: false, params: "CancelParams" }
];
var SHAPES = {
ActivateParams: {
strict: false,
required: { owner: { ref: "OwnerRef" }, plugin_name: "string", entry: { ref: "EntryRef" } },
optional: { config: "json" }
},
CancelParams: {
strict: true,
required: { id: "uint" },
optional: {}
},
DeactivateParams: {
strict: false,
required: { owner: { ref: "OwnerRef" } },
optional: {}
},
EmptyParams: {
strict: true,
required: {},
optional: {}
},
EntryRef: {
strict: false,
required: { path: "string", sha256: "string" },
optional: {}
},
FaultedParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, error: "string" },
optional: {}
},
HelloParams: {
strict: true,
required: { protocol: { ref: "ProtocolRange" }, host_version: "string", bundle_sha256: "string", runtime: { ref: "HelloRuntime" } },
optional: { memory_limit_mib: "uint" }
},
HelloRuntime: {
strict: true,
required: { name: "string", version: "string" },
optional: {}
},
HostLimits: {
strict: false,
required: { max_frame: "uint", max_inflight: "uint", dispose_deadline_ms: "uint", activate_deadline_ms: "uint" },
optional: {}
},
InitializeParams: {
strict: false,
required: { protocol: "uint", limits: { ref: "HostLimits" } },
optional: {}
},
LogParams: {
strict: true,
required: { level: "string", msg: "string" },
optional: { plugin_id: "string" }
},
OwnerRef: {
strict: true,
required: { plugin_id: "string", generation: "uint", owner_token: "string" },
optional: {}
},
ProtocolRange: {
strict: true,
required: { min: "uint", max: "uint" },
optional: {}
},
RegisterParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool"] }, spec: { ref: "ToolSpecWire" } },
optional: {}
},
RpcErrorWire: {
strict: true,
required: { code: "integer", message: "string" },
optional: { data: "json" }
},
ToolCallParams: {
strict: false,
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
optional: {}
},
ToolSpecWire: {
strict: true,
required: { name: "string", description: "string", input_schema: "object" },
optional: {}
},
UnregisterParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, handle: "uint" },
optional: {}
}
};
// src/protocol.ts
var MAGIC = Buffer.from(MAGIC_ASCII, "ascii");
var FrameError = class extends Error {
constructor(message) {
super(message);
@@ -76,157 +177,101 @@ var FrameDecoder = class {
function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function checkShape(where, value, required, optional = {}, strict = false) {
function checkShape(where, value, shape) {
if (!isObject(value)) throw new ProtocolError(`${where}: expected an object`);
for (const [key, kind] of Object.entries(required)) {
for (const [key, kind] of Object.entries(shape.required)) {
if (!(key in value)) throw new ProtocolError(`${where}: missing field \`${key}\``);
checkKind(`${where}.${key}`, value[key], kind);
}
for (const [key, kind] of Object.entries(optional)) {
if (key in value && value[key] !== void 0) checkKind(`${where}.${key}`, value[key], kind);
for (const [key, kind] of Object.entries(shape.optional)) {
if (value[key] !== void 0 && value[key] !== null) checkKind(`${where}.${key}`, value[key], kind);
}
if (strict) {
if (shape.strict) {
for (const key of Object.keys(value)) {
if (!(key in required) && !(key in optional)) {
if (!(key in shape.required) && !(key in shape.optional)) {
throw new ProtocolError(`${where}: unknown field \`${key}\``);
}
}
}
}
function checkKind(where, value, kind) {
if (typeof kind === "object") {
if ("ref" in kind) return checkShape(where, value, SHAPES[kind.ref]);
if ("enum" in kind) {
if (typeof value !== "string" || !kind.enum.includes(value)) {
throw new ProtocolError(`${where}: expected one of ${kind.enum.map((v) => `\`${v}\``).join(", ")}`);
}
return;
}
if (!Array.isArray(value)) throw new ProtocolError(`${where}: expected an array`);
value.forEach((item, index) => checkKind(`${where}[${index}]`, item, kind.items));
return;
}
switch (kind) {
case "string":
if (typeof value !== "string") throw new ProtocolError(`${where}: expected a string`);
return;
case "number":
if (typeof value !== "number" || !Number.isFinite(value)) throw new ProtocolError(`${where}: expected a number`);
return;
case "u64":
case "uint":
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new ProtocolError(`${where}: expected an unsigned integer`);
}
return;
case "integer":
if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new ProtocolError(`${where}: expected an integer`);
return;
case "boolean":
if (typeof value !== "boolean") throw new ProtocolError(`${where}: expected a boolean`);
return;
case "object":
if (!isObject(value)) throw new ProtocolError(`${where}: expected an object`);
return;
case "array":
if (!Array.isArray(value)) throw new ProtocolError(`${where}: expected an array`);
return;
case "owner":
checkShape(where, value, { plugin_id: "string", generation: "u64", owner_token: "string" }, {}, true);
return;
case "json":
return;
}
}
var PARAMS = {
// host → core
"host/hello": {
dir: "host",
required: { protocol: "object", host_version: "string", bundle_sha256: "string", runtime: "object" },
// A kernel memory limit the host applied to itself (macOS + Bun), in MiB.
optional: { memory_limit_mib: "u64" },
check: (p, strict) => {
checkShape("host/hello.protocol", p.protocol, { min: "u64", max: "u64" }, {}, strict);
checkShape("host/hello.runtime", p.runtime, { name: "string", version: "string" }, {}, strict);
if (p.runtime.name !== "bun" && p.runtime.name !== "node") {
throw new ProtocolError(`host/hello.runtime.name: unknown runtime \`${p.runtime.name}\``);
}
}
},
"host/ready": { dir: "host", required: {} },
"registry/register": {
dir: "host",
required: { owner: "owner", kind: "string", spec: "object" },
check: (p, strict) => {
if (p.kind !== "tool") throw new ProtocolError(`registry/register: unsupported kind \`${p.kind}\``);
checkShape("registry/register.spec", p.spec, { name: "string", description: "string", input_schema: "object" }, {}, strict);
}
},
"registry/unregister": { dir: "host", required: { owner: "owner", handle: "u64" } },
"ext/faulted": { dir: "host", required: { owner: "owner", error: "string" } },
log: { dir: "host", required: { level: "string", msg: "string" }, optional: { plugin_id: "string" } },
// core → host
"host/initialize": {
dir: "core",
required: { protocol: "u64", limits: "object" },
check: (p, strict) => checkShape(
"host/initialize.limits",
p.limits,
{ max_frame: "u64", max_inflight: "u64", dispose_deadline_ms: "u64", activate_deadline_ms: "u64" },
{},
strict
)
},
"host/shutdown": { dir: "core", required: {} },
"host/ping": { dir: "core", required: {} },
"ext/activate": {
dir: "core",
required: { owner: "owner", plugin_name: "string", entry: "object" },
optional: { config: "json" },
check: (p, strict) => checkShape("ext/activate.entry", p.entry, { path: "string", sha256: "string" }, {}, strict)
},
"ext/deactivate": { dir: "core", required: { owner: "owner" } },
"tool/call": {
dir: "core",
required: { handle: "u64", call_id: "string", input: "json", deadline_ms: "u64" }
}
};
var CANCEL_SHAPE = { id: "u64" };
function validateMessage(value, direction) {
const strict = direction === "host_to_core";
if (!isObject(value)) throw new ProtocolError("message: expected an object");
if (value.jsonrpc !== "2.0") throw new ProtocolError('message: jsonrpc must be "2.0"');
const hasId = "id" in value;
if (hasId) checkKind("message.id", value.id, "u64");
if (hasId) checkKind("message.id", value.id, "uint");
if ("method" in value) {
checkShape("message", value, { jsonrpc: "string", method: "string" }, { id: "u64", params: "json" }, strict);
checkShape("message", value, { strict, required: { jsonrpc: "string", method: "string" }, optional: { id: "uint", params: "json" } });
const method = value.method;
const params = value.params ?? {};
if (method === "$/cancel") {
if (hasId) throw new ProtocolError("$/cancel must be a notification");
checkShape("$/cancel", params, CANCEL_SHAPE, {}, strict);
return value;
const spec = METHODS.find((entry) => entry.name === method && entry.direction === direction);
if (!spec) throw new ProtocolError(`unknown ${direction} method \`${method}\``);
if (spec.request !== hasId) {
throw new ProtocolError(`\`${method}\` must be ${spec.request ? "a request (with id)" : "a notification (no id)"}`);
}
const spec = PARAMS[method];
const expectedDir = direction === "host_to_core" ? "host" : "core";
if (!spec || spec.dir !== expectedDir) {
throw new ProtocolError(`unknown ${direction} method \`${method}\``);
const params = "params" in value ? value.params : {};
checkShape(method, params, SHAPES[spec.params]);
if (method === "host/hello" && params.runtime.name !== "bun" && params.runtime.name !== "node") {
throw new ProtocolError(`host/hello.runtime.name: unknown runtime \`${params.runtime.name}\``);
}
const isRequest = (direction === "host_to_core" ? HOST_TO_CORE.requests : CORE_TO_HOST.requests).includes(
method
);
if (isRequest !== hasId) {
throw new ProtocolError(`\`${method}\` must be ${isRequest ? "a request (with id)" : "a notification (no id)"}`);
}
checkShape(method, params, spec.required, spec.optional ?? {}, strict);
spec.check?.(params, strict);
return value;
}
if (!hasId) throw new ProtocolError("response: missing id");
if ("error" in value) {
checkShape("message", value, { jsonrpc: "string", id: "u64", error: "object" }, {}, strict);
checkShape("error", value.error, { code: "number", message: "string" }, { data: "json" }, strict);
checkShape("message", value, { strict, required: { jsonrpc: "string", id: "uint", error: { ref: "RpcErrorWire" } }, optional: {} });
return value;
}
if (!("result" in value)) throw new ProtocolError("response: needs result or error");
checkShape("message", value, { jsonrpc: "string", id: "u64", result: "json" }, {}, strict);
checkShape("message", value, { strict, required: { jsonrpc: "string", id: "uint", result: "json" }, optional: {} });
return value;
}
export {
CORE_TO_HOST,
ErrorCode,
FrameDecoder,
FrameError,
HEADER_LEN,
HOST_TO_CORE,
MAGIC,
MAGIC_ASCII,
MAX_FRAME,
MAX_INFLIGHT,
METHODS,
PROTOCOL_VERSION,
ProtocolError,
SHAPES,
encodeFrame,
validateMessage
};
@@ -0,0 +1,275 @@
// @generated from the Rust protocol types in crates/tui/src/extension_host/protocol.rs
// by `extension_host::protocol::tests`. Do not edit: change the Rust side, re-record with
// CODEWHALE_CONFORMANCE_UPDATE=1 cargo test -p codewhale-tui --lib extension_host::protocol
// and rebuild dist/ with `npm run build`.
export const PROTOCOL_VERSION = 1
export const MAGIC_ASCII = 'CWX1'
export const HEADER_LEN = 8
export const MAX_FRAME = 33554432
export const MAX_INFLIGHT = 256
/** JSON-RPC error codes used on this channel. */
export const ErrorCode = {
ParseError: -32700,
InvalidRequest: -32600,
MethodNotFound: -32601,
InvalidParams: -32602,
Internal: -32603,
ExecutionFailed: -32000,
NotAvailable: -32001,
Cancelled: -32800,
} as const
export type Direction = 'core_to_host' | 'host_to_core'
/** Every method either side may send; nothing else is admitted. */
export const METHODS = [
{ name: 'host/initialize', direction: 'core_to_host', request: true, params: 'InitializeParams' },
{ name: 'host/ping', direction: 'core_to_host', request: true, params: 'EmptyParams' },
{ name: 'host/shutdown', direction: 'core_to_host', request: true, params: 'EmptyParams' },
{ name: 'ext/activate', direction: 'core_to_host', request: true, params: 'ActivateParams' },
{ name: 'ext/deactivate', direction: 'core_to_host', request: true, params: 'DeactivateParams' },
{ name: 'tool/call', direction: 'core_to_host', request: true, params: 'ToolCallParams' },
{ name: '$/cancel', direction: 'core_to_host', request: false, params: 'CancelParams' },
{ name: 'host/hello', direction: 'host_to_core', request: false, params: 'HelloParams' },
{ name: 'host/ready', direction: 'host_to_core', request: false, params: 'EmptyParams' },
{ name: 'registry/register', direction: 'host_to_core', request: true, params: 'RegisterParams' },
{ name: 'registry/unregister', direction: 'host_to_core', request: true, params: 'UnregisterParams' },
{ name: 'ext/faulted', direction: 'host_to_core', request: false, params: 'FaultedParams' },
{ name: 'log', direction: 'host_to_core', request: false, params: 'LogParams' },
{ name: '$/cancel', direction: 'host_to_core', request: false, params: 'CancelParams' },
] as const
/** A field's wire kind: the Rust field's serde type, normalized for validation. */
export type Kind =
| 'string'
| 'boolean'
| 'uint'
| 'integer'
| 'object'
| 'json'
| { readonly ref: string }
| { readonly enum: readonly string[] }
| { readonly items: Kind }
/** An object's fields; `strict` is Rust's `deny_unknown_fields`. */
export interface Shape {
readonly strict: boolean
readonly required: { readonly [field: string]: Kind }
readonly optional: { readonly [field: string]: Kind }
}
/** Every method's params, and an error response's `error`. */
export const SHAPES: { readonly [name: string]: Shape } = {
ActivateParams: {
strict: false,
required: { owner: { ref: 'OwnerRef' }, plugin_name: 'string', entry: { ref: 'EntryRef' } },
optional: { config: 'json' },
},
CancelParams: {
strict: true,
required: { id: 'uint' },
optional: {},
},
DeactivateParams: {
strict: false,
required: { owner: { ref: 'OwnerRef' } },
optional: {},
},
EmptyParams: {
strict: true,
required: {},
optional: {},
},
EntryRef: {
strict: false,
required: { path: 'string', sha256: 'string' },
optional: {},
},
FaultedParams: {
strict: true,
required: { owner: { ref: 'OwnerRef' }, error: 'string' },
optional: {},
},
HelloParams: {
strict: true,
required: { protocol: { ref: 'ProtocolRange' }, host_version: 'string', bundle_sha256: 'string', runtime: { ref: 'HelloRuntime' } },
optional: { memory_limit_mib: 'uint' },
},
HelloRuntime: {
strict: true,
required: { name: 'string', version: 'string' },
optional: {},
},
HostLimits: {
strict: false,
required: { max_frame: 'uint', max_inflight: 'uint', dispose_deadline_ms: 'uint', activate_deadline_ms: 'uint' },
optional: {},
},
InitializeParams: {
strict: false,
required: { protocol: 'uint', limits: { ref: 'HostLimits' } },
optional: {},
},
LogParams: {
strict: true,
required: { level: 'string', msg: 'string' },
optional: { plugin_id: 'string' },
},
OwnerRef: {
strict: true,
required: { plugin_id: 'string', generation: 'uint', owner_token: 'string' },
optional: {},
},
ProtocolRange: {
strict: true,
required: { min: 'uint', max: 'uint' },
optional: {},
},
RegisterParams: {
strict: true,
required: { owner: { ref: 'OwnerRef' }, kind: { enum: ['tool'] }, spec: { ref: 'ToolSpecWire' } },
optional: {},
},
RpcErrorWire: {
strict: true,
required: { code: 'integer', message: 'string' },
optional: { data: 'json' },
},
ToolCallParams: {
strict: false,
required: { handle: 'uint', call_id: 'string', input: 'json', deadline_ms: 'uint' },
optional: {},
},
ToolSpecWire: {
strict: true,
required: { name: 'string', description: 'string', input_schema: 'object' },
optional: {},
},
UnregisterParams: {
strict: true,
required: { owner: { ref: 'OwnerRef' }, handle: 'uint' },
optional: {},
},
}
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
export interface ActivateParams {
owner: OwnerRef
plugin_name: string
entry: EntryRef
config?: Json
}
export type ActivateResult = { status: 'ok'; tools: string[] } | { status: 'failed'; diagnostic: string }
export interface CancelParams {
id: number
}
export type ContentBlockWire = { type: 'text'; text: string }
export interface DeactivateParams {
owner: OwnerRef
}
export interface DeactivateResult {
disposed: boolean
leaked: string[]
}
export interface EmptyParams {}
export interface EntryRef {
path: string
sha256: string
}
export interface FaultedParams {
owner: OwnerRef
error: string
}
export interface HelloParams {
protocol: ProtocolRange
host_version: string
bundle_sha256: string
runtime: HelloRuntime
memory_limit_mib?: number
}
export interface HelloRuntime {
name: string
version: string
}
export interface HostLimits {
max_frame: number
max_inflight: number
dispose_deadline_ms: number
activate_deadline_ms: number
}
export interface InitializeParams {
protocol: number
limits: HostLimits
}
export interface LogParams {
level: string
msg: string
plugin_id?: string
}
export interface OwnerRef {
plugin_id: string
generation: number
owner_token: string
}
export interface ProtocolRange {
min: number
max: number
}
export type RegisterKind = 'tool'
export interface RegisterParams {
owner: OwnerRef
kind: RegisterKind
spec: ToolSpecWire
}
export type RegisterResult = { handle: number } | { refused: string }
export interface RpcErrorWire {
code: number
message: string
data?: Json
}
export interface ToolCallParams {
handle: number
call_id: string
input: Json
deadline_ms: number
}
export interface ToolResultWire {
content: ContentBlockWire[]
is_error: boolean
structured?: Json
}
export interface ToolSpecWire {
name: string
description: string
input_schema: { [key: string]: Json }
}
export interface UnregisterParams {
owner: OwnerRef
handle: number
}
+56 -179
View File
@@ -2,76 +2,22 @@
* Codewhale extension-host protocol, version 1 (phase 1 subset).
*
* The Rust serde types in `crates/tui/src/extension_host/protocol.rs` are the
* source of truth. Phase 1 keeps this file hand-written and pins both sides
* to the shared corpus in `crates/tui/tests/fixtures/extension_host/protocol`,
* which both parsers must accept (or reject) identically and round-trip.
* source of truth. The constants, the method table, every params shape and the
* wire types come from `protocol.generated.ts`, which a Rust test renders from
* those types and fails on when the committed file drifts. Hand-written here:
* the frame codec, the JSON-RPC envelope checks, and the one rule Rust applies
* beyond its types (`host/hello`'s runtime name). Both sides also parse the
* shared corpus in `crates/tui/tests/fixtures/extension_host/protocol`.
*
* Frame: 4-byte magic `CWX1`, u32 little-endian payload length, UTF-8 JSON.
* Envelope: JSON-RPC 2.0.
*/
import { MAGIC_ASCII, MAX_FRAME, HEADER_LEN, METHODS, SHAPES } from './protocol.generated.ts'
import type { Direction, Kind, RpcErrorWire, Shape } from './protocol.generated.ts'
export const PROTOCOL_VERSION = 1
export const MAGIC = Buffer.from('CWX1', 'ascii')
export const MAX_FRAME = 32 * 1024 * 1024
export const HEADER_LEN = 8
export * from './protocol.generated.ts'
/** JSON-RPC error codes used on this channel. */
export const ErrorCode = {
ParseError: -32700,
InvalidRequest: -32600,
MethodNotFound: -32601,
InvalidParams: -32602,
Internal: -32603,
/** The tool body threw. */
ExecutionFailed: -32000,
/** The handle is unknown, revoked, or its owner is not active. */
NotAvailable: -32001,
/** Cancelled by `$/cancel` (LSP's RequestCancelled). */
Cancelled: -32800,
} as const
/** Methods the core sends to the host. */
export const CORE_TO_HOST = {
requests: ['host/initialize', 'host/shutdown', 'host/ping', 'ext/activate', 'ext/deactivate', 'tool/call'],
notifications: ['$/cancel'],
} as const
/** Methods the host sends to the core. */
export const HOST_TO_CORE = {
requests: ['registry/register', 'registry/unregister'],
notifications: ['host/hello', 'host/ready', 'ext/faulted', 'log', '$/cancel'],
} as const
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
export interface OwnerRef {
plugin_id: string
generation: number
owner_token: string
}
export interface ToolSpecWire {
name: string
description: string
input_schema: { [key: string]: Json }
}
export interface ContentBlockWire {
type: 'text'
text: string
}
export interface ToolResultWire {
content: ContentBlockWire[]
is_error: boolean
structured?: Json
}
export interface RpcErrorWire {
code: number
message: string
data?: Json
}
export const MAGIC = Buffer.from(MAGIC_ASCII, 'ascii')
export type Message =
| { jsonrpc: '2.0'; id: number; method: string; params?: any }
@@ -134,175 +80,106 @@ export class FrameDecoder {
}
// ---------------------------------------------------------------------------
// Validation. Host→core output is validated strictly before it is sent (the
// Rust side uses deny_unknown_fields for it); core→host input is validated
// tolerantly (unknown fields ignored), mirroring the Rust side.
// Validation, driven by the generated shapes. A shape is strict (unknown fields
// rejected) exactly where the Rust type has `deny_unknown_fields`: every
// host→core type and `OwnerRef`. The envelope is checked strictly for
// host→core messages and tolerantly for core→host ones.
// ---------------------------------------------------------------------------
type Shape = Record<string, 'string' | 'number' | 'boolean' | 'object' | 'array' | 'json' | 'owner' | 'u64'>
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function checkShape(
where: string,
value: unknown,
required: Shape,
optional: Shape = {},
strict = false,
): asserts value is Record<string, any> {
function checkShape(where: string, value: unknown, shape: Shape): asserts value is Record<string, any> {
if (!isObject(value)) throw new ProtocolError(`${where}: expected an object`)
for (const [key, kind] of Object.entries(required)) {
for (const [key, kind] of Object.entries(shape.required)) {
if (!(key in value)) throw new ProtocolError(`${where}: missing field \`${key}\``)
checkKind(`${where}.${key}`, value[key], kind)
}
for (const [key, kind] of Object.entries(optional)) {
if (key in value && value[key] !== undefined) checkKind(`${where}.${key}`, value[key], kind)
for (const [key, kind] of Object.entries(shape.optional)) {
// Every optional Rust field is an `Option` or a defaulted JSON value, so
// an explicit `null` reads as absent there too.
if (value[key] !== undefined && value[key] !== null) checkKind(`${where}.${key}`, value[key], kind)
}
if (strict) {
if (shape.strict) {
for (const key of Object.keys(value)) {
if (!(key in required) && !(key in optional)) {
if (!(key in shape.required) && !(key in shape.optional)) {
throw new ProtocolError(`${where}: unknown field \`${key}\``)
}
}
}
}
function checkKind(where: string, value: unknown, kind: Shape[string]) {
function checkKind(where: string, value: unknown, kind: Kind): void {
if (typeof kind === 'object') {
if ('ref' in kind) return checkShape(where, value, SHAPES[kind.ref])
if ('enum' in kind) {
if (typeof value !== 'string' || !kind.enum.includes(value)) {
throw new ProtocolError(`${where}: expected one of ${kind.enum.map((v) => `\`${v}\``).join(', ')}`)
}
return
}
if (!Array.isArray(value)) throw new ProtocolError(`${where}: expected an array`)
value.forEach((item, index) => checkKind(`${where}[${index}]`, item, kind.items))
return
}
switch (kind) {
case 'string':
if (typeof value !== 'string') throw new ProtocolError(`${where}: expected a string`)
return
case 'number':
if (typeof value !== 'number' || !Number.isFinite(value)) throw new ProtocolError(`${where}: expected a number`)
return
case 'u64':
case 'uint':
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new ProtocolError(`${where}: expected an unsigned integer`)
}
return
case 'integer':
if (typeof value !== 'number' || !Number.isSafeInteger(value)) throw new ProtocolError(`${where}: expected an integer`)
return
case 'boolean':
if (typeof value !== 'boolean') throw new ProtocolError(`${where}: expected a boolean`)
return
case 'object':
if (!isObject(value)) throw new ProtocolError(`${where}: expected an object`)
return
case 'array':
if (!Array.isArray(value)) throw new ProtocolError(`${where}: expected an array`)
return
case 'owner':
checkShape(where, value, { plugin_id: 'string', generation: 'u64', owner_token: 'string' }, {}, true)
return
case 'json':
return
}
}
/** Params validation per method and direction. `strict` = deny unknown fields. */
const PARAMS: Record<string, { dir: 'core' | 'host'; required: Shape; optional?: Shape; check?: (p: any, strict: boolean) => void }> = {
// host → core
'host/hello': {
dir: 'host',
required: { protocol: 'object', host_version: 'string', bundle_sha256: 'string', runtime: 'object' },
// A kernel memory limit the host applied to itself (macOS + Bun), in MiB.
optional: { memory_limit_mib: 'u64' },
check: (p, strict) => {
checkShape('host/hello.protocol', p.protocol, { min: 'u64', max: 'u64' }, {}, strict)
checkShape('host/hello.runtime', p.runtime, { name: 'string', version: 'string' }, {}, strict)
if (p.runtime.name !== 'bun' && p.runtime.name !== 'node') {
throw new ProtocolError(`host/hello.runtime.name: unknown runtime \`${p.runtime.name}\``)
}
},
},
'host/ready': { dir: 'host', required: {} },
'registry/register': {
dir: 'host',
required: { owner: 'owner', kind: 'string', spec: 'object' },
check: (p, strict) => {
if (p.kind !== 'tool') throw new ProtocolError(`registry/register: unsupported kind \`${p.kind}\``)
checkShape('registry/register.spec', p.spec, { name: 'string', description: 'string', input_schema: 'object' }, {}, strict)
},
},
'registry/unregister': { dir: 'host', required: { owner: 'owner', handle: 'u64' } },
'ext/faulted': { dir: 'host', required: { owner: 'owner', error: 'string' } },
log: { dir: 'host', required: { level: 'string', msg: 'string' }, optional: { plugin_id: 'string' } },
// core → host
'host/initialize': {
dir: 'core',
required: { protocol: 'u64', limits: 'object' },
check: (p, strict) =>
checkShape(
'host/initialize.limits',
p.limits,
{ max_frame: 'u64', max_inflight: 'u64', dispose_deadline_ms: 'u64', activate_deadline_ms: 'u64' },
{},
strict,
),
},
'host/shutdown': { dir: 'core', required: {} },
'host/ping': { dir: 'core', required: {} },
'ext/activate': {
dir: 'core',
required: { owner: 'owner', plugin_name: 'string', entry: 'object' },
optional: { config: 'json' },
check: (p, strict) => checkShape('ext/activate.entry', p.entry, { path: 'string', sha256: 'string' }, {}, strict),
},
'ext/deactivate': { dir: 'core', required: { owner: 'owner' } },
'tool/call': {
dir: 'core',
required: { handle: 'u64', call_id: 'string', input: 'json', deadline_ms: 'u64' },
},
}
/** `$/cancel` flows both ways. */
const CANCEL_SHAPE: Shape = { id: 'u64' }
export type Direction = 'core_to_host' | 'host_to_core'
/**
* Validate one decoded message travelling in `direction`. Host→core messages
* are validated strictly (unknown fields rejected); core→host tolerantly.
* Responses are validated as envelopes only: their result shape depends on the
* request, which the RPC layer checks.
* Validate one decoded message travelling in `direction`. Only methods in the
* generated table are admitted, each with its params shape. Responses are
* validated as envelopes only: their result shape depends on the request,
* which the RPC layer checks.
*/
export function validateMessage(value: unknown, direction: Direction): Message {
const strict = direction === 'host_to_core'
if (!isObject(value)) throw new ProtocolError('message: expected an object')
if (value.jsonrpc !== '2.0') throw new ProtocolError('message: jsonrpc must be "2.0"')
const hasId = 'id' in value
if (hasId) checkKind('message.id', value.id, 'u64')
if (hasId) checkKind('message.id', value.id, 'uint')
if ('method' in value) {
checkShape('message', value, { jsonrpc: 'string', method: 'string' }, { id: 'u64', params: 'json' }, strict)
checkShape('message', value, { strict, required: { jsonrpc: 'string', method: 'string' }, optional: { id: 'uint', params: 'json' } })
const method = value.method as string
const params = value.params ?? {}
if (method === '$/cancel') {
if (hasId) throw new ProtocolError('$/cancel must be a notification')
checkShape('$/cancel', params, CANCEL_SHAPE, {}, strict)
return value as Message
const spec = METHODS.find((entry) => entry.name === method && entry.direction === direction)
if (!spec) throw new ProtocolError(`unknown ${direction} method \`${method}\``)
if (spec.request !== hasId) {
throw new ProtocolError(`\`${method}\` must be ${spec.request ? 'a request (with id)' : 'a notification (no id)'}`)
}
const spec = PARAMS[method]
const expectedDir = direction === 'host_to_core' ? 'host' : 'core'
if (!spec || spec.dir !== expectedDir) {
throw new ProtocolError(`unknown ${direction} method \`${method}\``)
const params = 'params' in value ? value.params : {}
checkShape(method, params, SHAPES[spec.params])
// Rust checks this after decoding `HelloParams` (`parse_host_message`).
if (method === 'host/hello' && params.runtime.name !== 'bun' && params.runtime.name !== 'node') {
throw new ProtocolError(`host/hello.runtime.name: unknown runtime \`${params.runtime.name}\``)
}
const isRequest = (direction === 'host_to_core' ? HOST_TO_CORE.requests : CORE_TO_HOST.requests).includes(
method as never,
)
if (isRequest !== hasId) {
throw new ProtocolError(`\`${method}\` must be ${isRequest ? 'a request (with id)' : 'a notification (no id)'}`)
}
checkShape(method, params, spec.required, spec.optional ?? {}, strict)
spec.check?.(params, strict)
return value as Message
}
if (!hasId) throw new ProtocolError('response: missing id')
if ('error' in value) {
checkShape('message', value, { jsonrpc: 'string', id: 'u64', error: 'object' }, {}, strict)
checkShape('error', value.error, { code: 'number', message: 'string' }, { data: 'json' }, strict)
checkShape('message', value, { strict, required: { jsonrpc: 'string', id: 'uint', error: { ref: 'RpcErrorWire' } }, optional: {} })
return value as Message
}
if (!('result' in value)) throw new ProtocolError('response: needs result or error')
checkShape('message', value, { jsonrpc: 'string', id: 'u64', result: 'json' }, {}, strict)
checkShape('message', value, { strict, required: { jsonrpc: 'string', id: 'uint', result: 'json' }, optional: {} })
return value as Message
}
+11 -11
View File
@@ -11,7 +11,16 @@ import { readFile } from 'node:fs/promises'
import { AsyncLocalStorage } from 'node:async_hooks'
import { pathToFileURL } from 'node:url'
import { Context, Inject, Service } from '@deepseek-ai/cordis'
import { ErrorCode, type ContentBlockWire, type Json, type OwnerRef, type ToolResultWire } from './protocol.ts'
import {
ErrorCode,
type ActivateParams,
type ActivateResult,
type ContentBlockWire,
type DeactivateResult,
type Json,
type OwnerRef,
type ToolResultWire,
} from './protocol.ts'
import { RpcError, type RpcPeer } from './rpc.ts'
import { explainImportError } from './dsh/resolve-hooks.ts'
@@ -65,15 +74,6 @@ interface LocalTool {
disposed: boolean
}
export interface ActivateParams {
owner: OwnerRef
plugin_name: string
entry: { path: string; sha256: string }
config?: Json
}
export type ActivateResult = { status: 'ok'; tools: string[] } | { status: 'failed'; diagnostic: string }
export const ownerStorage = new AsyncLocalStorage<OwnerRecord>()
function describeError(error: unknown): string {
@@ -289,7 +289,7 @@ export class HostRoot {
return owner.disposing
}
async deactivate(ref: OwnerRef): Promise<{ disposed: boolean; leaked: string[] }> {
async deactivate(ref: OwnerRef): Promise<DeactivateResult> {
const owner = this.owners.get(ref.owner_token)
if (!owner) return { disposed: true, leaked: [] }
let disposed = true
+2 -4
View File
@@ -5,7 +5,7 @@
* core aborts it. The core resolves a cancelled call on its own side after a
* 500 ms grace, so a late answer here is harmless (the core drops it).
*/
import { ErrorCode, validateMessage, type Message, type RpcErrorWire } from './protocol.ts'
import { ErrorCode, MAX_INFLIGHT, validateMessage, type Message, type RpcErrorWire } from './protocol.ts'
export class RpcError extends Error {
constructor(
@@ -32,9 +32,6 @@ export interface RequestContext {
type RequestHandler = (params: any, cx: RequestContext) => Promise<unknown> | unknown
type NotificationHandler = (params: any) => void
/** Maximum outbound requests awaiting an answer (matches the core's limit). */
export const MAX_INFLIGHT = 256
export class RpcPeer {
private nextId = 1
private readonly pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>()
@@ -56,6 +53,7 @@ export class RpcPeer {
/** Send a host→core request. Outbound messages are validated strictly first. */
request<T = any>(method: string, params: unknown): Promise<T> {
if (this.closed) return Promise.reject(new RpcError(ErrorCode.NotAvailable, 'channel closed'))
// The core's per-direction limit.
if (this.pending.size >= MAX_INFLIGHT) {
return Promise.reject(new RpcError(ErrorCode.Internal, `more than ${MAX_INFLIGHT} requests in flight`))
}
@@ -356,12 +356,10 @@ fn list_bundles_and_legacy_tools(
output
};
append_diagnostics(presentation, &mut output, &plugin.registry_diagnostics());
if let Some(report) = crate::extension_host::status_report() {
// Diagnostics and the stderr tail carry plugin-controlled text.
for line in report.lines() {
output.push('\n');
output.push_str(&escape_review_text(line));
}
// Diagnostics and the stderr tail carry plugin-controlled text.
for line in crate::extension_host::status_report().lines() {
output.push('\n');
output.push_str(&escape_review_text(line));
}
if let Ok(Some(scan)) = plugin.legacy_scan() {
+1 -1
View File
@@ -38,7 +38,7 @@
#[cfg(test)]
mod events;
#[cfg(test)]
mod golden;
pub(crate) mod golden;
#[cfg(unix)]
#[cfg(test)]
mod hooks;
+1 -1
View File
@@ -63,7 +63,7 @@ pub(super) fn update_mode() -> bool {
/// Compare `actual` with the golden at `path`; in update mode write it
/// instead. Returns a failure description rather than panicking so a family
/// can report every drifted case in one run.
pub(super) fn check_golden(path: &Path, actual: &str) -> Result<(), String> {
pub(crate) fn check_golden(path: &Path, actual: &str) -> Result<(), String> {
let update = update_mode();
let expected = std::fs::read_to_string(path).ok();
if expected.as_deref() == Some(actual) {
+120 -74
View File
@@ -36,6 +36,11 @@
//! explicit plugin change/reload to retry. Two dirty teardowns within ten
//! minutes retire the process once non-heartbeat calls are idle, without
//! resetting or consuming the unexpected-crash budget.
//! * Every awaited core→host request is bounded by its method's deadline
//! (`protocol::CoreRequest::deadline`) and then cancelled with `$/cancel`
//! (`supervisor::HostProcess::call`). Cancellation is a request: a plugin
//! that ignores its abort signal keeps running in the host until the host
//! is torn down, though the core has already failed the call.
//! * One host per engine process and one trust tier. On macOS (Seatbelt) the
//! host has no direct network, and cannot read the Codewhale home (except
//! the bundle, its data dir and plugin code), the Codex and DSH credential
@@ -93,6 +98,7 @@ pub(crate) mod tool;
pub(crate) mod tests;
use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
@@ -106,7 +112,7 @@ use self::protocol::{
OwnerRef, RegisterResult,
};
use self::registry::{OwnerRegistry, OwnerState, ToolRegistration};
use self::supervisor::{ACTIVATE_DEADLINE, DISPOSE_DEADLINE, HostEvents, HostProcess};
use self::supervisor::{HostEvents, HostProcess};
use crate::plugins::PluginRegistry;
use crate::plugins::activation::{self, PluginActivationCapability};
use crate::plugins::types::PluginAuthority;
@@ -223,6 +229,10 @@ pub struct SupervisionOptions {
pub dirty_limit: usize,
/// Host memory cap in bytes (`supervisor::HOST_MEMORY_CAP`).
pub memory_cap: u64,
/// How long one extension tool call may take before the core cancels it
/// (`tool::TOOL_CALL_DEADLINE`); sent to the host as `deadline_ms`. The
/// other methods' deadlines are fixed (`CoreRequest::deadline`).
pub tool_call_deadline: Duration,
}
impl ExtensionHostOptions {
@@ -246,7 +256,7 @@ impl Default for SupervisionOptions {
Self {
heartbeat_interval: Duration::from_secs(3),
ping_timeout: Duration::from_secs(3),
hang_timeout: Duration::from_secs(10),
hang_timeout: supervisor::PING_DEADLINE,
restart_backoff: Duration::from_millis(250),
crash_window: Duration::from_secs(5 * 60),
crash_limit: 3,
@@ -254,6 +264,7 @@ impl Default for SupervisionOptions {
dirty_window: Duration::from_secs(10 * 60),
dirty_limit: 2,
memory_cap: supervisor::HOST_MEMORY_CAP,
tool_call_deadline: tool::TOOL_CALL_DEADLINE,
}
}
}
@@ -302,9 +313,13 @@ impl SupervisionState {
}
}
/// Observable host state, for `/plugin`, doctor and tests.
/// Observable host state: the one source for `/plugin` and for the error a
/// tool call routed to the host gets while it is down ([`fmt::Display`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostStatus {
/// `[features] extension_host` is off for this process.
Disabled,
/// Not started: nothing has needed it yet.
Idle,
Starting,
Ready {
@@ -321,15 +336,41 @@ pub enum HostStatus {
Unresponsive {
pid: Option<u32>,
},
/// Crashed (or retired for maintenance); the supervisor restarts it.
Restarting {
reason: String,
},
/// Start refused (`start failed: …`) or crash budget exhausted; only an
/// explicit plugin change or reload retries.
Failed {
reason: String,
stderr_tail: String,
},
}
/// Why the host can or cannot take a call, in one phrase.
impl fmt::Display for HostStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Disabled => f.write_str("disabled by config ([features] extension_host is off)"),
Self::Idle => f.write_str(
"not started (it starts in the background when a reviewed plugin with host code is enabled)",
),
Self::Starting => f.write_str("starting"),
Self::Ready { .. } => f.write_str("running"),
Self::Unresponsive { pid } => write!(
f,
"unresponsive (pid {} missed its heartbeat; the supervisor restarts it if it stays silent)",
pid.map_or_else(|| "?".to_string(), |pid| pid.to_string())
),
Self::Restarting { reason } => write!(f, "restarting after: {reason}"),
Self::Failed { reason, .. } => {
write!(f, "{reason} (change or reload a plugin to retry)")
}
}
}
}
enum HostSlot {
Idle,
Starting,
@@ -339,6 +380,42 @@ enum HostSlot {
Failed { reason: String, stderr_tail: String },
}
impl HostSlot {
fn status(&self) -> HostStatus {
match self {
Self::Idle => HostStatus::Idle,
Self::Starting => HostStatus::Starting,
Self::Ready(host) if host.is_retiring() => HostStatus::Restarting {
reason: DIRTY_RESTART_REASON.to_string(),
},
// The exit watcher reports the exit a moment later.
Self::Ready(host) | Self::Unresponsive(host) if host.has_exited() => {
HostStatus::Restarting {
reason: "the host process exited".to_string(),
}
}
Self::Ready(host) => HostStatus::Ready {
pid: host.pid,
runtime: host.runtime.kind.name(),
runtime_version: host.runtime_version.get().cloned().unwrap_or_default(),
sandbox: host.sandbox.clone(),
memory: host.memory(),
},
Self::Unresponsive(host) => HostStatus::Unresponsive { pid: host.pid },
Self::Restarting { reason } => HostStatus::Restarting {
reason: reason.clone(),
},
Self::Failed {
reason,
stderr_tail,
} => HostStatus::Failed {
reason: reason.clone(),
stderr_tail: stderr_tail.clone(),
},
}
}
}
struct DesiredOwner {
plugin_name: String,
authority: PluginAuthority,
@@ -431,12 +508,11 @@ fn prepare_launch(
impl ManagerShared {
async fn deactivate_owner(&self, host: &Arc<HostProcess>, owner: &OwnerRef) {
let diagnostic = match host
.request_with_deadline(
.call(
CoreRequest::Deactivate(DeactivateParams {
owner: owner.clone(),
}),
None,
DISPOSE_DEADLINE + Duration::from_millis(500),
)
.await
.map(serde_json::from_value::<DeactivateResult>)
@@ -491,22 +567,29 @@ impl ManagerShared {
}
}
fn ready_host(&self) -> Option<Arc<HostProcess>> {
/// The host, when it can take a call; otherwise why not.
fn ready_host(&self) -> Result<Arc<HostProcess>, HostStatus> {
match &*self.host.lock().expect("host lock") {
HostSlot::Ready(host) if !host.has_exited() && !host.is_retiring() => {
Some(Arc::clone(host))
Ok(Arc::clone(host))
}
_ => None,
slot => Err(slot.status()),
}
}
/// Re-check everything a call depends on, immediately before it is sent:
/// a running host (first, so a call to a host that is down says why),
/// exact owner generation, the reviewed receipt and staged bytes, the
/// Native adapter in this build's policy, and a running host.
/// Native adapter in this build's policy, and the host again.
pub(crate) async fn live_host_for(
&self,
registration: &ToolRegistration,
) -> Result<Arc<HostProcess>, String> {
let policy = activation::extension_host_policy_enabled();
if !policy {
return Err(host_down(&HostStatus::Disabled));
}
self.ready_host().map_err(|status| host_down(&status))?;
let authority = {
let registry = self.registry.lock().expect("registry lock");
if !registry.is_live(registration.handle, &registration.owner) {
@@ -519,7 +602,6 @@ impl ManagerShared {
.authority_for(&registration.owner)
.ok_or_else(|| "extension owner has no authority".to_string())?
};
let policy = activation::extension_host_policy_enabled();
tokio::task::spawn_blocking(move || {
let _scope = activation::PolicyScope::propagate(policy);
crate::plugins::registry::verify_plugin_component_authority(
@@ -529,11 +611,15 @@ impl ManagerShared {
})
.await
.map_err(|error| format!("authority check failed: {error}"))??;
self.ready_host()
.ok_or_else(|| "the extension host is not running".to_string())
self.ready_host().map_err(|status| host_down(&status))
}
}
/// The error a host tool call gets while the host cannot take it.
fn host_down(status: &HostStatus) -> String {
format!("extension host is down: {status}")
}
/// Channel callbacks. Holds a `Weak` so the host process (which owns the
/// callbacks) never keeps the manager alive.
struct Events {
@@ -882,31 +968,7 @@ impl ExtensionHostManager {
#[must_use]
pub fn status(&self) -> HostStatus {
match &*self.shared.host.lock().expect("host lock") {
HostSlot::Idle => HostStatus::Idle,
HostSlot::Starting => HostStatus::Starting,
HostSlot::Ready(host) if host.is_retiring() => HostStatus::Restarting {
reason: DIRTY_RESTART_REASON.to_string(),
},
HostSlot::Ready(host) => HostStatus::Ready {
pid: host.pid,
runtime: host.runtime.kind.name(),
runtime_version: host.runtime_version.get().cloned().unwrap_or_default(),
sandbox: host.sandbox.clone(),
memory: host.memory(),
},
HostSlot::Unresponsive(host) => HostStatus::Unresponsive { pid: host.pid },
HostSlot::Restarting { reason } => HostStatus::Restarting {
reason: reason.clone(),
},
HostSlot::Failed {
reason,
stderr_tail,
} => HostStatus::Failed {
reason: reason.clone(),
stderr_tail: stderr_tail.clone(),
},
}
self.shared.host.lock().expect("host lock").status()
}
/// The pinned runtime's one-line summary, once a host on it has completed
@@ -1247,7 +1309,7 @@ impl ExtensionHostManager {
}
}
}
let host = shared.ready_host();
let host = shared.ready_host().ok();
for owner in revoked {
shared.plugin_diagnostic(
&owner.plugin_id,
@@ -1292,13 +1354,7 @@ impl ExtensionHostManager {
},
config: json!({}),
});
let outcome = host
.request_with_deadline(
request,
Some(plugin_id.to_string()),
ACTIVATE_DEADLINE + std::time::Duration::from_secs(1),
)
.await;
let outcome = host.call(request, Some(plugin_id.to_string())).await;
match outcome.map(serde_json::from_value::<ActivateResult>) {
Ok(Ok(ActivateResult::Ok { tools: mut names })) => tools.append(&mut names),
Ok(Ok(ActivateResult::Failed { diagnostic })) => {
@@ -1361,17 +1417,8 @@ impl ExtensionHostManager {
HostSlot::Ready(host) if !host.has_exited() && !host.is_retiring() => {
return Ok(Arc::clone(host));
}
HostSlot::Ready(_) | HostSlot::Unresponsive(_) => {
return Err("extension host is unavailable; waiting for supervision".into());
}
HostSlot::Restarting { .. } => return Err("extension host is restarting".into()),
HostSlot::Failed { reason, .. } => {
return Err(format!(
"extension host is failed ({reason}); change/reload a plugin to retry"
));
}
HostSlot::Starting => return Err("extension host is starting".into()),
HostSlot::Idle => {}
other => return Err(host_down(&other.status())),
}
*slot = HostSlot::Starting;
let mut supervision = shared.supervision.lock().expect("supervision lock");
@@ -1489,7 +1536,7 @@ impl ExtensionHostManager {
.expect("supervision lock")
.launch_failed = true;
*slot = HostSlot::Failed {
reason: reason.clone(),
reason: format!("start failed: {reason}"),
stderr_tail: String::new(),
};
drop(slot);
@@ -1531,12 +1578,15 @@ impl ExtensionHostManager {
#[cfg(test)]
pub(crate) fn host_requests_started(&self) -> Option<u64> {
self.shared.ready_host().map(|host| host.requests_started())
self.shared
.ready_host()
.ok()
.map(|host| host.requests_started())
}
#[cfg(test)]
pub(crate) fn host_pid(&self) -> Option<u32> {
self.shared.ready_host().and_then(|host| host.pid)
self.shared.ready_host().ok().and_then(|host| host.pid)
}
}
@@ -1546,10 +1596,6 @@ pub(crate) fn render_status(manager: &ExtensionHostManager) -> String {
let mut out = String::from("Extension host (experimental): ");
let status = manager.status();
match status.clone() {
HostStatus::Idle => out.push_str(
"not running (starts in the background when a reviewed plugin with host code is enabled)",
),
HostStatus::Starting => out.push_str("starting"),
HostStatus::Ready {
pid,
runtime,
@@ -1569,15 +1615,8 @@ pub(crate) fn render_status(manager: &ExtensionHostManager) -> String {
}
);
}
HostStatus::Unresponsive { pid } => {
let _ = write!(out, "unresponsive · pid {} (supervisor is waiting for a pong)", pid.map_or_else(|| "?".into(), |pid| pid.to_string()));
}
HostStatus::Restarting { reason } => { let _ = write!(out, "restarting after: {reason}"); }
HostStatus::Failed {
reason,
stderr_tail,
} => {
let _ = write!(out, "failed: {reason} (change/reload a plugin to retry)");
HostStatus::Failed { stderr_tail, .. } => {
let _ = write!(out, "{status}");
let tail = stderr_tail.trim();
if !tail.is_empty() {
let start = tail
@@ -1588,6 +1627,9 @@ pub(crate) fn render_status(manager: &ExtensionHostManager) -> String {
let _ = write!(out, "\n stderr: {}", &tail[start..]);
}
}
down => {
let _ = write!(out, "{down}");
}
}
if let Some(summary) = manager.runtime_summary() {
let _ = write!(out, "\n runtime: {summary}");
@@ -1644,10 +1686,14 @@ pub fn plugins_changed(plugins: Arc<PluginRegistry>) {
}
}
/// The `/plugin` section, or `None` when the experimental host is off.
/// The `/plugin` section. With the experimental host off it is one line
/// saying so.
#[must_use]
pub fn status_report() -> Option<String> {
activation::extension_host_policy_enabled().then(|| render_status(&manager()))
pub fn status_report() -> String {
if !activation::extension_host_policy_enabled() {
return format!("Extension host (experimental): {}", HostStatus::Disabled);
}
render_status(&manager())
}
pub struct OwnerReport {
+198 -43
View File
@@ -6,13 +6,26 @@
//! host instead of desynchronising it.
//!
//! Host→core types use `deny_unknown_fields` — host output is untrusted
//! input. Core→host types are what this side writes. The TypeScript mirror is
//! hand-written in phase 1 (`crates/tui/extension-host/src/protocol.ts`); both
//! parse the shared corpus in `tests/fixtures/extension_host/protocol`.
//! input. Core→host types are what this side writes.
//!
//! **This file is the protocol's single source.** [`METHODS`] is every method
//! either side may send; both parsers admit nothing else. The TypeScript
//! side's constants, method table, params shapes and wire types are generated
//! from the types here into `crates/tui/extension-host/src/protocol.generated.ts`
//! by `protocol/tests.rs`, which fails when the committed file drifts. What
//! stays hand-written in `protocol.ts`: the frame codec, the JSON-RPC envelope
//! checks, and the one rule [`parse_host_message`] applies beyond the types
//! (`host/hello`'s runtime name). Both sides also parse the shared corpus in
//! `tests/fixtures/extension_host/protocol`. Known limit: result shapes are
//! generated as TypeScript types only; the host does not validate the
//! results the core sends it, and the core validates what it reads.
//!
//! There is deliberately no method that expresses approval, and nothing a
//! host can send makes the core *do* anything in phase 1: registrations are
//! admitted or refused, and tool calls only flow core→host after the gate.
//! The authority lint in `protocol/tests.rs` keeps [`METHODS`] that way.
use std::time::Duration;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer, Serialize};
@@ -34,6 +47,101 @@ pub mod error_code {
pub const NOT_AVAILABLE: i64 = -32001;
/// Cancelled by `$/cancel`.
pub const CANCELLED: i64 = -32800;
/// Every code on the channel, by the name the TypeScript side uses. The
/// core interprets only the three above; the host also answers with the
/// standard JSON-RPC codes and `ExecutionFailed` (a tool body threw),
/// which reach the caller as `HostCallError::Rpc`.
#[cfg(test)]
pub const ALL: &[(&str, i64)] = &[
("ParseError", -32700),
("InvalidRequest", -32600),
("MethodNotFound", -32601),
("InvalidParams", INVALID_PARAMS),
("Internal", -32603),
("ExecutionFailed", -32000),
("NotAvailable", NOT_AVAILABLE),
("Cancelled", CANCELLED),
];
}
/// Which side sends a method.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
CoreToHost,
HostToCore,
}
impl Direction {
/// The corpus and TypeScript spelling.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::CoreToHost => "core_to_host",
Self::HostToCore => "host_to_core",
}
}
}
/// One method either side may send.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MethodSpec {
pub name: &'static str,
pub direction: Direction,
/// A request carries an id and gets a response; a notification does not.
pub request: bool,
}
const fn row(direction: Direction, name: &'static str, request: bool) -> MethodSpec {
MethodSpec {
name,
direction,
request,
}
}
/// The whole protocol surface. A method missing here is refused by both
/// parsers; a method added here must pass the authority lint.
pub const METHODS: &[MethodSpec] = &[
row(Direction::CoreToHost, "host/initialize", true),
row(Direction::CoreToHost, "host/ping", true),
row(Direction::CoreToHost, "host/shutdown", true),
row(Direction::CoreToHost, "ext/activate", true),
row(Direction::CoreToHost, "ext/deactivate", true),
row(Direction::CoreToHost, "tool/call", true),
row(Direction::CoreToHost, "$/cancel", false),
row(Direction::HostToCore, "host/hello", false),
row(Direction::HostToCore, "host/ready", false),
row(Direction::HostToCore, "registry/register", true),
row(Direction::HostToCore, "registry/unregister", true),
row(Direction::HostToCore, "ext/faulted", false),
row(Direction::HostToCore, "log", false),
row(Direction::HostToCore, "$/cancel", false),
];
/// Admit `method` travelling in `direction` from [`METHODS`]: anything not
/// in the table is refused, a request must carry an id and a notification
/// must not. Returns the id.
fn admit(
direction: Direction,
method: &str,
id: Option<u64>,
) -> Result<Option<u64>, ProtocolError> {
let spec = METHODS
.iter()
.find(|spec| spec.direction == direction && spec.name == method)
.ok_or_else(|| perr(format!("unknown {} method `{method}`", direction.as_str())))?;
match (spec.request, id) {
(true, Some(_)) | (false, None) => Ok(id),
(true, None) => Err(perr(format!("`{method}` must be a request (with id)"))),
(false, Some(_)) => Err(perr(format!("`{method}` must be a notification (no id)"))),
}
}
fn undecoded(method: &str) -> ProtocolError {
perr(format!(
"`{method}` is in the method table but has no decoder"
))
}
#[derive(Debug, thiserror::Error)]
@@ -111,6 +219,7 @@ fn present<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<Value>,
/// in `ext/activate`. It catches bugs and stale fibers; it is not a boundary
/// against a malicious plugin in the same process (see the design, §4.4).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct OwnerRef {
pub plugin_id: String,
@@ -119,6 +228,7 @@ pub struct OwnerRef {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct RpcErrorWire {
pub code: i64,
@@ -136,6 +246,7 @@ pub struct RpcErrorWire {
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ProtocolRange {
pub min: u32,
@@ -143,6 +254,7 @@ pub struct ProtocolRange {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct HelloParams {
pub protocol: ProtocolRange,
@@ -159,6 +271,7 @@ pub struct HelloParams {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct HelloRuntime {
/// `bun` or `node`.
@@ -167,10 +280,12 @@ pub struct HelloRuntime {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct EmptyParams {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RegisterKind {
/// The only kind in phase 1.
@@ -178,6 +293,7 @@ pub enum RegisterKind {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ToolSpecWire {
pub name: String,
@@ -186,6 +302,7 @@ pub struct ToolSpecWire {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct RegisterParams {
pub owner: OwnerRef,
@@ -194,6 +311,7 @@ pub struct RegisterParams {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct UnregisterParams {
pub owner: OwnerRef,
@@ -201,6 +319,7 @@ pub struct UnregisterParams {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct FaultedParams {
pub owner: OwnerRef,
@@ -208,6 +327,7 @@ pub struct FaultedParams {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct LogParams {
pub level: String,
@@ -217,6 +337,7 @@ pub struct LogParams {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct CancelParams {
pub id: u64,
@@ -276,20 +397,15 @@ fn decode_envelope(value: Value) -> Result<Envelope, ProtocolError> {
Ok(envelope)
}
/// Params are always named: an array would otherwise decode positionally
/// into a struct (`[]` into [`EmptyParams`]), which the TypeScript side
/// rejects.
fn params<T: DeserializeOwned>(method: &str, params: Option<Value>) -> Result<T, ProtocolError> {
serde_json::from_value(params.unwrap_or_else(|| json!({})))
.map_err(|e| perr(format!("{method}: {e}")))
}
fn expect_request(method: &str, id: Option<u64>) -> Result<u64, ProtocolError> {
id.ok_or_else(|| perr(format!("`{method}` must be a request (with id)")))
}
fn expect_notification(method: &str, id: Option<u64>) -> Result<(), ProtocolError> {
match id {
None => Ok(()),
Some(_) => Err(perr(format!("`{method}` must be a notification (no id)"))),
let params = params.unwrap_or_else(|| json!({}));
if !params.is_object() {
return Err(perr(format!("{method}: params must be an object")));
}
serde_json::from_value(params).map_err(|e| perr(format!("{method}: {e}")))
}
fn decode_response(
@@ -318,19 +434,18 @@ pub fn parse_host_message(value: Value) -> Result<HostMessage, ProtocolError> {
"`{method}`: a request carries no result or error"
)));
}
let id = envelope.id;
let id = admit(Direction::HostToCore, &method, envelope.id)?;
let p = envelope.params;
let message = match method.as_str() {
"registry/register" => HostMessage::Request {
id: expect_request(&method, id)?,
let message = match (method.as_str(), id) {
("registry/register", Some(id)) => HostMessage::Request {
id,
request: HostRequest::Register(params(&method, p)?),
},
"registry/unregister" => HostMessage::Request {
id: expect_request(&method, id)?,
("registry/unregister", Some(id)) => HostMessage::Request {
id,
request: HostRequest::Unregister(params(&method, p)?),
},
"host/hello" => {
expect_notification(&method, id)?;
("host/hello", None) => {
let hello: HelloParams = params(&method, p)?;
if !matches!(hello.runtime.name.as_str(), "bun" | "node") {
return Err(perr(format!(
@@ -340,24 +455,18 @@ pub fn parse_host_message(value: Value) -> Result<HostMessage, ProtocolError> {
}
HostMessage::Notification(HostNotification::Hello(hello))
}
"host/ready" => {
expect_notification(&method, id)?;
("host/ready", None) => {
let _: EmptyParams = params(&method, p)?;
HostMessage::Notification(HostNotification::Ready)
}
"ext/faulted" => {
expect_notification(&method, id)?;
("ext/faulted", None) => {
HostMessage::Notification(HostNotification::Faulted(params(&method, p)?))
}
"log" => {
expect_notification(&method, id)?;
HostMessage::Notification(HostNotification::Log(params(&method, p)?))
}
"$/cancel" => {
expect_notification(&method, id)?;
("log", None) => HostMessage::Notification(HostNotification::Log(params(&method, p)?)),
("$/cancel", None) => {
HostMessage::Notification(HostNotification::Cancel(params(&method, p)?))
}
other => return Err(perr(format!("unknown host_to_core method `{other}`"))),
_ => return Err(undecoded(&method)),
};
Ok(message)
}
@@ -410,6 +519,7 @@ impl HostMessage {
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct HostLimits {
pub max_frame: u64,
pub max_inflight: u64,
@@ -418,18 +528,21 @@ pub struct HostLimits {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct InitializeParams {
pub protocol: u32,
pub limits: HostLimits,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct EntryRef {
pub path: String,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct ActivateParams {
pub owner: OwnerRef,
pub plugin_name: String,
@@ -443,11 +556,13 @@ fn empty_object() -> Value {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct DeactivateParams {
pub owner: OwnerRef,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct ToolCallParams {
pub handle: u64,
pub call_id: String,
@@ -495,6 +610,35 @@ impl CoreRequest {
}
}
/// How long the core waits for this request's answer before it sends
/// `$/cancel`, forgets the call and fails it with
/// `HostCallError::Timeout` (`HostProcess::call`). The match is
/// exhaustive, so no request can be added without a deadline.
///
/// | method | deadline | why |
/// |---|---|---|
/// | `host/initialize` | `HANDSHAKE_DEADLINE` (30 s) | the whole handshake has the same budget |
/// | `host/ping` | `PING_DEADLINE` (10 s) | the heartbeat supervises pings with its own `ping_timeout`/`hang_timeout` and kills a silent host; this bounds any other caller |
/// | `host/shutdown` | 2 s | tests only |
/// | `ext/activate` | `ACTIVATE_DEADLINE` + 1 s | the host enforces activation's own deadline; 1 s for its answer to arrive |
/// | `ext/deactivate` | `DISPOSE_DEADLINE` + 500 ms | the same, for disposal |
/// | `tool/call` | its `deadline_ms` | the host is told the bound the core enforces (`SupervisionOptions::tool_call_deadline`, 120 s) |
#[must_use]
pub fn deadline(&self) -> Duration {
use super::supervisor::{
ACTIVATE_DEADLINE, DISPOSE_DEADLINE, HANDSHAKE_DEADLINE, PING_DEADLINE,
};
match self {
Self::Ping => PING_DEADLINE,
Self::Initialize(_) => HANDSHAKE_DEADLINE,
#[cfg(test)]
Self::Shutdown => Duration::from_secs(2),
Self::Activate(_) => ACTIVATE_DEADLINE + Duration::from_secs(1),
Self::Deactivate(_) => DISPOSE_DEADLINE + Duration::from_millis(500),
Self::ToolCall(params) => Duration::from_millis(params.deadline_ms),
}
}
#[must_use]
pub fn to_value(&self, id: u64) -> Value {
request_value(id, self.method(), self.params())
@@ -504,6 +648,7 @@ impl CoreRequest {
/// `registry/register` answer: a handle, or a refusal the host reports as a
/// failed activation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum RegisterResult {
Admitted { handle: u64 },
@@ -511,6 +656,7 @@ pub enum RegisterResult {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ActivateResult {
Ok { tools: Vec<String> },
@@ -518,18 +664,21 @@ pub enum ActivateResult {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct DeactivateResult {
pub disposed: bool,
pub leaked: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlockWire {
Text { text: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct ToolResultWire {
pub content: Vec<ContentBlockWire>,
pub is_error: bool,
@@ -564,24 +713,27 @@ pub fn parse_core_message(value: Value) -> Result<CoreMessage, ProtocolError> {
let (id, outcome) = decode_response(envelope)?;
return Ok(CoreMessage::Response { id, outcome });
};
let id = envelope.id;
let p = envelope.params;
if method == "$/cancel" {
expect_notification(&method, id)?;
return Ok(CoreMessage::Cancel(params(&method, p)?));
}
let id = expect_request(&method, id)?;
let Some(id) = admit(Direction::CoreToHost, &method, envelope.id)? else {
return match method.as_str() {
"$/cancel" => Ok(CoreMessage::Cancel(params(&method, p)?)),
_ => Err(undecoded(&method)),
};
};
let request = match method.as_str() {
"host/initialize" => CoreRequest::Initialize(params(&method, p)?),
"host/shutdown" => CoreRequest::Shutdown,
"host/shutdown" => {
let _: EmptyParams = params(&method, p)?;
CoreRequest::Shutdown
}
"host/ping" => {
let _: serde_json::Map<String, Value> = params(&method, p)?;
let _: EmptyParams = params(&method, p)?;
CoreRequest::Ping
}
"ext/activate" => CoreRequest::Activate(params(&method, p)?),
"ext/deactivate" => CoreRequest::Deactivate(params(&method, p)?),
"tool/call" => CoreRequest::ToolCall(params(&method, p)?),
other => return Err(perr(format!("unknown core_to_host method `{other}`"))),
_ => return Err(undecoded(&method)),
};
Ok(CoreMessage::Request { id, request })
}
@@ -607,3 +759,6 @@ pub fn cancel_value(id: u64) -> Value {
pub fn response_ok(id: u64, result: Value) -> Value {
response_value(id, &Ok(result))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,578 @@
//! The TypeScript half of the protocol, generated from the Rust types.
//!
//! `schemars` reads each wire type's serde shape (field names, `Option` and
//! `default` fields, `deny_unknown_fields`, tags); [`render`] normalizes that
//! into the small vocabulary the host's validator checks and renders
//! `extension-host/src/protocol.generated.ts`. The committed file must equal
//! the rendering: re-record it with `CODEWHALE_CONFORMANCE_UPDATE=1` (refused
//! under CI, like every conformance golden), then rebuild `dist/`.
//!
//! Known limit: a wire shape outside that vocabulary (floats, inline
//! objects, unions in params) panics here rather than being approximated.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::Path;
use schemars::generate::SchemaSettings;
use schemars::{Schema, SchemaGenerator};
// `Value` and every wire type come from the parent module.
use super::*;
/// The params type of every method in [`METHODS`].
fn params_schema(method: &str, generator: &mut SchemaGenerator) -> Schema {
match method {
"host/initialize" => generator.subschema_for::<InitializeParams>(),
"host/ping" | "host/shutdown" | "host/ready" => generator.subschema_for::<EmptyParams>(),
"ext/activate" => generator.subschema_for::<ActivateParams>(),
"ext/deactivate" => generator.subschema_for::<DeactivateParams>(),
"tool/call" => generator.subschema_for::<ToolCallParams>(),
"$/cancel" => generator.subschema_for::<CancelParams>(),
"host/hello" => generator.subschema_for::<HelloParams>(),
"registry/register" => generator.subschema_for::<RegisterParams>(),
"registry/unregister" => generator.subschema_for::<UnregisterParams>(),
"ext/faulted" => generator.subschema_for::<FaultedParams>(),
"log" => generator.subschema_for::<LogParams>(),
other => panic!("method `{other}` has no params type in the TypeScript generator"),
}
}
/// A field's wire type, normalized.
enum Ty {
String,
Boolean,
Uint,
Integer,
/// A JSON object with any keys (`Map<String, Value>`).
Object,
/// Any JSON value.
Json,
Ref(String),
/// A string tag inside a union member.
Const(String),
Array(Box<Ty>),
}
struct Field {
name: String,
ty: Ty,
required: bool,
}
enum Def {
Object { strict: bool, fields: Vec<Field> },
Enum(Vec<String>),
Union(Vec<Vec<Field>>),
}
fn ref_name(schema: &Value) -> Option<String> {
let reference = schema.get("$ref")?.as_str()?;
Some(
reference
.strip_prefix("#/$defs/")
.unwrap_or_else(|| panic!("unexpected $ref `{reference}`"))
.to_string(),
)
}
fn parse_ty(at: &str, schema: &Value) -> Ty {
let Some(object) = schema.as_object() else {
assert_eq!(schema, &Value::Bool(true), "{at}: unsupported schema");
return Ty::Json;
};
if let Some(name) = ref_name(schema) {
return Ty::Ref(name);
}
if let Some(value) = object.get("const").and_then(Value::as_str) {
return Ty::Const(value.to_string());
}
let types: Vec<&str> = match object.get("type") {
Some(Value::String(ty)) => vec![ty.as_str()],
Some(Value::Array(types)) => types
.iter()
.filter_map(Value::as_str)
.filter(|ty| *ty != "null")
.collect(),
// Only annotations (`default`, `description`): any value.
None if !["enum", "anyOf", "oneOf", "allOf", "properties", "items"]
.iter()
.any(|key| object.contains_key(*key)) =>
{
return Ty::Json;
}
_ => panic!("{at}: unsupported wire schema {schema}"),
};
match types.as_slice() {
["string"] => Ty::String,
["boolean"] => Ty::Boolean,
["integer"] if object.get("minimum").and_then(Value::as_u64) == Some(0) => Ty::Uint,
["integer"] => Ty::Integer,
["object"]
if object.get("additionalProperties") == Some(&Value::Bool(true))
&& !object.contains_key("properties") =>
{
Ty::Object
}
["array"] => Ty::Array(Box::new(parse_ty(
&format!("{at}[]"),
object
.get("items")
.unwrap_or_else(|| panic!("{at}: array without items")),
))),
_ => panic!("{at}: unsupported wire schema {schema}"),
}
}
fn object_fields(at: &str, schema: &Value) -> Vec<Field> {
assert_eq!(
schema.get("type").and_then(Value::as_str),
Some("object"),
"{at}: expected an object schema, got {schema}"
);
let required: Vec<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|names| names.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
schema
.get("properties")
.and_then(Value::as_object)
.map(|properties| {
properties
.iter()
.map(|(name, schema)| Field {
name: name.clone(),
ty: parse_ty(&format!("{at}.{name}"), schema),
required: required.contains(&name.as_str()),
})
.collect()
})
.unwrap_or_default()
}
fn parse_def(name: &str, schema: &Value) -> Def {
let members = schema
.get("oneOf")
.or_else(|| schema.get("anyOf"))
.and_then(Value::as_array);
if let Some(members) = members {
// Unit variants, with or without doc comments: a string enum.
let consts: Option<Vec<String>> = members
.iter()
.map(|member| {
member
.get("const")
.and_then(Value::as_str)
.map(str::to_string)
})
.collect();
return match consts {
Some(values) => Def::Enum(values),
None => Def::Union(
members
.iter()
.map(|member| object_fields(name, member))
.collect(),
),
};
}
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
return Def::Enum(
values
.iter()
.map(|value| {
value
.as_str()
.unwrap_or_else(|| panic!("{name}: non-string enum value"))
.to_string()
})
.collect(),
);
}
Def::Object {
strict: schema.get("additionalProperties") == Some(&Value::Bool(false)),
fields: object_fields(name, schema),
}
}
fn quote(value: &str) -> String {
assert!(
!value.contains(['\'', '\\']),
"`{value}` needs escaping in TypeScript"
);
format!("'{value}'")
}
/// The validator's kind for `ty`: a string enum is inlined, an object is a
/// reference into `SHAPES`.
fn kind(ty: &Ty, defs: &BTreeMap<String, Def>) -> String {
match ty {
Ty::String => "'string'".into(),
Ty::Boolean => "'boolean'".into(),
Ty::Uint => "'uint'".into(),
Ty::Integer => "'integer'".into(),
Ty::Object => "'object'".into(),
Ty::Json => "'json'".into(),
Ty::Ref(name) => match &defs[name] {
Def::Object { .. } => format!("{{ ref: {} }}", quote(name)),
Def::Enum(values) => format!(
"{{ enum: [{}] }}",
values
.iter()
.map(|v| quote(v))
.collect::<Vec<_>>()
.join(", ")
),
Def::Union(_) => panic!("{name}: the host validator does not check unions"),
},
Ty::Const(value) => panic!("`{value}`: a tag outside a union"),
Ty::Array(item) => format!("{{ items: {} }}", kind(item, defs)),
}
}
/// The kinds of the `fields` that are (or are not) `required`.
fn kinds(fields: &[Field], required: bool, defs: &BTreeMap<String, Def>) -> String {
let rendered: Vec<String> = fields
.iter()
.filter(|field| field.required == required)
.map(|field| format!("{}: {}", field.name, kind(&field.ty, defs)))
.collect();
if rendered.is_empty() {
"{}".into()
} else {
format!("{{ {} }}", rendered.join(", "))
}
}
fn ts(ty: &Ty) -> String {
match ty {
Ty::String => "string".into(),
Ty::Boolean => "boolean".into(),
Ty::Uint | Ty::Integer => "number".into(),
Ty::Object => "{ [key: string]: Json }".into(),
Ty::Json => "Json".into(),
Ty::Ref(name) => name.clone(),
Ty::Const(value) => quote(value),
Ty::Array(item) => format!("{}[]", ts(item)),
}
}
/// An array's item type, however deeply nested.
fn innermost(ty: &Ty) -> &Ty {
match ty {
Ty::Array(item) => innermost(item),
ty => ty,
}
}
fn member(field: &Field) -> String {
let optional = if field.required { "" } else { "?" };
format!("{}{optional}: {}", field.name, ts(&field.ty))
}
const HEADER: &str = "\
// @generated from the Rust protocol types in crates/tui/src/extension_host/protocol.rs
// by `extension_host::protocol::tests`. Do not edit: change the Rust side, re-record with
// CODEWHALE_CONFORMANCE_UPDATE=1 cargo test -p codewhale-tui --lib extension_host::protocol
// and rebuild dist/ with `npm run build`.
";
const VALIDATOR_TYPES: &str = "
/** A field's wire kind: the Rust field's serde type, normalized for validation. */
export type Kind =
| 'string'
| 'boolean'
| 'uint'
| 'integer'
| 'object'
| 'json'
| { readonly ref: string }
| { readonly enum: readonly string[] }
| { readonly items: Kind }
/** An object's fields; `strict` is Rust's `deny_unknown_fields`. */
export interface Shape {
readonly strict: boolean
readonly required: { readonly [field: string]: Kind }
readonly optional: { readonly [field: string]: Kind }
}
";
/// Render `protocol.generated.ts` from [`METHODS`] and the wire types.
fn render() -> String {
let mut generator = SchemaSettings::draft2020_12().into_generator();
let methods: Vec<(&MethodSpec, String)> = METHODS
.iter()
.map(|spec| {
let schema = params_schema(spec.name, &mut generator);
let name = ref_name(schema.as_value())
.unwrap_or_else(|| panic!("{}: params must be a named type", spec.name));
(spec, name)
})
.collect();
let error = generator.subschema_for::<RpcErrorWire>();
let error = ref_name(error.as_value()).expect("RpcErrorWire is a named type");
// Results are typed for the host, not validated by it.
let _ = generator.subschema_for::<RegisterResult>();
let _ = generator.subschema_for::<ActivateResult>();
let _ = generator.subschema_for::<DeactivateResult>();
let _ = generator.subschema_for::<ToolResultWire>();
let defs: BTreeMap<String, Def> = generator
.definitions()
.iter()
.map(|(name, schema)| (name.clone(), parse_def(name, schema)))
.collect();
// What the validator checks: every params type and the error object,
// with the objects they reference.
let mut validated = BTreeSet::new();
let mut pending: Vec<String> = methods
.iter()
.map(|(_, name)| name.clone())
.chain([error])
.collect();
while let Some(name) = pending.pop() {
if let Def::Object { fields, .. } = &defs[&name]
&& validated.insert(name.clone())
{
for field in fields {
if let Ty::Ref(name) = innermost(&field.ty) {
pending.push(name.clone());
}
}
}
}
let mut out = String::from(HEADER);
let magic = std::str::from_utf8(&MAGIC).expect("ASCII magic");
let _ = writeln!(out, "export const PROTOCOL_VERSION = {PROTOCOL_VERSION}");
let _ = writeln!(out, "export const MAGIC_ASCII = {}", quote(magic));
let _ = writeln!(out, "export const HEADER_LEN = {HEADER_LEN}");
let _ = writeln!(out, "export const MAX_FRAME = {MAX_FRAME}");
let _ = writeln!(out, "export const MAX_INFLIGHT = {MAX_INFLIGHT}");
out.push_str(
"\n/** JSON-RPC error codes used on this channel. */\nexport const ErrorCode = {\n",
);
for (name, code) in error_code::ALL {
let _ = writeln!(out, " {name}: {code},");
}
out.push_str("} as const\n\nexport type Direction = 'core_to_host' | 'host_to_core'\n");
out.push_str("\n/** Every method either side may send; nothing else is admitted. */\nexport const METHODS = [\n");
for (spec, params) in &methods {
let _ = writeln!(
out,
" {{ name: {}, direction: {}, request: {}, params: {} }},",
quote(spec.name),
quote(spec.direction.as_str()),
spec.request,
quote(params)
);
}
out.push_str("] as const\n");
out.push_str(VALIDATOR_TYPES);
out.push_str("\n/** Every method's params, and an error response's `error`. */\nexport const SHAPES: { readonly [name: string]: Shape } = {\n");
for name in &validated {
let Def::Object { strict, fields } = &defs[name] else {
unreachable!("only objects are validated");
};
let _ = writeln!(out, " {name}: {{");
let _ = writeln!(out, " strict: {strict},");
let _ = writeln!(out, " required: {},", kinds(fields, true, &defs));
let _ = writeln!(out, " optional: {},", kinds(fields, false, &defs));
out.push_str(" },\n");
}
out.push_str(
"}\n\nexport type Json = null | boolean | number | string | Json[] | { [key: string]: Json }\n",
);
for (name, def) in &defs {
out.push('\n');
match def {
Def::Object { fields, .. } if fields.is_empty() => {
let _ = writeln!(out, "export interface {name} {{}}");
}
Def::Object { fields, .. } => {
let _ = writeln!(out, "export interface {name} {{");
for field in fields {
let _ = writeln!(out, " {}", member(field));
}
out.push_str("}\n");
}
Def::Enum(values) => {
let values: Vec<String> = values.iter().map(|v| quote(v)).collect();
let _ = writeln!(out, "export type {name} = {}", values.join(" | "));
}
Def::Union(members) => {
let members: Vec<String> = members
.iter()
.map(|fields| {
// Tags first, then the variant's fields in order.
let (tags, rest): (Vec<&Field>, Vec<&Field>) = fields
.iter()
.partition(|field| matches!(field.ty, Ty::Const(_)));
let parts: Vec<String> = tags.into_iter().chain(rest).map(member).collect();
format!("{{ {} }}", parts.join("; "))
})
.collect();
let _ = writeln!(out, "export type {name} = {}", members.join(" | "));
}
}
}
out
}
#[test]
fn typescript_protocol_is_generated_from_the_rust_types() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("extension-host")
.join("src")
.join("protocol.generated.ts");
if let Err(drift) = crate::conformance::golden::check_golden(&path, &render()) {
panic!(
"{drift}\nThe TypeScript protocol is generated from protocol.rs; rebuild dist/ after re-recording."
);
}
}
/// Authority only the core may hold (CURRENT_DECISIONS §26): the event
/// authority, the store, approval, secrets and credentials, the turn loop,
/// sessions and the prompt. A method whose name mentions any of these is
/// refused outright, whatever its reviewed reason.
const CORE_ONLY: &[&str] = &[
"event",
"store",
"approv",
"secret",
"credential",
"token",
"auth",
"turn",
"loop",
"session",
"prompt",
];
/// Every method, with why it gives the host no core authority. Adding a
/// method means adding its row here, in review, with that reason.
const REVIEWED: &[(&str, &str, &str)] = &[
(
"core_to_host",
"host/initialize",
"the core states its limits; the host answers `{}`",
),
(
"core_to_host",
"host/ping",
"heartbeat; the host answers `{}`",
),
(
"core_to_host",
"host/shutdown",
"bounded teardown, sent by tests only",
),
(
"core_to_host",
"ext/activate",
"the core names the reviewed entry and its hash; the host reports tool names",
),
(
"core_to_host",
"ext/deactivate",
"sent after the core has already revoked the owner",
),
(
"core_to_host",
"tool/call",
"sent only after the core's approval gate has passed the call",
),
(
"core_to_host",
"$/cancel",
"the core withdraws its own request",
),
(
"host_to_core",
"host/hello",
"handshake facts the core checks against what it launched",
),
(
"host_to_core",
"host/ready",
"handshake completion; no payload",
),
(
"host_to_core",
"registry/register",
"a proposal the core admits or refuses; an admitted tool always needs approval",
),
(
"host_to_core",
"registry/unregister",
"the host can only withdraw its own owner's registration",
),
(
"host_to_core",
"ext/faulted",
"a report; the core revokes the owner",
),
(
"host_to_core",
"log",
"diagnostic text the core bounds and escapes",
),
(
"host_to_core",
"$/cancel",
"ignored: phase 1 has no host-originated requests to cancel",
),
];
/// The CI lint for the host protocol. It covers both directions, host→core
/// requests included: [`METHODS`] is every method either parser admits, and
/// the TypeScript validator admits only its generated copy, so the host can
/// neither send nor answer anything outside it.
#[test]
fn host_protocol_never_gains_core_authority() {
for spec in METHODS {
let name = spec.name.to_ascii_lowercase();
for word in CORE_ONLY {
assert!(
!name.contains(word),
"{} method `{}` reaches for core-only authority (`{word}`); \
the extension host must never hold it (CURRENT_DECISIONS §26)",
spec.direction.as_str(),
spec.name
);
}
}
let table: BTreeSet<(&str, &str)> = METHODS
.iter()
.map(|spec| (spec.direction.as_str(), spec.name))
.collect();
let reviewed: BTreeSet<(&str, &str)> = REVIEWED
.iter()
.map(|(direction, name, _)| (*direction, *name))
.collect();
assert_eq!(
table, reviewed,
"the method table changed: review each method in REVIEWED with why it gives the host no core authority"
);
// Nothing is decoded or sent outside the table: every method-shaped
// literal in this module is a table row.
let literal = regex::Regex::new(r#""([A-Za-z$][\w$]*/[A-Za-z_]+)""#).expect("regex");
let source = include_str!("../protocol.rs");
let mut seen = 0;
for capture in literal.captures_iter(source) {
let name = &capture[1];
assert!(
METHODS.iter().any(|spec| spec.name == name),
"protocol.rs uses method `{name}` outside METHODS"
);
seen += 1;
}
assert!(
seen >= METHODS.len(),
"the source scan matched only {seen} literals"
);
}
+53 -31
View File
@@ -84,6 +84,9 @@ use super::protocol::{
pub const HANDSHAKE_DEADLINE: Duration = Duration::from_secs(30);
pub const ACTIVATE_DEADLINE: Duration = Duration::from_secs(5);
pub const DISPOSE_DEADLINE: Duration = Duration::from_secs(2);
/// A `host/ping` answer later than this means a hung host: the heartbeat's
/// default `hang_timeout`, and the bound for any other ping.
pub const PING_DEADLINE: Duration = Duration::from_secs(10);
/// Grace between `$/cancel` and resolving a call as cancelled on this side.
pub const CANCEL_GRACE: Duration = Duration::from_millis(500);
const STDERR_TAIL_BYTES: usize = 8 * 1024;
@@ -97,8 +100,11 @@ pub enum HostCallError {
Exited(String),
#[error("cancelled: {0}")]
Cancelled(String),
#[error("timed out after {0:?}")]
Timeout(Duration),
#[error("`{method}` timed out after {after:?}; the host was told to cancel it")]
Timeout {
method: &'static str,
after: Duration,
},
#[error("extension host channel is full")]
Busy,
}
@@ -863,7 +869,7 @@ impl HostProcess {
activate_deadline_ms: ACTIVATE_DEADLINE.as_millis() as u64,
},
});
host.request(initialize, None)
host.call(initialize, None)
.await
.map_err(|error| format!("host/initialize failed: {error}"))?;
ready_rx
@@ -1007,34 +1013,37 @@ impl HostProcess {
Ok((id, rx))
}
pub(crate) async fn request(
/// Send `request` and wait at most its method's deadline
/// ([`CoreRequest::deadline`]) for the answer. On expiry the host is sent
/// `$/cancel`, the call is forgotten (a late answer is dropped) and the
/// caller gets [`HostCallError::Timeout`]. Dropping the returned future
/// (a turn interrupt) cancels the same way. Every awaited core→host
/// request goes through here; only the heartbeat drives
/// [`Self::start_request`] itself, under its own timeouts.
pub(crate) async fn call(
&self,
request: CoreRequest,
owner: Option<String>,
) -> Result<Value, HostCallError> {
let (_, rx) = self.start_request(request, owner)?;
rx.await
.unwrap_or_else(|_| Err(HostCallError::Exited("channel closed".to_string())))
}
/// Request with a deadline; on expiry the call is cancelled host-side.
pub(crate) async fn request_with_deadline(
&self,
request: CoreRequest,
owner: Option<String>,
deadline: Duration,
) -> Result<Value, HostCallError> {
let method = request.method();
let deadline = request.deadline();
let (id, rx) = self.start_request(request, owner)?;
match tokio::time::timeout(deadline, rx).await {
Ok(result) => {
result.unwrap_or_else(|_| Err(HostCallError::Exited("channel closed".to_string())))
}
Err(_) => {
self.cancel(id);
self.pending.lock().expect("pending lock").remove(&id);
Err(HostCallError::Timeout(deadline))
}
}
let mut guard = CancelOnDrop {
host: self,
id,
armed: true,
};
let Ok(answer) = tokio::time::timeout(deadline, rx).await else {
// `guard` is still armed: dropping it sends `$/cancel`.
return Err(HostCallError::Timeout {
method,
after: deadline,
});
};
// Answered, drained at exit, or resolved by revocation: nothing left
// to cancel.
guard.armed = false;
answer.unwrap_or_else(|_| Err(HostCallError::Exited("channel closed".to_string())))
}
/// Fire `$/cancel`. Best effort: a full or closed channel is fine, the
@@ -1081,11 +1090,7 @@ impl HostProcess {
/// process group at 3 s total.
#[cfg(test)]
pub(crate) async fn shutdown(&self) {
let _ = tokio::time::timeout(
Duration::from_secs(2),
self.request(CoreRequest::Shutdown, None),
)
.await;
let _ = self.call(CoreRequest::Shutdown, None).await;
let mut exited = self.exited.clone();
let waited = tokio::time::timeout(Duration::from_secs(1), async {
while !*exited.borrow() {
@@ -1101,6 +1106,23 @@ impl HostProcess {
}
}
/// Cancels a [`HostProcess::call`] that stops waiting before its answer:
/// deadline expiry, or the caller's future being dropped.
struct CancelOnDrop<'a> {
host: &'a HostProcess,
id: u64,
armed: bool,
}
impl Drop for CancelOnDrop<'_> {
fn drop(&mut self) {
if self.armed {
self.host.cancel(self.id);
self.host.forget(self.id);
}
}
}
impl Drop for HostProcess {
fn drop(&mut self) {
// The exit watcher also holds the tree; kill explicitly so dropping
+118 -4
View File
@@ -1407,6 +1407,58 @@ fn dirty_teardown_window_is_bounded_and_a_requested_restart_waits_for_idle() {
assert!(state.crashes.is_empty());
}
#[tokio::test]
async fn a_call_past_its_method_deadline_is_cancelled_and_the_host_stays_usable() {
let Some(node) = node_for_tests("method deadline") else {
return;
};
let _policy = TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["slow-tool", "clash-script"]).await;
let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions {
runtime: NODE,
node_override: Some(node),
bun_override: None,
root: Some(fixture.root.clone()),
supervision: super::SupervisionOptions {
tool_call_deadline: Duration::from_millis(300),
..Default::default()
},
}));
let engine = manager.attach(fixture.registry());
engine.sync().await.unwrap();
let pid = manager.host_pid();
let context = ToolContext::new(fixture.workspace());
// `slow_wait` answers after 30 s unless it is cancelled.
let slow = host_tool(&engine, fixture.workspace(), "slow_wait");
let started = Instant::now();
let outcome = tokio::time::timeout(Duration::from_secs(5), slow.execute(json!({}), &context))
.await
.expect("the method deadline bounds the call");
let elapsed = started.elapsed();
assert!(
matches!(outcome, Err(ToolError::Timeout { .. })),
"{outcome:?}"
);
assert!(elapsed < Duration::from_secs(2), "{elapsed:?}");
// The same host takes the next call.
let quick = host_tool(&engine, fixture.workspace(), "fixture_script_tool");
let result = quick.execute(json!({}), &context).await.unwrap();
assert!(result.success, "{}", result.content);
assert_eq!(manager.host_pid(), pid);
assert_eq!(manager.spawn_attempts(), 1);
// `slow-tool`'s disposer awaits its in-flight call, so a clean teardown
// proves the host received `$/cancel` and aborted the timed-out call
// (uncancelled, it outlives the host's 2 s dispose deadline).
engine.set_plugins(fixture.disable("slow-tool"));
engine.sync().await.unwrap();
let diagnostics = manager.diagnostics();
assert!(
!diagnostics.iter().any(|line| line.contains("teardown")),
"{diagnostics:?}"
);
manager.shutdown().await;
}
#[tokio::test]
async fn ordinary_exit_rejects_requests_from_a_drained_calls_waker() {
use std::future::Future;
@@ -1474,9 +1526,7 @@ async fn ordinary_exit_rejects_requests_from_a_drained_calls_waker() {
.unwrap();
// A following response proves the writer flushed the slow call and is
// waiting for another frame, so a closed outbound queue cannot mask the bug.
host.request_with_deadline(protocol::CoreRequest::Ping, None, Duration::from_secs(5))
.await
.unwrap();
host.call(protocol::CoreRequest::Ping, None).await.unwrap();
let probe = Arc::new(AdmissionProbe {
host: Arc::clone(&host),
result: Mutex::new(None),
@@ -1537,7 +1587,10 @@ async fn idle_retirement_seals_admission_and_does_not_wait_for_heartbeat() {
.start_request(protocol::CoreRequest::Ping, None)
.unwrap();
assert!(host.terminate_if_idle(super::DIRTY_RESTART_REASON));
assert!(manager.shared.ready_host().is_none());
assert!(matches!(
manager.shared.ready_host(),
Err(HostStatus::Restarting { .. })
));
assert!(matches!(manager.status(), HostStatus::Restarting { .. }));
// Poll without yielding to the exit watcher. A reconcile in this exact
// gap must not start activation on the sealed process and falsely fail
@@ -1789,6 +1842,67 @@ fn opening_an_engine_never_resets_a_crash_budget() {
assert_eq!(manager.status(), HostStatus::Idle);
}
/// One typed state, `HostStatus`, names why the host is down in `/plugin`
/// and in the error a tool call routed to it gets.
#[tokio::test]
async fn a_host_that_is_down_names_why_in_plugin_status_and_tool_errors() {
let policy = TestPolicyGuard::extension_host(true);
let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions::default()));
let registration = {
let mut registry = manager.shared.registry.lock().unwrap();
let owner = registry.begin_owner("probe", "probe", fake_authority("probe"), "hash");
registry.mark_active(&owner);
register(&mut registry, &owner, "probe_tool").unwrap();
registry.live_tools()[0].clone()
};
let tool = super::tool::HostToolSpec::new(registration, Arc::clone(&manager.shared));
let context = ToolContext::new(Path::new("/fixture"));
let refused = "start failed: host did not apply the requested 1024 MiB kernel memory limit; initialization refused";
for (slot, why) in [
(
super::HostSlot::Restarting {
reason: "exited with signal: 9 (SIGKILL)".into(),
},
"restarting after: exited with signal: 9 (SIGKILL)".to_string(),
),
(
super::HostSlot::Failed {
reason: refused.into(),
stderr_tail: String::new(),
},
format!("{refused} (change or reload a plugin to retry)"),
),
(super::HostSlot::Idle, "not started".to_string()),
] {
*manager.shared.host.lock().unwrap() = slot;
let report = super::render_status(&manager);
assert!(
report.starts_with(&format!("Extension host (experimental): {why}")),
"{report}"
);
match tool.execute(json!({}), &context).await {
Err(ToolError::NotAvailable { message }) => assert!(
message.starts_with(&format!("extension host is down: {why}")),
"{message}"
),
other => panic!("expected a typed not-available error, got {other:?}"),
}
}
drop(policy);
let _off = TestPolicyGuard::extension_host(false);
let disabled = "disabled by config ([features] extension_host is off)";
assert_eq!(
super::status_report(),
format!("Extension host (experimental): {disabled}")
);
match tool.execute(json!({}), &context).await {
Err(ToolError::NotAvailable { message }) => {
assert_eq!(message, format!("extension host is down: {disabled}"))
}
other => panic!("expected a typed not-available error, got {other:?}"),
}
}
#[tokio::test]
async fn three_crashes_stop_replay_until_explicit_retry() {
let Some(node) = node_for_tests("crash budget") else {
+11 -39
View File
@@ -27,13 +27,14 @@ use serde_json::Value;
use super::ManagerShared;
use super::protocol::{ContentBlockWire, CoreRequest, ToolCallParams, ToolResultWire};
use super::registry::ToolRegistration;
use super::supervisor::{HostCallError, HostProcess};
use super::supervisor::HostCallError;
use crate::tools::spec::{
ApprovalRequirement, PreparedToolCall, ToolCapability, ToolContext, ToolError, ToolResult,
ToolSpec,
};
/// Default per-call deadline, matching script tools.
/// Default per-call deadline, matching script tools
/// (`SupervisionOptions::tool_call_deadline`).
pub const TOOL_CALL_DEADLINE: Duration = Duration::from_secs(120);
pub(crate) struct HostToolSpec {
@@ -93,29 +94,12 @@ impl std::fmt::Debug for HostToolSpec {
}
}
/// Sends `$/cancel` if the call future is dropped before it resolves (turn
/// interrupt, deadline, or the engine abandoning the call).
struct CancelOnDrop {
host: Arc<HostProcess>,
id: u64,
armed: bool,
}
impl Drop for CancelOnDrop {
fn drop(&mut self) {
if self.armed {
self.host.cancel(self.id);
self.host.forget(self.id);
}
}
}
fn map_call_error(tool: &str, error: HostCallError) -> ToolError {
match error {
HostCallError::Cancelled(reason) => ToolError::Cancelled {
message: format!("extension tool `{tool}`: {reason}"),
},
HostCallError::Timeout(after) => ToolError::Timeout {
HostCallError::Timeout { after, .. } => ToolError::Timeout {
seconds: after.as_secs(),
},
HostCallError::Exited(reason) => {
@@ -232,31 +216,19 @@ impl ToolSpec for HostToolSpec {
.clone()
.map(|agent| format!("{agent}:{}", uuid::Uuid::new_v4().simple()))
.unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
// The deadline travels with the call: the host is told the bound
// `HostProcess::call` enforces (and cancels at).
let deadline = self.manager.options.supervision.tool_call_deadline;
let request = CoreRequest::ToolCall(ToolCallParams {
handle: registration.handle,
call_id,
input,
deadline_ms: TOOL_CALL_DEADLINE.as_millis() as u64,
deadline_ms: u64::try_from(deadline.as_millis()).unwrap_or(u64::MAX),
});
let (id, rx) = host
.start_request(request, Some(registration.owner.plugin_id.clone()))
let value = host
.call(request, Some(registration.owner.plugin_id.clone()))
.await
.map_err(|error| map_call_error(&registration.name, error))?;
let mut guard = CancelOnDrop {
host: Arc::clone(&host),
id,
armed: true,
};
let outcome = match tokio::time::timeout(TOOL_CALL_DEADLINE, rx).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(HostCallError::Exited("channel closed".to_string())),
Err(_) => Err(HostCallError::Timeout(TOOL_CALL_DEADLINE)),
};
// A timeout leaves the guard armed so the host is told to stop.
if !matches!(outcome, Err(HostCallError::Timeout(_))) {
guard.armed = false;
}
drop(guard);
let value = outcome.map_err(|error| map_call_error(&registration.name, error))?;
let wire: ToolResultWire = serde_json::from_value(value).map_err(|error| {
ToolError::execution_failed(format!(
"extension tool `{}` returned a malformed result: {error}",
@@ -0,0 +1,9 @@
{
"direction": "host_to_core",
"valid": false,
"frame": {
"jsonrpc": "2.0",
"method": "host/ready",
"params": []
}
}
+9
View File
@@ -66,6 +66,15 @@ to use it. `codewhale doctor` and `/plugin` show which runtime runs the host,
its version and path, how its memory cap is enforced, and, when `auto` used
Node, why Bun was not used.
When the host cannot take a call, `/plugin` and the failing extension tool
call give the same reason: not started, starting, unresponsive, restarting
after a crash (with how it exited), failed to start or out of crash budget
(with the reason; change or reload a plugin to retry), or disabled by config.
Every call into the host has a deadline, 120 s for a tool call; past it
Codewhale cancels the call, reports a timeout, and the host keeps serving
other calls. A plugin that ignores cancellation keeps running inside the host
until the host is torn down.
Write extensions for both runtimes. Use Node's APIs (Bun implements them) and
only erasable TypeScript in `.mts`. Enums, decorators and syntax that needs
transformation require a separate author build to JavaScript. Bun would accept
+3 -2
View File
@@ -395,7 +395,8 @@ The package goes at **`crates/tui/extension-host/`**, named `@codewhale/extensio
crates/tui/extension-host/
package.json engines.node "^22.19.0 || >=24.0.0"; build + test scripts
src/main.ts boot: framing, console rebinding, parent watchdog, crash attribution
src/protocol.gen.ts GENERATED from Rust (phase 2; hand-written + conformance corpus in phase 1)
src/protocol.generated.ts GENERATED from the Rust protocol types (constants, method table, params shapes, types)
src/protocol.ts frame codec + envelope checks over the generated shapes
src/root.ts Cordis root, shim services, refusal list
src/shims/{tools,commands,skills,system-prompt,mcp-resources,logger}.ts
src/dsh/{resolve-hooks,profile,dsh-tools-compat}.ts
@@ -521,7 +522,7 @@ Rust then respawns the host and replays activations from its own record of which
- **Rust serde types are the source of truth.** They go in `crates/tui/src/extension_host/protocol.rs`, not `crates/protocol`, because the host protocol is private to the engine process and the app-server has no reason to see it.
- Host → core types use `#[serde(deny_unknown_fields, tag = "kind")]`, because host output is untrusted input.
- Core → host types are tolerant.
- **Generated TypeScript.** `schemars` (already a `crates/tui` dependency) generates `extension-host-protocol.schema.json`. `json-schema-to-typescript` turns that into `src/protocol.gen.ts`, which is committed, with a CI drift check. Phase 1 uses hand-written TS types plus a shared JSON fixture corpus (`crates/tui/tests/fixtures/extension_host/*.json`) that both sides must parse and round-trip.
- **Generated TypeScript.** `schemars` (already a `crates/tui` dependency, derived on the wire types under `cfg(test)`) reads each type's serde shape, and a Rust test (`crates/tui/src/extension_host/protocol/tests.rs`) renders `src/protocol.generated.ts` from it and from the Rust method table (`protocol::METHODS`): constants, error codes, the method table, every params shape the host validates, and the wire types. The file is committed and the test fails on drift (re-record with `CODEWHALE_CONFORMANCE_UPDATE=1`, then rebuild `dist/`); no npm generator is involved. Both sides still parse and round-trip the shared JSON fixture corpus (`crates/tui/tests/fixtures/extension_host/protocol/*.json`).
- **Handshake:**
```