[Init] Initial commit - NetMesh terminal manager
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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
"use strict";
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("../constants.cjs");
const {
getCapabilityByCliCommand,
listCapabilities,
} = require("../registry.cjs");
const { TOOL_INPUT_FIELDS } = require("../schemas/toolInputs.cjs");
/** Maps TOOL_INPUT_FIELDS keys to CLI flag names and opts property names. */
const CLI_FIELD_BINDINGS = Object.freeze({
hostId: { flag: "--host-id", optKey: "hostId" },
filename: { flag: "--filename", optKey: "filename" },
snippetId: { flag: "--snippet-id", optKey: "snippetId" },
scriptId: { flag: "--script-id", optKey: "scriptId" },
runId: { flag: "--run-id", optKey: "runId" },
ruleId: { flag: "--rule-id", optKey: "ruleId" },
notes: { flag: "--notes", optKey: "notes" },
sessionId: { flag: "--session", optKey: "sessionId" },
variables: { flag: "--variables", optKey: "variables" },
wait: { flag: "--wait", optKey: "wait" },
scriptIds: { flag: "--script-ids", optKey: "scriptIds" },
label: { flag: "--label", optKey: "label" },
kind: { flag: "--kind", optKey: "kind" },
trigger: { flag: "--trigger", optKey: "trigger" },
triggerPattern: { flag: "--trigger-pattern", optKey: "triggerPattern" },
targets: { flag: "--targets", optKey: "targets" },
targetGroups: { flag: "--target-groups", optKey: "targetGroups" },
targetsAllHosts: { flag: "--targets-all-hosts", optKey: "targetsAllHosts" },
description: { flag: "--description", optKey: "description" },
language: { flag: "--language", optKey: "language" },
package: { flag: "--package", optKey: "package" },
shortkey: { flag: "--shortkey", optKey: "shortkey" },
noAutoRun: { flag: "--no-auto-run", optKey: "noAutoRun" },
multiLineRunMode: { flag: "--multi-line-run-mode", optKey: "multiLineRunMode" },
path: { flag: "--remote-path", optKey: "remotePath" },
remotePath: { flag: "--remote-path", optKey: "remotePath" },
localPath: { flag: "--local-path", optKey: "localPath" },
oldPath: { flag: "--old-remote-path", optKey: "oldRemotePath" },
newPath: { flag: "--new-remote-path", optKey: "newRemotePath" },
content: { flag: "--content", optKey: "content" },
mode: { flag: "--mode", optKey: "mode" },
command: { flag: "--", optKey: "command" },
jobId: { flag: "--job", optKey: "jobId" },
offset: { flag: "--offset", optKey: "offset" },
});
function resolveCliRpcMethod(capability) {
if (!capability) return null;
return capability.surfaces?.[CAPABILITY_SURFACES.BUILTIN]?.rpcMethod
|| capability.surfaces?.[CAPABILITY_SURFACES.GLOBAL]?.rpcMethod
|| capability.surfaces?.[CAPABILITY_SURFACES.PUBLIC]?.rpcMethod
|| null;
}
function getCliRpcMethod(commandParts) {
const capability = getCapabilityByCliCommand(commandParts);
return resolveCliRpcMethod(capability);
}
function listCliCapabilities(options = {}) {
const surface = options.surface || CAPABILITY_SURFACES.CLI;
const status = Object.prototype.hasOwnProperty.call(options, "status")
? options.status
: CAPABILITY_STATUS.IMPLEMENTED;
return listCapabilities({ surface, status: status || undefined })
.filter((capability) => Array.isArray(capability.surfaces?.[surface]?.command))
.map((capability) => ({
id: capability.id,
domain: capability.domain,
status: capability.status,
description: capability.description,
command: capability.surfaces[surface].command,
rpcMethod: resolveCliRpcMethod(capability),
policy: capability.policy,
}));
}
function formatCliHelpLines(options = {}) {
return listCliCapabilities(options).flatMap((entry) => {
const statusSuffix = entry.status === CAPABILITY_STATUS.PLANNED ? " (planned)" : "";
return [` netcatty-tool-cli ${entry.command.join(" ")}${statusSuffix}`];
});
}
function buildCatalogCliParams(capabilityId, opts, createError) {
const fields = TOOL_INPUT_FIELDS[capabilityId];
if (!fields) {
return {};
}
const params = {};
for (const [fieldName, fieldDef] of Object.entries(fields)) {
const binding = CLI_FIELD_BINDINGS[fieldName];
if (!binding) continue;
let value = opts[binding.optKey];
if (fieldName === "command" && Array.isArray(value)) {
value = value.length === 1 ? value[0] : null;
}
if (fieldName === "variables" && typeof value === "string" && value.trim()) {
try {
value = JSON.parse(value);
} catch {
throw createError("INVALID_ARGUMENT", `--variables must be valid JSON for ${capabilityId}.`);
}
}
if (fieldName === "offset" && value != null) {
value = Number(value);
}
if (value == null || value === "") {
if (!fieldDef.optional) {
throw createError(
"INVALID_ARGUMENT",
`Missing required ${binding.flag} for ${capabilityId}.`,
);
}
continue;
}
params[fieldName] = value;
}
return params;
}
module.exports = {
CLI_FIELD_BINDINGS,
buildCatalogCliParams,
getCliRpcMethod,
listCliCapabilities,
formatCliHelpLines,
resolveCliRpcMethod,
};

View File

@@ -0,0 +1,91 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
getCliRpcMethod,
listCliCapabilities,
buildCatalogCliParams,
} = require("./cliAdapter.cjs");
const { CAPABILITY_STATUS } = require("../constants.cjs");
function fakeCreateError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
test("getCliRpcMethod resolves implemented cli commands to rpc methods", () => {
assert.equal(getCliRpcMethod(["exec"]), "netcatty/exec");
assert.equal(getCliRpcMethod(["attachment", "read"]), "netcatty/readAttachment");
assert.equal(getCliRpcMethod(["sftp", "list"]), "netcatty/sftp/list");
assert.equal(getCliRpcMethod(["vault", "host", "get"]), "vault/host/get");
assert.equal(getCliRpcMethod(["portforward", "rules", "list"]), "portforward/rules/list");
assert.equal(getCliRpcMethod(["capabilities"]), null);
});
test("listCliCapabilities returns implemented commands by default", () => {
const entries = listCliCapabilities();
assert.ok(entries.some((entry) => entry.id === "terminal.execute"));
assert.ok(entries.some((entry) => entry.id === "attachment.list"));
assert.ok(entries.some((entry) => entry.id === "attachment.read"));
assert.ok(entries.some((entry) => entry.id === "vault.host.get"));
assert.ok(entries.every((entry) => entry.status === CAPABILITY_STATUS.IMPLEMENTED));
assert.ok(entries.every((entry) => entry.rpcMethod));
});
test("listCliCapabilities can include planned commands", () => {
const entries = listCliCapabilities({ status: CAPABILITY_STATUS.PLANNED });
assert.ok(entries.length >= 0);
});
test("buildCatalogCliParams maps vault host get flags", () => {
const params = buildCatalogCliParams("vault.host.get", { hostId: "host-1" }, fakeCreateError);
assert.deepEqual(params, { hostId: "host-1" });
});
test("buildCatalogCliParams maps attachment filename", () => {
const params = buildCatalogCliParams(
"attachment.read",
{ filename: "hosts.csv" },
fakeCreateError,
);
assert.deepEqual(params, { filename: "hosts.csv" });
});
test("buildCatalogCliParams parses snippet variables JSON", () => {
const params = buildCatalogCliParams("vault.snippets.run", {
snippetId: "snip-1",
sessionId: "sess-1",
variables: "{\"name\":\"prod\"}",
}, fakeCreateError);
assert.equal(params.snippetId, "snip-1");
assert.equal(params.sessionId, "sess-1");
assert.deepEqual(params.variables, { name: "prod" });
});
test("buildCatalogCliParams maps snippet multi-line run mode", () => {
const params = buildCatalogCliParams("vault.snippets.create", {
label: "login",
content: "user\npass",
multiLineRunMode: "lineDelay",
}, fakeCreateError);
assert.equal(params.multiLineRunMode, "lineDelay");
});
test("buildCatalogCliParams maps dynamic script group targets", () => {
const params = buildCatalogCliParams("vault.scripts.targets.set", {
scriptId: "script-1",
targetGroups: '["Production","Staging/Web"]',
}, fakeCreateError);
assert.equal(params.scriptId, "script-1");
assert.equal(params.targetGroups, '["Production","Staging/Web"]');
});
test("buildCatalogCliParams throws for missing required fields", () => {
assert.throws(
() => buildCatalogCliParams("vault.host.get", {}, fakeCreateError),
/Missing required --host-id/,
);
});

View File

@@ -0,0 +1,9 @@
"use strict";
const cliAdapter = require("./cliAdapter.cjs");
const mcpAdapter = require("./mcpAdapter.cjs");
module.exports = {
...cliAdapter,
...mcpAdapter,
};

View File

@@ -0,0 +1,38 @@
"use strict";
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("../constants.cjs");
const {
getCapabilityByMcpTool,
getCapabilityByRpcMethod,
listCapabilities,
} = require("../registry.cjs");
function listMcpTools(surface = CAPABILITY_SURFACES.BUILTIN, options = {}) {
const status = options.status || CAPABILITY_STATUS.IMPLEMENTED;
return listCapabilities({ surface, status })
.filter((capability) => capability.surfaces?.[surface]?.mcpTool)
.map((capability) => ({
id: capability.id,
toolName: capability.surfaces[surface].mcpTool,
rpcMethod: capability.surfaces[surface].rpcMethod,
description: capability.description,
policy: capability.policy,
status: capability.status,
}));
}
function getMcpToolRpcMethod(toolName, surface = CAPABILITY_SURFACES.BUILTIN) {
const capability = getCapabilityByMcpTool(toolName, surface);
return capability?.surfaces?.[surface]?.rpcMethod || null;
}
function getMcpToolNameForRpcMethod(rpcMethod, surface = CAPABILITY_SURFACES.BUILTIN) {
const capability = getCapabilityByRpcMethod(rpcMethod, surface);
return capability?.surfaces?.[surface]?.mcpTool || null;
}
module.exports = {
listMcpTools,
getMcpToolRpcMethod,
getMcpToolNameForRpcMethod,
};

View File

@@ -0,0 +1,33 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
listMcpTools,
getMcpToolRpcMethod,
getMcpToolNameForRpcMethod,
} = require("./mcpAdapter.cjs");
const { CAPABILITY_SURFACES } = require("../constants.cjs");
test("listMcpTools exposes builtin terminal tools", () => {
const tools = listMcpTools(CAPABILITY_SURFACES.BUILTIN);
assert.ok(tools.some((tool) => tool.toolName === "terminal_execute"));
assert.ok(tools.every((tool) => tool.rpcMethod));
});
test("getMcpToolRpcMethod resolves tool names", () => {
assert.equal(
getMcpToolRpcMethod("terminal_execute", CAPABILITY_SURFACES.BUILTIN),
"netcatty/exec",
);
});
test("public surface includes sftp tools for future public mcp registration", () => {
const tools = listMcpTools(CAPABILITY_SURFACES.PUBLIC);
assert.ok(tools.some((tool) => tool.toolName === "sftp_list"));
assert.equal(
getMcpToolNameForRpcMethod("public/sftp/list", CAPABILITY_SURFACES.PUBLIC),
"sftp_list",
);
});

View File

@@ -0,0 +1,136 @@
"use strict";
const { CAPABILITY_STATUS } = require("../constants.cjs");
/** Catty-only harness tools (sidebar agent; renderer-local; not MCP/CLI). */
/** @type {import("../types.cjs").CapabilityDefinition[]} */
const HARNESS_CAPABILITIES = [
{
id: "harness.tool_output.read",
domain: "harness",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Read stored tool output by handle id when a prior tool result was truncated.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
catty: { toolName: "tool_output_read" },
},
},
{
id: "harness.workspace.get_info",
domain: "harness",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Get information about the current workspace, including all terminal sessions and their connection status.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
catty: { toolName: "workspace_get_info" },
},
},
{
id: "harness.workspace.get_session_info",
domain: "harness",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Get detailed information about a specific terminal or SFTP session.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
catty: { toolName: "workspace_get_session_info" },
},
},
{
id: "harness.terminal.read_context",
domain: "harness",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Read a bounded slice of the current terminal screen or scrollback from the active AI scope.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
catty: { toolName: "terminal_read_context" },
},
},
{
id: "harness.web.search",
domain: "harness",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Search the web for current information when configured in AI settings.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
catty: { toolName: "web_search" },
},
},
{
id: "harness.url.fetch",
domain: "harness",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Fetch and read the content of an HTTPS URL.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
catty: { toolName: "url_fetch" },
},
},
{
id: "harness.skill.run",
domain: "harness",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Run a built-in diagnostic skill that executes a pre-crafted sequence of shell commands on a target session and returns a structured report.",
policy: {
write: false,
sensitiveRead: false,
longRunning: true,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
catty: { toolName: "skill_run" },
},
},
];
module.exports = { HARNESS_CAPABILITIES };

View File

@@ -0,0 +1,27 @@
"use strict";
const { META_CAPABILITIES } = require("./meta.cjs");
const { TERMINAL_CAPABILITIES } = require("./terminal.cjs");
const { SFTP_CAPABILITIES } = require("./sftp.cjs");
const { VAULT_CAPABILITIES } = require("./vault.cjs");
const { PORT_FORWARD_CAPABILITIES } = require("./portforward.cjs");
const { HARNESS_CAPABILITIES } = require("./harness.cjs");
const ALL_CAPABILITIES = Object.freeze([
...META_CAPABILITIES,
...TERMINAL_CAPABILITIES,
...SFTP_CAPABILITIES,
...VAULT_CAPABILITIES,
...PORT_FORWARD_CAPABILITIES,
...HARNESS_CAPABILITIES,
]);
module.exports = {
META_CAPABILITIES,
TERMINAL_CAPABILITIES,
SFTP_CAPABILITIES,
VAULT_CAPABILITIES,
PORT_FORWARD_CAPABILITIES,
HARNESS_CAPABILITIES,
ALL_CAPABILITIES,
};

View File

@@ -0,0 +1,86 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { ALL_CAPABILITIES } = require("../index.cjs");
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("../constants.cjs");
const { getCliRpcMethod } = require("../adapters/cliAdapter.cjs");
const IMPLEMENTED_CLI_COMMANDS = [
["status"],
["env"],
["session"],
["exec"],
["job-start"],
["job-poll"],
["job-stop"],
["sftp", "list"],
["sftp", "read"],
["sftp", "write"],
["sftp", "download"],
["sftp", "upload"],
["sftp", "mkdir"],
["sftp", "delete"],
["sftp", "rename"],
["sftp", "stat"],
["sftp", "chmod"],
["sftp", "home"],
["cancel"],
["resume"],
["vault", "host", "get"],
["vault", "host", "open"],
["vault", "host-notes", "get"],
["vault", "host-notes", "set"],
["snippets", "list"],
["snippets", "get"],
["snippets", "run"],
["snippets", "create"],
["snippets", "update"],
["snippets", "delete"],
["scripts", "list"],
["scripts", "get"],
["scripts", "run"],
["scripts", "create"],
["scripts", "update"],
["scripts", "delete"],
["scripts", "reference"],
["scripts", "runs", "list"],
["scripts", "run", "stop"],
["scripts", "run", "pause"],
["scripts", "run", "resume"],
["scripts", "targets", "set"],
["vault", "host", "connect-scripts", "list"],
["vault", "host", "connect-scripts", "set"],
["portforward", "rules", "list"],
["portforward", "tunnels", "list"],
["portforward", "start"],
["portforward", "stop"],
];
test("every implemented cli command maps to an rpc method", () => {
for (const command of IMPLEMENTED_CLI_COMMANDS) {
const rpcMethod = getCliRpcMethod(command);
assert.ok(rpcMethod, `missing rpc mapping for ${command.join(" ")}`);
}
});
test("implemented capabilities expose at least one surface binding", () => {
for (const capability of ALL_CAPABILITIES) {
if (capability.status !== CAPABILITY_STATUS.IMPLEMENTED) continue;
const surfaces = Object.keys(capability.surfaces || {});
assert.ok(surfaces.length > 0, `${capability.id} has no surfaces`);
const hasRpc = surfaces.some((surface) => capability.surfaces[surface]?.rpcMethod);
const hasCli = surfaces.some((surface) => capability.surfaces[surface]?.command);
const hasCatty = Boolean(capability.surfaces[CAPABILITY_SURFACES.CATTY]?.toolName);
assert.ok(
hasRpc || hasCli || hasCatty || capability.surfaces[CAPABILITY_SURFACES.BUILTIN]?.mcpTool,
`${capability.id} has no rpc/cli/catty/mcp binding`,
);
}
});
test("capability ids are unique", () => {
const ids = ALL_CAPABILITIES.map((capability) => capability.id);
assert.equal(new Set(ids).size, ids.length);
});

View File

@@ -0,0 +1,163 @@
"use strict";
const { CAPABILITY_STATUS } = require("../constants.cjs");
/** @type {import("../types.cjs").CapabilityDefinition[]} */
const META_CAPABILITIES = [
{
id: "session.environment",
domain: "session",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List scoped terminal sessions available to the agent.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
builtin: { rpcMethod: "netcatty/getContext", mcpTool: "get_environment" },
public: { rpcMethod: "public/getEnvironment", mcpTool: "get_environment" },
cli: { command: ["env"] },
},
},
{
id: "meta.status",
domain: "meta",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Return bridge runtime status and policy configuration.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
builtin: { rpcMethod: "netcatty/getStatus" },
public: { rpcMethod: "public/getStatus" },
cli: { command: ["status"] },
},
},
{
id: "attachment.list",
domain: "attachment",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List user-attached files in the current AI chat scope.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
builtin: { rpcMethod: "netcatty/listAttachments", mcpTool: "list_attachments" },
cli: { command: ["attachment", "list"] },
},
},
{
id: "attachment.read",
domain: "attachment",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Read a user-attached file from the current AI chat scope.",
policy: {
write: false,
sensitiveRead: true,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
builtin: { rpcMethod: "netcatty/readAttachment", mcpTool: "read_attachment" },
cli: { command: ["attachment", "read"] },
},
},
{
id: "session.cancel",
domain: "session",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Cancel in-flight operations for a chat session.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
builtin: { rpcMethod: "netcatty/setCancelled" },
cli: { command: ["cancel"] },
},
},
{
id: "session.resume",
domain: "session",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Resume write operations for a cancelled chat session.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
builtin: { rpcMethod: "netcatty/setCancelled" },
cli: { command: ["resume"] },
},
},
{
id: "session.get",
domain: "session",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Get metadata for a single scoped session.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
builtin: { rpcMethod: "netcatty/getContext" },
cli: { command: ["session"] },
},
},
{
id: "session.close",
domain: "session",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Close a terminal session previously opened by host_open in the current AI scope.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: true,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
global: { rpcMethod: "session/close" },
public: { rpcMethod: "public/session/close", mcpTool: "session_close" },
},
},
];
module.exports = { META_CAPABILITIES };

View File

