mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
commit
ce1ecc8dc2
18 files changed
+1749
-608
No files matched your search
+148
-106
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
#[cfg(test)]
|
||||
mod events;
|
||||
#[cfg(test)]
|
||||
mod golden;
|
||||
pub(crate) mod golden;
|
||||
#[cfg(unix)]
|
||||
#[cfg(test)]
|
||||
mod hooks;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, ®istration.owner) {
|
||||
@@ -519,7 +602,6 @@ impl ManagerShared {
|
||||
.authority_for(®istration.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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(®istration.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(®istration.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": []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
```
|
||||
|
||||
Reference in new issue
Block a user