Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
505 lines
18 KiB
JavaScript
505 lines
18 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const test = require("node:test");
|
|
const { EventEmitter } = require("node:events");
|
|
|
|
const { UtilityPluginRuntime, resolveUtilityEntrypoint } = require("./utilityPluginRuntime.cjs");
|
|
|
|
test("utility runtime waits for acceptance of a dedicated terminal interceptor port", async () => {
|
|
const requests = [];
|
|
const runtime = new UtilityPluginRuntime({
|
|
utilityProcess: {},
|
|
plugin: { id: "com.example", manifest: { main: { node: "dist/index.js" } } },
|
|
packageRoot: "/tmp/plugin",
|
|
bootstrapPath: "/runtime.mjs",
|
|
moduleMappings: {},
|
|
});
|
|
runtime.router = {
|
|
request(method, params, options) {
|
|
requests.push({ method, params, options });
|
|
return Promise.resolve(options.validateResult({ accepted: true }));
|
|
},
|
|
};
|
|
const port = { close() {} };
|
|
await runtime.attachTerminalInterceptor(
|
|
{
|
|
providerId: "com.example.input",
|
|
direction: "input",
|
|
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
|
},
|
|
port,
|
|
);
|
|
assert.equal(requests[0].method, "plugin.terminal.interceptor.attach");
|
|
assert.deepEqual(requests[0].params, {
|
|
descriptor: {
|
|
providerId: "com.example.input",
|
|
direction: "input",
|
|
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
|
},
|
|
});
|
|
assert.deepEqual(requests[0].options.transfer, [port]);
|
|
assert.equal(requests[0].options.timeoutMs > 0, true);
|
|
});
|
|
|
|
test("utility runtime validates terminal attachment params and results against the canonical contract", async () => {
|
|
const runtime = new UtilityPluginRuntime({
|
|
utilityProcess: {},
|
|
plugin: { id: "com.example", manifest: { main: { node: "dist/index.js" } } },
|
|
packageRoot: "/tmp/plugin",
|
|
bootstrapPath: "/runtime.mjs",
|
|
moduleMappings: {},
|
|
});
|
|
let requests = 0;
|
|
runtime.router = {
|
|
request(_method, _params, options) {
|
|
requests += 1;
|
|
return Promise.resolve().then(() => (
|
|
options.validateResult({ accepted: true, unexpected: true })
|
|
));
|
|
},
|
|
};
|
|
const port = { close() {} };
|
|
assert.throws(
|
|
() => runtime.attachTerminalInterceptor(
|
|
{ providerId: "com.example.input", direction: "input", session: { sessionId: "session-1" } },
|
|
port,
|
|
),
|
|
/attachment params violates the plugin contract/,
|
|
);
|
|
assert.equal(requests, 0);
|
|
await assert.rejects(
|
|
runtime.attachTerminalInterceptor(
|
|
{
|
|
providerId: "com.example.input",
|
|
direction: "input",
|
|
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
|
},
|
|
port,
|
|
),
|
|
/attachment result violates the plugin contract/,
|
|
);
|
|
assert.equal(requests, 1);
|
|
});
|
|
|
|
test("utility entrypoint is realpath-contained at the moment of launch", async (context) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-utility-entry-"));
|
|
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const packageRoot = path.join(root, "package");
|
|
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
|
|
fs.writeFileSync(path.join(packageRoot, "dist/index.js"), "export default {};\n");
|
|
assert.equal(
|
|
await resolveUtilityEntrypoint(packageRoot, "dist/index.js"),
|
|
fs.realpathSync(path.join(packageRoot, "dist/index.js")),
|
|
);
|
|
if (process.platform !== "win32") {
|
|
const outside = path.join(root, "outside.js");
|
|
fs.writeFileSync(outside, "export default {};\n");
|
|
fs.symlinkSync(outside, path.join(packageRoot, "dist/escape.js"));
|
|
await assert.rejects(
|
|
resolveUtilityEntrypoint(packageRoot, "dist/escape.js"),
|
|
/escapes its package/,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("utility runtime launches without a shell using a minimal environment", async (context) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-utility-launch-"));
|
|
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const packageRoot = path.join(root, "package");
|
|
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
|
|
fs.writeFileSync(path.join(packageRoot, "dist/index.js"), "export default {};\n");
|
|
let forkOptions;
|
|
const messages = [];
|
|
const transferLists = [];
|
|
class FakeChild extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.stdout = new EventEmitter();
|
|
this.stderr = new EventEmitter();
|
|
}
|
|
postMessage(message, transfer = []) {
|
|
messages.push(message);
|
|
transferLists.push(transfer);
|
|
if (message.type === "netcatty-plugin:bootstrap") {
|
|
queueMicrotask(() => this.emit("message", { type: "netcatty-plugin:ready" }));
|
|
} else if (message.method === "plugin.initialize") {
|
|
queueMicrotask(() => this.emit("message", {
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
pluginId: "com.example.utility",
|
|
pluginVersion: "1.0.0",
|
|
apiVersion: "0.1.0-internal",
|
|
enabledFeatures: [],
|
|
},
|
|
}));
|
|
} else if (message.method === "plugin.activate") {
|
|
queueMicrotask(() => this.emit("message", { jsonrpc: "2.0", id: message.id, result: null }));
|
|
} else if (message.method === "plugin.terminal.interceptor.attach") {
|
|
queueMicrotask(() => this.emit("message", {
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: { accepted: true },
|
|
}));
|
|
}
|
|
}
|
|
kill() { return true; }
|
|
}
|
|
const child = new FakeChild();
|
|
const onBeforeMessage = () => {};
|
|
let processReadyCalls = 0;
|
|
const runtime = new UtilityPluginRuntime({
|
|
utilityProcess: {
|
|
fork(_bootstrap, _args, options) {
|
|
forkOptions = options;
|
|
return child;
|
|
},
|
|
},
|
|
plugin: {
|
|
id: "com.example.utility",
|
|
manifest: { main: { node: "dist/index.js" } },
|
|
},
|
|
packageRoot,
|
|
bootstrapPath: path.join(root, "utilityRuntime.mjs"),
|
|
moduleMappings: {},
|
|
handlers: {},
|
|
onBeforeMessage,
|
|
onProcessReady: () => { processReadyCalls += 1; },
|
|
logger: { write() {} },
|
|
});
|
|
await runtime.start({
|
|
pluginId: "com.example.utility",
|
|
pluginVersion: "1.0.0",
|
|
netcattyVersion: "0.0.0",
|
|
apiVersion: "0.1.0-internal",
|
|
supportedFeatures: [],
|
|
enabledFeatures: [],
|
|
environment: { locale: "en" },
|
|
}, {
|
|
getActivationEnvironment: () => ({ locale: "zh-CN", theme: "dark" }),
|
|
});
|
|
assert.deepEqual(messages.find((message) => message.method === "plugin.activate").params, {
|
|
environment: { locale: "zh-CN", theme: "dark" },
|
|
});
|
|
const interceptorPort = { close() {} };
|
|
await runtime.attachTerminalInterceptor(
|
|
{
|
|
providerId: "com.example.input",
|
|
direction: "input",
|
|
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
|
},
|
|
interceptorPort,
|
|
);
|
|
assert.deepEqual(transferLists.at(-1), [interceptorPort]);
|
|
const transferable = new ArrayBuffer(8);
|
|
runtime.router.send({ type: "transfer-test", transferable }, [transferable]);
|
|
assert.deepEqual(transferLists.at(-1), [transferable]);
|
|
assert.equal(forkOptions.cwd, packageRoot);
|
|
assert.equal(runtime.router.onBeforeMessage, onBeforeMessage);
|
|
assert.deepEqual(forkOptions.stdio, ["ignore", "pipe", "pipe"]);
|
|
assert.equal(forkOptions.allowLoadingUnsignedLibraries, false);
|
|
assert.equal(forkOptions.disclaim, process.platform === "darwin");
|
|
assert.equal(Object.hasOwn(forkOptions, "shell"), false);
|
|
assert.equal(Object.hasOwn(forkOptions.env, "NODE_OPTIONS"), false);
|
|
assert.equal(Object.hasOwn(forkOptions.env, "HOME"), false);
|
|
assert.equal(processReadyCalls, 1);
|
|
});
|
|
|
|
test("utility runtime stop waits for the child exit before releasing the activation", async (context) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-utility-stop-"));
|
|
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const packageRoot = path.join(root, "package");
|
|
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
|
|
fs.writeFileSync(path.join(packageRoot, "dist/index.js"), "export default {};\n");
|
|
let kills = 0;
|
|
let exitEvents = 0;
|
|
class FakeChild extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.pid = 1234;
|
|
this.stdout = new EventEmitter();
|
|
this.stderr = new EventEmitter();
|
|
}
|
|
postMessage(message) {
|
|
if (message.type === "netcatty-plugin:bootstrap") {
|
|
queueMicrotask(() => this.emit("message", { type: "netcatty-plugin:ready" }));
|
|
} else if (message.method === "plugin.initialize") {
|
|
queueMicrotask(() => this.emit("message", {
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
pluginId: "com.example.utility-stop",
|
|
pluginVersion: "1.0.0",
|
|
apiVersion: "0.1.0-internal",
|
|
enabledFeatures: [],
|
|
},
|
|
}));
|
|
} else if (message.method === "plugin.activate" || message.method === "plugin.deactivate") {
|
|
queueMicrotask(() => this.emit("message", { jsonrpc: "2.0", id: message.id, result: null }));
|
|
}
|
|
}
|
|
kill() {
|
|
kills += 1;
|
|
return true;
|
|
}
|
|
}
|
|
const child = new FakeChild();
|
|
const runtime = new UtilityPluginRuntime({
|
|
utilityProcess: { fork: () => child },
|
|
plugin: {
|
|
id: "com.example.utility-stop",
|
|
manifest: { main: { node: "dist/index.js" } },
|
|
},
|
|
packageRoot,
|
|
bootstrapPath: path.join(root, "utilityRuntime.mjs"),
|
|
moduleMappings: {},
|
|
handlers: {},
|
|
logger: { write() {} },
|
|
onExit: () => { exitEvents += 1; },
|
|
});
|
|
await runtime.start({
|
|
pluginId: "com.example.utility-stop",
|
|
pluginVersion: "1.0.0",
|
|
netcattyVersion: "0.0.0",
|
|
apiVersion: "0.1.0-internal",
|
|
supportedFeatures: [],
|
|
enabledFeatures: [],
|
|
});
|
|
|
|
const stopping = runtime.stop();
|
|
assert.equal(runtime.stop(), stopping);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(kills, 1);
|
|
assert.equal(exitEvents, 0);
|
|
assert.equal(await Promise.race([stopping.then(() => "stopped"), Promise.resolve("pending")]), "pending");
|
|
|
|
child.pid = undefined;
|
|
child.emit("exit", 0);
|
|
await stopping;
|
|
assert.equal(exitEvents, 0);
|
|
});
|
|
|
|
test("utility runtime force-kills a child that ignores graceful termination", async (context) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-utility-force-stop-"));
|
|
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const packageRoot = path.join(root, "package");
|
|
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
|
|
fs.writeFileSync(path.join(packageRoot, "dist/index.js"), "export default {};\n");
|
|
let gracefulKills = 0;
|
|
const forcedPids = [];
|
|
class FakeChild extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.pid = 2468;
|
|
this.stdout = new EventEmitter();
|
|
this.stderr = new EventEmitter();
|
|
}
|
|
postMessage(message) {
|
|
if (message.type === "netcatty-plugin:bootstrap") {
|
|
queueMicrotask(() => this.emit("message", { type: "netcatty-plugin:ready" }));
|
|
} else if (message.method === "plugin.initialize") {
|
|
queueMicrotask(() => this.emit("message", {
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
pluginId: "com.example.utility-force-stop",
|
|
pluginVersion: "1.0.0",
|
|
apiVersion: "0.1.0-internal",
|
|
enabledFeatures: [],
|
|
},
|
|
}));
|
|
} else if (message.method === "plugin.activate" || message.method === "plugin.deactivate") {
|
|
queueMicrotask(() => this.emit("message", { jsonrpc: "2.0", id: message.id, result: null }));
|
|
}
|
|
}
|
|
kill() {
|
|
gracefulKills += 1;
|
|
throw new Error("graceful termination unavailable");
|
|
}
|
|
}
|
|
const child = new FakeChild();
|
|
const runtime = new UtilityPluginRuntime({
|
|
utilityProcess: { fork: () => child },
|
|
plugin: {
|
|
id: "com.example.utility-force-stop",
|
|
manifest: { main: { node: "dist/index.js" } },
|
|
},
|
|
packageRoot,
|
|
bootstrapPath: path.join(root, "utilityRuntime.mjs"),
|
|
moduleMappings: {},
|
|
handlers: {},
|
|
logger: { write() {} },
|
|
terminationGraceMs: 5,
|
|
forceExitTimeoutMs: 50,
|
|
forceKillProcess(pid) {
|
|
forcedPids.push(pid);
|
|
queueMicrotask(() => {
|
|
child.pid = undefined;
|
|
child.emit("exit", 137);
|
|
});
|
|
},
|
|
});
|
|
await runtime.start({
|
|
pluginId: "com.example.utility-force-stop",
|
|
pluginVersion: "1.0.0",
|
|
netcattyVersion: "0.0.0",
|
|
apiVersion: "0.1.0-internal",
|
|
supportedFeatures: [],
|
|
enabledFeatures: [],
|
|
});
|
|
|
|
await runtime.stop();
|
|
assert.equal(gracefulKills, 1);
|
|
assert.deepEqual(forcedPids, [2468]);
|
|
});
|
|
|
|
test("utility runtime reports containment failure when forced termination is not reaped", async (context) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-utility-unreaped-"));
|
|
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const packageRoot = path.join(root, "package");
|
|
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
|
|
fs.writeFileSync(path.join(packageRoot, "dist/index.js"), "export default {};\n");
|
|
class FakeChild extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.pid = 1357;
|
|
this.stdout = new EventEmitter();
|
|
this.stderr = new EventEmitter();
|
|
}
|
|
postMessage(message) {
|
|
if (message.type === "netcatty-plugin:bootstrap") {
|
|
queueMicrotask(() => this.emit("message", { type: "netcatty-plugin:ready" }));
|
|
} else if (message.method === "plugin.initialize") {
|
|
queueMicrotask(() => this.emit("message", {
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
pluginId: "com.example.utility-unreaped",
|
|
pluginVersion: "1.0.0",
|
|
apiVersion: "0.1.0-internal",
|
|
enabledFeatures: [],
|
|
},
|
|
}));
|
|
} else if (message.method === "plugin.activate" || message.method === "plugin.deactivate") {
|
|
queueMicrotask(() => this.emit("message", { jsonrpc: "2.0", id: message.id, result: null }));
|
|
}
|
|
}
|
|
kill() { return true; }
|
|
}
|
|
const runtime = new UtilityPluginRuntime({
|
|
utilityProcess: { fork: () => new FakeChild() },
|
|
plugin: {
|
|
id: "com.example.utility-unreaped",
|
|
manifest: { main: { node: "dist/index.js" } },
|
|
},
|
|
packageRoot,
|
|
bootstrapPath: path.join(root, "utilityRuntime.mjs"),
|
|
moduleMappings: {},
|
|
handlers: {},
|
|
logger: { write() {} },
|
|
terminationGraceMs: 5,
|
|
forceExitTimeoutMs: 5,
|
|
forceKillProcess() {},
|
|
});
|
|
await runtime.start({
|
|
pluginId: "com.example.utility-unreaped",
|
|
pluginVersion: "1.0.0",
|
|
netcattyVersion: "0.0.0",
|
|
apiVersion: "0.1.0-internal",
|
|
supportedFeatures: [],
|
|
enabledFeatures: [],
|
|
});
|
|
|
|
await assert.rejects(runtime.stop(), /did not exit after forced termination/);
|
|
});
|
|
|
|
test("utility fatal errors are reported only after the child is reaped", async (context) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-utility-error-"));
|
|
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
const packageRoot = path.join(root, "package");
|
|
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
|
|
fs.writeFileSync(path.join(packageRoot, "dist/index.js"), "export default {};\n");
|
|
const exits = [];
|
|
class FakeChild extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.pid = 5678;
|
|
this.stdout = new EventEmitter();
|
|
this.stderr = new EventEmitter();
|
|
}
|
|
postMessage(message) {
|
|
if (message.type === "netcatty-plugin:bootstrap") {
|
|
queueMicrotask(() => this.emit("message", { type: "netcatty-plugin:ready" }));
|
|
} else if (message.method === "plugin.initialize") {
|
|
queueMicrotask(() => this.emit("message", {
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
pluginId: "com.example.utility-error",
|
|
pluginVersion: "1.0.0",
|
|
apiVersion: "0.1.0-internal",
|
|
enabledFeatures: [],
|
|
},
|
|
}));
|
|
} else if (message.method === "plugin.activate") {
|
|
queueMicrotask(() => this.emit("message", { jsonrpc: "2.0", id: message.id, result: null }));
|
|
}
|
|
}
|
|
kill() { return true; }
|
|
}
|
|
const child = new FakeChild();
|
|
const runtime = new UtilityPluginRuntime({
|
|
utilityProcess: { fork: () => child },
|
|
plugin: {
|
|
id: "com.example.utility-error",
|
|
manifest: { main: { node: "dist/index.js" } },
|
|
},
|
|
packageRoot,
|
|
bootstrapPath: path.join(root, "utilityRuntime.mjs"),
|
|
moduleMappings: {},
|
|
handlers: {},
|
|
logger: { write() {} },
|
|
onExit: (details) => exits.push(details),
|
|
});
|
|
await runtime.start({
|
|
pluginId: "com.example.utility-error",
|
|
pluginVersion: "1.0.0",
|
|
netcattyVersion: "0.0.0",
|
|
apiVersion: "0.1.0-internal",
|
|
supportedFeatures: [],
|
|
enabledFeatures: [],
|
|
});
|
|
|
|
child.emit("error", "FatalError", "fixture-location");
|
|
assert.deepEqual(exits, []);
|
|
child.pid = undefined;
|
|
child.emit("exit", 1);
|
|
assert.equal(exits.length, 1);
|
|
assert.equal(exits[0].expected, false);
|
|
assert.match(exits[0].error.message, /fixture-location/);
|
|
});
|
|
|
|
test("an already-aborted utility startup never forks a process", async () => {
|
|
let forks = 0;
|
|
const runtime = new UtilityPluginRuntime({
|
|
utilityProcess: { fork() { forks += 1; } },
|
|
plugin: {
|
|
id: "com.example.cancelled-utility",
|
|
manifest: { main: { node: "dist/index.js" } },
|
|
},
|
|
packageRoot: "/missing",
|
|
bootstrapPath: "/runtime/utilityRuntime.mjs",
|
|
moduleMappings: {},
|
|
handlers: {},
|
|
logger: { write() {} },
|
|
});
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("cancelled"));
|
|
await assert.rejects(runtime.start({}, { signal: controller.signal }), /cancelled/);
|
|
assert.equal(forks, 0);
|
|
});
|