@@ -0,0 +1,113 @@
"use strict";
const { CAPABILITY_STATUS } = require("../constants.cjs");
/** @type {import("../types.cjs").CapabilityDefinition[]} */
const PORT_FORWARD_CAPABILITIES = [
{
id: "portforward.rules.list",
domain: "portforward",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List persisted port forwarding rules.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["portforward", "rules", "list"] },
global: { rpcMethod: "portforward/rules/list" },
public: { rpcMethod: "public/portforward/rules/list", mcpTool: "portforward_rules_list" },
},
},
{
id: "portforward.tunnels.list",
domain: "portforward",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List active port forwarding tunnels.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["portforward", "tunnels", "list"] },
global: { rpcMethod: "portforward/tunnels/list" },
public: { rpcMethod: "public/portforward/tunnels/list", mcpTool: "portforward_tunnels_list" },
},
},
...[
["portforward.rules.create", "Create a persisted port forwarding rule.", "create", "portforward_rules_create"],
["portforward.rules.update", "Update a persisted port forwarding rule.", "update", "portforward_rules_update"],
["portforward.rules.duplicate", "Duplicate a persisted port forwarding rule.", "duplicate", "portforward_rules_duplicate"],
["portforward.rules.delete", "Delete a persisted port forwarding rule.", "delete", "portforward_rules_delete"],
].map(([id, description, action, mcpTool]) => ({
id,
domain: "portforward",
status: CAPABILITY_STATUS.IMPLEMENTED,
description,
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
global: { rpcMethod: `portforward/rules/${action}` },
public: { rpcMethod: `public/portforward/rules/${action}`, mcpTool },
},
})),
{
id: "portforward.start",
domain: "portforward",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Start a port forwarding tunnel for a rule.",
policy: {
write: true,
sensitiveRead: false,
longRunning: true,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["portforward", "start"] },
global: { rpcMethod: "portforward/start" },
public: { rpcMethod: "public/portforward/start", mcpTool: "portforward_start" },
},
},
{
id: "portforward.stop",
domain: "portforward",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Stop an active port forwarding tunnel.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["portforward", "stop"] },
global: { rpcMethod: "portforward/stop" },
public: { rpcMethod: "public/portforward/stop", mcpTool: "portforward_stop" },
},
},
];
module.exports = { PORT_FORWARD_CAPABILITIES };

View File

@@ -0,0 +1,139 @@
"use strict";
const { CAPABILITY_STATUS } = require("../constants.cjs");
function sftpCapability(id, description, policyOverrides, surfaces) {
return {
id,
domain: "sftp",
status: CAPABILITY_STATUS.IMPLEMENTED,
description,
policy: {
write: false,
sensitiveRead: false,
longRunning: true,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
...policyOverrides,
},
surfaces,
};
}
/** @type {import("../types.cjs").CapabilityDefinition[]} */
const SFTP_CAPABILITIES = [
sftpCapability(
"sftp.list",
"List a remote directory over the session file backend (SFTP or SCP-mode).",
{ sensitiveRead: true },
{
builtin: { rpcMethod: "netcatty/sftp/list" },
public: { rpcMethod: "public/sftp/list", mcpTool: "sftp_list", confirmInConfirmMode: true },
cli: { command: ["sftp", "list"] },
},
),
sftpCapability(
"sftp.read",
"Read a remote file over the session file backend (SFTP or SCP-mode).",
{ sensitiveRead: true },
{
builtin: { rpcMethod: "netcatty/sftp/read" },
public: { rpcMethod: "public/sftp/readFile", mcpTool: "sftp_read_file", confirmInConfirmMode: true },
cli: { command: ["sftp", "read"] },
},
),
sftpCapability(
"sftp.write",
"Write a remote file over the session file backend (SFTP or SCP-mode).",
{ write: true, bypassesApproval: false, bypassesChatCancel: false },
{
builtin: { rpcMethod: "netcatty/sftp/write" },
public: { rpcMethod: "public/sftp/writeFile", mcpTool: "sftp_write_file" },
cli: { command: ["sftp", "write"] },
},
),
sftpCapability(
"sftp.download",
"Download a remote file to a local path.",
{ write: true, bypassesApproval: false, bypassesChatCancel: false },
{
builtin: { rpcMethod: "netcatty/sftp/download" },
public: { rpcMethod: "public/sftp/download", mcpTool: "sftp_download" },
cli: { command: ["sftp", "download"] },
},
),
sftpCapability(
"sftp.upload",
"Upload a local file to a remote path.",
{ write: true, bypassesApproval: false, bypassesChatCancel: false },
{
builtin: { rpcMethod: "netcatty/sftp/upload" },
public: { rpcMethod: "public/sftp/upload", mcpTool: "sftp_upload" },
cli: { command: ["sftp", "upload"] },
},
),
sftpCapability(
"sftp.stat",
"Get remote file metadata over the session file backend (SFTP or SCP-mode).",
{ sensitiveRead: true },
{
builtin: { rpcMethod: "netcatty/sftp/stat" },
public: { rpcMethod: "public/sftp/stat", mcpTool: "sftp_stat", confirmInConfirmMode: true },
cli: { command: ["sftp", "stat"] },
},
),
sftpCapability(
"sftp.home",
"Get the remote home directory for a session.",
{ sensitiveRead: true },
{
builtin: { rpcMethod: "netcatty/sftp/home" },
public: { rpcMethod: "public/sftp/home", mcpTool: "sftp_home", confirmInConfirmMode: true },
cli: { command: ["sftp", "home"] },
},
),
sftpCapability(
"sftp.mkdir",
"Create a remote directory over the session file backend (SFTP or SCP-mode).",
{ write: true, bypassesApproval: false, bypassesChatCancel: false },
{
builtin: { rpcMethod: "netcatty/sftp/mkdir" },
public: { rpcMethod: "public/sftp/mkdir", mcpTool: "sftp_mkdir" },
cli: { command: ["sftp", "mkdir"] },
},
),
sftpCapability(
"sftp.delete",
"Delete a remote file or directory over the session file backend (SFTP or SCP-mode).",
{ write: true, bypassesApproval: false, bypassesChatCancel: false },
{
builtin: { rpcMethod: "netcatty/sftp/delete" },
public: { rpcMethod: "public/sftp/delete", mcpTool: "sftp_delete" },
cli: { command: ["sftp", "delete"] },
},
),
sftpCapability(
"sftp.rename",
"Rename a remote file or directory over the session file backend (SFTP or SCP-mode).",
{ write: true, bypassesApproval: false, bypassesChatCancel: false },
{
builtin: { rpcMethod: "netcatty/sftp/rename" },
public: { rpcMethod: "public/sftp/rename", mcpTool: "sftp_rename" },
cli: { command: ["sftp", "rename"] },
},
),
sftpCapability(
"sftp.chmod",
"Change remote file permissions over the session file backend (SFTP or SCP-mode).",
{ write: true, bypassesApproval: false, bypassesChatCancel: false },
{
builtin: { rpcMethod: "netcatty/sftp/chmod" },
public: { rpcMethod: "public/sftp/chmod", mcpTool: "sftp_chmod" },
cli: { command: ["sftp", "chmod"] },
},
),
];
module.exports = { SFTP_CAPABILITIES };

View File

@@ -0,0 +1,89 @@
"use strict";
const { CAPABILITY_STATUS } = require("../constants.cjs");
/** @type {import("../types.cjs").CapabilityDefinition[]} */
const TERMINAL_CAPABILITIES = [
{
id: "terminal.execute",
domain: "terminal",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Execute a short command in a terminal session and wait for completion.",
policy: {
write: true,
sensitiveRead: false,
longRunning: true,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
builtin: { rpcMethod: "netcatty/exec", mcpTool: "terminal_execute" },
public: { rpcMethod: "public/terminalExecute", mcpTool: "terminal_execute" },
cli: { command: ["exec"] },
},
},
{
id: "terminal.start",
domain: "terminal",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Start a long-running command in a terminal session.",
policy: {
write: true,
sensitiveRead: false,
longRunning: true,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
builtin: { rpcMethod: "netcatty/jobStart", mcpTool: "terminal_start" },
public: { rpcMethod: "public/terminalStart", mcpTool: "terminal_start" },
cli: { command: ["job-start"] },
},
},
{
id: "terminal.poll",
domain: "terminal",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Poll incremental output from a long-running terminal job.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
builtin: { rpcMethod: "netcatty/jobPoll", mcpTool: "terminal_poll" },
public: { rpcMethod: "public/terminalPoll", mcpTool: "terminal_poll" },
cli: { command: ["job-poll"] },
},
},
{
id: "terminal.stop",
domain: "terminal",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Stop a long-running terminal job.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: true,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
builtin: { rpcMethod: "netcatty/jobStop", mcpTool: "terminal_stop" },
public: { rpcMethod: "public/terminalStop", mcpTool: "terminal_stop" },
cli: { command: ["job-stop"] },
},
},
];
module.exports = { TERMINAL_CAPABILITIES };

View File

@@ -0,0 +1,713 @@
"use strict";
const { AGENT_KINDS, CAPABILITY_STATUS } = require("../constants.cjs");
/** @type {import("../types.cjs").CapabilityDefinition[]} */
const VAULT_CAPABILITIES = [
{
id: "vault.host.get",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Get host metadata from the vault.",
policy: {
write: false,
sensitiveRead: true,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["vault", "host", "get"] },
global: { rpcMethod: "vault/host/get" },
public: { rpcMethod: "public/vault/host/get", mcpTool: "host_get" },
},
},
{
id: "vault.host.list",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List saved hosts in the vault (metadata only — no passwords or keys).",
policy: {
write: false,
sensitiveRead: true,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
global: { rpcMethod: "vault/hosts/list" },
public: { rpcMethod: "public/vault/hosts/list", mcpTool: "vault_hosts_list" },
},
},
{
id: "vault.host.open",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description:
"Open a vault host by creating a new terminal tab and starting the connection. Returns the new sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname.",
// Sidebar Catty is scoped to already-open terminals/workspaces and must not
// expand that scope mid-turn. Keep host_open for MCP / CLI / global agent.
agentKinds: [AGENT_KINDS.GLOBAL],
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["vault", "host", "open"] },
global: { rpcMethod: "vault/hosts/open" },
public: { rpcMethod: "public/vault/hosts/open", mcpTool: "host_open" },
},
},
{
id: "vault.hosts.create",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Create vault hosts from structured host objects. Use when the user wants to add/create a host (Vault → Hosts). NOT for Vault → Notes sidebar documentation.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
global: { rpcMethod: "vault/hosts/create" },
public: { rpcMethod: "public/vault/hosts/create", mcpTool: "vault_hosts_create" },
},
},
{
id: "vault.host.update",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Update selected fields on an existing vault host. Use vault_hosts_list first to resolve the hostId.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
global: { rpcMethod: "vault/hosts/update" },
public: { rpcMethod: "public/vault/hosts/update", mcpTool: "vault_hosts_update" },
},
},
{
id: "vault.host.delete",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Delete a saved vault host by id. Use vault_hosts_list first to resolve the hostId.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
global: { rpcMethod: "vault/hosts/delete" },
public: { rpcMethod: "public/vault/hosts/delete", mcpTool: "vault_hosts_delete" },
},
},
{
id: "vault.host.import",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Parse known host export file formats (PuTTY, MobaXterm, CSV, SecureCRT, ssh_config) into vault hosts. For arbitrary unstructured text, map to host objects and use vault_hosts_create instead.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
global: { rpcMethod: "vault/hosts/import" },
public: { rpcMethod: "public/vault/hosts/import", mcpTool: "vault_hosts_import" },
},
},
{
id: "vault.host.notes.get",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Read host metadata notes attached to a saved host (Host Details panel — not Vault sidebar Notes).",
policy: {
write: false,
sensitiveRead: true,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["vault", "host-notes", "get"] },
global: { rpcMethod: "vault/host/notes/get" },
public: { rpcMethod: "public/vault/hostNotes/get", mcpTool: "host_notes_get" },
},
},
{
id: "vault.host.notes.set",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Update host metadata notes on a saved host (Host Details panel — not Vault sidebar Notes).",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["vault", "host-notes", "set"] },
global: { rpcMethod: "vault/host/notes/set" },
public: { rpcMethod: "public/vault/hostNotes/set", mcpTool: "host_notes_set" },
},
},
{
id: "vault.note.list",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List notes in Vault → Notes (markdown notes visible in the vault sidebar).",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
global: { rpcMethod: "vault/notes/list" },
public: { rpcMethod: "public/vault/notes/list", mcpTool: "vault_notes_list" },
},
},
{
id: "vault.note.get",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Read or search a Vault → Notes entry by exact id, at most 6000 characters per call. Content is only the returned range, not necessarily the whole note. Follow nextOffset with expectedUpdatedAt until null for a complete read; query searches only return matching excerpts. Read every range without query before summarizing the whole note or replacing its content; never treat unread text as absent. For long notes, retain section summaries rather than repeatedly loading all ranges. If the note changed, restart.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
global: { rpcMethod: "vault/notes/get" },
public: { rpcMethod: "public/vault/notes/get", mcpTool: "vault_notes_get" },
},
},
{
id: "vault.note.create",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Create a note in Vault → Notes sidebar (markdown documentation). NOT for adding SSH hosts — use vault_hosts_create for that.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
global: { rpcMethod: "vault/notes/create" },
public: { rpcMethod: "public/vault/notes/create", mcpTool: "vault_notes_create" },
},
},
{
id: "vault.note.update",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Update an existing Vault → Notes entry by id.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
global: { rpcMethod: "vault/notes/update" },
public: { rpcMethod: "public/vault/notes/update", mcpTool: "vault_notes_update" },
},
},
{
id: "vault.note.delete",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Delete a Vault → Notes entry by id.",
policy: { write: true, sensitiveRead: false, longRunning: false, requiresChatSession: false, bypassesObserverBlock: false, bypassesApproval: false, bypassesChatCancel: false },
surfaces: {
global: { rpcMethod: "vault/notes/delete" },
public: { rpcMethod: "public/vault/notes/delete", mcpTool: "vault_notes_delete" },
},
},
{
id: "vault.identity.list",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List reusable vault identities without passwords, private keys, or passphrases.",
policy: { write: false, sensitiveRead: true, longRunning: false, requiresChatSession: false, bypassesObserverBlock: false, bypassesApproval: true, bypassesChatCancel: true },
surfaces: {
global: { rpcMethod: "vault/identities/list" },
public: { rpcMethod: "public/vault/identities/list", mcpTool: "vault_identities_list" },
},
},
{
id: "vault.proxyProfile.list",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List reusable proxy profiles without credentials.",
policy: { write: false, sensitiveRead: true, longRunning: false, requiresChatSession: false, bypassesObserverBlock: false, bypassesApproval: true, bypassesChatCancel: true },
surfaces: {
global: { rpcMethod: "vault/proxyProfiles/list" },
public: { rpcMethod: "public/vault/proxyProfiles/list", mcpTool: "vault_proxy_profiles_list" },
},
},
...[
["vault.group.list", "List vault groups and their safe default settings.", "list", "vault_groups_list", false],
["vault.group.create", "Create a vault group with optional default connection settings.", "create", "vault_groups_create", true],
["vault.group.update", "Update or rename a vault group and its default connection settings.", "update", "vault_groups_update", true],
["vault.group.delete", "Delete a vault group, moving its hosts to the root unless deleteHosts is true.", "delete", "vault_groups_delete", true],
].map(([id, description, action, mcpTool, write]) => ({
id,
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description,
policy: { write, sensitiveRead: false, longRunning: false, requiresChatSession: false, bypassesObserverBlock: false, bypassesApproval: !write, bypassesChatCancel: !write },
surfaces: {
global: { rpcMethod: `vault/groups/${action}` },
public: { rpcMethod: `public/vault/groups/${action}`, mcpTool },
},
})),
{
id: "vault.snippets.list",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List code snippets stored in the vault.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["snippets", "list"] },
global: { rpcMethod: "vault/snippets/list" },
public: { rpcMethod: "public/vault/snippets/list", mcpTool: "snippets_list" },
},
},
{
id: "vault.snippets.get",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Get a single code snippet from the vault.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["snippets", "get"] },
global: { rpcMethod: "vault/snippets/get" },
public: { rpcMethod: "public/vault/snippets/get", mcpTool: "snippets_get" },
},
},
{
id: "vault.snippets.run",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Run a vault snippet or automation script in a terminal session. Text snippets paste shell commands; scripts (kind=script) run via the nct JavaScript runtime.",
policy: {
write: true,
sensitiveRead: false,
longRunning: true,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["snippets", "run"] },
global: { rpcMethod: "vault/snippets/run" },
public: { rpcMethod: "public/vault/snippets/run", mcpTool: "snippets_run" },
},
},
{
id: "vault.snippets.create",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Create a vault snippet or automation script (set kind=script for nct automation).",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["snippets", "create"] },
global: { rpcMethod: "vault/snippets/create" },
public: { rpcMethod: "public/vault/snippets/create", mcpTool: "snippets_create" },
},
},
{
id: "vault.snippets.update",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Update an existing vault snippet or automation script by id.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["snippets", "update"] },
global: { rpcMethod: "vault/snippets/update" },
public: { rpcMethod: "public/vault/snippets/update", mcpTool: "snippets_update" },
},
},
{
id: "vault.snippets.delete",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Delete a vault snippet or automation script by id.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["snippets", "delete"] },
global: { rpcMethod: "vault/snippets/delete" },
public: { rpcMethod: "public/vault/snippets/delete", mcpTool: "snippets_delete" },
},
},
{
id: "vault.scripts.list",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List automation scripts (kind=script) in the vault.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["scripts", "list"] },
global: { rpcMethod: "vault/scripts/list" },
public: { rpcMethod: "public/vault/scripts/list", mcpTool: "scripts_list" },
},
},
{
id: "vault.scripts.get",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Get a single automation script including JavaScript source.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["scripts", "get"] },
global: { rpcMethod: "vault/scripts/get" },
public: { rpcMethod: "public/vault/scripts/get", mcpTool: "scripts_get" },
},
},
{
id: "vault.scripts.create",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Create an automation script using the nct JavaScript API. Call scripts_reference first when authoring nct automation.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["scripts", "create"] },
global: { rpcMethod: "vault/scripts/create" },
public: { rpcMethod: "public/vault/scripts/create", mcpTool: "scripts_create" },
},
},
{
id: "vault.scripts.update",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Update an automation script by id (partial fields).",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["scripts", "update"] },
global: { rpcMethod: "vault/scripts/update" },
public: { rpcMethod: "public/vault/scripts/update", mcpTool: "scripts_update" },
},
},
{
id: "vault.scripts.delete",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Delete an automation script and remove host connect bindings.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["scripts", "delete"] },
global: { rpcMethod: "vault/scripts/delete" },
public: { rpcMethod: "public/vault/scripts/delete", mcpTool: "scripts_delete" },
},
},
{
id: "vault.scripts.run",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Run an automation script in a terminal session via the nct runtime. Set wait=true to block until completion.",
policy: {
write: true,
sensitiveRead: false,
longRunning: true,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["scripts", "run"] },
global: { rpcMethod: "vault/scripts/run" },
public: { rpcMethod: "public/vault/scripts/run", mcpTool: "scripts_run" },
},
},
{
id: "vault.scripts.reference",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Return Netcatty automation script syntax: nct API, triggers, host targeting, and source wrapping rules.",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["scripts", "reference"] },
global: { rpcMethod: "vault/scripts/reference" },
public: { rpcMethod: "public/vault/scripts/reference", mcpTool: "scripts_reference" },
},
},
{
id: "vault.scripts.runs.list",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List automation script runs (optionally filter by sessionId).",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["scripts", "runs", "list"] },
global: { rpcMethod: "vault/scripts/runs/list" },
public: { rpcMethod: "public/vault/scripts/runs/list", mcpTool: "scripts_runs_list" },
},
},
{
id: "vault.scripts.run.stop",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Stop a running automation script by runId.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["scripts", "run", "stop"] },
global: { rpcMethod: "vault/scripts/run/stop" },
public: { rpcMethod: "public/vault/scripts/run/stop", mcpTool: "scripts_run_stop" },
},
},
{
id: "vault.scripts.run.pause",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Pause a running automation script by runId.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["scripts", "run", "pause"] },
global: { rpcMethod: "vault/scripts/run/pause" },
public: { rpcMethod: "public/vault/scripts/run/pause", mcpTool: "scripts_run_pause" },
},
},
{
id: "vault.scripts.run.resume",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Resume a paused automation script by runId.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: true,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["scripts", "run", "resume"] },
global: { rpcMethod: "vault/scripts/run/resume" },
public: { rpcMethod: "public/vault/scripts/run/resume", mcpTool: "scripts_run_resume" },
},
},
{
id: "vault.scripts.targets.set",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Set host IDs, dynamic group paths, or targetsAllHosts for an automation script. onConnect host IDs sync host connect queues.",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["scripts", "targets", "set"] },
global: { rpcMethod: "vault/scripts/targets/set" },
public: { rpcMethod: "public/vault/scripts/targets/set", mcpTool: "scripts_targets_set" },
},
},
{
id: "vault.host.connectScripts.list",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "List resolved onConnect automation scripts for a host (global, dynamic group, then host queue).",
policy: {
write: false,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: true,
bypassesChatCancel: true,
},
surfaces: {
cli: { command: ["vault", "host", "connect-scripts", "list"] },
global: { rpcMethod: "vault/host/connectScripts/list" },
public: { rpcMethod: "public/vault/hostConnectScripts/list", mcpTool: "host_connect_scripts_list" },
},
},
{
id: "vault.host.connectScripts.set",
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Set ordered onConnect script IDs for a host (host-specific queue; globals run separately).",
policy: {
write: true,
sensitiveRead: false,
longRunning: false,
requiresChatSession: false,
bypassesObserverBlock: false,
bypassesApproval: false,
bypassesChatCancel: false,
},
surfaces: {
cli: { command: ["vault", "host", "connect-scripts", "set"] },
global: { rpcMethod: "vault/host/connectScripts/set" },
public: { rpcMethod: "public/vault/hostConnectScripts/set", mcpTool: "host_connect_scripts_set" },
},
},
];
module.exports = { VAULT_CAPABILITIES };

