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
421 lines
15 KiB
JavaScript
421 lines
15 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const { createHash } = require("node:crypto");
|
|
const { EventEmitter } = require("node:events");
|
|
const fs = require("node:fs");
|
|
const fsp = require("node:fs/promises");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { PassThrough } = require("node:stream");
|
|
const test = require("node:test");
|
|
|
|
const {
|
|
CompanionRpcPeer,
|
|
PluginCompanionSupervisor,
|
|
terminateCompanionProcessTree,
|
|
} = require("./companionSupervisor.cjs");
|
|
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
|
|
|
function createRoot(context) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-companion-"));
|
|
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
return root;
|
|
}
|
|
|
|
function runtimeContext(packageRoot, digest, overrides = {}) {
|
|
return {
|
|
pluginId: "com.example.companion",
|
|
pluginVersion: "1.0.0",
|
|
runtimeId: "runtime-1",
|
|
runtimeKind: "utility",
|
|
securityPrincipal: "unsigned-package:test",
|
|
packageRoot,
|
|
manifest: {
|
|
main: { node: "dist/node.js" },
|
|
permissions: {
|
|
required: [
|
|
"runtime.advanced",
|
|
{
|
|
permission: "companion.execute",
|
|
resources: ["com.example.companion.helper"],
|
|
},
|
|
],
|
|
},
|
|
companionExecutables: [{
|
|
id: "com.example.companion.helper",
|
|
variants: [{ path: "bin/helper", platforms: [`${process.platform}-${process.arch}`], sha256: digest }],
|
|
}],
|
|
},
|
|
assertActive: async () => {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("companion requests accept only the public secret-lease reference shape", () => {
|
|
const supervisor = new PluginCompanionSupervisor({
|
|
paths: { data: path.join(os.tmpdir(), "netcatty-plugin-companion-validation") },
|
|
});
|
|
const params = {
|
|
handleId: "companion-handle-0001",
|
|
method: "authenticate",
|
|
credentialLeases: {
|
|
password: { kind: "secret-lease", id: "credential-lease-password" },
|
|
},
|
|
operationId: "login",
|
|
};
|
|
|
|
assert.deepEqual(supervisor.validateRequest(params), {
|
|
...params,
|
|
timeoutMs: 30_000,
|
|
});
|
|
assert.throws(
|
|
() => supervisor.validateRequest({
|
|
...params,
|
|
credentialLeases: {
|
|
password: { kind: "secretLease", id: "credential-lease-password" },
|
|
},
|
|
}),
|
|
(error) => error.code === RPC_ERRORS.invalidArgument,
|
|
);
|
|
});
|
|
|
|
test("ordinary browser runtimes cannot authorize or launch native companions", async (context) => {
|
|
const root = createRoot(context);
|
|
let spawnCalls = 0;
|
|
const supervisor = new PluginCompanionSupervisor({
|
|
paths: { data: path.join(root, "data") },
|
|
spawn: () => {
|
|
spawnCalls += 1;
|
|
throw new Error("browser companion must never spawn");
|
|
},
|
|
});
|
|
const browser = runtimeContext(root, "0".repeat(64), {
|
|
runtimeKind: "browser",
|
|
manifest: {
|
|
main: { browser: "dist/browser.js" },
|
|
permissions: {
|
|
required: [{
|
|
permission: "companion.execute",
|
|
resources: ["com.example.companion.helper"],
|
|
}],
|
|
},
|
|
companionExecutables: [{
|
|
id: "com.example.companion.helper",
|
|
variants: [{
|
|
path: "bin/helper",
|
|
platforms: [`${process.platform}-${process.arch}`],
|
|
sha256: "0".repeat(64),
|
|
}],
|
|
}],
|
|
},
|
|
});
|
|
assert.throws(
|
|
() => supervisor.describeStartAuthorization({
|
|
companionId: "com.example.companion.helper",
|
|
}, browser),
|
|
(error) => error.code === RPC_ERRORS.permissionDenied,
|
|
);
|
|
await assert.rejects(
|
|
supervisor.start({ companionId: "com.example.companion.helper" }, browser),
|
|
(error) => error.code === RPC_ERRORS.permissionDenied,
|
|
);
|
|
assert.equal(spawnCalls, 0);
|
|
await supervisor.shutdown();
|
|
});
|
|
|
|
class FakeChild extends EventEmitter {
|
|
constructor(contract, responseId = (id) => id) {
|
|
super();
|
|
this.stdin = new PassThrough();
|
|
this.stdout = new PassThrough();
|
|
this.stderr = new PassThrough();
|
|
this.exitCode = null;
|
|
this.signalCode = null;
|
|
const decoder = new contract.ContentLengthFrameDecoder();
|
|
this.stdin.on("data", (chunk) => {
|
|
for (const message of decoder.push(chunk)) {
|
|
if (!Object.hasOwn(message, "id")) continue;
|
|
queueMicrotask(() => this.stdout.write(contract.encodeContentLengthFrame({
|
|
jsonrpc: "2.0",
|
|
id: responseId(message.id),
|
|
result: { echoed: message.params ?? null },
|
|
})));
|
|
}
|
|
});
|
|
queueMicrotask(() => this.emit("spawn"));
|
|
}
|
|
|
|
kill(signal = "SIGTERM") {
|
|
this.signalCode = signal;
|
|
queueMicrotask(() => this.emit("exit", null, signal));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class ManualChild extends EventEmitter {
|
|
constructor(contract) {
|
|
super();
|
|
this.stdin = new PassThrough();
|
|
this.stdout = new PassThrough();
|
|
this.stderr = new PassThrough();
|
|
this.exitCode = null;
|
|
this.signalCode = null;
|
|
this.messages = [];
|
|
const decoder = new contract.ContentLengthFrameDecoder();
|
|
this.stdin.on("data", (chunk) => this.messages.push(...decoder.push(chunk)));
|
|
}
|
|
|
|
respond(contract, id, result) {
|
|
this.stdout.write(contract.encodeContentLengthFrame({ jsonrpc: "2.0", id, result }));
|
|
}
|
|
}
|
|
|
|
test("companion RPC correlation keeps numeric and string IDs distinct", async () => {
|
|
const contract = await import("@netcatty/plugin-contract");
|
|
const child = new FakeChild(contract, (id) => String(id));
|
|
const errors = [];
|
|
const peer = new CompanionRpcPeer({
|
|
child,
|
|
contract,
|
|
onProtocolError: (error) => errors.push(error.message),
|
|
});
|
|
await assert.rejects(peer.request("echo", null, 1_000), /unknown response ID/);
|
|
assert.deepEqual(errors, ["Plugin companion returned an unknown response ID"]);
|
|
});
|
|
|
|
test("companion RPC ignores one late timed-out response and never reuses its ID", async () => {
|
|
const contract = await import("@netcatty/plugin-contract");
|
|
const child = new ManualChild(contract);
|
|
const errors = [];
|
|
const peer = new CompanionRpcPeer({
|
|
child,
|
|
contract,
|
|
onProtocolError: (error) => errors.push(error.message),
|
|
});
|
|
await assert.rejects(
|
|
peer.request("slow", null, 5),
|
|
(error) => error.code === RPC_ERRORS.deadlineExceeded,
|
|
);
|
|
assert.equal(child.messages[0].id, 0);
|
|
peer.nextId = 0;
|
|
const next = peer.request("next", { value: 1 }, 1_000);
|
|
assert.equal(child.messages[1].id, 1);
|
|
child.respond(contract, 0, { late: true });
|
|
child.respond(contract, 1, { ok: true });
|
|
assert.deepEqual(await next, { ok: true });
|
|
assert.deepEqual(errors, []);
|
|
peer.close();
|
|
});
|
|
|
|
test("companion launch verifies digest immediately and never spawns a mismatch", async (context) => {
|
|
const root = createRoot(context);
|
|
const packageRoot = path.join(root, "package");
|
|
const executable = path.join(packageRoot, "bin/helper");
|
|
await fsp.mkdir(path.dirname(executable), { recursive: true });
|
|
await fsp.writeFile(executable, "binary");
|
|
let spawnCalls = 0;
|
|
const supervisor = new PluginCompanionSupervisor({
|
|
paths: { data: path.join(root, "data") },
|
|
spawn: () => { spawnCalls += 1; throw new Error("must not spawn"); },
|
|
});
|
|
await assert.rejects(
|
|
supervisor.start(
|
|
{ companionId: "com.example.companion.helper" },
|
|
runtimeContext(packageRoot, "0".repeat(64)),
|
|
),
|
|
(error) => error.code === RPC_ERRORS.dataLoss,
|
|
);
|
|
assert.equal(spawnCalls, 0);
|
|
await supervisor.shutdown();
|
|
});
|
|
|
|
test("companion runs shell-free with sanitized environment and bounded host-only RPC", async (context) => {
|
|
const root = createRoot(context);
|
|
const packageRoot = path.join(root, "package");
|
|
const executable = path.join(packageRoot, "bin/helper");
|
|
await fsp.mkdir(path.dirname(executable), { recursive: true });
|
|
const contents = Buffer.from("binary");
|
|
await fsp.writeFile(executable, contents);
|
|
const digest = createHash("sha256").update(contents).digest("hex");
|
|
const contract = await import("@netcatty/plugin-contract");
|
|
const spawns = [];
|
|
const tracked = [];
|
|
const supervisor = new PluginCompanionSupervisor({
|
|
paths: { data: path.join(root, "data") },
|
|
spawn: (command, args, options) => {
|
|
const child = new FakeChild(contract);
|
|
spawns.push({ command, args, options, child });
|
|
return child;
|
|
},
|
|
quotaManager: {
|
|
trackProcess: (resourceId, identity) => tracked.push({ resourceId, identity }),
|
|
releaseProcess() {},
|
|
chargeBytes() {},
|
|
},
|
|
});
|
|
const runtime = runtimeContext(packageRoot, digest);
|
|
const handle = await supervisor.start({ companionId: "com.example.companion.helper" }, runtime);
|
|
assert.equal(spawns[0].command, await fsp.realpath(executable));
|
|
assert.deepEqual(spawns[0].args, []);
|
|
assert.equal(spawns[0].options.shell, false);
|
|
assert.equal(spawns[0].options.detached, process.platform !== "win32");
|
|
assert.deepEqual(Object.keys(spawns[0].options.env).sort(), ["LANG", "LC_ALL"]);
|
|
assert.deepEqual(tracked, [{
|
|
resourceId: `${runtime.runtimeId}\0companion:${handle.handleId}`,
|
|
identity: {
|
|
pluginId: runtime.pluginId,
|
|
pluginVersion: runtime.pluginVersion,
|
|
runtimeId: runtime.runtimeId,
|
|
runtimeKind: runtime.runtimeKind,
|
|
securityPrincipal: runtime.securityPrincipal,
|
|
},
|
|
}]);
|
|
|
|
assert.deepEqual(await supervisor.request({
|
|
handleId: handle.handleId,
|
|
method: "echo",
|
|
params: { value: 1 },
|
|
}, runtime), { echoed: { value: 1 } });
|
|
await assert.rejects(
|
|
supervisor.request({ handleId: handle.handleId, method: "echo" }, {
|
|
...runtime,
|
|
runtimeId: "runtime-2",
|
|
}),
|
|
(error) => error.code === RPC_ERRORS.notFound,
|
|
);
|
|
await supervisor.stop({ handleId: handle.handleId }, runtime);
|
|
assert.equal(spawns[0].child.signalCode, "SIGTERM");
|
|
await supervisor.shutdown();
|
|
});
|
|
|
|
test("companion cleanup reports the complete runtime identity and rejects containment failure", async (context) => {
|
|
const root = createRoot(context);
|
|
const packageRoot = path.join(root, "package");
|
|
const executable = path.join(packageRoot, "bin/helper");
|
|
await fsp.mkdir(path.dirname(executable), { recursive: true });
|
|
const contents = Buffer.from("binary");
|
|
await fsp.writeFile(executable, contents);
|
|
const digest = createHash("sha256").update(contents).digest("hex");
|
|
const contract = await import("@netcatty/plugin-contract");
|
|
const failures = [];
|
|
const supervisor = new PluginCompanionSupervisor({
|
|
paths: { data: path.join(root, "data") },
|
|
spawn: () => new FakeChild(contract),
|
|
terminateProcessTree: async () => { throw new Error("process tree survived"); },
|
|
onContainmentFailure: (identity, error) => failures.push({ identity, error }),
|
|
});
|
|
const runtime = runtimeContext(packageRoot, digest);
|
|
await supervisor.start({ companionId: "com.example.companion.helper" }, runtime);
|
|
|
|
await assert.rejects(
|
|
supervisor.releaseRuntime(runtime.runtimeId),
|
|
(error) => error.code === RPC_ERRORS.failedPrecondition,
|
|
);
|
|
assert.deepEqual(failures.map(({ identity }) => identity), [{
|
|
pluginId: runtime.pluginId,
|
|
pluginVersion: runtime.pluginVersion,
|
|
runtimeId: runtime.runtimeId,
|
|
runtimeKind: runtime.runtimeKind,
|
|
securityPrincipal: runtime.securityPrincipal,
|
|
}]);
|
|
assert.match(failures[0].error.message, /could not be reaped/);
|
|
await supervisor.shutdown();
|
|
});
|
|
|
|
test("Windows companion termination uses shell-free tree cleanup for both stages", async () => {
|
|
const calls = [];
|
|
await terminateCompanionProcessTree({
|
|
pid: 1234,
|
|
exitCode: null,
|
|
signalCode: null,
|
|
}, {
|
|
platform: "win32",
|
|
delay: async () => {},
|
|
execFile(executable, args, options, callback) {
|
|
calls.push({ executable, args, options });
|
|
callback(null);
|
|
},
|
|
});
|
|
assert.equal(calls.length, 2);
|
|
assert.deepEqual(calls[0].args, ["/PID", "1234", "/T"]);
|
|
assert.deepEqual(calls[1].args, ["/PID", "1234", "/T", "/F"]);
|
|
assert.equal(calls[0].options.windowsHide, true);
|
|
});
|
|
|
|
test("companion stop reaps its POSIX descendant process group", {
|
|
skip: process.platform === "win32" || /\s/u.test(process.execPath),
|
|
}, async (context) => {
|
|
const root = createRoot(context);
|
|
const packageRoot = path.join(root, "package");
|
|
const executable = path.join(packageRoot, "bin/helper");
|
|
const dataDirectory = path.join(root, "data", "com.example.companion");
|
|
const pidFile = path.join(dataDirectory, "descendant.pid");
|
|
await fsp.mkdir(path.dirname(executable), { recursive: true });
|
|
const contents = Buffer.from(`#!${process.execPath}\n`
|
|
+ `const fs = require("node:fs");\n`
|
|
+ `const { spawn } = require("node:child_process");\n`
|
|
+ `process.on("SIGTERM", () => {});\n`
|
|
+ `const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], { stdio: "ignore" });\n`
|
|
+ `fs.writeFileSync("descendant.pid", String(child.pid));\n`
|
|
+ `setInterval(() => {}, 1000);\n`);
|
|
await fsp.writeFile(executable, contents, { mode: 0o755 });
|
|
await fsp.chmod(executable, 0o755);
|
|
const digest = createHash("sha256").update(contents).digest("hex");
|
|
const supervisor = new PluginCompanionSupervisor({ paths: { data: path.join(root, "data") } });
|
|
const runtime = runtimeContext(packageRoot, digest);
|
|
const handle = await supervisor.start({ companionId: "com.example.companion.helper" }, runtime);
|
|
let descendantPid;
|
|
// The complete plugin suite runs many test files in parallel, so process
|
|
// scheduling can exceed one second on a busy CI host. Treat the PID file as
|
|
// the explicit readiness barrier and keep a bounded ten-second deadline.
|
|
for (let attempt = 0; attempt < 1_000; attempt += 1) {
|
|
try {
|
|
descendantPid = Number(await fsp.readFile(pidFile, "utf8"));
|
|
break;
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
}
|
|
assert.ok(Number.isSafeInteger(descendantPid) && descendantPid > 0);
|
|
context.after(() => {
|
|
if (!descendantPid) return;
|
|
try { process.kill(descendantPid, "SIGKILL"); } catch {}
|
|
});
|
|
await supervisor.stop({ handleId: handle.handleId }, runtime);
|
|
await assert.rejects(
|
|
Promise.resolve().then(() => process.kill(descendantPid, 0)),
|
|
(error) => error?.code === "ESRCH",
|
|
);
|
|
await supervisor.shutdown();
|
|
});
|
|
|
|
test("concurrent companion starts reserve the per-runtime process quota", async (context) => {
|
|
const root = createRoot(context);
|
|
const packageRoot = path.join(root, "package");
|
|
const executable = path.join(packageRoot, "bin/helper");
|
|
await fsp.mkdir(path.dirname(executable), { recursive: true });
|
|
const contents = Buffer.from("binary");
|
|
await fsp.writeFile(executable, contents);
|
|
const digest = createHash("sha256").update(contents).digest("hex");
|
|
const contract = await import("@netcatty/plugin-contract");
|
|
let spawns = 0;
|
|
const supervisor = new PluginCompanionSupervisor({
|
|
paths: { data: path.join(root, "data") },
|
|
spawn: () => {
|
|
spawns += 1;
|
|
return new FakeChild(contract);
|
|
},
|
|
});
|
|
const runtime = runtimeContext(packageRoot, digest);
|
|
const results = await Promise.allSettled(Array.from({ length: 5 }, () => (
|
|
supervisor.start({ companionId: "com.example.companion.helper" }, runtime)
|
|
)));
|
|
assert.equal(results.filter(({ status }) => status === "fulfilled").length, 4);
|
|
assert.equal(results.filter(({ status }) => status === "rejected").length, 1);
|
|
assert.equal(results.find(({ status }) => status === "rejected").reason.code, RPC_ERRORS.resourceExhausted);
|
|
assert.equal(spawns, 4);
|
|
await supervisor.shutdown();
|
|
});
|