View File

@@ -0,0 +1,79 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
function loadFreshBridge() {
const bridgePath = require.resolve("../../bridges/mcpServerBridge.cjs");
delete require.cache[bridgePath];
return require("../../bridges/mcpServerBridge.cjs");
}
function envPairsToObject(envPairs) {
const env = { ...process.env };
for (const pair of envPairs || []) {
if (!pair?.name) continue;
env[pair.name] = String(pair.value ?? "");
}
return env;
}
test("MCP environment resource serializes terminal tool hints from Netcatty context", async (t) => {
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
const bridge = loadFreshBridge();
const chatSessionId = `chat-resource-${Date.now()}`;
let client = null;
t.after(async () => {
try {
await client?.close();
} catch {
// Ignore teardown failures after a failed connect.
}
bridge.cleanup();
});
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
request() {
throw new Error("resource context should not need a worker round trip");
},
},
});
bridge.setPermissionMode("auto");
bridge.updateSessionMetadata([
{
sessionId: "ssh-1",
hostname: "host.example",
label: "Prod",
username: "root",
protocol: "ssh",
shellType: "bash",
connected: true,
},
], chatSessionId);
const port = await bridge.getOrCreateHost();
const config = bridge.buildMcpServerConfig(port, [], chatSessionId);
const transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: envPairsToObject(config.env),
});
client = new Client({ name: "netcatty-resource-test", version: "1.0.0" });
await client.connect(transport);
const resource = await client.readResource({ uri: "netcatty://context" });
const text = resource.contents?.[0]?.text || "";
const context = JSON.parse(text);
assert.equal(context.hostCount, 1);
assert.equal(context.hosts[0].sessionId, "ssh-1");
assert.equal(context.tools.terminal.execute, "terminal_execute");
assert.equal(context.tools.terminal.start, "terminal_start");
assert.match(context.description, /terminal_execute/);
});

View File

@@ -0,0 +1,259 @@
"use strict";
const { z } = require("zod");
const { listMcpTools } = require("./toolSurfaces.cjs");
const TERMINAL_EXECUTE_TOOLS = new Set(["terminal_execute"]);
const TERMINAL_START_TOOLS = new Set(["terminal_start"]);
const TERMINAL_POLL_TOOLS = new Set(["terminal_poll"]);
const TERMINAL_STOP_TOOLS = new Set(["terminal_stop"]);
const SESSION_CLOSE_TOOLS = new Set(["session_close"]);
const CONTEXT_TOOLS = new Set(["get_environment"]);
const ATTACHMENT_LIST_TOOLS = new Set(["list_attachments"]);
const ATTACHMENT_READ_TOOLS = new Set(["read_attachment"]);
const SFTP_WRITE_TOOLS = new Set([
"sftp_write_file",
"sftp_mkdir",
"sftp_delete",
"sftp_rename",
"sftp_chmod",
]);
function buildZodField(field) {
let schema = field.type === "number" ? z.number() : z.string();
if (field.type === "number") {
schema = schema.int().min(0);
}
if (field.description) {
schema = schema.describe(field.description);
}
if (field.optional) {
schema = schema.optional();
}
return schema;
}
function buildZodShapeObject(inputShape) {
const shape = {};
for (const [key, field] of Object.entries(inputShape || {})) {
shape[key] = buildZodField(field);
}
return shape;
}
function buildZodSchema(inputShape) {
return z.object(buildZodShapeObject(inputShape));
}
function isEmptyMcpInputShape(inputShape) {
return Object.keys(inputShape || {}).length === 0;
}
function formatRpcError(result) {
if (result?.error) return `Error: ${result.error}`;
if (result?.code) return `Error: Operation failed (${result.code})`;
return "Error: Operation failed";
}
/**
* True when the RPC result carries command-run evidence (output and/or exit
* code). Non-zero exits from PTY exec set ok:false without an error string —
* that is still a completed command, not an operational failure.
*/
function hasTerminalExecuteEvidence(result) {
if (!result || typeof result !== "object") return false;
if (typeof result.stdout === "string" && result.stdout.length > 0) return true;
if (typeof result.stderr === "string" && result.stderr.length > 0) return true;
return result.exitCode != null;
}
function formatTerminalExecuteResult(result) {
const parts = [];
if (result?.stdout) parts.push(result.stdout);
if (result?.stderr) parts.push(`[stderr] ${result.stderr}`);
if (result?.exitCode != null) {
parts.push(`[exit code: ${result.exitCode}]`);
}
if (result?.error) parts.push(`[error] ${result.error}`);
return parts.join("\n");
}
// Align with Catty executor wording when a command ran but produced no text.
const TERMINAL_EXECUTE_NO_OUTPUT_TEXT = "Command completed (no output)";
/**
* Format terminal_execute for MCP clients.
* Match Catty semantics: when the command actually ran, always surface
* stdout/stderr/exitCode so the model can judge the failure. Only pure
* operational failures (session missing, blocked, etc.) become a bare
* "Error: …" payload. See issue #2718.
*
* Successful empty results (serial/network raw PTY often returns ok:true with
* empty stdout/stderr and exitCode:null) must NOT fall back to
* "Error: Operation failed" — Codex P2 on PR #2724.
*/
function formatTerminalExecuteMcpResponse(result) {
if (!result || typeof result !== "object") {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
// Operational failure with no command evidence (session not found, busy, blocked…).
if (result.ok === false && !hasTerminalExecuteEvidence(result)) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
// Empty successful payloads (serial/raw PTY): neutral text.
// Never fall back to formatRpcError here — that mislabels silent successes (#2724 P2).
// When result.error is set, formatTerminalExecuteResult already includes `[error] …`.
const text = formatTerminalExecuteResult(result) || TERMINAL_EXECUTE_NO_OUTPUT_TEXT;
// Mark isError only for operational/runtime failures that include an error
// string (timeout, cancel, stream lost). Non-zero exit alone is success of
// the tool call with a failed command — same as Catty ok:true + exitCode.
if (result.ok === false && result.error) {
return { content: [{ type: "text", text }], isError: true };
}
return { content: [{ type: "text", text }] };
}
function createToolHandler(toolDef, deps) {
const {
rpcCall,
scopeParams,
guardWriteOperation,
catalogDescription,
} = deps;
const { mcpTool, rpcMethod, description, inputShape, policy } = toolDef;
return async (args) => {
if (TERMINAL_EXECUTE_TOOLS.has(mcpTool) || TERMINAL_START_TOOLS.has(mcpTool)) {
const { sessionId, command } = args;
const guardErr = guardWriteOperation(command, { skipBlocklist: true });
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall(rpcMethod, { ...scopeParams, sessionId, command });
if (TERMINAL_START_TOOLS.has(mcpTool)) {
if (!result?.ok) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
return {
content: [{
type: "text",
text: JSON.stringify({
jobId: result.jobId,
sessionId: result.sessionId,
status: result.status,
startedAt: result.startedAt,
outputMode: result.outputMode,
recommendedPollIntervalMs: result.recommendedPollIntervalMs,
}, null, 2),
}],
};
}
return formatTerminalExecuteMcpResponse(result);
}
if (TERMINAL_POLL_TOOLS.has(mcpTool) || TERMINAL_STOP_TOOLS.has(mcpTool)) {
const params = TERMINAL_POLL_TOOLS.has(mcpTool)
? { ...scopeParams, jobId: args.jobId, offset: args.offset || 0 }
: { ...scopeParams, jobId: args.jobId };
const result = await rpcCall(rpcMethod, params);
if (!result.ok) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
if (SESSION_CLOSE_TOOLS.has(mcpTool)) {
const result = await rpcCall(rpcMethod, { ...scopeParams, sessionId: args.sessionId });
if (!result.ok) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
if (CONTEXT_TOOLS.has(mcpTool)) {
const ctx = await rpcCall(rpcMethod, scopeParams);
return { content: [{ type: "text", text: JSON.stringify(ctx, null, 2) }] };
}
if (ATTACHMENT_LIST_TOOLS.has(mcpTool)) {
const result = await rpcCall(rpcMethod, scopeParams);
if (!result.ok) {
return {
content: [{ type: "text", text: formatRpcError(result) }],
isError: true,
};
}
return {
content: [{ type: "text", text: JSON.stringify(result.attachments || [], null, 2) }],
};
}
if (ATTACHMENT_READ_TOOLS.has(mcpTool)) {
const { filePath, filename } = args;
const result = await rpcCall(rpcMethod, { ...scopeParams, filePath, filename });
if (!result.ok) {
return {
content: [{ type: "text", text: formatRpcError(result) }],
isError: true,
};
}
const payload = {
filename: result.filename,
mediaType: result.mediaType,
filePath: result.filePath,
sizeBytes: result.sizeBytes,
...(result.text != null ? { text: result.text } : { base64Data: result.base64Data }),
};
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
}
if (policy?.write) {
const guardErr = guardWriteOperation("", { skipBlocklist: true });
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
}
const result = await rpcCall(rpcMethod, { ...scopeParams, ...args });
if (result && typeof result === "object" && result.ok === false) {
return {
content: [{ type: "text", text: formatRpcError(result) }],
isError: true,
};
}
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
};
}
function registerMcpTools(server, deps) {
const tools = listMcpTools();
for (const toolDef of tools) {
if (!toolDef.mcpTool || !toolDef.rpcMethod) continue;
const handler = createToolHandler(toolDef, deps);
const toolDescription = deps.catalogDescription(toolDef.mcpTool, toolDef.description);
// Empty Zod objects reject omitted `arguments`. MCP allows omitting them
// for no-arg tools; skip the schema so both omitted and {} are valid.
if (isEmptyMcpInputShape(toolDef.inputShape)) {
server.tool(toolDef.mcpTool, toolDescription, async () => handler({}));
continue;
}
server.tool(toolDef.mcpTool, toolDescription, buildZodShapeObject(toolDef.inputShape), handler);
}
return tools.length;
}
module.exports = {
buildZodSchema,
buildZodShapeObject,
isEmptyMcpInputShape,
formatRpcError,
formatTerminalExecuteResult,
formatTerminalExecuteMcpResponse,
hasTerminalExecuteEvidence,
registerMcpTools,
SFTP_WRITE_TOOLS,
};

View File

@@ -0,0 +1,204 @@
"use strict";
const { AGENT_KINDS, CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("../constants.cjs");
const { ALL_CAPABILITIES } = require("../catalog/index.cjs");
const { TOOL_INPUT_FIELDS, MODEL_DESCRIPTION_HINTS } = require("../schemas/toolInputs.cjs");
function buildZodShape(fields) {
const shape = {};
for (const [key, field] of Object.entries(fields || {})) {
shape[key] = {
type: field.type,
optional: Boolean(field.optional),
description: field.description || "",
};
}
return shape;
}
function getMcpToolName(capability) {
return capability.surfaces?.public?.mcpTool
|| capability.surfaces?.builtin?.mcpTool
|| null;
}
function getCattyToolName(capability) {
return capability.surfaces?.[CAPABILITY_SURFACES.CATTY]?.toolName
|| getMcpToolName(capability)
|| capability.id.replace(/\./g, "_");
}
function getCattyRpcMethod(capability) {
return capability.surfaces?.builtin?.rpcMethod
|| capability.surfaces?.global?.rpcMethod
|| capability.surfaces?.public?.rpcMethod
|| null;
}
function getAgentToolName(capability, agentKind) {
if (agentKind === AGENT_KINDS.GLOBAL) {
return capability.surfaces?.[CAPABILITY_SURFACES.GLOBAL_AGENT]?.toolName
|| getMcpToolName(capability)
|| capability.id.replace(/\./g, "_");
}
return getCattyToolName(capability);
}
function getAgentRpcMethod(capability) {
return getCattyRpcMethod(capability);
}
function buildToolDescription(capability) {
const hint = MODEL_DESCRIPTION_HINTS[capability.id];
if (!hint) return capability.description;
return `${capability.description} ${hint}`;
}
function listToolSurfaces(options = {}) {
const {
surface = CAPABILITY_SURFACES.PUBLIC,
status = CAPABILITY_STATUS.IMPLEMENTED,
includeCatty = true,
} = options;
const tools = [];
for (const capability of ALL_CAPABILITIES) {
if (capability.status !== status) continue;
const binding = capability.surfaces?.[surface] || capability.surfaces?.public || capability.surfaces?.builtin;
if (!binding) continue;
const mcpTool = getMcpToolName(capability);
const cattyToolName = getCattyToolName(capability);
if (!includeCatty && !mcpTool) continue;
const builtinRpc = capability.surfaces?.builtin?.rpcMethod || binding.rpcMethod || null;
tools.push({
capabilityId: capability.id,
domain: capability.domain,
toolName: cattyToolName,
mcpTool,
rpcMethod: builtinRpc,
publicRpcMethod: capability.surfaces?.public?.rpcMethod || null,
description: buildToolDescription(capability),
policy: capability.policy,
inputShape: buildZodShape(TOOL_INPUT_FIELDS[capability.id]),
cattyEnabled: includeCatty && Boolean(TOOL_INPUT_FIELDS[capability.id] != null || mcpTool),
});
}
return tools;
}
function listMcpTools() {
return listToolSurfaces({ surface: CAPABILITY_SURFACES.PUBLIC, includeCatty: false })
.filter((tool) => tool.mcpTool);
}
/** Capabilities excluded from Catty even when implemented (CLI-only / meta). */
const CATTY_CAPABILITY_DENYLIST = new Set([
"meta.status",
"session.cancel",
"session.resume",
"session.get",
]);
function isCattyOnlyCapability(capability) {
return isAgentLocalOnlyCapability(capability, AGENT_KINDS.SIDEBAR);
}
function isAgentLocalOnlyCapability(capability, agentKind) {
if (agentKind === AGENT_KINDS.GLOBAL) {
return Boolean(capability.surfaces?.[CAPABILITY_SURFACES.GLOBAL_AGENT]?.toolName)
&& !getAgentRpcMethod(capability)
&& !getMcpToolName(capability);
}
return Boolean(capability.surfaces?.[CAPABILITY_SURFACES.CATTY]?.toolName)
&& !getAgentRpcMethod(capability)
&& !getMcpToolName(capability);
}
/**
* Resolve which agents may use a capability when agentKinds is not set explicitly.
* - surfaces.globalAgent only → global agent
* - surfaces.catty only (harness) → sidebar agent
* - RPC/MCP-backed tools → both agents (shared infrastructure)
*/
function resolveAgentKinds(capability) {
if (Array.isArray(capability.agentKinds) && capability.agentKinds.length > 0) {
return capability.agentKinds;
}
if (capability.surfaces?.[CAPABILITY_SURFACES.GLOBAL_AGENT]) {
return [AGENT_KINDS.GLOBAL];
}
if (isAgentLocalOnlyCapability(capability, AGENT_KINDS.SIDEBAR)) {
return [AGENT_KINDS.SIDEBAR];
}
if (isAgentEligibleForKind(capability, AGENT_KINDS.SIDEBAR, { skipAgentKindCheck: true })) {
return [AGENT_KINDS.SIDEBAR, AGENT_KINDS.GLOBAL];
}
return [];
}
function isAgentEligibleForKind(capability, agentKind, options = {}) {
if (capability.status !== CAPABILITY_STATUS.IMPLEMENTED) return false;
if (CATTY_CAPABILITY_DENYLIST.has(capability.id)) return false;
if (!options.skipAgentKindCheck && !resolveAgentKinds(capability).includes(agentKind)) {
return false;
}
const hasInputFields = Object.prototype.hasOwnProperty.call(TOOL_INPUT_FIELDS, capability.id);
if (!hasInputFields) return false;
if (isAgentLocalOnlyCapability(capability, agentKind)) return true;
const hasBuiltinRpc = Boolean(capability.surfaces?.builtin?.rpcMethod);
const hasGlobalRpc = Boolean(capability.surfaces?.global?.rpcMethod);
return hasBuiltinRpc || hasGlobalRpc || Boolean(getMcpToolName(capability));
}
function isCattyEligible(capability) {
return isAgentEligibleForKind(capability, AGENT_KINDS.SIDEBAR);
}
function listAgentToolSpecs(agentKind = AGENT_KINDS.SIDEBAR) {
return ALL_CAPABILITIES
.filter((capability) => isAgentEligibleForKind(capability, agentKind))
.map((capability) => {
const spec = {
capabilityId: capability.id,
toolName: getAgentToolName(capability, agentKind),
rpcMethod: getAgentRpcMethod(capability),
localExecution: isAgentLocalOnlyCapability(capability, agentKind),
description: buildToolDescription(capability),
inputShape: buildZodShape(TOOL_INPUT_FIELDS[capability.id]),
policy: capability.policy,
};
if (agentKind !== AGENT_KINDS.SIDEBAR) {
spec.agentKind = agentKind;
}
return spec;
});
}
function listCattyToolSpecs() {
return listAgentToolSpecs(AGENT_KINDS.SIDEBAR);
}
module.exports = {
AGENT_KINDS,
buildZodShape,
buildToolDescription,
CATTY_CAPABILITY_DENYLIST,
getAgentRpcMethod,
getAgentToolName,
getCattyToolName,
getCattyRpcMethod,
isAgentEligibleForKind,
isAgentLocalOnlyCapability,
isCattyEligible,
isCattyOnlyCapability,
listAgentToolSpecs,
listToolSurfaces,
listMcpTools,
listCattyToolSpecs,
resolveAgentKinds,
};

View File

@@ -0,0 +1,455 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { CAPABILITY_STATUS } = require("../constants.cjs");
const { ALL_CAPABILITIES } = require("../catalog/index.cjs");
const { TOOL_INPUT_FIELDS } = require("../schemas/toolInputs.cjs");
const {
listMcpTools,
listCattyToolSpecs,
CATTY_CAPABILITY_DENYLIST,
isCattyEligible,
} = require("./toolSurfaces.cjs");
const { registerMcpTools, buildZodShapeObject, isEmptyMcpInputShape } = require("./mcpToolRegistry.cjs");
function mcpToolHandler(schemaOrHandler, maybeHandler) {
return typeof schemaOrHandler === "function" ? schemaOrHandler : maybeHandler;
}
test("listCattyToolSpecs includes terminal long-running tools", () => {
const specs = listCattyToolSpecs();
const names = specs.map((spec) => spec.toolName);
assert.ok(names.includes("terminal_execute"));
assert.ok(names.includes("terminal_start"));
assert.ok(names.includes("terminal_poll"));
assert.ok(names.includes("terminal_stop"));
});
test("listCattyToolSpecs includes SFTP write tools and attachments", () => {
const capabilityIds = listCattyToolSpecs().map((spec) => spec.capabilityId);
assert.ok(capabilityIds.includes("attachment.list"));
assert.ok(capabilityIds.includes("attachment.read"));
assert.ok(capabilityIds.includes("sftp.write"));
assert.ok(capabilityIds.includes("sftp.mkdir"));
assert.ok(capabilityIds.includes("sftp.delete"));
assert.ok(capabilityIds.includes("sftp.rename"));
assert.ok(capabilityIds.includes("sftp.chmod"));
assert.ok(!capabilityIds.includes("meta.status"));
assert.ok(!capabilityIds.includes("session.cancel"));
});
test("listCattyToolSpecs includes vault host tools and SFTP transfer", () => {
const capabilityIds = listCattyToolSpecs().map((spec) => spec.capabilityId);
assert.ok(capabilityIds.includes("vault.host.get"));
assert.ok(capabilityIds.includes("vault.host.list"));
// Sidebar Catty must not open hosts; that expands scope mid-turn.
assert.ok(!capabilityIds.includes("vault.host.open"));
assert.ok(capabilityIds.includes("vault.hosts.create"));
assert.ok(capabilityIds.includes("vault.host.update"));
assert.ok(capabilityIds.includes("vault.host.delete"));
assert.ok(capabilityIds.includes("vault.host.import"));
assert.ok(capabilityIds.includes("vault.note.create"));
assert.ok(capabilityIds.includes("vault.note.list"));
assert.ok(capabilityIds.includes("sftp.download"));
assert.ok(capabilityIds.includes("sftp.upload"));
});
test("host_open stays on MCP and global agent, not sidebar Catty", () => {
const { AGENT_KINDS, listAgentToolSpecs } = require("./toolSurfaces.cjs");
const sidebarIds = listAgentToolSpecs(AGENT_KINDS.SIDEBAR).map((spec) => spec.capabilityId);
const globalIds = listAgentToolSpecs(AGENT_KINDS.GLOBAL).map((spec) => spec.capabilityId);
const mcpHostOpen = listMcpTools().find((tool) => tool.mcpTool === "host_open");
assert.ok(!sidebarIds.includes("vault.host.open"));
assert.ok(globalIds.includes("vault.host.open"));
assert.ok(mcpHostOpen);
assert.equal(mcpHostOpen.capabilityId, "vault.host.open");
});
test("listMcpTools includes vault host update and delete for external MCP clients", () => {
const tools = listMcpTools();
const create = tools.find((tool) => tool.mcpTool === "vault_hosts_create");
const update = tools.find((tool) => tool.mcpTool === "vault_hosts_update");
const remove = tools.find((tool) => tool.mcpTool === "vault_hosts_delete");
assert.match(create?.inputShape.hosts?.description ?? "", /passphrase/i);
assert.equal(update?.capabilityId, "vault.host.update");
assert.equal(update?.publicRpcMethod, "public/vault/hosts/update");
assert.ok(update?.inputShape.keyPath);
assert.ok(update?.inputShape.keypath);
assert.ok(update?.inputShape.savePassword);
assert.ok(update?.inputShape.passphrase);
assert.equal(remove?.capabilityId, "vault.host.delete");
assert.equal(remove?.publicRpcMethod, "public/vault/hosts/delete");
});
test("listMcpTools includes host_open for external MCP clients", () => {
const tools = listMcpTools();
const hostOpen = tools.find((tool) => tool.mcpTool === "host_open");
assert.ok(hostOpen);
assert.equal(hostOpen.capabilityId, "vault.host.open");
assert.equal(hostOpen.publicRpcMethod, "public/vault/hosts/open");
});
test("session_close is exposed to agents and external MCP clients", () => {
const mcpTool = listMcpTools().find((tool) => tool.mcpTool === "session_close");
assert.ok(mcpTool);
assert.equal(mcpTool.capabilityId, "session.close");
assert.equal(mcpTool.publicRpcMethod, "public/session/close");
const cattyTool = listCattyToolSpecs().find((tool) => tool.toolName === "session_close");
assert.ok(cattyTool);
assert.equal(cattyTool.rpcMethod, "session/close");
});
test("host_open tells agents to close sessions after use", () => {
const hostOpen = listMcpTools().find((tool) => tool.mcpTool === "host_open");
assert.match(hostOpen?.description || "", /session_close/i);
});
test("vault host import tool description routes unknown attached host text to host creation", () => {
const importSpec = listCattyToolSpecs().find((spec) => spec.capabilityId === "vault.host.import");
assert.ok(importSpec);
assert.match(importSpec.description, /known export formats/i);
assert.match(importSpec.description, /unknown/i);
assert.match(importSpec.description, /read_attachment/i);
assert.match(importSpec.description, /vault_hosts_create/i);
});
test("listCattyToolSpecs binds vault note tools to global RPC methods", () => {
const specs = listCattyToolSpecs();
const noteCreate = specs.find((spec) => spec.capabilityId === "vault.note.create");
assert.equal(noteCreate?.rpcMethod, "vault/notes/create");
const noteList = specs.find((spec) => spec.capabilityId === "vault.note.list");
assert.equal(noteList?.rpcMethod, "vault/notes/list");
});
test("listCattyToolSpecs binds vault and portforward tools to global RPC methods", () => {
const specs = listCattyToolSpecs();
const hostNotesSet = specs.find((spec) => spec.capabilityId === "vault.host.notes.set");
assert.equal(hostNotesSet?.rpcMethod, "vault/host/notes/set");
const portforwardStart = specs.find((spec) => spec.capabilityId === "portforward.start");
assert.equal(portforwardStart?.rpcMethod, "portforward/start");
});
test("generic snippet agent tools expose dynamic group targets", () => {
const { AGENT_KINDS, listAgentToolSpecs } = require("./toolSurfaces.cjs");
for (const kind of [AGENT_KINDS.SIDEBAR, AGENT_KINDS.GLOBAL]) {
const specs = listAgentToolSpecs(kind);
for (const capabilityId of ["vault.snippets.create", "vault.snippets.update"]) {
const spec = specs.find((entry) => entry.capabilityId === capabilityId);
assert.ok(spec, `${capabilityId} should be exposed to ${kind}`);
assert.match(spec.inputShape.targetGroups?.description ?? "", /group paths/i);
}
}
});
test("listAgentToolSpecs splits sidebar harness tools from shared RPC tools", () => {
const { AGENT_KINDS, listAgentToolSpecs } = require("./toolSurfaces.cjs");
const sidebarIds = listAgentToolSpecs(AGENT_KINDS.SIDEBAR).map((spec) => spec.capabilityId);
const globalIds = listAgentToolSpecs(AGENT_KINDS.GLOBAL).map((spec) => spec.capabilityId);
assert.ok(sidebarIds.includes("harness.workspace.get_info"));
assert.ok(!globalIds.includes("harness.workspace.get_info"));
assert.ok(sidebarIds.includes("terminal.execute"));
assert.ok(globalIds.includes("terminal.execute"));
assert.ok(globalIds.includes("vault.note.create"));
assert.ok(globalIds.every((id) => sidebarIds.includes(id) || id.startsWith("harness.") === false));
});
test("listCattyToolSpecs includes harness catty-only tools with local execution", () => {
const specs = listCattyToolSpecs();
assert.ok(specs.length >= 40);
const harness = specs.filter((spec) => spec.capabilityId.startsWith("harness."));
assert.equal(harness.length, 6);
for (const spec of harness) {
assert.equal(spec.localExecution, true);
assert.equal(spec.rpcMethod, null);
}
const harnessIds = harness.map((spec) => spec.capabilityId);
assert.ok(harnessIds.includes("harness.tool_output.read"));
assert.ok(harnessIds.includes("harness.workspace.get_info"));
assert.ok(harnessIds.includes("harness.terminal.read_context"));
});
test("harness capabilities are not exposed on MCP", () => {
const mcpCapabilityIds = listMcpTools().map((tool) => tool.capabilityId);
for (const capabilityId of mcpCapabilityIds) {
assert.ok(!capabilityId.startsWith("harness."));
}
});
test("listMcpTools descriptions stay aligned with catalog capability ids", () => {
const mcpTools = listMcpTools();
assert.ok(mcpTools.length >= 35);
for (const tool of mcpTools) {
assert.ok(tool.capabilityId);
assert.ok(tool.mcpTool);
assert.ok(tool.description.length > 0);
assert.ok(tool.rpcMethod);
}
});
test("catty and mcp terminal tools share capability ids", () => {
const catty = listCattyToolSpecs().find((spec) => spec.toolName === "terminal_execute");
const mcp = listMcpTools().find((tool) => tool.mcpTool === "terminal_execute");
assert.equal(catty?.capabilityId, "terminal.execute");
assert.equal(mcp?.capabilityId, "terminal.execute");
});
test("implemented catalog tools with inputs are catty-eligible unless denylisted or agentKinds-restricted", () => {
const implemented = ALL_CAPABILITIES.filter((cap) => cap.status === CAPABILITY_STATUS.IMPLEMENTED);
for (const capability of implemented) {
const hasInputs = Object.prototype.hasOwnProperty.call(TOOL_INPUT_FIELDS, capability.id);
if (!hasInputs) continue;
if (CATTY_CAPABILITY_DENYLIST.has(capability.id)) {
assert.equal(isCattyEligible(capability), false);
continue;
}
if (Array.isArray(capability.agentKinds) && capability.agentKinds.length > 0
&& !capability.agentKinds.includes("sidebar")) {
assert.equal(isCattyEligible(capability), false);
continue;
}
const hasRpc = Boolean(
capability.surfaces?.builtin?.rpcMethod
|| capability.surfaces?.public?.mcpTool,
);
if (hasRpc) {
assert.equal(isCattyEligible(capability), true);
}
}
});
test("mcp registry builds zod shapes for every MCP tool", () => {
for (const tool of listMcpTools()) {
const shape = buildZodShapeObject(tool.inputShape);
assert.equal(typeof shape, "object");
}
});
test("registerMcpTools registers one handler per catalog MCP tool", () => {
const registered = [];
const fakeServer = {
tool(name, _description, schemaOrHandler, maybeHandler) {
registered.push({ name, handler: typeof mcpToolHandler(schemaOrHandler, maybeHandler) });
},
};
const count = registerMcpTools(fakeServer, {
rpcCall: async () => ({ ok: true }),
scopeParams: {},
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
assert.equal(count, listMcpTools().length);
assert.equal(registered.length, listMcpTools().length);
});
test("no-arg MCP tools register without a params schema so omitted arguments are valid (#3049)", () => {
const registrations = [];
const fakeServer = {
tool(name, _description, schemaOrHandler, maybeHandler) {
registrations.push({
name,
hasSchema: typeof schemaOrHandler !== "function",
handler: mcpToolHandler(schemaOrHandler, maybeHandler),
});
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({ ok: true }),
scopeParams: {},
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const noArgNames = listMcpTools()
.filter((tool) => isEmptyMcpInputShape(tool.inputShape))
.map((tool) => tool.mcpTool);
assert.ok(noArgNames.includes("get_environment"));
assert.ok(noArgNames.includes("list_attachments"));
for (const name of noArgNames) {
const registration = registrations.find((entry) => entry.name === name);
assert.equal(registration?.hasSchema, false, `${name} should omit the params schema`);
}
const execute = registrations.find((entry) => entry.name === "terminal_execute");
assert.equal(execute?.hasSchema, true);
});
test("get_environment handler runs when MCP arguments are omitted (#3049)", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, schemaOrHandler, maybeHandler) {
if (name === "get_environment") handler = mcpToolHandler(schemaOrHandler, maybeHandler);
},
};
let rpcMethod = null;
registerMcpTools(fakeServer, {
rpcCall: async (method) => {
rpcMethod = method;
return { sessions: [] };
},
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
assert.ok(handler, "get_environment handler registered");
const result = await handler();
assert.equal(result.isError, undefined);
assert.equal(rpcMethod, "netcatty/getContext");
assert.match(result.content?.[0]?.text || "", /sessions/);
});
test("session_close remains available as a cleanup action in observer mode", async () => {
let handler = null;
let guardCalls = 0;
const fakeServer = {
tool(name, _description, schemaOrHandler, maybeHandler) {
if (name === "session_close") handler = mcpToolHandler(schemaOrHandler, maybeHandler);
},
};
registerMcpTools(fakeServer, {
rpcCall: async (_method, params) => ({ ok: true, sessionId: params.sessionId, status: "closed" }),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => {
guardCalls += 1;
return "Observer mode";
},
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "session-1" });
assert.equal(result.isError, undefined);
assert.equal(guardCalls, 0);
});
test("terminal_execute MCP response preserves stdout/exitCode on non-zero exit (#2718)", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, schemaOrHandler, maybeHandler) {
if (name === "terminal_execute") handler = mcpToolHandler(schemaOrHandler, maybeHandler);
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: false,
stdout: "du: cannot access '/missing': No such file or directory",
stderr: "",
exitCode: 1,
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
assert.ok(handler, "terminal_execute handler registered");
const result = await handler({ sessionId: "sess-1", command: "du /missing" });
assert.equal(result.isError, undefined);
const text = result.content?.[0]?.text || "";
assert.match(text, /cannot access '\/missing'/);
assert.match(text, /\[exit code: 1\]/);
assert.doesNotMatch(text, /Operation failed/);
});
test("terminal_execute MCP response keeps operational failures as isError", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, schemaOrHandler, maybeHandler) {
if (name === "terminal_execute") handler = mcpToolHandler(schemaOrHandler, maybeHandler);
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({ ok: false, error: "Session not found" }),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "gone", command: "uptime" });
assert.equal(result.isError, true);
assert.equal(result.content?.[0]?.text, "Error: Session not found");
});
test("terminal_execute MCP response includes partial output on timeout", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, schemaOrHandler, maybeHandler) {
if (name === "terminal_execute") handler = mcpToolHandler(schemaOrHandler, maybeHandler);
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: false,
stdout: "partial lines",
stderr: "",
exitCode: -1,
error: "Command timed out (60s)",
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "sess-1", command: "sleep 999" });
assert.equal(result.isError, true);
const text = result.content?.[0]?.text || "";
assert.match(text, /partial lines/);
assert.match(text, /\[exit code: -1\]/);
assert.match(text, /\[error\] Command timed out \(60s\)/);
});
test("terminal_execute MCP response uses neutral text for successful empty output (#2724)", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, schemaOrHandler, maybeHandler) {
if (name === "terminal_execute") handler = mcpToolHandler(schemaOrHandler, maybeHandler);
},
};
// Serial/network-device raw PTY success: ok true, empty streams, exitCode null.
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: true,
stdout: "",
stderr: "",
exitCode: null,
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "sess-1", command: "configure terminal" });
assert.equal(result.isError, undefined);
assert.equal(result.content?.[0]?.text, "Command completed (no output)");
assert.doesNotMatch(result.content?.[0]?.text || "", /Operation failed/);
});
test("terminal_execute MCP response keeps exit-only non-zero without isError", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, schemaOrHandler, maybeHandler) {
if (name === "terminal_execute") handler = mcpToolHandler(schemaOrHandler, maybeHandler);
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: false,
stdout: "",
stderr: "",
exitCode: 1,
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "sess-1", command: "false" });
assert.equal(result.isError, undefined);
assert.equal(result.content?.[0]?.text, "[exit code: 1]");
assert.doesNotMatch(result.content?.[0]?.text || "", /Operation failed/);
});

View File

@@ -0,0 +1,51 @@
"use strict";
/** @typedef {'builtin' | 'public' | 'cli' | 'global' | 'catty' | 'globalAgent'} CapabilitySurface */
/** @typedef {'implemented' | 'planned'} CapabilityStatus */
/** @typedef {'observer' | 'confirm' | 'auto'} PermissionMode */
/** @typedef {'sidebar' | 'global'} AgentKind */
const CAPABILITY_SURFACES = Object.freeze({
BUILTIN: "builtin",
PUBLIC: "public",
CLI: "cli",
GLOBAL: "global",
/** Renderer-local sidebar (Catty) harness tools (no MCP/CLI exposure). */
CATTY: "catty",
/** Renderer-local global agent tools (no MCP/CLI exposure). */
GLOBAL_AGENT: "globalAgent",
});
/** Where in the app an agent runs — orthogonal to RPC/MCP/CLI capability surfaces. */
const AGENT_KINDS = Object.freeze({
/** Chat side panel (Catty). */
SIDEBAR: "sidebar",
/** Future app-wide agent (cross-window / proactive). */
GLOBAL: "global",
});
const CAPABILITY_STATUS = Object.freeze({
IMPLEMENTED: "implemented",
PLANNED: "planned",
});
const PERMISSION_MODES = Object.freeze({
OBSERVER: "observer",
CONFIRM: "confirm",
AUTO: "auto",
});
const RPC_TIMEOUT_DEFAULTS = Object.freeze({
DEFAULT_RPC_TIMEOUT_MS: 30_000,
DEFAULT_OPERATION_TIMEOUT_MS: 60_000,
RPC_TIMEOUT_BUFFER_MS: 5_000,
DEFAULT_APPROVAL_TIMEOUT_MS: 110_000,
});
module.exports = {
AGENT_KINDS,
CAPABILITY_SURFACES,
CAPABILITY_STATUS,
PERMISSION_MODES,
RPC_TIMEOUT_DEFAULTS,
};

View File

@@ -0,0 +1,39 @@
"use strict";
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("./constants.cjs");
const { getCapabilityByRpcMethod } = require("./registry.cjs");
function createRegistryDispatcher({
surface = CAPABILITY_SURFACES.BUILTIN,
handlers = {},
fallback,
}) {
if (typeof fallback !== "function") {
throw new Error("fallback handler is required");
}
return async function dispatchRpc(rpcMethod, params = {}) {
const capability = getCapabilityByRpcMethod(rpcMethod, surface);
if (!capability) {
return fallback(rpcMethod, params);
}
if (capability.status !== CAPABILITY_STATUS.IMPLEMENTED) {
return {
ok: false,
error: `Capability "${capability.id}" is not implemented yet.`,
code: "CAPABILITY_NOT_IMPLEMENTED",
};
}
const handler = handlers[capability.id];
if (typeof handler !== "function") {
return fallback(rpcMethod, params, capability);
}
return await handler(params, capability);
};
}
module.exports = {
createRegistryDispatcher,
};

View File

@@ -0,0 +1,46 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createRegistryDispatcher } = require("./dispatch.cjs");
const { CAPABILITY_SURFACES } = require("./constants.cjs");
test("createRegistryDispatcher routes implemented capabilities to handlers", async () => {
const calls = [];
const dispatch = createRegistryDispatcher({
surface: CAPABILITY_SURFACES.BUILTIN,
handlers: {
"meta.status": async (params) => ({ ok: true, params }),
},
fallback: async (method) => ({ ok: false, error: `unknown:${method}` }),
});
const result = await dispatch("netcatty/getStatus", { chatSessionId: "chat-1" });
assert.equal(result.ok, true);
assert.equal(result.params.chatSessionId, "chat-1");
assert.equal(calls.length, 0);
});
test("createRegistryDispatcher falls back for implemented capabilities without handlers", async () => {
const dispatch = createRegistryDispatcher({
surface: CAPABILITY_SURFACES.GLOBAL,
handlers: {},
fallback: async (method) => ({ ok: false, error: `unknown:${method}` }),
});
const result = await dispatch("vault/host/notes/get", { hostId: "host-1" });
assert.equal(result.ok, false);
assert.equal(result.error, "unknown:vault/host/notes/get");
});
test("createRegistryDispatcher falls back for unknown rpc methods", async () => {
const dispatch = createRegistryDispatcher({
surface: CAPABILITY_SURFACES.BUILTIN,
handlers: {},
fallback: async (method) => ({ ok: false, error: `unknown:${method}` }),
});
const result = await dispatch("auth/verify", { token: "abc" });
assert.equal(result.error, "unknown:auth/verify");
});

View File

@@ -0,0 +1,25 @@
"use strict";
const constants = require("./constants.cjs");
const catalog = require("./catalog/index.cjs");
const registry = require("./registry.cjs");
const policy = require("./policy.cjs");
const rpcTimeouts = require("./rpcTimeouts.cjs");
const rpcTransport = require("./rpcTransport.cjs");
const dispatch = require("./dispatch.cjs");
const scope = require("./scope.cjs");
const adapters = require("./adapters/index.cjs");
const services = require("./services/index.cjs");
module.exports = {
...constants,
...catalog,
...registry,
...policy,
...rpcTimeouts,
...rpcTransport,
...dispatch,
...scope,
...adapters,
...services,
};

View File

@@ -0,0 +1,170 @@
"use strict";
const { CAPABILITY_SURFACES, PERMISSION_MODES } = require("./constants.cjs");
const { getCapabilityByRpcMethod } = require("./registry.cjs");
const OBSERVER_DENY_MESSAGE = 'Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "auto" in Settings → AI → Safety to allow this action.';
const CHAT_SESSION_REQUIRED_MESSAGE = "chatSessionId is required for write operations.";
const CHAT_SESSION_CANCELLED_MESSAGE = "Operation cancelled: the SDK agent session was stopped.";
const USER_DENIED_MESSAGE = "Operation denied by user.";
function requiresApprovalInConfirmMode(capability, surface) {
if (!capability) return false;
const binding = capability.surfaces?.[surface];
if (binding?.confirmInConfirmMode === true) return true;
if (capability.policy.bypassesApproval) return false;
if (capability.policy.write) return true;
if (capability.policy.sensitiveRead && binding?.confirmInConfirmMode !== false) {
return binding?.confirmInConfirmMode === true;
}
return false;
}
function isBlockedInObserverMode(capability) {
if (!capability) return false;
if (capability.policy.bypassesObserverBlock) return false;
return capability.policy.write;
}
function evaluateRpcPermission({
rpcMethod,
surface = CAPABILITY_SURFACES.BUILTIN,
permissionMode = PERMISSION_MODES.CONFIRM,
params = {},
context = {},
}) {
const capability = getCapabilityByRpcMethod(rpcMethod, surface);
if (!capability) {
return {
allowed: true,
requiresApproval: false,
capability: null,
};
}
if (capability?.policy.write && !params?.chatSessionId && surface === CAPABILITY_SURFACES.BUILTIN) {
return {
allowed: false,
requiresApproval: false,
error: CHAT_SESSION_REQUIRED_MESSAGE,
capability,
};
}
if (
capability?.policy.write
&& !capability.policy.bypassesChatCancel
&& context.chatSessionCancelled
&& surface === CAPABILITY_SURFACES.BUILTIN
) {
return {
allowed: false,
requiresApproval: false,
error: CHAT_SESSION_CANCELLED_MESSAGE,
capability,
};
}
if (permissionMode === PERMISSION_MODES.OBSERVER && isBlockedInObserverMode(capability)) {
return {
allowed: false,
requiresApproval: false,
error: OBSERVER_DENY_MESSAGE,
capability,
};
}
const requiresApproval = permissionMode === PERMISSION_MODES.CONFIRM
&& requiresApprovalInConfirmMode(capability, surface);
return {
allowed: true,
requiresApproval,
capability,
};
}
function evaluatePermissionWithGrants(ctx, grants = []) {
const base = evaluateRpcPermission(ctx);
if (!base.allowed || !base.requiresApproval || !base.capability) {
return base;
}
const { matchPermissionGrant } = require("../shared/permissionGrants.cjs");
const params = ctx?.params && typeof ctx.params === "object" ? ctx.params : {};
const matched = matchPermissionGrant(grants, {
capabilityId: base.capability.id,
chatSessionId: params.chatSessionId,
sessionId: params.sessionId,
args: params,
});
if (matched) {
return {
...base,
requiresApproval: false,
matchedGrantId: matched.id,
};
}
return base;
}
function buildRpcMethodSet(surface, predicate) {
const { getRpcMethodsForSurface } = require("./registry.cjs");
const methods = new Set();
for (const rpcMethod of getRpcMethodsForSurface(surface)) {
const capability = getCapabilityByRpcMethod(rpcMethod, surface);
if (!capability || capability.status !== "implemented") continue;
if (predicate(capability, rpcMethod)) {
methods.add(rpcMethod);
}
}
return methods;
}
const BUILTIN_WRITE_RPC_METHODS = buildRpcMethodSet(CAPABILITY_SURFACES.BUILTIN, (capability) => capability.policy.write);
const BUILTIN_APPROVAL_RPC_METHODS = buildRpcMethodSet(
CAPABILITY_SURFACES.BUILTIN,
(capability, rpcMethod) => requiresApprovalInConfirmMode(capability, CAPABILITY_SURFACES.BUILTIN),
);
const PUBLIC_WRITE_RPC_METHODS = buildRpcMethodSet(CAPABILITY_SURFACES.PUBLIC, (capability) => capability.policy.write);
const PUBLIC_CONFIRM_RPC_METHODS = buildRpcMethodSet(
CAPABILITY_SURFACES.PUBLIC,
(capability) => requiresApprovalInConfirmMode(capability, CAPABILITY_SURFACES.PUBLIC),
);
function isBuiltinWriteRpcMethod(method) {
return BUILTIN_WRITE_RPC_METHODS.has(method);
}
function isBuiltinApprovalRpcMethod(method) {
return BUILTIN_APPROVAL_RPC_METHODS.has(method);
}
function isPublicWriteRpcMethod(method) {
return PUBLIC_WRITE_RPC_METHODS.has(method);
}
function isPublicConfirmRpcMethod(method) {
return PUBLIC_CONFIRM_RPC_METHODS.has(method);
}
module.exports = {
OBSERVER_DENY_MESSAGE,
CHAT_SESSION_REQUIRED_MESSAGE,
CHAT_SESSION_CANCELLED_MESSAGE,
USER_DENIED_MESSAGE,
requiresApprovalInConfirmMode,
isBlockedInObserverMode,
evaluateRpcPermission,
evaluatePermissionWithGrants,
BUILTIN_WRITE_RPC_METHODS,
BUILTIN_APPROVAL_RPC_METHODS,
PUBLIC_WRITE_RPC_METHODS,
PUBLIC_CONFIRM_RPC_METHODS,
isBuiltinWriteRpcMethod,
isBuiltinApprovalRpcMethod,
isPublicWriteRpcMethod,
isPublicConfirmRpcMethod,
};

View File

@@ -0,0 +1,491 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
BUILTIN_WRITE_RPC_METHODS,
BUILTIN_APPROVAL_RPC_METHODS,
PUBLIC_CONFIRM_RPC_METHODS,
evaluateRpcPermission,
evaluatePermissionWithGrants,
OBSERVER_DENY_MESSAGE,
} = require("./policy.cjs");
const { CAPABILITY_SURFACES, PERMISSION_MODES } = require("./constants.cjs");
const { ALL_CAPABILITIES } = require("./catalog/index.cjs");
test("new vault management writes use the standard permission policy", () => {
const ids = [
"portforward.rules.create", "portforward.rules.update", "portforward.rules.duplicate", "portforward.rules.delete",
"vault.note.delete", "vault.group.create", "vault.group.update", "vault.group.delete",
];
for (const id of ids) {
const capability = ALL_CAPABILITIES.find((entry) => entry.id === id);
assert.ok(capability, id);
assert.equal(capability.policy.write, true, id);
assert.equal(capability.policy.bypassesObserverBlock, false, id);
assert.equal(capability.policy.bypassesApproval, false, id);
}
});
test("builtin write methods match legacy mcpServerBridge write set", () => {
const legacyWriteMethods = [
"netcatty/exec",
"netcatty/sftp/write",
"netcatty/sftp/download",
"netcatty/sftp/upload",
"netcatty/sftp/mkdir",
"netcatty/sftp/delete",
"netcatty/sftp/rename",
"netcatty/sftp/chmod",
"netcatty/jobStart",
"netcatty/jobStop",
];
assert.deepEqual(new Set(legacyWriteMethods), BUILTIN_WRITE_RPC_METHODS);
});
test("builtin approval methods exclude jobStop and non-write control rpc", () => {
assert.equal(BUILTIN_APPROVAL_RPC_METHODS.has("netcatty/jobStop"), false);
assert.equal(BUILTIN_APPROVAL_RPC_METHODS.has("netcatty/setCancelled"), false);
assert.equal(BUILTIN_APPROVAL_RPC_METHODS.has("netcatty/exec"), true);
assert.equal(BUILTIN_APPROVAL_RPC_METHODS.has("netcatty/sftp/write"), true);
});
test("observer mode blocks writes but allows terminal poll", () => {
const denied = evaluateRpcPermission({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.OBSERVER,
params: { chatSessionId: "chat-1" },
});
assert.equal(denied.allowed, false);
assert.match(denied.error, /observer/i);
const allowed = evaluateRpcPermission({
rpcMethod: "netcatty/jobPoll",
permissionMode: PERMISSION_MODES.OBSERVER,
params: { chatSessionId: "chat-1" },
});
assert.equal(allowed.allowed, true);
assert.equal(allowed.requiresApproval, false);
});
test("confirm mode requires approval for writes but not sftp list on builtin surface", () => {
const writeDecision = evaluateRpcPermission({
rpcMethod: "netcatty/sftp/write",
permissionMode: PERMISSION_MODES.CONFIRM,
params: { chatSessionId: "chat-1" },
});
assert.equal(writeDecision.allowed, true);
assert.equal(writeDecision.requiresApproval, true);
const readDecision = evaluateRpcPermission({
rpcMethod: "netcatty/sftp/list",
permissionMode: PERMISSION_MODES.CONFIRM,
params: { chatSessionId: "chat-1" },
});
assert.equal(readDecision.allowed, true);
assert.equal(readDecision.requiresApproval, false);
});
test("public surface treats sensitive reads as confirm-gated", () => {
const decision = evaluateRpcPermission({
rpcMethod: "public/sftp/list",
surface: CAPABILITY_SURFACES.PUBLIC,
permissionMode: PERMISSION_MODES.CONFIRM,
params: { sessionId: "sess-1" },
});
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
assert.equal(PUBLIC_CONFIRM_RPC_METHODS.has("public/sftp/list"), true);
});
test("write operations require chatSessionId on builtin surface", () => {
const decision = evaluateRpcPermission({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.AUTO,
params: {},
});
assert.equal(decision.allowed, false);
assert.match(decision.error, /chatSessionId/i);
});
test("cancelled chat sessions block terminal writes", () => {
const decision = evaluateRpcPermission({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.AUTO,
params: { chatSessionId: "chat-1" },
context: { chatSessionCancelled: true },
});
assert.equal(decision.allowed, false);
assert.match(decision.error, /cancelled/i);
});
test("cancelled chat sessions block sftp writes", () => {
const decision = evaluateRpcPermission({
rpcMethod: "netcatty/sftp/write",
permissionMode: PERMISSION_MODES.AUTO,
params: { chatSessionId: "chat-1" },
context: { chatSessionCancelled: true },
});
assert.equal(decision.allowed, false);
assert.match(decision.error, /cancelled/i);
});
test("cancelled chat sessions still allow sftp reads", () => {
const decision = evaluateRpcPermission({
rpcMethod: "netcatty/sftp/list",
permissionMode: PERMISSION_MODES.AUTO,
params: { chatSessionId: "chat-1" },
context: { chatSessionCancelled: true },
});
assert.equal(decision.allowed, true);
});
test("jobStop bypasses observer and cancelled chat checks", () => {
const observerDecision = evaluateRpcPermission({
rpcMethod: "netcatty/jobStop",
permissionMode: PERMISSION_MODES.OBSERVER,
params: { chatSessionId: "chat-1" },
context: { chatSessionCancelled: true },
});
assert.equal(observerDecision.allowed, true);
assert.notEqual(observerDecision.error, OBSERVER_DENY_MESSAGE);
});
test("unknown rpc methods pass through policy checks", () => {
const decision = evaluateRpcPermission({
rpcMethod: "auth/verify",
permissionMode: PERMISSION_MODES.OBSERVER,
params: {},
});
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, false);
assert.equal(decision.capability, null);
});
test("confirm mode requires approval for portforward start and host notes set", () => {
const portforwardDecision = evaluateRpcPermission({
rpcMethod: "public/portforward/start",
surface: CAPABILITY_SURFACES.PUBLIC,
permissionMode: PERMISSION_MODES.CONFIRM,
params: { chatSessionId: "chat-1", ruleId: "rule-1" },
});
assert.equal(portforwardDecision.requiresApproval, true);
const notesDecision = evaluateRpcPermission({
rpcMethod: "vault/host/notes/set",
surface: CAPABILITY_SURFACES.GLOBAL,
permissionMode: PERMISSION_MODES.CONFIRM,
params: { chatSessionId: "chat-1", hostId: "host-1" },
});
assert.equal(notesDecision.requiresApproval, true);
const publicNotesDecision = evaluateRpcPermission({
rpcMethod: "public/vault/hostNotes/set",
surface: CAPABILITY_SURFACES.PUBLIC,
permissionMode: PERMISSION_MODES.CONFIRM,
params: { chatSessionId: "chat-1", hostId: "host-1" },
});
assert.equal(publicNotesDecision.requiresApproval, true);
});
test("evaluatePermissionWithGrants skips approval when a grant matches", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: "ls -la",
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-1",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "ls *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, false);
});
test("evaluatePermissionWithGrants does not let a comment grant approve a multiline command", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: [
"# 1a) clear the kernel_options_post profile field",
"cobbler profile edit --name=openEuler-22.03-aarch64 --kernel-options-post=\"\"",
].join("\n"),
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-comment",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "# *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});
test("evaluatePermissionWithGrants does not let a here-doc body grant approve the command", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: [
"cat <<'EOF'",
"rm -rf /tmp/demo",
"EOF",
].join("\n"),
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-rm",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "rm *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});
test("evaluatePermissionWithGrants does not let a piped here-doc body grant approve the command", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: [
"cat <<EOF | grep needle",
"rm -rf /tmp/demo",
"EOF",
].join("\n"),
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-rm",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "rm *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});
test("evaluatePermissionWithGrants does not let an fd-prefixed here-doc body grant approve the command", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: [
"cat 0<<EOF",
"rm -rf /tmp/demo",
"EOF",
].join("\n"),
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-rm",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "rm *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});
test("evaluatePermissionWithGrants does not let a background command grant approve the next command", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: "cd /tmp; sleep 1 & rm -rf demo",
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-sleep",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "sleep *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});
test("evaluatePermissionWithGrants does not let cwd substitutions hide before a later grant", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: "cd \"$(pwd)\"; ls -la",
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-ls",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "ls *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});
test("evaluatePermissionWithGrants does not let quoted here-doc operator text hide later commands", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: [
"cd /tmp; echo '<<EOF'",
"rm -rf demo",
"EOF",
].join("\n"),
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-echo",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "echo *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});
test("evaluatePermissionWithGrants keeps commands after mixed-quoted here-doc delimiters grantable", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: [
"cat <<E\"OF\"",
"body text",
"EOF",
"ls -la",
].join("\n"),
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-cat",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "cat *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});
test("evaluatePermissionWithGrants does not let arithmetic shifts hide following commands", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: [
"ls $((1 << 2))",
"rm -rf demo",
].join("\n"),
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-ls",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "ls *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});
test("evaluatePermissionWithGrants keeps commands after ANSI-C quoted here-doc delimiters grantable", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: [
"cat <<$'E\\x4fF'",
"body text",
"EOF",
"rm -rf demo",
].join("\n"),
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-cat",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "cat *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});
test("evaluatePermissionWithGrants keeps commands after dollar-quoted here-doc delimiters grantable", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
permissionMode: PERMISSION_MODES.CONFIRM,
params: {
chatSessionId: "chat-1",
sessionId: "session-a",
command: [
"cat <<$'EOF'",
"body text",
"EOF",
"rm -rf demo",
].join("\n"),
},
context: { chatSessionCancelled: false },
}, [{
id: "grant-cat",
capabilityId: "terminal.execute",
sessionPattern: "session-a",
commandPattern: "cat *",
createdAt: Date.now(),
}]);
assert.equal(decision.allowed, true);
assert.equal(decision.requiresApproval, true);
});

View File

@@ -0,0 +1,111 @@
"use strict";
const { ALL_CAPABILITIES } = require("./catalog/index.cjs");
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("./constants.cjs");
function buildRegistryIndex(capabilities) {
const byId = new Map();
const byRpcMethod = new Map();
const byMcpTool = new Map();
const byCliCommand = new Map();
const byDomain = new Map();
const byCattyTool = new Map();
for (const capability of capabilities) {
byId.set(capability.id, capability);
const domainList = byDomain.get(capability.domain) || [];
domainList.push(capability);
byDomain.set(capability.domain, domainList);
for (const [surfaceName, binding] of Object.entries(capability.surfaces || {})) {
if (binding?.rpcMethod) {
byRpcMethod.set(`${surfaceName}:${binding.rpcMethod}`, capability);
}
if (binding?.mcpTool) {
byMcpTool.set(`${surfaceName}:${binding.mcpTool}`, capability);
}
if (Array.isArray(binding?.command) && binding.command.length > 0) {
byCliCommand.set(binding.command.join(" "), capability);
}
if (binding?.toolName) {
byCattyTool.set(binding.toolName, capability);
}
}
}
return {
capabilities,
byId,
byRpcMethod,
byMcpTool,
byCliCommand,
byCattyTool,
byDomain,
};
}
const registryIndex = buildRegistryIndex(ALL_CAPABILITIES);
function listCapabilities(options = {}) {
const { status, domain, surface } = options;
return registryIndex.capabilities.filter((capability) => {
if (status && capability.status !== status) return false;
if (domain && capability.domain !== domain) return false;
if (surface && !capability.surfaces?.[surface]) return false;
return true;
});
}
function getCapabilityById(id) {
return registryIndex.byId.get(id) || null;
}
function getCapabilityByRpcMethod(rpcMethod, surface = CAPABILITY_SURFACES.BUILTIN) {
return registryIndex.byRpcMethod.get(`${surface}:${rpcMethod}`) || null;
}
function getCapabilityByMcpTool(toolName, surface = CAPABILITY_SURFACES.BUILTIN) {
return registryIndex.byMcpTool.get(`${surface}:${toolName}`) || null;
}
function getCapabilityByCliCommand(commandParts) {
const key = Array.isArray(commandParts) ? commandParts.join(" ") : String(commandParts || "");
return registryIndex.byCliCommand.get(key) || null;
}
function getCapabilityByCattyToolName(toolName) {
return registryIndex.byCattyTool.get(toolName) || null;
}
function getRpcMethodsForSurface(surface, filter = {}) {
const methods = new Set();
for (const capability of registryIndex.capabilities) {
const binding = capability.surfaces?.[surface];
if (!binding?.rpcMethod) continue;
if (filter.status && capability.status !== filter.status) continue;
if (filter.write === true && !capability.policy.write) continue;
if (filter.write === false && capability.policy.write) continue;
if (filter.longRunning === true && !capability.policy.longRunning) continue;
if (filter.longRunning === false && capability.policy.longRunning) continue;
methods.add(binding.rpcMethod);
}
return methods;
}
function getImplementedRpcMethodsForSurface(surface) {
return getRpcMethodsForSurface(surface, { status: CAPABILITY_STATUS.IMPLEMENTED });
}
module.exports = {
ALL_CAPABILITIES,
listCapabilities,
getCapabilityById,
getCapabilityByRpcMethod,
getCapabilityByMcpTool,
getCapabilityByCliCommand,
getCapabilityByCattyToolName,
getRpcMethodsForSurface,
getImplementedRpcMethodsForSurface,
buildRegistryIndex,
};

View File

@@ -0,0 +1,45 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
ALL_CAPABILITIES,
getCapabilityById,
getCapabilityByRpcMethod,
getCapabilityByMcpTool,
getCapabilityByCliCommand,
listCapabilities,
} = require("./registry.cjs");
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("./constants.cjs");
test("registry contains implemented capabilities", () => {
assert.ok(ALL_CAPABILITIES.length >= 20);
const implemented = listCapabilities({ status: CAPABILITY_STATUS.IMPLEMENTED });
assert.ok(implemented.length >= 20);
});
test("registry resolves builtin rpc methods and mcp tools", () => {
const exec = getCapabilityByRpcMethod("netcatty/exec", CAPABILITY_SURFACES.BUILTIN);
assert.equal(exec?.id, "terminal.execute");
const tool = getCapabilityByMcpTool("terminal_execute", CAPABILITY_SURFACES.BUILTIN);
assert.equal(tool?.id, "terminal.execute");
});
test("registry resolves public rpc aliases for future surfaces", () => {
const publicExec = getCapabilityByRpcMethod("public/terminalExecute", CAPABILITY_SURFACES.PUBLIC);
assert.equal(publicExec?.id, "terminal.execute");
assert.equal(publicExec?.status, CAPABILITY_STATUS.IMPLEMENTED);
});
test("registry resolves cli commands", () => {
const env = getCapabilityByCliCommand(["env"]);
assert.equal(env?.id, "session.environment");
const sftpList = getCapabilityByCliCommand(["sftp", "list"]);
assert.equal(sftpList?.id, "sftp.list");
});
test("vault and portforward capabilities are implemented", () => {
assert.equal(getCapabilityById("vault.host.notes.get")?.status, CAPABILITY_STATUS.IMPLEMENTED);
assert.equal(getCapabilityById("portforward.start")?.status, CAPABILITY_STATUS.IMPLEMENTED);
});

View File

@@ -0,0 +1,67 @@
"use strict";
const { CAPABILITY_SURFACES, PERMISSION_MODES, RPC_TIMEOUT_DEFAULTS } = require("./constants.cjs");
const { getCapabilityByRpcMethod } = require("./registry.cjs");
const { requiresApprovalInConfirmMode } = require("./policy.cjs");
const {
DEFAULT_RPC_TIMEOUT_MS,
DEFAULT_OPERATION_TIMEOUT_MS,
RPC_TIMEOUT_BUFFER_MS,
DEFAULT_APPROVAL_TIMEOUT_MS,
} = RPC_TIMEOUT_DEFAULTS;
function isLongRunningRpcMethod(method, surface = CAPABILITY_SURFACES.BUILTIN) {
const capability = getCapabilityByRpcMethod(method, surface);
return Boolean(capability?.policy.longRunning);
}
function isApprovalWaitRpcMethod(method, surface, permissionMode) {
if (permissionMode !== PERMISSION_MODES.CONFIRM) return false;
const capability = getCapabilityByRpcMethod(method, surface);
return requiresApprovalInConfirmMode(capability, surface);
}
function resolveRpcTimeoutMs(
method,
{
surface = CAPABILITY_SURFACES.BUILTIN,
bridgeCommandTimeoutMs = null,
bridgePermissionMode = null,
bridgeApprovalTimeoutMs = null,
defaultOperationTimeoutMs = DEFAULT_OPERATION_TIMEOUT_MS,
defaultApprovalTimeoutMs = DEFAULT_APPROVAL_TIMEOUT_MS,
defaultRpcTimeoutMs = DEFAULT_RPC_TIMEOUT_MS,
timeoutBufferMs = RPC_TIMEOUT_BUFFER_MS,
} = {},
) {
const operationTimeoutMs = isLongRunningRpcMethod(method, surface)
? (Number.isFinite(bridgeCommandTimeoutMs) && bridgeCommandTimeoutMs > 0
? bridgeCommandTimeoutMs
: defaultOperationTimeoutMs)
: 0;
const approvalTimeoutMs = isApprovalWaitRpcMethod(method, surface, bridgePermissionMode)
? (Number.isFinite(bridgeApprovalTimeoutMs) && bridgeApprovalTimeoutMs > 0
? bridgeApprovalTimeoutMs
: defaultApprovalTimeoutMs)
: 0;
if (operationTimeoutMs > 0 && approvalTimeoutMs > 0) {
return Math.max(defaultRpcTimeoutMs, approvalTimeoutMs + operationTimeoutMs + timeoutBufferMs);
}
if (operationTimeoutMs > 0) {
return Math.max(defaultRpcTimeoutMs, operationTimeoutMs + timeoutBufferMs);
}
if (approvalTimeoutMs > 0) {
return Math.max(defaultRpcTimeoutMs, approvalTimeoutMs + timeoutBufferMs);
}
return defaultRpcTimeoutMs;
}
module.exports = {
isLongRunningRpcMethod,
isApprovalWaitRpcMethod,
resolveRpcTimeoutMs,
RPC_TIMEOUT_DEFAULTS,
};

View File

@@ -0,0 +1,57 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { resolveRpcTimeoutMs, isLongRunningRpcMethod, isApprovalWaitRpcMethod } = require("./rpcTimeouts.cjs");
const { CAPABILITY_SURFACES, PERMISSION_MODES, RPC_TIMEOUT_DEFAULTS } = require("./constants.cjs");
test("long-running rpc methods include exec and sftp home", () => {
assert.equal(isLongRunningRpcMethod("netcatty/exec"), true);
assert.equal(isLongRunningRpcMethod("netcatty/sftp/read"), true);
assert.equal(isLongRunningRpcMethod("netcatty/sftp/home"), true);
assert.equal(isLongRunningRpcMethod("netcatty/jobPoll"), false);
});
test("approval wait methods follow confirm mode and capability policy", () => {
assert.equal(
isApprovalWaitRpcMethod("netcatty/exec", CAPABILITY_SURFACES.BUILTIN, PERMISSION_MODES.CONFIRM),
true,
);
assert.equal(
isApprovalWaitRpcMethod("netcatty/jobStop", CAPABILITY_SURFACES.BUILTIN, PERMISSION_MODES.CONFIRM),
false,
);
assert.equal(
isApprovalWaitRpcMethod("netcatty/sftp/list", CAPABILITY_SURFACES.BUILTIN, PERMISSION_MODES.CONFIRM),
false,
);
assert.equal(
isApprovalWaitRpcMethod("public/sftp/list", CAPABILITY_SURFACES.PUBLIC, PERMISSION_MODES.CONFIRM),
true,
);
});
test("resolveRpcTimeoutMs combines operation and approval budgets", () => {
const timeoutMs = resolveRpcTimeoutMs("netcatty/exec", {
surface: CAPABILITY_SURFACES.BUILTIN,
bridgeCommandTimeoutMs: 60_000,
bridgePermissionMode: PERMISSION_MODES.CONFIRM,
bridgeApprovalTimeoutMs: 110_000,
});
assert.equal(
timeoutMs,
Math.max(
RPC_TIMEOUT_DEFAULTS.DEFAULT_RPC_TIMEOUT_MS,
110_000 + 60_000 + RPC_TIMEOUT_DEFAULTS.RPC_TIMEOUT_BUFFER_MS,
),
);
});
test("resolveRpcTimeoutMs falls back to default for lightweight rpc", () => {
const timeoutMs = resolveRpcTimeoutMs("netcatty/getStatus", {
surface: CAPABILITY_SURFACES.BUILTIN,
bridgePermissionMode: PERMISSION_MODES.CONFIRM,
});
assert.equal(timeoutMs, RPC_TIMEOUT_DEFAULTS.DEFAULT_RPC_TIMEOUT_MS);
});

View File

@@ -0,0 +1,190 @@
"use strict";
const { CAPABILITY_SURFACES } = require("./constants.cjs");
const { resolveRpcTimeoutMs } = require("./rpcTimeouts.cjs");
function createTaggedError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function createUnavailableError(message) {
return createTaggedError("RPC_UNAVAILABLE", message);
}
function createRpcTimeoutError(method, timeoutMs, createError = createTaggedError) {
return createError(
"RPC_TIMEOUT",
`Timed out waiting for RPC response to "${method}" after ${timeoutMs}ms.`,
);
}
/**
* Create a newline-delimited JSON-RPC client over an existing TCP socket.
*/
function createNdjsonRpcClient({
socket,
surface = CAPABILITY_SURFACES.BUILTIN,
setTimeoutImpl = setTimeout,
clearTimeoutImpl = clearTimeout,
onBridgeStatus,
createError = createTaggedError,
messages = {},
}) {
if (!socket) {
throw new Error("socket is required");
}
const connectionClosedMessage = messages.connectionClosed
|| "RPC connection closed.";
const connectionClosedWhileCallMessage = messages.connectionClosedWhileCall
|| connectionClosedMessage;
const connectionErrorMessage = messages.connectionError
|| ((error) => `RPC connection failed: ${error?.message || error}`);
const rpcTimeoutMessage = messages.rpcTimeout
|| ((method, timeoutMs) => `Timed out waiting for RPC response to "${method}" after ${timeoutMs}ms.`);
const writeFailedMessage = messages.writeFailed
|| ((method, error) => `Failed to send RPC "${method}": ${error?.message || error}`);
let nextRpcId = 1;
let buffer = "";
const pending = new Map();
let bridgeCommandTimeoutMs = null;
let bridgePermissionMode = null;
let bridgeApprovalTimeoutMs = null;
function settle(id, resolve, reject, payload) {
pending.delete(id);
clearTimeoutImpl(payload.timeoutId);
if (payload.error) {
reject(payload.error);
return;
}
resolve(payload.result);
}
function rejectAll(error) {
for (const [id, entry] of pending) {
settle(id, entry.resolve, entry.reject, { timeoutId: entry.timeoutId, error });
}
}
socket.on("data", (chunk) => {
buffer += chunk;
let newlineIndex;
while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
if (!line.trim()) continue;
let message;
try {
message = JSON.parse(line);
} catch {
continue;
}
if (message?.id == null) continue;
const entry = pending.get(message.id);
if (!entry) continue;
if (message.error) {
settle(message.id, entry.resolve, entry.reject, {
timeoutId: entry.timeoutId,
error: createError(
"RPC_ERROR",
message.error.message || JSON.stringify(message.error),
),
});
} else {
settle(message.id, entry.resolve, entry.reject, {
timeoutId: entry.timeoutId,
result: message.result,
});
}
}
});
socket.on("error", (error) => {
rejectAll(createError("CONNECTION_ERROR", connectionErrorMessage(error)));
});
socket.on("close", () => {
rejectAll(createError("CONNECTION_CLOSED", connectionClosedMessage));
});
async function call(method, params) {
if (socket.destroyed || !socket.writable) {
throw createError("CONNECTION_CLOSED", connectionClosedWhileCallMessage);
}
const id = nextRpcId++;
const timeoutMs = resolveRpcTimeoutMs(method, {
surface,
bridgeCommandTimeoutMs,
bridgePermissionMode,
bridgeApprovalTimeoutMs,
});
return await new Promise((resolve, reject) => {
const timeoutId = setTimeoutImpl(() => {
pending.delete(id);
reject(createError("RPC_TIMEOUT", rpcTimeoutMessage(method, timeoutMs)));
}, timeoutMs);
pending.set(id, { resolve, reject, timeoutId });
const payload = `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
try {
socket.write(payload, (writeError) => {
if (!writeError) return;
settle(id, resolve, reject, {
timeoutId,
error: createError("WRITE_FAILED", writeFailedMessage(method, writeError)),
});
});
} catch (writeError) {
settle(id, resolve, reject, {
timeoutId,
error: createError("WRITE_FAILED", writeFailedMessage(method, writeError)),
});
}
});
}
function ingestBridgeStatus(statusResult) {
if (Number.isFinite(statusResult?.commandTimeoutMs) && statusResult.commandTimeoutMs > 0) {
bridgeCommandTimeoutMs = statusResult.commandTimeoutMs;
}
if (typeof statusResult?.permissionMode === "string") {
bridgePermissionMode = statusResult.permissionMode;
}
if (Number.isFinite(statusResult?.approvalTimeoutMs) && statusResult.approvalTimeoutMs > 0) {
bridgeApprovalTimeoutMs = statusResult.approvalTimeoutMs;
}
onBridgeStatus?.({
bridgeCommandTimeoutMs,
bridgePermissionMode,
bridgeApprovalTimeoutMs,
});
}
return {
call,
ingestBridgeStatus,
close() {
try {
socket.end();
} catch {
// Ignore shutdown errors.
}
},
};
}
module.exports = {
createTaggedError,
createUnavailableError,
createRpcTimeoutError,
createNdjsonRpcClient,
};

View File

@@ -0,0 +1,113 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const {
createNdjsonRpcClient,
createTaggedError,
} = require("./rpcTransport.cjs");
const { CAPABILITY_SURFACES } = require("./constants.cjs");
function createFakeSocket() {
const socket = new EventEmitter();
socket.destroyed = false;
socket.writable = true;
socket.setEncoding = () => {};
socket.end = () => {
socket.writable = false;
socket.destroyed = true;
socket.emit("close");
};
socket.write = (line, callback) => {
const request = JSON.parse(line);
socket.emit("data", `${JSON.stringify({ jsonrpc: "2.0", id: request.id, result: { ok: true } })}\n`);
callback?.(null);
return true;
};
return socket;
}
test("createNdjsonRpcClient resolves rpc responses", async () => {
const socket = createFakeSocket();
const client = createNdjsonRpcClient({ socket, surface: CAPABILITY_SURFACES.BUILTIN });
const result = await client.call("netcatty/getStatus", {});
assert.deepEqual(result, { ok: true });
});
test("createNdjsonRpcClient surfaces RPC_ERROR with code for bridge failures", async () => {
const socket = new EventEmitter();
socket.destroyed = false;
socket.writable = true;
socket.setEncoding = () => {};
socket.write = (line, callback) => {
const request = JSON.parse(line);
socket.emit("data", `${JSON.stringify({
jsonrpc: "2.0",
id: request.id,
error: { message: "Operation denied by user." },
})}\n`);
callback?.(null);
return true;
};
const client = createNdjsonRpcClient({
socket,
surface: CAPABILITY_SURFACES.BUILTIN,
createError: createTaggedError,
});
await assert.rejects(
() => client.call("netcatty/exec", { sessionId: "sess-1", command: "pwd" }),
(error) => error.code === "RPC_ERROR" && error.message === "Operation denied by user.",
);
});
test("createNdjsonRpcClient uses injectable cli-compatible timeout messages", async () => {
const socket = createFakeSocket();
const client = createNdjsonRpcClient({
socket,
surface: CAPABILITY_SURFACES.BUILTIN,
createError: createTaggedError,
setTimeoutImpl: (callback) => {
callback();
return 1;
},
messages: {
rpcTimeout: (method, timeoutMs) => (
`Timed out waiting for Netcatty RPC response to "${method}" after ${timeoutMs}ms.`
),
},
});
await assert.rejects(
() => client.call("netcatty/exec", { sessionId: "sess-1", command: "pwd", chatSessionId: "chat-1" }),
(error) => error.code === "RPC_TIMEOUT" && /Netcatty RPC response/.test(error.message),
);
});
test("createNdjsonRpcClient ingests bridge status for timeout calculation", async () => {
const socket = createFakeSocket();
let captured = null;
const client = createNdjsonRpcClient({
socket,
surface: CAPABILITY_SURFACES.BUILTIN,
onBridgeStatus: (status) => {
captured = status;
},
});
client.ingestBridgeStatus({
commandTimeoutMs: 45_000,
permissionMode: "confirm",
approvalTimeoutMs: 120_000,
});
assert.deepEqual(captured, {
bridgeCommandTimeoutMs: 45_000,
bridgePermissionMode: "confirm",
bridgeApprovalTimeoutMs: 120_000,
});
});

View File

@@ -0,0 +1,431 @@
"use strict";
/**
* Input field definitions keyed by capability id.
* Single source for MCP, Catty, and CLI tool schemas.
*/
const TOOL_INPUT_FIELDS = Object.freeze({
"terminal.execute": {
sessionId: { type: "string", description: "The terminal session ID to execute on." },
command: { type: "string", description: "The shell command to execute in the target session." },
},
"terminal.start": {
sessionId: { type: "string", description: "The terminal session ID to start a long-running command on." },
command: { type: "string", description: "The command to start in the target session." },
},
"terminal.poll": {
jobId: { type: "string", description: "The background job ID returned by terminal_start." },
offset: { type: "number", optional: true, description: "Character offset from a previous poll (default 0)." },
},
"terminal.stop": {
jobId: { type: "string", description: "The background job ID returned by terminal_start." },
},
"session.environment": {},
"session.close": {
sessionId: { type: "string", description: "The session ID returned by host_open." },
},
"attachment.list": {},
"attachment.read": {
filePath: { type: "string", optional: true, description: "Exact local attachment path." },
filename: { type: "string", optional: true, description: "Attachment filename from list_attachments." },
},
"sftp.list": {
sessionId: { type: "string", description: "SFTP session ID." },
path: { type: "string", description: "Remote directory path." },
},
"sftp.read": {
sessionId: { type: "string", description: "SFTP session ID." },
path: { type: "string", description: "Remote file path to read." },
},
"sftp.stat": {
sessionId: { type: "string", description: "SFTP session ID." },
path: { type: "string", description: "Remote path to stat." },
},
"sftp.home": {
sessionId: { type: "string", description: "SFTP session ID." },
},
"sftp.write": {
sessionId: { type: "string", description: "SFTP session ID." },
path: { type: "string", description: "Remote file path to write." },
content: { type: "string", description: "File content to write." },
},
"sftp.download": {
sessionId: { type: "string", description: "SFTP session ID." },
remotePath: { type: "string", description: "Remote file path to download." },
localPath: { type: "string", description: "Local destination path." },
},
"sftp.upload": {
sessionId: { type: "string", description: "SFTP session ID." },
localPath: { type: "string", description: "Local file path to upload." },
remotePath: { type: "string", description: "Remote destination path." },
},
"sftp.mkdir": {
sessionId: { type: "string", description: "SFTP session ID." },
path: { type: "string", description: "Remote directory path to create." },
},
"sftp.delete": {
sessionId: { type: "string", description: "SFTP session ID." },
path: { type: "string", description: "Remote file or directory path to delete." },
},
"sftp.rename": {
sessionId: { type: "string", description: "SFTP session ID." },
oldPath: { type: "string", description: "Current remote path." },
newPath: { type: "string", description: "New remote path." },
},
"sftp.chmod": {
sessionId: { type: "string", description: "SFTP session ID." },
path: { type: "string", description: "Remote file path." },
mode: { type: "string", description: "Octal permission mode (e.g. 755)." },
},
"vault.host.get": {
hostId: { type: "string", description: "Vault host ID." },
},
"vault.host.list": {},
"vault.host.open": {
hostId: { type: "string", description: "Vault host ID to open. Use vault_hosts_list / host_get to resolve id from label or hostname." },
},
"vault.hosts.create": {
hosts: {
type: "string",
description:
"JSON array of host objects you extracted from the user's text. Each object: hostname (required; host/ip aliases accepted), label (name alias accepted), port, username, password, keyPath or keypath (local private-key file path), passphrase (saved passphrase for that key path), group, tags (array or comma-separated string), notes (Host Details remarks — NOT Vault sidebar Notes), protocol (ssh|telnet|local), os (linux|windows|macos).",
},
dryRun: {
type: "string",
optional: true,
description: "Set to true to validate and preview without writing to the vault.",
},
skipDuplicates: {
type: "string",
optional: true,
description: "Set to false to create even when a matching host already exists (default true).",
},
},
"vault.host.update": {
hostId: { type: "string", description: "Vault host ID from vault_hosts_list." },
label: { type: "string", optional: true, description: "New display name." },
name: { type: "string", optional: true, description: "Alias for label." },
hostname: { type: "string", optional: true, description: "New hostname or IP address." },
host: { type: "string", optional: true, description: "Alias for hostname." },
ip: { type: "string", optional: true, description: "Alias for hostname." },
port: { type: "number", optional: true, description: "New connection port (1-65535)." },
username: { type: "string", optional: true, description: "New login username. Clears any reusable identity binding so this value takes effect." },
password: { type: "string", optional: true, description: "New password without changing key-based login. Password identities are detached so the new value takes effect. Empty string clears it and blocks inherited saved passwords. Pair with keyPath set to an empty string to switch from key login to password login." },
savePassword: { type: "string", optional: true, description: "Set to true or false to enable or disable saved passwords. Pass true when setting a new password after clearing one." },
keyPath: { type: "string", optional: true, description: "Local private-key file path. Empty string clears and blocks an inherited path." },
keypath: { type: "string", optional: true, description: "Alias for keyPath." },
passphrase: { type: "string", optional: true, description: "Saved passphrase for the host's local private-key path. Empty string clears the saved passphrase." },
group: { type: "string", optional: true, description: "New group path. Empty string moves the host to the root." },
tags: { type: "string", optional: true, description: "JSON array or comma-separated tag names. Empty string clears tags." },
notes: { type: "string", optional: true, description: "Host Details remarks. Empty string clears notes." },
protocol: { type: "string", optional: true, description: "New protocol: ssh, telnet, local, or serial." },
os: { type: "string", optional: true, description: "Operating system override: auto (default), linux, windows, macos, freebsd, or unknown. Use auto to use detected system information; network device mode is separate." },
identityId: { type: "string", optional: true, description: "Reusable identity ID from vault_identities_list. Empty string detaches the identity." },
jumpHostIds: { type: "string", optional: true, description: "JSON array of vault host IDs in jump order. Empty array clears the chain." },
proxyProfileId: { type: "string", optional: true, description: "Reusable proxy ID from vault_proxy_profiles_list. Empty string clears it." },
startupCommand: { type: "string", optional: true, description: "Command to run after connecting. Empty string clears it." },
startupCommandRunMode: { type: "string", optional: true, description: "paste or lineDelay." },
environmentVariables: { type: "string", optional: true, description: "JSON object or array of {name,value} entries. Empty object clears them." },
moshEnabled: { type: "string", optional: true, description: "true or false." },
moshServerPath: { type: "string", optional: true, description: "Optional mosh-server path." },
etEnabled: { type: "string", optional: true, description: "true or false." },
etPort: { type: "number", optional: true, description: "Eternal Terminal server port." },
serialConfig: { type: "string", optional: true, description: "JSON object for serial connections: path, baudRate, and optional dataBits, stopBits, parity, flowControl, localEcho, lineMode, backspaceBehavior (default or ctrl-h). Existing backspaceBehavior is preserved when omitted." },
},
"vault.host.delete": {
hostId: { type: "string", description: "Vault host ID from vault_hosts_list." },
},
"vault.host.import": {
format: {
type: "string",
description: "Import format: csv, putty, mobaxterm, securecrt, ssh_config, or auto to detect from text.",
},
text: { type: "string", description: "Exported host data text to import." },
dryRun: {
type: "string",
optional: true,
description: "Set to true to parse and preview without writing to the vault.",
},
skipDuplicates: {
type: "string",
optional: true,
description: "Set to false to import even when a matching host already exists (default true).",
},
fileName: {
type: "string",
optional: true,
description: "Optional source file name (helps SecureCRT .ini parsing).",
},
},
"vault.host.notes.get": {
hostId: { type: "string", description: "Vault host ID." },
},
"vault.host.notes.set": {
hostId: { type: "string", description: "Vault host ID." },
notes: { type: "string", description: "Host metadata notes (Host Details — not Vault sidebar Notes)." },
},
"vault.note.list": {},
"vault.note.get": {
noteId: { type: "string", description: "Vault note ID from vault_notes_list." },
offset: { type: "number", optional: true, description: "Zero-based UTF-16 offset; default 0. Continue with returned nextOffset." },
maxChars: { type: "number", optional: true, description: "Maximum excerpt length in UTF-16 units, at least 2; default and hard cap 6000." },
query: { type: "string", optional: true, description: "Optional case-sensitive literal search, 1-200 UTF-16 units. Returns an excerpt starting at the next match at/after offset; matchOffset=null means no match. Keep query when continuing search." },
expectedUpdatedAt: { type: "number", optional: true, description: "Pass note.updatedAt from the first read on subsequent reads to detect changes; restart if it changed." },
},
"vault.note.create": {
title: { type: "string", description: "Note title shown in Vault → Notes." },
content: { type: "string", description: "Markdown note body." },
group: { type: "string", optional: true, description: "Optional folder path (e.g. infra/prod)." },
linkedHostIds: { type: "string", optional: true, description: "Optional JSON array of vault host IDs to link." },
tags: { type: "string", optional: true, description: "Optional JSON array of tag strings." },
},
"vault.note.update": {
noteId: { type: "string", description: "Vault note ID to update." },
title: { type: "string", optional: true, description: "New title." },
content: { type: "string", optional: true, description: "New markdown body." },
group: { type: "string", optional: true, description: "New folder path." },
linkedHostIds: { type: "string", optional: true, description: "Optional JSON array of vault host IDs to link." },
tags: { type: "string", optional: true, description: "Optional JSON array of tag strings." },
},
"vault.note.delete": {
noteId: { type: "string", description: "Vault note ID to delete." },
},
"vault.identity.list": {},
"vault.proxyProfile.list": {},
"vault.group.list": {},
"vault.group.create": {
path: { type: "string", description: "New group path, for example prod/web." },
defaults: { type: "string", optional: true, description: "JSON object of group defaults: username, identityId, proxyProfileId, jumpHostIds, startupCommand, environmentVariables, moshEnabled, moshServerPath, etEnabled, etPort." },
},
"vault.group.update": {
path: { type: "string", description: "Existing group path." },
newPath: { type: "string", optional: true, description: "Optional replacement path to rename or move the group and descendants." },
defaults: { type: "string", optional: true, description: "JSON object containing only group defaults to change." },
},
"vault.group.delete": {
path: { type: "string", description: "Group path to delete, including descendants." },
deleteHosts: { type: "string", optional: true, description: "Set true to delete hosts in the group; default moves them to the root." },
},
"vault.snippets.list": {},
"vault.snippets.get": {
snippetId: { type: "string", description: "Snippet ID." },
},
"vault.snippets.run": {
snippetId: { type: "string", description: "Snippet ID to run." },
sessionId: { type: "string", description: "Terminal session ID to execute on." },
variables: { type: "string", optional: true, description: "JSON object of snippet variable values." },
wait: { type: "string", optional: true, description: "Set to true to wait for script completion when kind=script." },
},
"vault.snippets.create": {
label: { type: "string", description: "Snippet or script label." },
content: { type: "string", description: "Snippet command text or script JavaScript source." },
kind: { type: "string", optional: true, description: "snippet (default) or script for nct automation." },
tags: { type: "string", optional: true, description: "Optional JSON array of tag strings." },
targets: { type: "string", optional: true, description: "Optional JSON array of vault host IDs." },
targetGroups: { type: "string", optional: true, description: "Optional JSON array of dynamic vault group paths." },
targetsAllHosts: { type: "string", optional: true, description: "Set true to target all connectable hosts." },
package: { type: "string", optional: true, description: "Optional vault package path." },
shortkey: { type: "string", optional: true, description: "Optional keyboard shortcut." },
noAutoRun: { type: "string", optional: true, description: "For text snippets: paste without pressing Enter." },
multiLineRunMode: { type: "string", optional: true, description: "For multi-line text snippets: paste (default) or lineDelay." },
language: { type: "string", optional: true, description: "javascript or python (UI label only; runtime is JS)." },
description: { type: "string", optional: true, description: "Optional script description." },
trigger: { type: "string", optional: true, description: "manual, onConnect, or onOutput (scripts)." },
triggerPattern: { type: "string", optional: true, description: "Regex when trigger=onOutput." },
},
"vault.snippets.update": {
snippetId: { type: "string", description: "Snippet ID to update." },
label: { type: "string", optional: true, description: "New label." },
content: { type: "string", optional: true, description: "New command or script source." },
kind: { type: "string", optional: true, description: "snippet or script." },
tags: { type: "string", optional: true, description: "Optional JSON array of tag strings." },
targets: { type: "string", optional: true, description: "Optional JSON array of vault host IDs." },
targetGroups: { type: "string", optional: true, description: "Optional JSON array of dynamic vault group paths." },
targetsAllHosts: { type: "string", optional: true, description: "Set true to target all connectable hosts." },
package: { type: "string", optional: true, description: "Optional vault package path." },
shortkey: { type: "string", optional: true, description: "Optional keyboard shortcut." },
noAutoRun: { type: "string", optional: true, description: "For text snippets: paste without pressing Enter." },
multiLineRunMode: { type: "string", optional: true, description: "For multi-line text snippets: paste (default) or lineDelay." },
language: { type: "string", optional: true, description: "javascript or python." },
description: { type: "string", optional: true, description: "Optional script description." },
trigger: { type: "string", optional: true, description: "manual, onConnect, or onOutput." },
triggerPattern: { type: "string", optional: true, description: "Regex when trigger=onOutput." },
},
"vault.snippets.delete": {
snippetId: { type: "string", description: "Snippet ID to delete." },
},
"vault.scripts.list": {},
"vault.scripts.get": {
scriptId: { type: "string", description: "Automation script ID." },
},
"vault.scripts.create": {
label: { type: "string", description: "Script label." },
content: { type: "string", description: "JavaScript automation source using nct.* API." },
tags: { type: "string", optional: true, description: "Optional JSON array of tag strings." },
targets: { type: "string", optional: true, description: "Optional JSON array of vault host IDs." },
targetGroups: { type: "string", optional: true, description: "Optional JSON array of dynamic vault group paths." },
targetsAllHosts: { type: "string", optional: true, description: "Set true to target all connectable hosts." },
package: { type: "string", optional: true, description: "Optional vault package path." },
description: { type: "string", optional: true, description: "Optional script description." },
trigger: { type: "string", optional: true, description: "manual (default), onConnect, or onOutput." },
triggerPattern: { type: "string", optional: true, description: "Regex when trigger=onOutput." },
},
"vault.scripts.update": {
scriptId: { type: "string", description: "Script ID to update." },
label: { type: "string", optional: true, description: "New label." },
content: { type: "string", optional: true, description: "New JavaScript source." },
tags: { type: "string", optional: true, description: "Optional JSON array of tag strings." },
targets: { type: "string", optional: true, description: "Optional JSON array of vault host IDs." },
targetGroups: { type: "string", optional: true, description: "Optional JSON array of dynamic vault group paths." },
targetsAllHosts: { type: "string", optional: true, description: "Set true to target all connectable hosts." },
package: { type: "string", optional: true, description: "Optional vault package path." },
description: { type: "string", optional: true, description: "Optional script description." },
trigger: { type: "string", optional: true, description: "manual, onConnect, or onOutput." },
triggerPattern: { type: "string", optional: true, description: "Regex when trigger=onOutput." },
},
"vault.scripts.delete": {
scriptId: { type: "string", description: "Script ID to delete." },
},
"vault.scripts.run": {
scriptId: { type: "string", description: "Script ID to run." },
sessionId: { type: "string", description: "Terminal session ID." },
wait: { type: "string", optional: true, description: "Set to true to block until the script completes." },
},
"vault.scripts.reference": {},
"vault.scripts.runs.list": {
sessionId: { type: "string", optional: true, description: "Optional terminal session ID filter." },
},
"vault.scripts.run.stop": {
runId: { type: "string", description: "Script run ID from scripts_run or scripts_runs_list." },
},
"vault.scripts.run.pause": {
runId: { type: "string", description: "Script run ID to pause." },
},
"vault.scripts.run.resume": {
runId: { type: "string", description: "Script run ID to resume." },
},
"vault.scripts.targets.set": {
scriptId: { type: "string", description: "Script ID." },
targets: { type: "string", optional: true, description: "JSON array of vault host IDs (omit when targetsAllHosts=true)." },
targetGroups: { type: "string", optional: true, description: "JSON array of dynamic vault group paths (omit when targetsAllHosts=true)." },
targetsAllHosts: { type: "string", optional: true, description: "Set true to target all connectable hosts." },
},
"vault.host.connectScripts.list": {
hostId: { type: "string", description: "Vault host ID." },
},
"vault.host.connectScripts.set": {
hostId: { type: "string", description: "Vault host ID." },
scriptIds: { type: "string", description: "JSON array of onConnect script IDs in run order." },
},
"portforward.rules.list": {},
"portforward.rules.create": {
label: { type: "string", optional: true, description: "Rule label." },
type: { type: "string", description: "local, remote, or dynamic." },
localPort: { type: "number", description: "Local listening port (1-65535)." },
bindAddress: { type: "string", optional: true, description: "Bind address; default 127.0.0.1." },
remoteHost: { type: "string", optional: true, description: "Required except for dynamic forwarding." },
remotePort: { type: "number", optional: true, description: "Required except for dynamic forwarding." },
hostId: { type: "string", description: "Vault host ID used for the tunnel." },
autoStart: { type: "string", optional: true, description: "true or false." },
},
"portforward.rules.update": {
ruleId: { type: "string", description: "Port forwarding rule ID." },
label: { type: "string", optional: true, description: "New rule label." },
type: { type: "string", optional: true, description: "local, remote, or dynamic." },
localPort: { type: "number", optional: true, description: "Local listening port." },
bindAddress: { type: "string", optional: true, description: "Bind address." },
remoteHost: { type: "string", optional: true, description: "Remote host." },
remotePort: { type: "number", optional: true, description: "Remote port." },
hostId: { type: "string", optional: true, description: "Vault host ID used for the tunnel." },
autoStart: { type: "string", optional: true, description: "true or false." },
},
"portforward.rules.duplicate": {
ruleId: { type: "string", description: "Port forwarding rule ID to copy." },
},
"portforward.rules.delete": {
ruleId: { type: "string", description: "Port forwarding rule ID to delete." },
},
"portforward.tunnels.list": {},
"portforward.start": {
ruleId: { type: "string", description: "Port forwarding rule ID." },
},
"portforward.stop": {
ruleId: { type: "string", description: "Port forwarding rule ID." },
},
"harness.tool_output.read": {
handleId: { type: "string", description: "Tool output handle id from a prior truncated result." },
mode: { type: "string", optional: true, description: "Which portion to read: head, tail, range, search, or bounded full." },
maxChars: { type: "number", optional: true, description: "Requested characters to return. The service enforces a hard upper bound." },
offset: { type: "number", optional: true, description: "Zero-based character offset for range reads or search continuation." },
query: { type: "string", optional: true, description: "Case-insensitive search text when mode is search." },
},
"harness.workspace.get_info": {},
"harness.workspace.get_session_info": {
sessionId: { type: "string", description: "The session ID to get information about." },
},
"harness.terminal.read_context": {
sessionId: { type: "string", optional: true, description: "Terminal session ID. Required when the current AI scope contains more than one terminal." },
range: { type: "string", optional: true, description: "Which terminal slice to read: viewport, tail, head, or lines. Defaults to viewport." },
startLine: { type: "number", optional: true, description: "Zero-based terminal buffer line to start from when range is lines." },
maxLines: { type: "number", optional: true, description: "Maximum terminal rows to return. Defaults to 80, capped at 300." },
},
"harness.web.search": {
query: { type: "string", description: "The search query to look up on the web." },
maxResults: { type: "number", optional: true, description: "Maximum number of search results to return." },
},
"harness.url.fetch": {
url: { type: "string", description: "The HTTPS URL to fetch." },
maxLength: { type: "number", optional: true, description: "Maximum characters to return (default 50000)." },
},
"harness.skill.run": {
sessionId: { type: "string", description: "Target terminal session ID." },
skillName: { type: "string", description: "Built-in skill name: diagnose_linux, diagnose_windows, check_ports, check_docker, security_audit." },
},
});
/** Long-form model guidance appended to terminal tool descriptions from catalog. */
const MODEL_DESCRIPTION_HINTS = Object.freeze({
"terminal.execute":
"Use only for commands expected to finish within about 60 seconds. For long-running commands use terminal_start and terminal_poll. Commands run in an isolated subshell of the visible terminal: the user sees the output live, but shell state such as cd, export, or set does not persist between calls — use absolute paths or combine cd with the command (cd /path && cmd).",
"terminal.start":
"Prefer for builds, scans, log-following, or anything likely to exceed about 2 minutes. Shell state such as cd or export does not persist between calls — combine cd with the command.",
"terminal.poll":
"Wait at least about 30 seconds between polls unless output justifies checking sooner.",
"vault.host.notes.get":
"Host metadata notes on a saved host — not Vault → Notes sidebar entries.",
"vault.host.notes.set":
"Host metadata notes on a saved host — not Vault → Notes sidebar entries. Prefer vault_notes_create/update when the user wants vault notes they can open in the Notes sidebar.",
"vault.host.open":
"Opens a terminal tab for a saved vault host (same as clicking the host in Netcatty). Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Call session_close with the returned sessionId when the task is finished. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"vault.host.import":
"Only for text in known export formats (PuTTY reg, MobaXterm ini, CSV template, SecureCRT, ssh_config). If attached host text is unknown or auto-detection fails, use read_attachment content, extract fields yourself, and call vault_hosts_create.",
"vault.hosts.create":
"Use when the user wants to add/create a host in Vault → Hosts (创建主机、SSH 连接凭据). NOT for Vault → Notes sidebar docs. Put SSH password in password, or a local private-key file path in keyPath. If that key is encrypted and the user supplied its passphrase, put it in passphrase so later connections do not prompt. Put long remarks/admin tables in host notes. Never fall back to vault_notes_create if this fails.",
"vault.host.update":
"Update only fields the user requested. Use vault_hosts_list first to resolve hostId. Empty group, tags, notes, password, keyPath, or passphrase values clear those fields or saved credentials.",
"vault.host.delete":
"Permanently deletes one saved host. Use vault_hosts_list first to resolve hostId and rely on the normal write approval flow before deleting.",
"vault.note.create":
"Use ONLY when the user wants markdown documentation in Vault → Notes sidebar (保险箱笔记). Do NOT use when the user asked to create/add a host — use vault_hosts_create instead.",
"vault.note.update":
"Update an existing Vault → Notes entry (visible in the vault notes sidebar).",
"vault.snippets.run":
"Text snippets (kind=snippet) paste shell commands with optional {{variables}}. Scripts (kind=script) run via nct JavaScript runtime — use scripts_run for script-only workflows.",
"vault.snippets.create":
"Create vault snippets (shell text) or scripts (kind=script, nct JavaScript). For multi-step terminal automation use kind=script and call scripts_reference.",
"vault.scripts.run":
"Runs automation scripts in the nct JavaScript sandbox. Use wait=true to block until completion. Prefer over terminal_execute for await nct.screen.* workflows.",
"vault.scripts.create":
"Create nct automation scripts. Call scripts_reference first to learn the nct API, triggers, and source wrapping rules.",
"vault.scripts.reference":
"Returns full Netcatty automation script syntax reference (nct API, triggers, host targeting). Read before authoring scripts.",
"vault.host.connectScripts.set":
"Sets per-host onConnect script queue order. Global and matching dynamic-group scripts run first automatically.",
"harness.skill.run":
"Prefer this over hand-rolling terminal_execute chains for host diagnostics. Built-in skills: diagnose_linux (OS/CPU/memory/disk/ports/Docker/systemd/kernel errors), diagnose_windows (OS/CPU/memory/disk/top procs/services/ports/event log via PowerShell), check_ports (all listening TCP/UDP with process info), check_docker (daemon health, containers, disk usage, images), security_audit (Linux SSH/firewall/failed logins/world-writable files). The skill auto-picks the right shell commands for the session's OS/shellType.",
});
module.exports = {
TOOL_INPUT_FIELDS,
MODEL_DESCRIPTION_HINTS,
};

View File

@@ -0,0 +1,41 @@
"use strict";
/**
* Scope helpers for capability execution boundaries.
*
* Concrete scope implementations (chat-scoped, public-exposure, global) will
* live with their bridges. This module defines shared validation helpers.
*/
function createScopeError(code, message) {
return { ok: false, code, error: message };
}
function validateSessionInList(sessionId, allowedSessionIds) {
if (!sessionId) {
return createScopeError("SESSION_REQUIRED", "sessionId is required.");
}
if (!Array.isArray(allowedSessionIds)) {
return createScopeError("SCOPE_UNAVAILABLE", "Session scope is unavailable.");
}
if (allowedSessionIds.length === 0) {
return createScopeError("SESSION_NOT_IN_SCOPE", "No sessions are available in the current scope.");
}
if (!allowedSessionIds.includes(sessionId)) {
return createScopeError("SESSION_NOT_IN_SCOPE", `Session "${sessionId}" is not in the current scope.`);
}
return { ok: true };
}
function intersectSessionIds(primaryIds, secondaryIds) {
if (!Array.isArray(primaryIds)) return Array.isArray(secondaryIds) ? [...secondaryIds] : null;
if (!Array.isArray(secondaryIds)) return [...primaryIds];
const secondary = new Set(secondaryIds);
return primaryIds.filter((sessionId) => secondary.has(sessionId));
}
module.exports = {
createScopeError,
validateSessionInList,
intersectSessionIds,
};

View File

@@ -0,0 +1,19 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { validateSessionInList, intersectSessionIds } = require("./scope.cjs");
test("validateSessionInList rejects unknown sessions", () => {
const result = validateSessionInList("sess-2", ["sess-1"]);
assert.equal(result.ok, false);
assert.equal(result.code, "SESSION_NOT_IN_SCOPE");
});
test("intersectSessionIds narrows explicit scope", () => {
assert.deepEqual(
intersectSessionIds(["a", "b", "c"], ["b", "c", "d"]),
["b", "c"],
);
});

View File

@@ -0,0 +1,11 @@
"use strict";
const notImplemented = require("./notImplemented.cjs");
const vaultService = require("./vaultService.cjs");
const portforwardService = require("./portforwardService.cjs");
module.exports = {
...notImplemented,
...vaultService,
...portforwardService,
};

View File

@@ -0,0 +1,18 @@
"use strict";
function createNotImplementedResult(capabilityId) {
return {
ok: false,
code: "CAPABILITY_NOT_IMPLEMENTED",
error: `Capability "${capabilityId}" is not implemented yet.`,
};
}
function createNotImplementedHandler(capabilityId) {
return async () => createNotImplementedResult(capabilityId);
}
module.exports = {
createNotImplementedResult,
createNotImplementedHandler,
};

View File

@@ -0,0 +1,65 @@
"use strict";
const portForwardingBridge = require("../../bridges/portForwardingBridge.cjs");
/**
* Port forwarding domain service. Tunnels live in the configured runtime
* (terminal worker in the current architecture); rules live in renderer vault.
*/
function createPortForwardService(ctx = {}) {
const { invokeVaultAgent } = ctx;
const listPortForwards = typeof ctx.listPortForwards === "function"
? ctx.listPortForwards
: () => portForwardingBridge.listPortForwards();
return {
listRules: async () => {
if (typeof invokeVaultAgent !== "function") {
return { ok: false, error: "Vault agent bridge is unavailable." };
}
return invokeVaultAgent("portforward.rules.list", {});
},
createRule: async (params = {}) => {
if (typeof invokeVaultAgent !== "function") return { ok: false, error: "Vault agent bridge is unavailable." };
return invokeVaultAgent("portforward.rules.create", params);
},
updateRule: async (params = {}) => {
if (typeof invokeVaultAgent !== "function") return { ok: false, error: "Vault agent bridge is unavailable." };
return invokeVaultAgent("portforward.rules.update", params);
},
duplicateRule: async (params = {}) => {
if (typeof invokeVaultAgent !== "function") return { ok: false, error: "Vault agent bridge is unavailable." };
return invokeVaultAgent("portforward.rules.duplicate", params);
},
deleteRule: async (params = {}) => {
if (typeof invokeVaultAgent !== "function") return { ok: false, error: "Vault agent bridge is unavailable." };
return invokeVaultAgent("portforward.rules.delete", params);
},
listTunnels: async () => {
const tunnels = await listPortForwards();
return { ok: true, tunnels };
},
start: async (params = {}) => {
if (typeof invokeVaultAgent !== "function") {
return { ok: false, error: "Vault agent bridge is unavailable." };
}
return invokeVaultAgent("portforward.start", {
ruleId: params.ruleId,
chatSessionId: params.chatSessionId,
});
},
stop: async (params = {}) => {
if (typeof invokeVaultAgent !== "function") {
return { ok: false, error: "Vault agent bridge is unavailable." };
}
return invokeVaultAgent("portforward.stop", {
ruleId: params.ruleId,
chatSessionId: params.chatSessionId,
});
},
};
}
module.exports = {
createPortForwardService,
};

View File

@@ -0,0 +1,51 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createPortForwardService } = require("./portforwardService.cjs");
test("portforward service lists active tunnels through the configured runtime", async () => {
const calls = [];
const service = createPortForwardService({
invokeVaultAgent: async () => ({ ok: true, rules: [] }),
listPortForwards: async () => {
calls.push("list");
return [{ tunnelId: "worker-tunnel", status: "active" }];
},
});
const result = await service.listTunnels();
assert.equal(result.ok, true);
assert.deepEqual(result.tunnels, [{ tunnelId: "worker-tunnel", status: "active" }]);
assert.deepEqual(calls, ["list"]);
});
test("portforward start delegates to vault agent bridge after approval path", async () => {
let invokedOp = null;
const service = createPortForwardService({
invokeVaultAgent: async (op, params) => {
invokedOp = op;
return { ok: true, ruleId: params.ruleId };
},
});
const result = await service.start({ ruleId: "rule-1", chatSessionId: "chat-1" });
assert.equal(invokedOp, "portforward.start");
assert.equal(result.ok, true);
});
test("portforward rule mutations delegate to the renderer vault", async () => {
const calls = [];
const service = createPortForwardService({
invokeVaultAgent: async (op, params) => {
calls.push({ op, params });
return { ok: true };
},
});
await service.createRule({ label: "Web" });
await service.updateRule({ ruleId: "rule-1", localPort: 8081 });
await service.duplicateRule({ ruleId: "rule-1" });
await service.deleteRule({ ruleId: "rule-1" });
assert.deepEqual(calls.map((call) => call.op), [
"portforward.rules.create", "portforward.rules.update", "portforward.rules.duplicate", "portforward.rules.delete",
]);
});

View File

@@ -0,0 +1,43 @@
"use strict";
function createSessionService(ctx = {}) {
const { invokeSessionAgent, validateClose, beforeClose, afterClose, onClosed } = ctx;
async function close(params = {}, options = {}) {
if (!params.sessionId || typeof params.sessionId !== "string") {
return { ok: false, error: "sessionId is required." };
}
if (!options.skipValidation && typeof validateClose === "function") {
const validation = validateClose(params);
if (validation && validation.ok === false) return validation;
}
if (typeof invokeSessionAgent !== "function") {
return { ok: false, error: "Session close bridge is unavailable." };
}
let result;
let closed = false;
try {
await beforeClose?.(params);
result = await invokeSessionAgent("session.close", { sessionId: params.sessionId });
if (result?.ok !== false) {
await onClosed?.(params.sessionId);
closed = true;
}
return result;
} finally {
await afterClose?.(params, {
closed,
notFound: /\bwas not found\b/i.test(result?.error || ""),
result,
});
}
}
return {
close: (params = {}) => close(params),
closeTracked: (params = {}) => close(params, { skipValidation: true }),
};
}
module.exports = { createSessionService };

View File

@@ -0,0 +1,55 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createSessionService } = require("./sessionService.cjs");
test("closeTracked uses the same lifecycle while bypassing user-scope validation", async () => {
const events = [];
const service = createSessionService({
validateClose: () => ({ ok: false, error: "scope was cleaned" }),
beforeClose: async () => events.push("before"),
invokeSessionAgent: async () => ({ ok: true, status: "closed" }),
onClosed: async () => events.push("closed"),
afterClose: async (_params, outcome) => events.push(outcome.closed ? "success" : "failed"),
});
const manualResult = await service.close({ sessionId: "session-1" });
assert.equal(manualResult.ok, false);
assert.deepEqual(events, []);
const idleResult = await service.closeTracked({ sessionId: "session-1" });
assert.equal(idleResult.ok, true);
assert.deepEqual(events, ["before", "closed", "success"]);
});
test("failed closes report the outcome so idle tracking can resume", async () => {
let outcome = null;
const service = createSessionService({
invokeSessionAgent: async () => ({ ok: false, error: "renderer unavailable" }),
afterClose: async (_params, value) => {
outcome = value;
},
});
const result = await service.closeTracked({ sessionId: "session-1" });
assert.equal(result.ok, false);
assert.equal(outcome.closed, false);
assert.equal(outcome.notFound, false);
assert.deepEqual(outcome.result, result);
});
test("already-missing sessions are distinguished from retryable close failures", async () => {
let outcome = null;
const service = createSessionService({
invokeSessionAgent: async () => ({ ok: false, error: 'Session "gone" was not found.' }),
afterClose: async (_params, value) => {
outcome = value;
},
});
const result = await service.closeTracked({ sessionId: "gone" });
assert.equal(result.ok, false);
assert.equal(outcome.closed, false);
assert.equal(outcome.notFound, true);
});

View File

@@ -0,0 +1,257 @@
"use strict";
/** Matches waitForScriptRun default (1h) plus bridge overhead. */
const VAULT_AGENT_SCRIPT_WAIT_TIMEOUT_MS = 3_605_000;
function parseVaultAgentWaitFlag(raw) {
if (raw === undefined || raw === null || raw === "") return false;
if (typeof raw === "boolean") return raw;
const normalized = String(raw).trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes";
}
function vaultAgentInvokeOptions(op, params = {}) {
if (op !== "snippets.run" && op !== "scripts.run") return undefined;
if (!parseVaultAgentWaitFlag(params.wait)) return undefined;
return { timeoutMs: VAULT_AGENT_SCRIPT_WAIT_TIMEOUT_MS };
}
/**
* Vault domain service. Read-only metadata and notes/snippets are served from
* renderer vault state via VaultAgentBridge; credentials never cross the bridge.
*/
function createVaultService(ctx = {}) {
const { invokeVaultAgent } = ctx;
function requireBridge() {
if (typeof invokeVaultAgent !== "function") {
return { ok: false, error: "Vault agent bridge is unavailable." };
}
return null;
}
return {
getHost: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("host.get", { hostId: params.hostId });
},
listHosts: async () => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("host.list", {});
},
openHost: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("host.open", {
hostId: params.hostId,
chatSessionId: params.chatSessionId,
});
},
createHosts: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("hosts.create", params);
},
updateHost: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("host.update", params);
},
deleteHost: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("host.delete", { hostId: params.hostId });
},
importHosts: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("host.import", params);
},
getHostNotes: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("host.notes.get", { hostId: params.hostId });
},
setHostNotes: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("host.notes.set", {
hostId: params.hostId,
notes: params.notes,
});
},
listNotes: async () => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("note.list", {});
},
getNote: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("note.get", {
noteId: params.noteId,
offset: params.offset,
maxChars: params.maxChars,
query: params.query,
expectedUpdatedAt: params.expectedUpdatedAt,
});
},
createNote: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("note.create", params);
},
updateNote: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("note.update", params);
},
deleteNote: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("note.delete", { noteId: params.noteId });
},
listIdentities: async () => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("identity.list", {});
},
listProxyProfiles: async () => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("proxyProfile.list", {});
},
listGroups: async () => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("group.list", {});
},
createGroup: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("group.create", params);
},
updateGroup: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("group.update", params);
},
deleteGroup: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("group.delete", params);
},
listSnippets: async () => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("snippets.list", {});
},
getSnippet: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("snippets.get", { snippetId: params.snippetId });
},
runSnippet: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("snippets.run", {
snippetId: params.snippetId,
sessionId: params.sessionId,
variables: params.variables,
chatSessionId: params.chatSessionId,
wait: params.wait,
}, vaultAgentInvokeOptions("snippets.run", params));
},
createSnippet: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("snippets.create", params);
},
updateSnippet: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("snippets.update", params);
},
deleteSnippet: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("snippets.delete", params);
},
listScripts: async () => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.list", {});
},
getScript: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.get", { scriptId: params.scriptId, snippetId: params.scriptId });
},
createScript: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.create", params);
},
updateScript: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.update", params);
},
deleteScript: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.delete", params);
},
runScript: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.run", params, vaultAgentInvokeOptions("scripts.run", params));
},
getScriptReference: async () => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.reference", {});
},
listScriptRuns: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.runs.list", params);
},
stopScriptRun: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.run.stop", params);
},
pauseScriptRun: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.run.pause", params);
},
resumeScriptRun: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.run.resume", params);
},
setScriptTargets: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("scripts.targets.set", params);
},
listHostConnectScripts: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("host.connectScripts.list", params);
},
setHostConnectScripts: async (params = {}) => {
const bridgeErr = requireBridge();
if (bridgeErr) return bridgeErr;
return invokeVaultAgent("host.connectScripts.set", params);
},
};
}
module.exports = {
createVaultService,
};

View File

@@ -0,0 +1,83 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createVaultService } = require("./vaultService.cjs");
test("vault service delegates host notes read to vault agent bridge", async () => {
let invokedOp = null;
const service = createVaultService({
invokeVaultAgent: async (op, params) => {
invokedOp = op;
return { ok: true, hostId: params.hostId, notes: "hello" };
},
});
const result = await service.getHostNotes({ hostId: "host-1" });
assert.equal(invokedOp, "host.notes.get");
assert.equal(result.ok, true);
assert.equal(result.notes, "hello");
});
test("vault service returns bridge unavailable when renderer bridge missing", async () => {
const service = createVaultService({});
const result = await service.listSnippets();
assert.equal(result.ok, false);
assert.match(result.error, /unavailable/i);
});
test("vault service delegates host open to vault agent bridge", async () => {
let invokedOp = null;
let invokedParams = null;
const service = createVaultService({
invokeVaultAgent: async (op, params) => {
invokedOp = op;
invokedParams = params;
return { ok: true, sessionId: "sess-1", hostId: params.hostId, status: "connecting" };
},
});
const result = await service.openHost({ hostId: "host-1", chatSessionId: "chat-1" });
assert.equal(invokedOp, "host.open");
assert.equal(invokedParams.hostId, "host-1");
assert.equal(invokedParams.chatSessionId, "chat-1");
assert.equal(result.ok, true);
assert.equal(result.sessionId, "sess-1");
});
test("vault service delegates host update and delete to vault agent bridge", async () => {
const calls = [];
const service = createVaultService({
invokeVaultAgent: async (op, params) => {
calls.push({ op, params });
return { ok: true, hostId: params.hostId };
},
});
await service.updateHost({ hostId: "host-1", label: "new" });
await service.deleteHost({ hostId: "host-1", ignored: "value" });
assert.equal(calls[0].op, "host.update");
assert.equal(calls[0].params.label, "new");
assert.equal(calls[1].op, "host.delete");
assert.deepEqual(calls[1].params, { hostId: "host-1" });
});
test("vault service delegates identities, groups, proxies, and note deletion", async () => {
const calls = [];
const service = createVaultService({
invokeVaultAgent: async (op, params) => {
calls.push({ op, params });
return { ok: true };
},
});
await service.listIdentities();
await service.listProxyProfiles();
await service.listGroups();
await service.createGroup({ path: "prod" });
await service.updateGroup({ path: "prod", defaults: "{}" });
await service.deleteGroup({ path: "prod" });
await service.deleteNote({ noteId: "note-1" });
assert.deepEqual(calls.map((call) => call.op), [
"identity.list", "proxyProfile.list", "group.list", "group.create", "group.update", "group.delete", "note.delete",
]);
});

View File

@@ -0,0 +1,46 @@
"use strict";
/**
* Shared capability layer types (JSDoc only).
*
* @typedef {import('./constants.cjs').CapabilitySurface} CapabilitySurface
* @typedef {import('./constants.cjs').CapabilityStatus} CapabilityStatus
* @typedef {import('./constants.cjs').PermissionMode} PermissionMode
* @typedef {import('./constants.cjs').AgentKind} AgentKind
*
* @typedef {Object} CapabilitySurfaceBinding
* @property {string} [rpcMethod]
* @property {string} [mcpTool]
* @property {string} [toolName]
* @property {string[]} [command]
* @property {boolean} [confirmInConfirmMode]
*
* @typedef {Object} CapabilityPolicy
* @property {boolean} write
* @property {boolean} sensitiveRead
* @property {boolean} longRunning
* @property {boolean} requiresChatSession
* @property {boolean} bypassesObserverBlock
* @property {boolean} bypassesApproval
* @property {boolean} bypassesChatCancel
*
* @typedef {Object} CapabilityDefinition
* @property {string} id
* @property {string} domain
* @property {CapabilityStatus} status
* @property {string} description
* @property {CapabilityPolicy} policy
* @property {Partial<Record<CapabilitySurface, CapabilitySurfaceBinding>>} surfaces
* @property {AgentKind[]} [agentKinds] Explicit agent placement; inferred when omitted (see resolveAgentKinds).
*
* @typedef {Object} RpcPermissionContext
* @property {boolean} [chatSessionCancelled]
*
* @typedef {Object} RpcPermissionDecision
* @property {boolean} allowed
* @property {boolean} requiresApproval
* @property {string} [error]
* @property {CapabilityDefinition} [capability]
*/
module.exports = {};