diff --git a/.changeset/mcp-opt-out.md b/.changeset/mcp-opt-out.md new file mode 100644 index 000000000..719b6fb25 --- /dev/null +++ b/.changeset/mcp-opt-out.md @@ -0,0 +1,9 @@ +--- +"@internal/server": minor +"@internal/node": patch +"@internal/docs": patch +--- + +The subshell MCP is now switchable. Two new controls, both on by default, stop Subshell from wiring it into panes: a **Subshell MCP** card in your Preferences controls your own account, and one on each node's overview page controls that machine. Either one off wins, and the node's refusal is enforced on the machine itself, so a harness that registered the MCP command directly stops with a clear notice rather than serving the tools. + +Turning a switch off changes new launches and restarts; running panes keep their live MCP until you restart them. A pane launched with the switch off also loses the reach the tools carried: its credential is minted reporting-only, so it can say when its turn ended but cannot see or touch any other subshell. Its attention bell, exit status, and session reporting still work. diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md index a61d1d814..c6e12995f 100644 --- a/.claude/rules/security-context.md +++ b/.claude/rules/security-context.md @@ -18,7 +18,7 @@ Four credential kinds; `apps/server/api/src/api/auth-guard.ts` is the only place - **Session cookie**: HttpOnly, `SameSite=Lax` always; `Secure` only when `APP_BASE_URL` is https (the marking follows the base URL, not `NODE_ENV`). The only actor admin surfaces accept, and the only one that mints an **unbound** WS attach token. - **System key** (`Bearer subshell_…`): new keys carry versioned explicit grants, checked by each route's `systemPermission`; unannotated guarded routes deny scoped keys. Legacy keys retain their existing non-node access. Derived pane credentials inherit the creator's ceiling, and restart cannot broaden it. Terminal token minting requires `subshells:execute` and binds the ticket to one accessible pane. Machine credentials never manage the instance (`requireAdmin` 403s every non-cookie actor). -- **Subshell token**: 7-day TTL, self-extending while its MCP child runs; revoked **instantly** on terminate/delete. The permission map is a scope gate, not a row fence (§4). Raw REST resolves a pane's token as its **OWNER** with boost and shares off, so a prompt-injected pane can act on the owner's OTHER subshells - the accepted sibling-action posture (§4, §11). +- **Subshell token**: 7-day TTL, self-extending while its MCP child runs AND while its `subshell report` hooks fire (every report but `exit` rides an extend in parallel, so a hook-live pane never reaches the wall even with no child); revoked **instantly** on terminate/delete. The permission map is a scope gate, not a row fence (§4). With the per-account or per-node MCP switch off (both default ON; off wins), a new pane's key is minted `{ report: ["write"] }` instead: hook reporting and self-extension survive, the sibling surface and channel/prompt/transfer scopes do not; opencode/pi disabled panes additionally load no roster bridge, since theirs rides the registration (§4, §6). Raw REST resolves a pane's token as its **OWNER** with boost and shares off, so a prompt-injected pane can act on the owner's OTHER subshells - the accepted sibling-action posture (§4, §11). - **Node key**: `/ws/node` and the encrypted discovery envelope only; **no plaintext bearer access on REST** (permanent guard rejection, §5.5); refused while its owner's account is disabled (`4403`); blast radius = impersonating that one node. Kept posture, in short: the api-key row is lifecycle truth (a key is a subshell principal only when the row, written only by `issueSubshellToken`, matches; self-service api-key endpoints are blocked at the mount); the first registered user becomes admin and the email gate is the **E-mail provider row's** `registration_enabled` (NULL = open only while the instance has no users at all; every OIDC provider answers its own, NULL reads closed); adding a provider is the same trust decision class as installing a plugin, and with `requireLocalEmailVerified: false` the provider's **verified email claim IS the link defense** (§5's inversion); passkeys mint the same session cookie and work only on the configured `APP_BASE_URL` host (rpID); break-glass is a destructive credential rewrite, not a bypass; account disable is one transaction that also kills that person's bearer tokens, live WebSockets, and every node they own; the deliberately public API reads and other conditional-authentication surfaces are inventoried in §3 and `api-surface-auth.test.ts`; WS attach is a 30 s single-use token, scoped tokens bind to one pane and redemption consumes before the bind check (§8); and login backoff (per email, `2^n` s capped at 30) is the only rate limit anywhere. diff --git a/apps/docs/content/docs/mcp/configure.mdx b/apps/docs/content/docs/mcp/configure.mdx index 4ee27e8ea..11172e13e 100644 --- a/apps/docs/content/docs/mcp/configure.mdx +++ b/apps/docs/content/docs/mcp/configure.mdx @@ -17,6 +17,12 @@ Claude Code, Codex, OpenCode, and Oh My Pi receive per-launch MCP configuration. The compiled server and node binaries both include an `mcp` subcommand. The host resolves which executable exists on the machine where the pane runs. +## Turning the wiring off + +Two switches stop Subshell from wiring the MCP into panes, and both default to on. In the server dashboard's sidebar bottom bar, choose **Account** → **Preferences** and find the **Subshell MCP** card to control it for your own account. The node's owner controls the machine-wide switch from that node's **Overview** page, under **Subshell MCP**. Either one off means off: a node whose owner refuses the MCP gets no wiring for any account, and an account that opts out gets none wherever its panes run. + +Turning a switch off changes new launches and restarts only; panes already running keep their live MCP until you restart them. A pane launched with the switch off still reports its own state, so its attention bell, exit status, and harness session reporting keep working. The coordination tool surface disappears, along with the context its tool list costs the agent every turn. One extra consequence for OpenCode and pi: their child-agent roster rides the same registration, so a switched-off pane of those agents stops reporting its roster too. The `subshell mcp` command that [Manual integration](#manual-integration) registers on a host refuses to run on a switched-off machine, so those harnesses' panes come up without the tools rather than failing. + ## Manual integration Grok Build, Hermes, and pi require one-time host setup. In the server dashboard's sidebar, choose **Settings** → **Presets** → **New preset** → **New agent preset** and select the agent. Find **Cross-subshell comms** near the bottom of the form for the registration commands. Run those commands on the machine where that agent will execute. See [Grok Build](/agents/grok-build), [Hermes Agent](/agents/hermes) and [pi](/agents/pi). diff --git a/apps/docs/content/docs/mcp/security.mdx b/apps/docs/content/docs/mcp/security.mdx index 1d1ef52b6..562e32628 100644 --- a/apps/docs/content/docs/mcp/security.mdx +++ b/apps/docs/content/docs/mcp/security.mdx @@ -23,6 +23,19 @@ A pane token allows operations on its owner's sessions and prompt library withou The node list is owner-scoped for pane tokens. A human's token doesn't automatically receive the server-host node just because the human can launch there through the browser. +## Turning coordination off + +The account and node switches under [MCP configuration](/mcp/configure) both +default to on. With either one off, a new pane gets no MCP wiring, and its +credential is minted with reporting rights only: the pane can still report its +own state and extend its own token, but it cannot list, read, launch, or +terminate any other subshell, post to channels, or touch the prompt library. +That removes the sibling surface this page warns about rather than leaving the +reach in the credential with no tool that names it. A node's refusal is also +enforced on the machine itself, so a harness registered on that host years ago +can't serve the tools anyway. A disabled pane still holds a credential: it +belongs to its owner, and a local OS account on that machine can still read it. + > [!warning] Trusted agents > Agents run with their execution account's filesystem access. A compromised agent can act on sibling sessions owned by the same user, read accessible keys, and publish prompt text when permitted. diff --git a/apps/docs/content/docs/mcp/troubleshooting.mdx b/apps/docs/content/docs/mcp/troubleshooting.mdx index 35a5a179a..7fb126d4e 100644 --- a/apps/docs/content/docs/mcp/troubleshooting.mdx +++ b/apps/docs/content/docs/mcp/troubleshooting.mdx @@ -11,6 +11,18 @@ Confirm the agent is running inside a Subshell-launched pane. Check [MCP configu Run `subshell-server status` on the server host to see which MCP executable it uses. If you've configured MCP manually, check that the executable exists on the machine where the agent runs. +## A child exits with a disabled notice + +When the MCP is switched off, a harness that registered `subshell mcp` on its +host still starts the child, and the child stops itself with a notice instead +of serving tools. `subshell mcp: the subshell MCP is turned off on this node` +names the node's own switch; `the subshell MCP is switched off for this pane` +means the account preference or the node's setting refuses it, and the pane's +credential cannot register a channel identity. Either notice means the switch +worked as intended, not that the registration is broken. A person with access +to the account or the node's settings can turn the switch back on; new +launches and restarts pick that up. See [MCP configuration](/mcp/configure#turning-the-wiring-off). + ## Missing identity environment `subshell mcp: SUBSHELL_API_KEY is not set` and `subshell mcp: SUBSHELL_ID is not set` mean the process lacks pane identity. Don't invent credentials or reuse another pane's environment. diff --git a/apps/docs/content/docs/nodes/manage.mdx b/apps/docs/content/docs/nodes/manage.mdx index ff402ee4c..1809c0c4e 100644 --- a/apps/docs/content/docs/nodes/manage.mdx +++ b/apps/docs/content/docs/nodes/manage.mdx @@ -65,6 +65,12 @@ The server host's node is named **Server** by default. Its internal ID remains ` Node owners manage the machine's settings. A share may allow launching there without granting ownership of service, directory, or enrollment controls. +## Subshell MCP + +In the server dashboard, open **Nodes**, select your node, and find the **Subshell MCP** card on its overview. **Allow the subshell MCP** controls whether Subshell wires its coordination server into panes launched on this machine. It's on by default, and only the node's owner can change it; an administrator manages it for the built-in node on the server host. + +Turning it off means new and restarted panes here get no subshell MCP and no sibling-coordination tools, whatever their owners prefer in their own [MCP configuration](/mcp/configure#turning-the-wiring-off). The refusal is enforced on this machine, so it also stops the MCP command for harnesses that registered it on the host directly. Panes already running keep their live MCP until they restart. + ## Outgoing SSH In the server dashboard, open **Nodes**, select your node, and find the **SSH** card on its overview. **Allow SSH** lets Subshell make outgoing SSH connections from this node and use its SSH agent to supply keys to other nodes. It doesn’t enable incoming SSH access to the node. diff --git a/apps/docs/content/docs/reference/node-protocol.mdx b/apps/docs/content/docs/reference/node-protocol.mdx index 5c32186b8..6726349ed 100644 --- a/apps/docs/content/docs/reference/node-protocol.mdx +++ b/apps/docs/content/docs/reference/node-protocol.mdx @@ -74,7 +74,7 @@ Registration is a pairing operation, not normal execution over plaintext. The no ## Readiness and machine reports -The `ready` event identifies the node's release version, protocol version, operating system, architecture, hostname, data directory, and capabilities. It can also report its home directory, invocation path, runtime and service details, maintenance state, and system specs (OS release, CPU model, logical CPU count, total memory, and report time). System specs are optional so older nodes can still connect; the server retains the last report for offline discovery. +The `ready` event identifies the node's release version, protocol version, operating system, architecture, hostname, data directory, and capabilities. It can also report its home directory, invocation path, runtime and service details, maintenance state, the machine's ssh, preview, and MCP-use mirror states, and system specs (OS release, CPU model, logical CPU count, total memory, and report time). System specs are optional so older nodes can still connect; the server retains the last report for offline discovery. These reports let the server plan launches for the execution machine rather than guessing its paths from the server host. They can disclose machine names, local paths, and selected environment information to the trusted control plane. @@ -126,7 +126,7 @@ Most commands execute in arrival order on a shared queue, so a long archive oper | Files and directories | `stat_dir`, `fs_ls`, `path_exists`, `write_file`, `remove_paths`, `set_allowed_dirs`. | | Archive transfer | `archive_create`, `file_read`, `transfer_write`, `archive_extract`, `tree_manifest`. | | Machine information | `inventory`, `detect`, `ping`. | -| Node management | `service`, `agent_log_read`, `set_server_url`, `set_log_level`, `set_maintenance`, `set_ssh_enabled`, `update`. | +| Node management | `service`, `agent_log_read`, `set_server_url`, `set_log_level`, `set_maintenance`, `set_ssh_enabled`, `set_mcp_enabled`, `update`. | | SSH discovery and trust | `ssh_discover_aliases`, `ssh_resolve_config`, `ssh_register_identity`, `ssh_agent_identities`, `ssh_host_key`, `ssh_machine_pin_repair`. | | SSH relay and remote setup | `ssh_relay_open`, `ssh_relay_close`, `ssh_exec_stdin` (setup scripts on stdin), legacy `ssh_exec`, `ssh_exec_status`. | @@ -173,7 +173,7 @@ The server applies two distinct gates to readiness: 1. The node release must meet the server's minimum supported release version. 2. Its protocol version must match the server's protocol exactly. -The implemented protocol is **26**. Its number is independent of the package release version. Additive wire changes also require a protocol bump and coordinated rollout; there is no per-feature compatibility window. +The implemented protocol is **27**. Its number is independent of the package release version. Additive wire changes also require a protocol bump and coordinated rollout; there is no per-feature compatibility window. A refused node can remain connected in a **held** state for updates while staying unavailable for launches and ordinary management commands. Only `update` can use that held connection. Older nodes on the legacy path receive that update command in its frozen plaintext recovery format; they aren't admitted for normal plaintext operation. Invalid credentials and disabled owners are authentication failures, not update holds. diff --git a/apps/node/agent/AGENTS.md b/apps/node/agent/AGENTS.md index 9da967240..36fcb0e4e 100644 --- a/apps/node/agent/AGENTS.md +++ b/apps/node/agent/AGENTS.md @@ -201,6 +201,25 @@ owns them. The reporting ORDER is load-bearing: `maybeReportMaintenance` runs inside `reportDeath` BEFORE the `exit` frame. **Working on maintenance: read `apps/node/agent/docs/maintenance.md` first.** +## MCP-use gate (`src/mcp-enabled.ts`, protocol 27) + +One mirror, `/mcp-enabled.json` = `{ on, changedAt }`, written 0600 +by atomic tmp+rename. The plane's `set_mcp_enabled` command is its ONLY +writer: the daemon stores the pushed bytes verbatim and never re-stamps, and +there is no machine-side verb for it. Its polarity INVERTS the maintenance +mirror beside it: an absent file reads ALLOW, because MCP wiring predates its +own switch and a node nobody has pushed stays exactly as it was. The read +answers four ways and `mcpAllowed` maps them: absent/on allow, off refuses, +unreadable refuses-and-announces (a corrupt file must not quietly re-arm +wiring; the plane repairs by pushing its row). Three enforcement points: +`subshell mcp` refuses the command itself; a launch frame that still carries +`mcp` under a refusing mirror drops the wiring material and launches the pane +without it, naming the drop in the node log; and `ready` states the mirror at +connect, with the heartbeat-belt `mcp_enabled` event catching one that moved +underneath the connection (absence stays quiet since silence agrees; an `off` +file always announces). Reach is new launches: a pane already running keeps +its live MCP child until it restarts. + ## Pane-log retention (`src/pane-log-retention.ts`, 2026-09-23) Pane logs (`/subshells/.log`, the verbatim typed transcript) age diff --git a/apps/node/agent/package.json b/apps/node/agent/package.json index f02f5f89b..f9f798f26 100644 --- a/apps/node/agent/package.json +++ b/apps/node/agent/package.json @@ -1,7 +1,7 @@ { "name": "@internal/node", "description": "subshell — node daemon: enrolls with the control plane and executes signed commands", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "type": "module", "private": true, diff --git a/apps/node/agent/src/__tests__/cli-mcp.test.ts b/apps/node/agent/src/__tests__/cli-mcp.test.ts index a89d22ee4..0c5595842 100644 --- a/apps/node/agent/src/__tests__/cli-mcp.test.ts +++ b/apps/node/agent/src/__tests__/cli-mcp.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { parseArgs, run } from "../cli.js"; +import { writeMcpEnabled } from "../mcp-enabled.js"; /** * The `subshell mcp` CLI surface (Task 13). ONLY the reject paths are run @@ -64,4 +68,25 @@ describe("subshell mcp (CLI wiring)", () => { expect(res.code).toBe(2); expect(res.err).toInclude("subshell mcp"); }); + + test("the mirror says off → exit 2 naming the switch, before the server starts", async () => { + // The whole point of checking the mirror HERE (not only in the launch + // executor): a harness that registered `subshell mcp` on the machine itself + // months ago has no per-launch registration for the plane to withhold, so + // this refusal is what makes "off" true for it. It must land before stdio + // is attached, which is why a complete pane env is safe to run here. + const dataDir = mkdtempSync(join(tmpdir(), "subshell-mcp-off-")); + writeMcpEnabled(dataDir, { on: false, changedAt: "2026-11-01T00:00:00.000Z" }); + process.env.SUBSHELL_API_KEY = "subshell_test_key"; + process.env.SUBSHELL_ID = "off-refuse-1"; + process.env.SUBSHELL_DATA_DIR = dataDir; + try { + const res = await run(["mcp"]); + expect(res.code).toBe(2); + expect(msgLine(res.err)).toInclude("turned off on this node"); + expect(res.out).toBe(""); // stdio never touched + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/node/agent/src/__tests__/commands-launch.test.ts b/apps/node/agent/src/__tests__/commands-launch.test.ts index 2bc2a6a27..9710c5656 100644 --- a/apps/node/agent/src/__tests__/commands-launch.test.ts +++ b/apps/node/agent/src/__tests__/commands-launch.test.ts @@ -16,7 +16,7 @@ import { HARNESS_BINARY_PLACEHOLDER, type NodeCommandBody, type NodeEvent } from import { spawnSync } from "bun"; import type { CommandContext, CommandResult } from "../commands/context.js"; import { dispatchCommand } from "../commands/index.js"; -import { execLaunch } from "../commands/launch.js"; +import { execLaunch, withoutMcpArgs } from "../commands/launch.js"; import { buildSubshellsReport, EXIT_WATCH_INTERVAL_MS, @@ -27,6 +27,7 @@ import { stopWatcher, } from "../commands/report.js"; import type { NodeConfig } from "../config.js"; +import { writeMcpEnabled } from "../mcp-enabled.js"; import { type SubshellMeta, SubshellMetaStore } from "../subshell-meta.js"; import { captureLogs } from "./helpers/capture-logs.js"; @@ -670,6 +671,145 @@ describe("execLaunch with a server-built argv (inversion §5)", () => { }); }); +/** + * The MCP-use switch enforced ON THE MACHINE. The plane composes no wiring for + * a node whose row says off, but a frame can still arrive carrying it — built + * moments before the flip landed, or from a plane that never consulted the row. + * The node's answer is to drop the wiring material and launch anyway: failing + * the pane would punish the user for a disagreement between two machines, and + * dropping the file, the argv run, and the wiring env IS the whole refusal (the + * dialect is nothing else). An ABSENT mirror must keep wiring (MCP predates its + * own switch) — the case that guards a future "make it fail-closed like ssh" + * edit from silently stripping every node that has not been pushed a value. + */ +describe("execLaunch honours the MCP-use mirror", () => { + const STAMP = "2026-11-01T00:00:00.000Z"; + + it("drops a full dialect (file + argv run + env) when the mirror says off, and still launches", async () => { + const dataDir = await freshDataDir("mcp-off-drop"); + writeMcpEnabled(dataDir, { on: false, changedAt: STAMP }); + const mcpFile = join(dataDir, "mcp", `${S1}.json`); + let paneCmd = ""; + const { ctx, calls } = makeCtx( + dataDir, + { newSubshell: (_s, _i, _c, cmd) => (paneCmd = cmd), pipePane: () => {}, resizeWindow: () => {} }, + [], + ); + const result = await dispatchCommand( + ctx, + launchCmd({ + argv: [HARNESS_BINARY_PLACEHOLDER, "--mcp-config", mcpFile], + mcp: { + path: mcpFile, + fileContent: '{"from":"the control plane"}\n', + files: { "bridge.mjs": "export default {};" }, + args: ["--mcp-config", mcpFile], + env: { OPENCODE_CONFIG: mcpFile }, + }, + }), + ); + expect(result).toEqual({ ok: true }); // the pane launches regardless + stopWatcher(ctx, S1); + // No config file, and the companion dir is not even created. + expect(existsSync(mcpFile)).toBe(false); + expect(existsSync(`${mcpFile}.d`)).toBe(false); + expect(existsSync(join(dataDir, "mcp"))).toBe(false); + // The argv run is gone from the command line, and so is the wiring env. + expect(paneCmd).not.toInclude("--mcp-config"); + expect(paneCmd).not.toInclude("OPENCODE_CONFIG"); + // The credential env still rides: it is the harness hooks' too, and the + // pane must still be able to report its own death. + expect(paneCmd).toInclude("SUBSHELL_API_KEY='k'"); + expect(methodsOf(calls)).toEqual(["newSubshell", "pipePane", "resizeWindow"]); + }); + + it("an ABSENT mirror keeps wiring exactly as before (guards the fail-closed mistake)", async () => { + const dataDir = await freshDataDir("mcp-absent-keeps"); + // No writeMcpEnabled: the file simply is not there, which is every node + // that has never been pushed a value. + const mcpFile = join(dataDir, "mcp", `${S1}.json`); + let paneCmd = ""; + const { ctx } = makeCtx( + dataDir, + { newSubshell: (_s, _i, _c, cmd) => (paneCmd = cmd), pipePane: () => {}, resizeWindow: () => {} }, + [], + ); + const result = await dispatchCommand( + ctx, + launchCmd({ + argv: [HARNESS_BINARY_PLACEHOLDER, "--mcp-config", mcpFile], + mcp: { path: mcpFile, fileContent: "{}\n", args: ["--mcp-config", mcpFile], env: { OPENCODE_CONFIG: mcpFile } }, + }), + ); + expect(result).toEqual({ ok: true }); + stopWatcher(ctx, S1); + expect(existsSync(mcpFile)).toBe(true); // wired, exactly as a pre-switch node does + expect(paneCmd).toInclude("--mcp-config"); + expect(paneCmd).toInclude("OPENCODE_CONFIG"); + }); + + it("an env-only dialect (no argv run) loses the env and keeps argv untouched", async () => { + const dataDir = await freshDataDir("mcp-off-env-only"); + writeMcpEnabled(dataDir, { on: false, changedAt: STAMP }); + const mcpFile = join(dataDir, "mcp", `${S1}.json`); + let paneCmd = ""; + const { ctx } = makeCtx( + dataDir, + { newSubshell: (_s, _i, _c, cmd) => (paneCmd = cmd), pipePane: () => {}, resizeWindow: () => {} }, + [], + ); + const result = await dispatchCommand( + ctx, + launchCmd({ + // Some harnesses register through an env var alone: argv has no MCP + // run, so there is nothing to strip from the command line. + argv: [HARNESS_BINARY_PLACEHOLDER, "run"], + mcp: { path: mcpFile, fileContent: "{}\n", args: [], env: { OPENCODE_CONFIG: mcpFile } }, + }), + ); + expect(result).toEqual({ ok: true }); + stopWatcher(ctx, S1); + expect(existsSync(mcpFile)).toBe(false); + expect(paneCmd).not.toInclude("OPENCODE_CONFIG"); + expect(paneCmd).toInclude("run"); // the command line itself is unchanged + }); + + it("a frame with NO mcp member launches byte-identically when off", async () => { + const dataDir = await freshDataDir("mcp-off-no-member"); + writeMcpEnabled(dataDir, { on: false, changedAt: STAMP }); + let paneCmd = ""; + const { ctx, calls } = makeCtx( + dataDir, + { newSubshell: (_s, _i, _c, cmd) => (paneCmd = cmd), pipePane: () => {}, resizeWindow: () => {} }, + [], + ); + const result = await dispatchCommand(ctx, launchCmd()); // the plain default frame + expect(result).toEqual({ ok: true }); + stopWatcher(ctx, S1); + expect(existsSync(join(dataDir, "mcp"))).toBe(false); // nothing was ever there to drop + expect(paneCmd).toInclude("SUBSHELL_API_KEY='k'"); + expect(methodsOf(calls)).toEqual(["newSubshell", "pipePane", "resizeWindow"]); + }); +}); + +describe("withoutMcpArgs (the argv strip)", () => { + const RUN = ["--mcp-config", "/d/mcp/s1.json"]; + it("removes one contiguous run, exactly once", () => { + expect(withoutMcpArgs(["bin", ...RUN, "rest"], RUN)).toEqual(["bin", "rest"]); + }); + it("does NOT eat an equal token that belongs to something else", () => { + // codex's dialect reuses `-c`; value-equality filtering would delete the + // unrelated one too. Sequence matching takes only the run. + const argv = ["bin", "-c", "x=1", "-c", "y=2"]; + expect(withoutMcpArgs(argv, ["-c", "y=2"])).toEqual(["bin", "-c", "x=1"]); + }); + it("returns argv unchanged when the run is absent, or when there are no args", () => { + expect(withoutMcpArgs(["bin", "run"], RUN)).toEqual(["bin", "run"]); + expect(withoutMcpArgs(["bin", "run"], [])).toEqual(["bin", "run"]); + expect(withoutMcpArgs(["bin", "run"], undefined)).toEqual(["bin", "run"]); + }); +}); + /** * Run a launch whose step-(7) subshells-dir mkdir MUST throw: the sabotage * lands between the meta record (which creates the dir) and the attach — diff --git a/apps/node/agent/src/__tests__/mcp-enabled.test.ts b/apps/node/agent/src/__tests__/mcp-enabled.test.ts new file mode 100644 index 000000000..9b96924a9 --- /dev/null +++ b/apps/node/agent/src/__tests__/mcp-enabled.test.ts @@ -0,0 +1,287 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { NodeCommandBody, NodeEvent } from "@internal/subshell-protocol"; +import type { CommandContext } from "../commands/context.js"; +import { dispatchCommand } from "../commands/index.js"; +import { maybeReportMcpEnabled, seedMcpEnabledMemo } from "../commands/report.js"; +import type { NodeConfig } from "../config.js"; +import { mcpAllowed, mcpEnabledPath, readMcpEnabled, reportableMcpEnabled, writeMcpEnabled } from "../mcp-enabled.js"; +import { SubshellMetaStore } from "../subshell-meta.js"; +import { captureLogs } from "./helpers/capture-logs.js"; + +/** + * The MCP-use mirror, whose family is `ssh-enabled.ts` and `preview/flags.ts` + * and whose DEFAULT is their inversion: an absent mirror ALLOWS, because MCP + * wiring predates the switch, and only a parsed `on: false` (or a present file + * that cannot be trusted) refuses. These cells exist to make each of the four + * answers, and the silence rules over them, expensive to break by accident: + * a future edit that folds the off-file into "absent" would un-say every + * owner's refusal, and one that folds "absent" into "unreadable" would strip + * cross-agent comms from every node that has never been pushed a value. + */ + +const STAMP = "2026-11-01T10:00:00.000Z"; +const made: string[] = []; + +function freshDataDir(): string { + // realpath'd like the commands harnesses: macOS hides /private behind the + // temp dir, and an assertion about the file path should test the path the + // daemon itself would write. + const dir = realpathSync(mkdtempSync(join(tmpdir(), "subshell-mcp-mirror-"))); + made.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** The mirror's own mtime, as the unreadable branch stamps it. */ +function mtimeOf(dataDir: string): string { + return statSync(mcpEnabledPath(dataDir)).mtime.toISOString(); +} + +/** A context whose tmux THROWS on anything unstubbed (the ssh harness). */ +function makeCtx(dataDir: string, events: NodeEvent[] = []): CommandContext { + const config: NodeConfig = { + serverUrl: "http://localhost:1", + nodeId: "node-1", + nodeKey: "k", + controlPublicKey: "{}", + dataDir, + name: "test-node", + }; + const tmux = new Proxy( + {}, + { + get: () => () => { + throw new Error("tmux must not be reached"); + }, + }, + ); + return { + config, + tmux: tmux as unknown as CommandContext["tmux"], + meta: new SubshellMetaStore(dataDir), + nowMs: () => 1_700_000_000_000, + ws: { send: (ev) => events.push(ev) }, + watchers: new Map(), + tails: new Map(), + uploads: new Map(), + runtime: null, + requestRestart: () => {}, + }; +} + +describe("persistence", () => { + it("an empty dir reads `absent`, and absence is the ALLOWED answer", () => { + const read = readMcpEnabled(freshDataDir()); + expect(read).toEqual({ kind: "absent" }); + expect(mcpAllowed(read)).toBe(true); + }); + + it("round-trips the exact stamp it was given", () => { + const dataDir = freshDataDir(); + expect(writeMcpEnabled(dataDir, { on: true, changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP }); + expect(readMcpEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); + }); + + it("stores the whole wire verbatim: `ready.mcpEnabled` is buildable from what was written", () => { + const dataDir = freshDataDir(); + writeMcpEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(JSON.parse(readFileSync(mcpEnabledPath(dataDir), "utf8"))).toEqual({ on: true, changedAt: STAMP }); + }); + + it("keeps `on: false` a VISIBLE kind, not a fold into absent", () => { + // The preview twin folds its off-file into "absent" because there the two + // answers coincide (both refuse). Here they do not: absence allows and the + // off-file refuses, and the refusal must be reportable WITH its stamp, so + // the kind stands on its own. + const dataDir = freshDataDir(); + const state = { on: false, changedAt: "2026-11-01T11:00:00.000Z" }; + writeMcpEnabled(dataDir, state); + expect(JSON.parse(readFileSync(mcpEnabledPath(dataDir), "utf8"))).toEqual(state); + expect(readMcpEnabled(dataDir)).toEqual({ kind: "off", changedAt: state.changedAt }); + }); + + it("writes the file 0600, and creates a missing data dir rather than throwing", () => { + const dataDir = freshDataDir(); + writeMcpEnabled(dataDir, { on: true, changedAt: STAMP }); + expect(statSync(mcpEnabledPath(dataDir)).mode & 0o777).toBe(0o600); + const nested = join(freshDataDir(), "nested", "data"); + writeMcpEnabled(nested, { on: true, changedAt: STAMP }); + expect(statSync(mcpEnabledPath(nested)).mode & 0o777).toBe(0o600); + }); + + it("leaves no temp file behind, and the file it leaves is whole", () => { + // Atomic because a half-written file parses as corrupt, and corrupt means + // REFUSED here, mid-launch. + const dataDir = freshDataDir(); + writeMcpEnabled(dataDir, { on: true, changedAt: STAMP }); + writeMcpEnabled(dataDir, { on: false, changedAt: "2026-11-01T12:00:00.000Z" }); + expect(readdirSync(dataDir)).toEqual(["mcp-enabled.json"]); + }); +}); + +describe("a present-but-broken file refuses", () => { + it("answers `unreadable` for corrupt JSON, carries the file's mtime, and says so once", () => { + const dataDir = freshDataDir(); + writeFileSync(mcpEnabledPath(dataDir), "{not json"); + const cap = captureLogs(); + try { + expect(readMcpEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) }); + } finally { + cap.restore(); + } + expect(cap.lines.filter((l) => l.includes("mcp-enabled"))).toHaveLength(1); + }); + + it("answers `unreadable` for a well-formed file missing a field, and for a non-boolean on", () => { + // Same classification the ssh and preview mirrors give garbage: both + // halves or nothing. `mcpAllowed` then refuses, exactly like a hand- + // deleted-while-reading ENOENT never does. + const dataDir = freshDataDir(); + writeFileSync(mcpEnabledPath(dataDir), JSON.stringify({ on: true })); + const cap = captureLogs(); + try { + expect(readMcpEnabled(dataDir)).toEqual({ kind: "unreadable", changedAt: mtimeOf(dataDir) }); + writeFileSync(mcpEnabledPath(dataDir), JSON.stringify({ on: "yes", changedAt: STAMP })); + expect(readMcpEnabled(dataDir).kind).toBe("unreadable"); + } finally { + cap.restore(); + } + }); +}); + +describe("the gate read", () => { + it("allows absent and on; refuses off and unreadable", () => { + // The whole inversion of the twin gates, in one table: what a preview + // mirror answers OFF here is either ALLOWED (absent) or the owner's own + // deliberate NO (off). + expect(mcpAllowed({ kind: "absent" })).toBe(true); + expect(mcpAllowed({ kind: "on", changedAt: STAMP })).toBe(true); + expect(mcpAllowed({ kind: "off", changedAt: STAMP })).toBe(false); + expect(mcpAllowed({ kind: "unreadable", changedAt: STAMP })).toBe(false); + }); +}); + +describe("what a read is worth announcing", () => { + it("announces `on` verbatim, and `off` too: the refusal is the news", () => { + expect(reportableMcpEnabled({ kind: "on", changedAt: STAMP })).toEqual({ on: true, changedAt: STAMP }); + expect(reportableMcpEnabled({ kind: "off", changedAt: STAMP })).toEqual({ on: false, changedAt: STAMP }); + }); + + it("announces an unreadable mirror as the REFUSAL it produces", () => { + // The machine owes the plane the answer it is actually acting on, and a + // row that says on pushes the clean file that repairs the broken one. + expect(reportableMcpEnabled({ kind: "unreadable", changedAt: STAMP })).toEqual({ on: false, changedAt: STAMP }); + }); + + it("sends nothing for absent, and nothing for an unstamped unreadable", () => { + // Silence reads ALLOWED plane-side, so absence agrees with a default row; + // an unstamped corruption has no timestamp to carry and inventing `now` + // would put a frame on the wire every heartbeat, forever. + expect(reportableMcpEnabled({ kind: "absent" })).toBeUndefined(); + expect(reportableMcpEnabled({ kind: "unreadable" })).toBeUndefined(); + }); +}); + +describe("set_mcp_enabled (the plane's half)", () => { + it("persists the plane's exact bytes and memoizes them, with no echo and no re-stamp", async () => { + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + const cmd = { type: "set_mcp_enabled", on: true, changedAt: STAMP } satisfies NodeCommandBody; + expect(await dispatchCommand(ctx, cmd)).toEqual({ ok: true }); + expect(readMcpEnabled(dataDir)).toEqual({ kind: "on", changedAt: STAMP }); + expect(ctx.lastReportedMcpEnabled).toEqual({ on: true, changedAt: STAMP }); + expect(events).toEqual([]); // the command answers with a result; it announces nothing + }); + + it("an off flip lands verbatim, and the machine says nothing more about it", async () => { + // The push is memoized AS REPORTED: the plane minted this value, so a + // bounce-back event would only re-tell it what it just said. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + await dispatchCommand(ctx, { type: "set_mcp_enabled", on: false, changedAt: "2026-11-01T13:00:00.000Z" }); + expect(readMcpEnabled(dataDir).kind).toBe("off"); + maybeReportMcpEnabled(ctx); + expect(events).toEqual([]); + }); +}); + +describe("the connect seed and the heartbeat belt", () => { + it("seeds the memo from the mirror and hands `ready` the same value", () => { + const dataDir = freshDataDir(); + const ctx = makeCtx(dataDir); + expect(seedMcpEnabledMemo(ctx)).toBeUndefined(); // absent: nothing to state + + writeMcpEnabled(dataDir, { on: false, changedAt: STAMP }); + expect(seedMcpEnabledMemo(ctx)).toEqual({ on: false, changedAt: STAMP }); + expect(ctx.lastReportedMcpEnabled).toEqual({ on: false, changedAt: STAMP }); + }); + + it("reports a mirror that moved outside the plane's own push, once", () => { + // The external-edit case the belt exists to cover: the file is rewritten + // by hand between commands, and the belt re-reads and re-reports it. + const dataDir = freshDataDir(); + const events: NodeEvent[] = []; + const ctx = makeCtx(dataDir, events); + writeMcpEnabled(dataDir, { on: true, changedAt: STAMP }); // as a plane push would, memo unmoved + + maybeReportMcpEnabled(ctx); + maybeReportMcpEnabled(ctx); + + expect(events).toEqual([{ type: "mcp_enabled", on: true, changedAt: STAMP }]); + expect(ctx.lastReportedMcpEnabled).toEqual({ on: true, changedAt: STAMP }); + }); + + it("announces an unreadable mirror once, as the refusal, and repairs nothing itself", () => { + // The repair is the plane's push (its row wins); a node overwriting its + // own mirror would make the belt disagree with the row by design. + const dataDir = freshDataDir(); + writeMcpEnabled(dataDir, { on: true, changedAt: STAMP }); + const ctx = makeCtx(dataDir, []); + seedMcpEnabledMemo(ctx); + writeFileSync(mcpEnabledPath(dataDir), "{corrupt"); + const events: NodeEvent[] = []; + ctx.ws.send = (ev) => events.push(ev); + + const cap = captureLogs(); + try { + maybeReportMcpEnabled(ctx); + maybeReportMcpEnabled(ctx); + } finally { + cap.restore(); + } + expect(events).toEqual([{ type: "mcp_enabled", on: false, changedAt: mtimeOf(dataDir) }]); + }); + + it("goes quiet when the mirror simply is not there", () => { + // Deletion is not an event to announce: silence reads ALLOWED plane-side, + // and chasing absence with a frame would push noise onto every + // never-flipped machine. If the row is actually OFF, the belt's silence + // does not strand it: the next `ready` carries no mirror either, and the + // reconciler's row-off-plus-silence cell (mcp-enabled.test.ts, the plane + // suite) pushes the refusal back down. Same-connection divergence is the + // OS-user's own doing, and that account already owns every pane here. + const dataDir = freshDataDir(); + const ctx = makeCtx(dataDir); + writeMcpEnabled(dataDir, { on: false, changedAt: STAMP }); + seedMcpEnabledMemo(ctx); + rmSync(mcpEnabledPath(dataDir)); + const events: NodeEvent[] = []; + ctx.ws.send = (ev) => events.push(ev); + + const cap = captureLogs(); + try { + maybeReportMcpEnabled(ctx); + } finally { + cap.restore(); + } + expect(events).toEqual([]); + }); +}); diff --git a/apps/node/agent/src/cli.ts b/apps/node/agent/src/cli.ts index f6432beb5..eb4c15525 100644 --- a/apps/node/agent/src/cli.ts +++ b/apps/node/agent/src/cli.ts @@ -18,6 +18,7 @@ import { runMaintenance, } from "./maintenance-cli.js"; import { runNodeMcp } from "./mcp/main.js"; +import { mcpAllowed, readMcpEnabled } from "./mcp-enabled.js"; import { defaultNodeResetDeps, type NodeResetDeps, runNodeReset } from "./reset-cli.js"; import { controlService, @@ -587,11 +588,26 @@ export async function run(argv: string[], deps: RunDeps = {}): Promise; try { - readMcpEnv(); + pane = readMcpEnv(); } catch (err) { return fail(2, err); } + // The owner's switch, honored HERE as well as in the launch executor. + // The launch gate covers a pane the plane wires; this one covers the + // harness that registered `subshell mcp` on the machine itself months + // ago (pi, hermes, Grok Build), whose registration the plane never + // composed and so cannot withhold. The mirror sits in the data dir the + // launch handed down, which on an agent node is this agent's own. + if (!mcpAllowed(readMcpEnabled(pane.dataDir))) { + return fail( + 2, + new Error( + "subshell mcp: the subshell MCP is turned off on this node (or its setting file cannot be read), so this command is refused; the node's owner can allow it again under the node's settings", + ), + ); + } await runNodeMcp(); // REACHABLE, and not the end: `connect()` resolves as soon as the // stdio transport attaches, so this await returns while the server is diff --git a/apps/node/agent/src/commands/context.ts b/apps/node/agent/src/commands/context.ts index 6ce0ddeaa..8b77e5287 100644 --- a/apps/node/agent/src/commands/context.ts +++ b/apps/node/agent/src/commands/context.ts @@ -4,6 +4,7 @@ import type { NodeCommandBody, NodeEvent, NodeMaintenanceWire, + NodeMcpEnabledWire, NodePreviewEnabledWire, NodeRuntimeReport, NodeSshEnabledWire, @@ -241,6 +242,14 @@ export interface CommandContext { * makes the plane push the row value back. */ lastReportedPreviewEnabled?: NodePreviewEnabledWire; + /** + * The last MCP-use mirror value this connection spoke about, the same echo + * memo as {@link lastReportedSshEnabled} and for the same reason: the plane + * is the mirror's only writer, so the belt exists for a file that moved by + * accident and never for the plane's own push. Seeded at every connect from + * the same read that built `ready`. + */ + lastReportedMcpEnabled?: NodeMcpEnabledWire; /** * Whether this connection has already reported a mirror it could not * rewrite (see `restoreMirrorFromMemo`). diff --git a/apps/node/agent/src/commands/index.ts b/apps/node/agent/src/commands/index.ts index 709ff5d4c..122af9994 100644 --- a/apps/node/agent/src/commands/index.ts +++ b/apps/node/agent/src/commands/index.ts @@ -30,6 +30,7 @@ import { execPromptDeliver } from "./prompt.js"; import { execService } from "./service.js"; import { execSetLogLevel } from "./set-log-level.js"; import { execSetMaintenance } from "./set-maintenance.js"; +import { execSetMcpEnabled } from "./set-mcp-enabled.js"; import { execSetPreviewEnabled } from "./set-preview-enabled-exec.js"; import { execSetServerUrl } from "./set-server-url.js"; import { execSetSshEnabled } from "./set-ssh-enabled.js"; @@ -62,7 +63,8 @@ export type { CommandContext, CommandResult, CommandWs, TailHandle } from "./con * commands `archive_create`, `file_read`, `transfer_write`, `archive_extract` * and `tree_manifest` (spec 2026-10-01 §4, protocol 15), and * `set_ssh_enabled` (spec 2026-10-07 §4.3, protocol 16 — the SSH gate's plane- - * to-machine write), the two SSH read arms `ssh_discover_aliases` and + * to-machine write), `set_mcp_enabled` (protocol 27, the MCP gate's plane-to- + * machine write), the two SSH read arms `ssh_discover_aliases` and * `ssh_resolve_config` (spec 2026-10-07 §5, protocol 17 — both refuse on the * local gate mirror before any lookup or spawn), the M2 §4.3 bootstrap * `ssh_register_identity` (spec 2026-10-08 §4.3 - the machine's own signing @@ -178,6 +180,8 @@ export async function dispatchCommand(ctx: CommandContext, cmd: NodeCommandBody) return execSetPreviewEnabled(ctx, cmd); case "set_ssh_enabled": return execSetSshEnabled(ctx, cmd); + case "set_mcp_enabled": + return execSetMcpEnabled(ctx, cmd); case "ssh_discover_aliases": return await execSshDiscoverAliases(ctx); case "ssh_resolve_config": diff --git a/apps/node/agent/src/commands/launch.ts b/apps/node/agent/src/commands/launch.ts index d38637609..a280cea03 100644 --- a/apps/node/agent/src/commands/launch.ts +++ b/apps/node/agent/src/commands/launch.ts @@ -13,6 +13,7 @@ import { HARNESS_BINARY_PLACEHOLDER, NODE_RESULT_MAINTENANCE } from "@internal/s import { DIR_REFUSED_MESSAGE, launchDirAllowed, readAllowedDirs } from "../allowed-dirs.js"; import { log } from "../log.js"; import { readMaintenance, reportableMaintenance } from "../maintenance.js"; +import { mcpAllowed, readMcpEnabled } from "../mcp-enabled.js"; import { pathAllowed, realpathRoots } from "../path-policy.js"; import { PREVIEW_DISPLAY_UNSUPPORTED } from "../preview/display.js"; import { getVirtualDisplayEnabled } from "../preview/flags.js"; @@ -39,6 +40,31 @@ import { gateOpen, SSH_GATE_REFUSAL } from "./ssh-shared.js"; * function, so the command line and its binary rule are always present here. */ +/** + * Take the MCP dialect's own argv out of a command line the plane composed. + * + * Every harness splices its registration in as ONE contiguous run, so the run + * is found by exact sequence match and removed whole. Matching the run rather + * than each token by value is the point: + * codex's dialect is three `-c` overrides interleaved with their values, and + * deleting every argv entry that merely EQUALS one of them would also eat a + * `-c` belonging to something else. + * + * An argv that does not contain the run comes back untouched. That is not a + * failure to strip: it means the run was never in there, and removing + * something else on the strength of a near-miss would damage the command. + * + * @param argv - the plane-built command line, placeholder included or resolved + * @param args - the registration's own argv, from the same frame + * @returns argv without that one run, or argv unchanged + */ +export function withoutMcpArgs(argv: string[], args: string[] | undefined): string[] { + if (!args || args.length === 0) return argv; + const start = argv.findIndex((_, i) => args.every((token, offset) => argv[i + offset] === token)); + if (start === -1) return argv; + return [...argv.slice(0, start), ...argv.slice(start + args.length)]; +} + /** * Start one harness subshell on this node. * @@ -123,24 +149,41 @@ export async function execLaunch(ctx: CommandContext, cmd: Cmd<"launch">): Promi // verbatim (spec §5; the args half is already inside the sent argv), so no // plugin function runs here and the old wire-vs-local drift rule has no // second source to disagree with. + // The owner's MCP switch, asked HERE and not only on the plane. The plane's + // row is the record and it composes no wiring for a node that says no, but a + // frame can still arrive carrying `mcp`: composed moments before the flip, or + // from a plane that never consulted the row at all. Dropping the material is + // the answer, and the pane still launches. Failing the launch would punish + // the user for a disagreement between two machines, and refusing is already + // complete on its own: the dialect is only ever the three things this block + // handles, the file, the argv run that names it, and the wiring env. + const mcp = cmd.mcp; + const mcpRefusedHere = mcp !== undefined && !mcpAllowed(readMcpEnabled(ctx.config.dataDir)); + if (mcp && mcpRefusedHere) { + log( + `mcp-enabled mirror refuses wiring on this node: dropped MCP registration for subshell ${cmd.subshellId}; launching without it`, + ); + argv = withoutMcpArgs(argv, mcp.args); + } + let mcpPaneEnv: Record | undefined; - if (cmd.mcp) { - if (!(await pathAllowed(cmd.mcp.path, await realpathRoots([ctx.config.dataDir])))) { + if (mcp && !mcpRefusedHere) { + if (!(await pathAllowed(mcp.path, await realpathRoots([ctx.config.dataDir])))) { return { ok: false, error: "mcp path refused" }; } - mcpPaneEnv = cmd.mcp.env; // the sent dialect is the whole dialect + mcpPaneEnv = mcp.env; // the sent dialect is the whole dialect // Canonical location is /mcp/.json (subshell-meta mcpPath twin); // mkdir the parent whatever the (already policy-passed) path names. - const dir = dirname(cmd.mcp.path); + const dir = dirname(mcp.path); await mkdir(dir, { recursive: true, mode: 0o700 }); await enforceMode(dir, 0o700); - if (cmd.mcp.files) { - const companionDir = `${cmd.mcp.path}.d`; + if (mcp.files) { + const companionDir = `${mcp.path}.d`; if (!(await pathAllowed(companionDir, await realpathRoots([ctx.config.dataDir])))) return { ok: false, error: "integration path refused" }; await mkdir(companionDir, { recursive: true, mode: 0o700 }); await enforceMode(companionDir, 0o700); - for (const [name, content] of Object.entries(cmd.mcp.files)) { + for (const [name, content] of Object.entries(mcp.files)) { const target = `${companionDir}/${name}`; if ( !/^[a-zA-Z0-9_-]+\.(mjs|py|json)$/.test(name) || @@ -151,8 +194,8 @@ export async function execLaunch(ctx: CommandContext, cmd: Cmd<"launch">): Promi await enforceMode(target, 0o600); } } - await writeFile(cmd.mcp.path, cmd.mcp.fileContent, { mode: 0o600 }); - await enforceMode(cmd.mcp.path, 0o600); // Task-2 re-tightening: umask cannot leak bits here + await writeFile(mcp.path, mcp.fileContent, { mode: 0o600 }); + await enforceMode(mcp.path, 0o600); // Task-2 re-tightening: umask cannot leak bits here } // (3b) SSH pane config (spec 2026-10-07 decision 4). The MCP block above is diff --git a/apps/node/agent/src/commands/report.ts b/apps/node/agent/src/commands/report.ts index 32a746fc7..11a255ee6 100644 --- a/apps/node/agent/src/commands/report.ts +++ b/apps/node/agent/src/commands/report.ts @@ -4,11 +4,13 @@ import { buildSshConfigPath } from "@internal/pane-runtime"; import type { NodeEvent, NodeMaintenanceWire, + NodeMcpEnabledWire, NodePreviewEnabledWire, NodeSshEnabledWire, } from "@internal/subshell-protocol"; import { log } from "../log.js"; import { readMaintenance, reportableMaintenance, writeMaintenance } from "../maintenance.js"; +import { readMcpEnabled, reportableMcpEnabled } from "../mcp-enabled.js"; import { readPreviewEnabled, reportablePreviewEnabled } from "../preview/flags.js"; import { selfInvocation } from "../self-invoke.js"; import { readSshEnabled, reportableSshEnabled } from "../ssh-enabled.js"; @@ -298,6 +300,58 @@ export function seedPreviewEnabledMemo(ctx: CommandContext): NodePreviewEnabledW return ctx.lastReportedPreviewEnabled; } +/** + * Announce one MCP-use mirror state to the plane and remember having done so. + * The preview and SSH twins' rule, cloned: the memo is what keeps the next + * heartbeat tick quiet about a value the plane already holds, and what stops a + * `set_mcp_enabled` the plane just pushed from coming back as an event. + * + * @param ctx - the daemon's command context (owns the socket seam and the memo) + * @param state - the mirror's answer, sent verbatim (never re-stamped here) + */ +export function reportMcpEnabled(ctx: CommandContext, state: NodeMcpEnabledWire): void { + ctx.ws.send({ type: "mcp_enabled", on: state.on, changedAt: state.changedAt }); + ctx.lastReportedMcpEnabled = state; +} + +/** + * Report the MCP mirror IF it has moved since this connection last spoke about + * it: the heartbeat belt, same posture as the ssh and preview twins, with one + * difference in what is quiet. + * + * An absent mirror says nothing here because silence means ALLOW on both ends, + * so a machine with no file agrees with a default row. An `off` file always + * announces: that refusal is what the owner asked for, and the plane needs to + * see it land. What this loop exists to catch is the file changing UNDER the + * connection (corruption, a hand edit), so the plane learns it now and pushes + * its row rather than discovering the disagreement at the next reconnect. + * + * @param ctx - the daemon's command context + */ +export function maybeReportMcpEnabled(ctx: CommandContext): void { + const state = reportableMcpEnabled(readMcpEnabled(ctx.config.dataDir)); + if (!state) return; + const last = ctx.lastReportedMcpEnabled; + if (last?.on === state.on && last.changedAt === state.changedAt) return; + reportMcpEnabled(ctx, state); +} + +/** + * Seed this connection's MCP memo from the mirror on disk, once per connect + * immediately before `ready` (which carries the same value, so the first + * heartbeat must not repeat it) and again on every reconnect, because a new + * socket has told the plane nothing. One read builds both, so the frame and + * the memo cannot disagree. + * + * @param ctx - the daemon's command context + * @returns the value `ready` should carry, or undefined when there is nothing + * to report (no file, which is agreement) + */ +export function seedMcpEnabledMemo(ctx: CommandContext): NodeMcpEnabledWire | undefined { + ctx.lastReportedMcpEnabled = reportableMcpEnabled(readMcpEnabled(ctx.config.dataDir)); + return ctx.lastReportedMcpEnabled; +} + /** Stop the shared tick if one is running (idempotent). */ function stopLoop(ctx: CommandContext): void { if (ctx.watchTick !== undefined) { diff --git a/apps/node/agent/src/commands/set-mcp-enabled.ts b/apps/node/agent/src/commands/set-mcp-enabled.ts new file mode 100644 index 000000000..49ea9c4bf --- /dev/null +++ b/apps/node/agent/src/commands/set-mcp-enabled.ts @@ -0,0 +1,31 @@ +import { writeMcpEnabled } from "../mcp-enabled.js"; +import type { Cmd, CommandContext, CommandResult } from "./context.js"; + +/** + * `set_mcp_enabled`: the plane's half of the node's MCP-use switch. + * + * It writes the file and NOTHING else. No pane is torn down and no child is + * killed: the switch is consulted when a pane is composed and when `subshell + * mcp` starts, so turning it off reaches the next launch or restart, and a + * pane mid-conversation keeps the tools it was started with rather than losing + * them under the agent's feet. + * + * The plane's own `changedAt` is stored verbatim, so both copies end + * byte-identical; re-stamping here would have the next `ready` report a + * disagreement the link latency invented. + * + * A failing write propagates: `dispatchCommand` turns the throw into + * `{ ok: false }`, so the plane learns its push did not land instead of + * recording a mirror that does not exist. + * + * @param ctx - the per-daemon execution context (data dir + the report memo) + * @param cmd - the verified `set_mcp_enabled` command + * @returns `{ ok: true }` once the file is on disk + */ +export function execSetMcpEnabled(ctx: CommandContext, cmd: Cmd<"set_mcp_enabled">): CommandResult { + // Memoized as REPORTED, not merely written: the plane is this value's only + // source, so sending it back on the next tick would be an echo the plane + // then reconciles against itself. + ctx.lastReportedMcpEnabled = writeMcpEnabled(ctx.config.dataDir, { on: cmd.on, changedAt: cmd.changedAt }); + return { ok: true }; +} diff --git a/apps/node/agent/src/daemon.ts b/apps/node/agent/src/daemon.ts index 8312c7f09..e9d7ee62a 100644 --- a/apps/node/agent/src/daemon.ts +++ b/apps/node/agent/src/daemon.ts @@ -9,6 +9,7 @@ import { NODE_PROTOCOL_VERSION, type NodeEvent, type NodeMaintenanceWire, + type NodeMcpEnabledWire, type NodePreviewEnabledWire, type NodeRuntimeReport, type NodeSshEnabledWire, @@ -24,9 +25,11 @@ import { dispatchCommand } from "./commands/index.js"; import { buildSubshellsReport, maybeReportMaintenance, + maybeReportMcpEnabled, maybeReportPreviewEnabled, maybeReportSshEnabled, seedMaintenanceMemo, + seedMcpEnabledMemo, seedPreviewEnabledMemo, seedSshEnabledMemo, } from "./commands/report.js"; @@ -368,6 +371,7 @@ function readyEvent( maintenance: NodeMaintenanceWire | undefined, sshEnabled: NodeSshEnabledWire | undefined, previewEnabled: NodePreviewEnabledWire | undefined, + mcpEnabled: NodeMcpEnabledWire | undefined, ): Extract { return { type: "ready", @@ -382,6 +386,12 @@ function readyEvent( // surface. `uploads` names the terminal-upload relay pipeline; `mcp` // (HAS_MCP, shipped in Task 13) is what lets the control plane register // `subshell mcp` for subshells launched here. + // + // `mcp` says this binary CAN serve the verb. It is NOT the owner's answer + // about whether they may: that is `mcpEnabled` below and the plane's row + // beside it, and it refuses panes while the capability stays advertised. + // Keep the two apart: a capability is a fact about the build, and a flip + // of the switch must not look like an agent that lost the command. capabilities: HAS_MCP ? ["uploads", "mcp"] : ["uploads"], // How to re-enter this binary, not `process.execPath`: under an // interpreter run execPath is `bun`, and `bun mcp` is not a command — @@ -428,6 +438,13 @@ function readyEvent( // so the plane can repair the file instead of holding a silent // disagreement until the next reconnect. ...(previewEnabled ? { previewEnabled } : {}), + // This machine's MCP-use mirror, carried by the SAME rules as the two + // above with the OPPOSITE reading of silence. An absent file is omitted, + // and here absence means WIRES NORMALLY: the switch is younger than the + // behavior, so a machine nobody has pushed yet agrees with the plane's own + // default and has nothing to announce. An `off` file is always carried, + // because that refusal is the news the owner asked for. + ...(mcpEnabled ? { mcpEnabled } : {}), }; } @@ -1335,6 +1352,7 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom seedMaintenanceMemo(ctx), seedSshEnabledMemo(ctx), seedPreviewEnabledMemo(ctx), + seedMcpEnabledMemo(ctx), ), ); // Connect-time `subshells_report` (spec §3.3): re-projects the panes @@ -1421,6 +1439,10 @@ export async function runDaemon(config: NodeConfig, deps: DaemonDeps = {}): Prom // closes no panes here, so this tick is the only way the plane // learns the mirror moved before the next reconnect. maybeReportPreviewEnabled(ctx); + // The MCP-use switch's belt: same quiet shape, same single + // mid-session path. An MCP flip severs no live pane, so this tick is + // the only way the plane learns the mirror moved underneath it. + maybeReportMcpEnabled(ctx); }, heartbeatMs); // Periodic `inventory` push — the "every 5 min" leg of spec §7 // (P3-T8c). The timer's lifecycle mirrors the heartbeat's — armed diff --git a/apps/node/agent/src/mcp-enabled.ts b/apps/node/agent/src/mcp-enabled.ts new file mode 100644 index 000000000..f0314ec65 --- /dev/null +++ b/apps/node/agent/src/mcp-enabled.ts @@ -0,0 +1,156 @@ +import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { type NodeMcpEnabledWire, parseNodeMcpEnabled } from "@internal/subshell-protocol"; +import { logger } from "./log.js"; + +/** + * This node's MIRROR of the MCP-use switch: one word meaning "the subshell MCP + * may run in panes launched on this machine". + * + * It is `ssh-enabled.ts` in mechanics and inverted in default, and the + * inversion is the part to keep hold of. SSH gates a feature that is opt-in, so + * an absent file refuses. MCP wiring predates its own switch: a machine that + * has never been pushed a value has to carry on wiring, so an ABSENT file + * allows and only an explicit `on: false` refuses. Fold that off-file into + * "absent" the way the ssh module folds its own off-file away, and an owner's + * refusal stops meaning anything. + * + * **The plane is the sole writer.** There is no `subshell mcp-enabled` verb: + * this is the same decision the SSH and preview gates reserve for a browser, an + * owner, and an audit row. The mirror's only readers are the launch executor + * (which drops wiring material it was sent) and the `mcp` CLI verb (which + * refuses to start), plus the `ready` report and the heartbeat belt that let + * the plane notice the two copies disagreeing. + * + * Nothing compares stamps across the two ends: the plane pushes its row, and + * this copy converges by obeying. + */ + +/** File name inside the agent data dir. */ +const FILE = "mcp-enabled.json"; + +/** Absolute path of the MCP-use mirror for a data dir. */ +export function mcpEnabledPath(dataDir: string): string { + return join(dataDir, FILE); +} + +/** + * What the mirror says, as the four answers a caller must tell apart. + * + * `absent`, `on` and `off` are the file's three true states, and `off` is a + * kind here in a way it is not in the ssh mirror: the refusal IS the news, so + * it carries its stamp rather than dissolving into silence. `unreadable` is the + * fourth: a file that exists and does not parse. It refuses, like the family, + * because a plane that said no should not be answered with wiring on the + * strength of a broken file, and it reports itself so the plane can repair it. + */ +export type McpEnabledRead = + /** No file at all: wiring continues exactly as it did before the switch existed. */ + | { kind: "absent" } + /** The mirror parsed and says yes, with the plane's stamp verbatim. */ + | { kind: "on"; changedAt: string } + /** The mirror parsed and says no. Every pane launched here loses its MCP wiring. */ + | { kind: "off"; changedAt: string } + /** The file exists but is not a state: REFUSED, reported under the file's mtime. */ + | { + kind: "unreadable"; + /** The file's mtime, ISO, or absent when the stat failed too. */ + changedAt?: string; + }; + +/** + * The gate's answer, spelled once so no call site can drift into its own + * reading of the four kinds: everything refuses except a present mirror that + * has not said no. + */ +export function mcpAllowed(read: McpEnabledRead): boolean { + return read.kind === "absent" || read.kind === "on"; +} + +/** + * Reads the mirror. Total: every failure answers rather than throwing, because + * each caller sits on a path where a throw costs more than an answer, the + * launch executor and the `mcp` verb included. + * + * The parse is the WIRE parser, not a private one: the file holds exactly what + * travels, and a second validator here would be a second opinion about what + * `{ on, changedAt }` means. + */ +export function readMcpEnabled(dataDir: string): McpEnabledRead { + const file = mcpEnabledPath(dataDir); + try { + const state = parseNodeMcpEnabled(JSON.parse(readFileSync(file, "utf8"))); + if (!state) throw new SyntaxError("not an mcp-enabled state"); + return state.on ? { kind: "on", changedAt: state.changedAt } : { kind: "off", changedAt: state.changedAt }; + } catch (err) { + // No ENOENT probe before the read: the read itself answers the existence + // question, and the answer for "not there" is the ALLOWED one, because an + // unpinned node is exactly the pre-switch status quo. A probe would race + // the read it guards and tell the same lie a hand-deleted file already + // can. + if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" }; + logger.withError(err).warn(`mcp-enabled: ${file} unreadable; treating this node as MCP-DISABLED`); + // The mtime, never `now`: this answer refuses, so the plane has to hear + // about it (a row that says on pushes the clean file that repairs it), and + // the mtime is the one timestamp nobody invented. A fresh `now` per read + // would defeat the report memo and put a frame on the wire every heartbeat, + // forever. + const changedAt = mtimeOf(file); + return changedAt ? { kind: "unreadable", changedAt } : { kind: "unreadable" }; + } +} + +/** + * A file's mtime as an ISO stamp. Total: every caller treats it as optional and + * a throw would cost the whole read. + */ +function mtimeOf(file: string): string | undefined { + try { + return statSync(file).mtime.toISOString(); + } catch { + return undefined; + } +} + +/** + * The wire value a read is worth announcing, or `undefined` when there is + * nothing truthful to say. + * + * Silence means ALLOW on the plane's side of this flag, so `absent` has nothing + * to announce: it agrees with a default row, and pushing an echo would only + * mint a file the machine never needed. An `off` mirror always announces, + * because that is the disagreement the owner asked for and the plane must be + * able to see it land. + * + * One known corner: an unreadable file whose stat ALSO fails reports nothing, + * and so the plane hears "allow" from a machine that is refusing. It is the + * only reading that is both truthful and unrepeatable, and it self-heals at + * the next reconnect, when the stat works or the row's push rewrites the file. + */ +export function reportableMcpEnabled(read: McpEnabledRead): NodeMcpEnabledWire | undefined { + if (read.kind === "on") return { on: true, changedAt: read.changedAt }; + if (read.kind === "off") return { on: false, changedAt: read.changedAt }; + if (read.kind === "unreadable" && read.changedAt) return { on: false, changedAt: read.changedAt }; + return undefined; +} + +/** + * Persists the mirror, 0600, via temp + rename. + * + * Atomic for the reason the other mirrors give and one sharper: the launch + * executor reads this while composing a pane, so a half-written file parses as + * corrupt, and a corrupt file refuses wiring rather than racing it. + * + * The `state` is stored VERBATIM. The plane sent it; re-stamping here would + * make the two copies differ by exactly the link delay, and the next `ready` + * would report a disagreement this process invented. + */ +export function writeMcpEnabled(dataDir: string, state: NodeMcpEnabledWire): NodeMcpEnabledWire { + const file = mcpEnabledPath(dataDir); + const body: NodeMcpEnabledWire = { on: state.on, changedAt: state.changedAt }; + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(body, null, 2), { mode: 0o600 }); + renameSync(tmp, file); + return body; +} diff --git a/apps/server/api/AGENTS.md b/apps/server/api/AGENTS.md index 7cec20d48..1cbc1805a 100644 --- a/apps/server/api/AGENTS.md +++ b/apps/server/api/AGENTS.md @@ -175,7 +175,7 @@ summary; it was auto-loaded everywhere and belongs to these routes): - **tmux install** `POST /api/setup/tmux/install`: admin cookie only (stricter than its `/api/setup` neighbours: it runs code); NO operator input; argv from a compiled-in table; **anything `sudo`-prefixed refused 409 before anything runs** (every Linux entry is sudo-prefixed, pinned by test; in practice macOS/Homebrew only); audited `tmux.install`. - **Shared spawn-core bounds** (§11.10, §11.13): the `CHILD_ENV_KEYS` allowlist (no `BETTER_AUTH_SECRET`, no database path) with two wrinkles: every `LC_*` variable passes through, and a caller's/plugin's `extra` may add variables but **never PATH**; stdin is closed **unless the plugin supplies `opts.stdin`** (a sudo prompt goes to the child's own tty, not a server-held pipe); the 64 KiB output cap is **PER STREAM** (stdout and stderr each); a 30 s default deadline capped at ten minutes. - Instance plugin secrets: **designed, NOT built**: `SUBSHELL_SECRETS_KEY` env-only (never a settings row, never the DB), write-only over the API, fails closed when unset, a lost key unrecoverable. Pane-side credentials stay in the node's own environment, which the plane never sees. -- **Harness self-report is exactly four own-key surfaces**: `POST /api/subshells/:id/attention`, `/:id/harness-session`, `/:id/subagents/report`, `/:id/exit` (the route checks `principal === sess:`), plus the token self-extension route. Subagent reports carry roster facts and harness-authored one-line task labels only, never prompts or transcripts; reported names and labels take the `normalizeLabel` NAME path at the boundary (they render for every viewer), while id fields carry length bounds, not normalization. A harness reports about itself, never another subshell; a forged session id merely selects a transcript the pane's own user could already read. +- **Harness self-report is exactly four own-key surfaces**: `POST /api/subshells/:id/attention`, `/:id/harness-session`, `/:id/subagents/report`, `/:id/exit` (the route checks `principal === sess:`), plus the token self-extension route. All four answer to the report-only token an MCP-disabled pane holds; one caveat is honest: for opencode and pi the roster reporter is an extension bundled in the MCP registration, so a disabled pane of those dialects loads no bridge and reports no roster (the door is open, nothing walks through it; shipping the bridge without the wiring would need a reporting-only registration in each plugin). Subagent reports carry roster facts and harness-authored one-line task labels only, never prompts or transcripts; reported names and labels take the `normalizeLabel` NAME path at the boundary (they render for every viewer), while id fields carry length bounds, not normalization. A harness reports about itself, never another subshell; a forged session id merely selects a transcript the pane's own user could already read. - **Beyond its row** (§4): raw REST resolves a pane's token as its OWNER with boost and shares off, so a prompt-injected pane can create, restart and **terminate** the owner's OTHER subshells (the sibling-action surface the "other panes are agents" posture rests on, accepted at the harness's own altitude (§11)). - `/api/files/explore` **403s machine credentials**. `SUBSHELL_FS_ROOT` unset = the host filesystem is browsable by design; the check is lexical AND realpath, unresolvable paths refused; it never confines a remote node. @@ -440,7 +440,7 @@ Four credential kinds; `apps/server/api/src/api/auth-guard.ts` is the only place - **Session cookie**: HttpOnly, `SameSite=Lax` always; `Secure` only when `APP_BASE_URL` is https (the marking follows the base URL, not `NODE_ENV`). The only actor admin surfaces accept, and the only one that mints an **unbound** WS attach token. - **System key** (`Bearer subshell_…`, admin-minted under Server Settings → API keys; plaintext shown once, hash at rest; disable/delete revokes instantly): long-lived, with explicit versioned permissions on new keys; unannotated authenticated routes deny scoped keys. Scoped keys need `subshells:execute` to mint a WS attach token for a **system-owned** pane. Legacy keys retain their previous non-node access and may bind a WS token to any named pane. See `docs/security.md` §2 for inheritance and revocation limits. -- **Subshell token**: 7-day TTL, self-extending while its MCP child runs; revoked **instantly** on terminate/delete; rotated on restart on the same row. Permissions `{channels:[read,write], subshells:[read,write], prompts:[read,write]}`: a scope gate, not a row fence (§4); a map minted before prompts lacks the key and passes only that resource's check (§4). Mints a WS attach token only for its **own** pane. +- **Subshell token**: 7-day TTL, self-extending while its MCP child runs and while its `subshell report` hooks fire (every report but `exit` rides an extend in parallel); revoked **instantly** on terminate/delete; rotated on restart on the same row. Permissions `{channels:[read,write], subshells:[read,write], prompts:[read,write], transfers:[read,write]}`, or `{report:[write]}` for a pane the account or node switch disabled (hooks and self-extension survive; the sibling surface does not): a scope gate, not a row fence (§4); a map minted before prompts lacks the key and passes only that resource's check (§4). Mints a WS attach token only for its **own** pane. - **Node key**: `/ws/node` and the encrypted, pinned-identity discovery envelope at `/api/nodes/route` only; **no plaintext bearer access on REST** (permanent guard rejection, §5.5); refused at the upgrade chain's final tier while its owner's account is disabled (HTTP-403-shaped close `4403` pre-socket, the check landing after `apiKeyId`; `local`/system-owned rows unaffected); blast radius = impersonating that one node. **Forgery defense**: the guard never trusts api-key metadata: a key is a subshell principal only when the subshell row's `api_key_id` (written only by `issueSubshellToken`) equals the presenting key's id; `/api/auth/api-key/*` self-service endpoints are blocked inside the auth passthrough on every method (Elysia's broader GET route can outrank an ALL wildcard); a key whose `referenceId` is not the `system` service user is not a system key; a valid key whose subshell row is gone returns 401 (**the row is lifecycle truth**); raw SQL against `apikey` lives only in `src/modules/auth/apikey-store.ts`, scoped with `json_extract`, never `LIKE`. @@ -578,7 +578,7 @@ The full inventory, moved here from the security summary ("§N" points into Read via `GET /api/audit?limit=50` (admin). Rules: auth-family metadata carries method and ids, NEVER tokens/emails/IPs/UAs; the user-management family names its subject by email by convention (§3). Sign-in rows are SUCCESS only (failures write nothing so the trail can't be spam-DoSed); `auth.sign_out` is one row per session ended; trusted-origin probes write no row. -Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_wide_forwards.update|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_machine_pin.repair (verified peer replacement; ids only)|ssh_identity.repair (admin-verified server disk identity; metadata null)|ssh_host_pin.create|ssh_host_pin.delete (canonical destination and public host fingerprint only)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A ids and relay ref, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|preview_forward.open|close, preview_watch.open|close, preview_display.open|close (lane, bridge, and display lifecycle, each close with the named reason word), preview_enabled.update (the per-node switch), preview_lane.over_cap (the plane's sender-cap lane death, the only lane-level action; the session survives it), preview_watch.viewer_drop (viewer-level drops that close nothing: the attach-cap refusal and the byte-debt detach, with the close-reason word in metadata and never a ref)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, relayRef on a relay launch, bounded forwards summary}` (success only, written after the pane spawned; the canonical `host:port` and the declared rows' ports and hosts, never config or key material; SSH preset CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. +Auth `auth.sign_in` / `auth.sign_out` · users/keys `user.create|role_change|password_reset|disabled_change|approve|reject`, `system-key.create|enable|disable|delete`, `emergency_login.rewrite_credential`, `setup_key.create` (no metadata) / `setup_key.revoke`, `settings.update` · auth providers `auth_provider.create|update|delete` (fields + issuer, never the secret) · subshells `subshell.create|terminate|restart|preset_switch|delete` · transfers `transfer.create|complete` (node ids, paths, byte and entry counts, sync flag, outcome; never contents) · nodes `node.enroll|delete|rename|key_rotate|reregister_key|reregister|allowed_dirs.update|config.update|maintenance.update|ssh_enabled.update|ssh_wide_forwards.update|mcp_enabled.update (the per-node MCP switch; the boolean and nothing else, and an untouched row reads ON, unlike the opt-in gates beside it)|ssh_identity.register (signing-key bootstrap over the link, ids + via only, never the key)|ssh_machine_pin.repair (verified peer replacement; ids only)|ssh_identity.repair (admin-verified server disk identity; metadata null)|ssh_host_pin.create|ssh_host_pin.delete (canonical destination and public host fingerprint only)|ssh_relay.open|ssh_relay.close (plane rows, actorUserId null, named reason word, fingerprint COUNT only)|ssh_upgrade.run (the "Set up Subshell here" act, Task 14: pane/B/A ids and relay ref, canonical destination, exec id, outcome + service-chosen stage word; the minted setup key and ALL installer output appear in NO row and NO refusal copy)|preview_forward.open|close, preview_watch.open|close, preview_display.open|close (lane, bridge, and display lifecycle, each close with the named reason word), preview_enabled.update (the per-node switch), preview_lane.over_cap (the plane's sender-cap lane death, the only lane-level action; the session survives it), preview_watch.viewer_drop (viewer-level drops that close nothing: the attach-cap refusal and the byte-debt detach, with the close-reason word in metadata and never a ref)|logging.update|update|update.unknown|shares_set|local_share_changed|service`, and the SSH launcher's `ssh.launch` `{nodeId, destination, subshellId, relayRef on a relay launch, bounded forwards summary}` (success only, written after the pane spawned; the canonical `host:port` and the declared rows' ports and hosts, never config or key material; SSH preset CRUD unaudited, the prompts precedent) · server `server.config.update|restart|logging.update|autostart.update|update|supervision.request` · plugins/networks/installers `plugin.install|enable|disable|uninstall|unpublish`, `network.configure|install|join|publish|unpublish|leave`, `agent.install`, `tmux.install`. Per-event metadata detail: §10. ## Updates & releases diff --git a/apps/server/api/src/api/__tests__/settings-route.test.ts b/apps/server/api/src/api/__tests__/settings-route.test.ts index 2ec9cc97d..b64ffbe8b 100644 --- a/apps/server/api/src/api/__tests__/settings-route.test.ts +++ b/apps/server/api/src/api/__tests__/settings-route.test.ts @@ -1039,3 +1039,133 @@ describe("/api/settings/terminal-history (per-user, cookie only)", () => { expect(res.status).toBe(403); }); }); + +/** + * GET/PATCH /api/settings/mcp — the per-USER subshell MCP preference. Same + * shape as the terminal-history pair: self-service (ANY cookie user, no + * admin), cookie-actor-only, per-user storage in `user_meta`, no audit row + * (`notify_enabled` and `terminal_replay_lines` write none either). The one + * difference is the default: no stored preference reads TRUE, because MCP + * wiring predates its own switch. + */ +describe("/api/settings/mcp (per-user, cookie only)", () => { + const app = new Elysia().use(errorHandlerPlugin).use(settingsRoutes); + const email = `mcp-pref-${crypto.randomUUID()}@subshell.local`; + const pw = "mcp-pref-pass-1"; + let userId: string; + let cookie: string; + let subshellId: string; + let apiKeyId: string | null = null; + + beforeAll(async () => { + await setupAuthTables(); + userId = await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role: "user", + }); + cookie = await signIn(email, pw); + // A real subshell of THIS user, for the bearer-actor refusal below. + subshellId = crypto.randomUUID(); + await new SubshellsRepository(db).create({ + id: subshellId, + userId, + presetId: "p", + harnessId: "claude-code", + name: "mcp-pref-token-test", + workingDir: "/tmp", + tmuxSocket: null, + }); + }); + + afterAll(async () => { + if (apiKeyId) authDatabase().run("DELETE FROM apikey WHERE id = ?", [apiKeyId]); + await db.deleteFrom("subshells").where("id", "=", subshellId).execute(); + await db.deleteFrom("userMeta").where("userId", "=", userId).execute(); + await deleteUserByEmailOrId(email); + }); + + const get = () => app.fetch(authedRequest("/api/settings/mcp", cookie)); + const patch = (mcpEnabled: unknown) => + app.fetch( + authedRequest("/api/settings/mcp", cookie, { + method: "PATCH", + body: JSON.stringify({ mcpEnabled }), + }), + ); + + /** Audit rows this user's preference writes: the pair's contract is ZERO. */ + async function prefAudits(): Promise { + const rows = await db.selectFrom("auditEvents").select("id").where("actorUserId", "=", userId).execute(); + return rows.length; + } + + it("no stored preference reads as ON (the default-allow upgrade story)", async () => { + const res = await get(); + expect(res.status).toBe(200); + expect(((await res.json()) as { mcpEnabled: boolean }).mcpEnabled).toBe(true); + }); + + it("PATCH persists per-user and GET reads it back; ON restores the default", async () => { + expect(((await (await patch(false)).json()) as { mcpEnabled: boolean }).mcpEnabled).toBe(false); + expect(((await (await get()).json()) as { mcpEnabled: boolean }).mcpEnabled).toBe(false); + expect(await new UserMetaRepository(db).getMcpEnabled(userId)).toBe(false); + + expect(((await (await patch(true)).json()) as { mcpEnabled: boolean }).mcpEnabled).toBe(true); + expect(await new UserMetaRepository(db).getMcpEnabled(userId)).toBe(true); + }); + + it("the preference is per-user: another user reads their own (on)", async () => { + await patch(false); + const otherEmail = `mcp-pref-other-${crypto.randomUUID()}@subshell.local`; + const otherId = await new UsersRepository(db).createUser({ + email: otherEmail, + name: otherEmail, + passwordHash: await hashPassword(pw), + role: "user", + }); + try { + const otherCookie = await signIn(otherEmail, pw); + const res = await app.fetch(authedRequest("/api/settings/mcp", otherCookie)); + expect(((await res.json()) as { mcpEnabled: boolean }).mcpEnabled).toBe(true); + } finally { + await db.deleteFrom("userMeta").where("userId", "=", otherId).execute(); + await deleteUserByEmailOrId(otherEmail); + await patch(true); + } + }); + + it("a non-boolean body -> 400 and the stored value is untouched", async () => { + await patch(false); + for (const bad of ["no", 1, null]) { + const res = await patch(bad); + expect(res.status).toBe(400); + } + expect(((await (await get()).json()) as { mcpEnabled: boolean }).mcpEnabled).toBe(false); + await patch(true); + }); + + it("writes no audit row (the notify_enabled and terminal-replay precedent)", async () => { + const before = await prefAudits(); + await patch(false); + await patch(true); + expect(await prefAudits()).toBe(before); + }); + + it("bearer actors are refused (cookie self-service only)", async () => { + const key = await issueSubshellToken(subshellId, userId); + apiKeyId = (await new SubshellsRepository(db).findById(subshellId))?.apiKeyId ?? null; + expect((await app.fetch(bearerRequest("/api/settings/mcp", key))).status).toBe(403); + const res = await app.fetch( + bearerRequest("/api/settings/mcp", key, { + method: "PATCH", + body: JSON.stringify({ mcpEnabled: false }), + }), + ); + expect(res.status).toBe(403); + // The refusal changed nothing: an MCP child cannot switch its own pane's + // owner's preference off or on. + expect(((await (await get()).json()) as { mcpEnabled: boolean }).mcpEnabled).toBe(true); + }); +}); diff --git a/apps/server/api/src/api/auth-guard.ts b/apps/server/api/src/api/auth-guard.ts index 4b459ece5..7124b4e6d 100644 --- a/apps/server/api/src/api/auth-guard.ts +++ b/apps/server/api/src/api/auth-guard.ts @@ -245,6 +245,31 @@ export function requirePerm( if (!(grants ?? []).includes(action)) throw new ForbiddenError(); } +/** + * Throws 403 unless the caller may report about its OWN pane: the four + * `subshell report` verbs the harness hooks run (`attention`, `session`, + * `exit`, `subagents`), which are what the dashboard's waiting indicator, turn + * notices, exit codes and child rosters are built from. + * + * It exists for one shape of token: a pane launched with the subshell MCP + * switched off is minted `{report:["write"]}` and nothing else, so it can + * describe itself and cannot reach another pane, a channel, a prompt or a + * file. Every other subshell token passes on the `subshells:write` it has + * always carried, and that fallback is not a convenience: self-extension never + * re-mints a permission map, so tokens minted before this scope existed, and + * every token belonging to an MCP-enabled pane, answer through it. + * + * Deliberately NOT a fifth member of {@link requirePerm}'s resource union. That + * union is what sibling-reaching routes check against, and `report` must never + * be spellable there: it is a self-only grant, and this function is the only + * door it opens. + */ +export function requireReportPerm(ctx: PermContext): void { + if (ctx.actor !== "subshell-key") return; + if ((ctx.apiKeyPermissions?.report ?? []).includes("write")) return; + requirePerm(ctx, "subshells", "write"); +} + /** * Cookie-only gate: surfaces whose action is a human-in-the-browser/device * act — enabling browser push, enrolling a phone — reject machine diff --git a/apps/server/api/src/api/nodes/__tests__/set-node-mcp-enabled.route.test.ts b/apps/server/api/src/api/nodes/__tests__/set-node-mcp-enabled.route.test.ts new file mode 100644 index 000000000..7d4462454 --- /dev/null +++ b/apps/server/api/src/api/nodes/__tests__/set-node-mcp-enabled.route.test.ts @@ -0,0 +1,285 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { nodesRoutes } from "@/api/nodes/index.js"; +import { systemKeysRoutes } from "@/api/system-keys.route.js"; +import { db } from "@/db/index.js"; +import { NodeSharesRepository } from "@/db/repositories/node-shares.repository.js"; +import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { ensureLocalNode } from "@/modules/nodes/seed-local.js"; +import { issueSubshellToken } from "@/modules/subshells/tokens.js"; +import { deleteUserByEmailOrId, setupAuthTables, signIn } from "../../../testing/api/auth-tables.js"; + +/** + * `PUT /api/nodes/:id/mcp-enabled`: the per-node subshell MCP switch, ON by + * default (the polarity that inverts the SSH and preview routes beside it: + * MCP wiring predates its own switch, so an untouched row reads ALLOWED). + * + * OWNER-only (`gate.canManage`, with the seeded-`local` admin exception), the + * same gate as SSH and previews: a grantee may restart the agent or read its + * log; deciding whether the MCP may run on this machine is not theirs. + * + * The route performs no write and records no audit itself: `setNodeMcpEnabled` + * is the one change-site (row + audit + push), so this file asserts the ROUTE + * wired the acting human into that row. + */ +describe("node mcp-enabled route", () => { + const pw = "mcp-enabled-1"; + const ownerEmail = `mc-owner-${crypto.randomUUID()}@subshell.local`; + const editorEmail = `mc-editor-${crypto.randomUUID()}@subshell.local`; + const otherEmail = `mc-other-${crypto.randomUUID()}@subshell.local`; + const adminEmail = `mc-admin-${crypto.randomUUID()}@subshell.local`; + let ownerId: string; + let editorId: string; + let ownerCookie: string; + let editorCookie: string; + let otherCookie: string; + let adminCookie: string; + const NODE = `n-mc-${crypto.randomUUID()}`; + const createdKeyIds: string[] = []; + const subshells = new SubshellsRepository(db); + const KEYED = `s-mc-key-${crypto.randomUUID()}`; + + async function mkUser(email: string, role: "user" | "admin" = "user"): Promise { + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword(pw), + role, + }); + } + + /** Every `node.mcp_enabled.update` row this file's node has collected, id-keyed. */ + async function mcpAudit(): Promise> { + const rows = await db + .selectFrom("auditEvents") + .select(["id", "actorUserId", "metadataJson"]) + .where("targetId", "=", NODE) + .where("action", "=", "node.mcp_enabled.update") + .execute(); + return new Map(rows.map((r) => [r.id, { actorUserId: r.actorUserId, metadataJson: r.metadataJson }])); + } + + function put(nodeId: string, cookie: string | undefined, on: boolean, bearer?: string) { + const headers: Record = { "content-type": "application/json" }; + if (cookie) headers.cookie = `better-auth.session_token=${cookie}`; + if (bearer) headers.authorization = `Bearer ${bearer}`; + return nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${nodeId}/mcp-enabled`, { + method: "PUT", + headers, + body: JSON.stringify({ on }), + }), + ); + } + + function authDelete(id: string): Request { + return new Request(`http://localhost:3080/api/system-keys/${id}`, { + method: "DELETE", + headers: { cookie: `better-auth.session_token=${adminCookie}` }, + }); + } + + async function mintKey(permissions: string[]): Promise { + const created = await systemKeysRoutes.fetch( + new Request("http://localhost:3080/api/system-keys", { + method: "POST", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${adminCookie}` }, + body: JSON.stringify({ name: `mc-pin-${crypto.randomUUID().slice(0, 8)}`, permissions }), + }), + ); + expect(created.status).toBe(200); + const { key, id } = (await created.json()) as { key: string; id: string }; + createdKeyIds.push(id); + return key; + } + + beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(ownerEmail); + editorId = await mkUser(editorEmail); + await mkUser(otherEmail); + await mkUser(adminEmail, "admin"); + ownerCookie = await signIn(ownerEmail, pw); + editorCookie = await signIn(editorEmail, pw); + otherCookie = await signIn(otherEmail, pw); + adminCookie = await signIn(adminEmail, pw); + await ensureLocalNode(db); + + const now = new Date().toISOString(); + await db + .insertInto("nodes") + .values({ + id: NODE, + ownerUserId: ownerId, + name: NODE, + kind: "agent", + // Offline on purpose: the route answers anyway (the flag is the + // plane's record), and no push can be seen from here - the push + // assertions live in the service test's scripted-wire pattern. + status: "offline", + createdAt: now, + updatedAt: now, + } as never) + .execute(); + await new NodeSharesRepository(db).replaceForNode(NODE, [{ granteeUserId: editorId, permission: "edit" }], ownerId); + }); + + afterAll(async () => { + await db.deleteFrom("subshells").where("id", "=", KEYED).execute(); + await db.deleteFrom("nodes").where("id", "=", NODE).execute(); + // Leave the shared `local` row exactly as found (the MCP default is ON; + // a flipped host row would read as the fleet default to a later test in + // THIS file's process). + await db + .updateTable("nodes") + .set({ mcpEnabled: 1, mcpEnabledAt: null } as never) + .where("id", "=", LOCAL_NODE_ID) + .execute(); + for (const id of createdKeyIds) await systemKeysRoutes.fetch(authDelete(id)); + for (const email of [ownerEmail, editorEmail, otherEmail, adminEmail]) await deleteUserByEmailOrId(email); + }); + + it("reads ON before anyone has flipped it (no write = the pre-switch status quo)", async () => { + const res = await nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${NODE}`, { + headers: { cookie: `better-auth.session_token=${ownerCookie}` }, + }), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as { mcpEnabled: boolean }).mcpEnabled).toBe(true); + }); + + it("the owner turns it off, and the answer is the node view carrying the flag", async () => { + const res = await put(NODE, ownerCookie, false); + expect(res.status).toBe(200); + const view = (await res.json()) as { mcpEnabled: boolean }; + expect(view.mcpEnabled).toBe(false); + }); + + it("names the acting human and carries ONLY {on} in the audit row", async () => { + // The ROUTE is the only place `actorUserId` is wired in: the service takes + // it as an argument, so a regression to a null actor passes every test of + // the service alone. + await put(NODE, ownerCookie, true); + const before = await mcpAudit(); + + expect((await put(NODE, ownerCookie, false)).status).toBe(200); + // Keyed on which row is NEW rather than on "the latest": rows share a + // millisecond stamp and their ids are unordered. + const added = [...(await mcpAudit())].filter(([id]) => !before.has(id)); + expect(added).toHaveLength(1); + expect(added[0][1].actorUserId).toBe(ownerId); + expect(added[0][1].metadataJson).toBe('{"on":false}'); + }); + + it("turning it back on lands on the row and the view", async () => { + const res = await put(NODE, ownerCookie, true); + expect(res.status).toBe(200); + expect(((await res.json()) as { mcpEnabled: boolean }).mcpEnabled).toBe(true); + }); + + it("an edit grantee is refused: it is not their machine to widen", async () => { + // Deliberately NOT `nodeCanConfigure`: a grantee may re-check and read + // logs; deciding what tool surface runs on someone else's machine is a + // different act. + expect((await put(NODE, editorCookie, false)).status).toBe(403); + }); + + it("a system key scoped to SSH cannot flip the MCP switch: the grants are separate", async () => { + // The catalog split this route had to make: reusing `nodes:ssh` would let + // every ssh-scoped key flip MCP access. A key minted with the SSH grant + // and without `nodes:mcp` is refused at the permission layer, before any + // gate reads the row. + const auditsBefore = (await mcpAudit()).size; + const key = await mintKey(["nodes:ssh"]); + const res = await put(NODE, undefined, false, key); + expect(res.status).toBe(403); + // The scoped-key refusal comes from the authGuard's grant check as a + // plain-text HttpError body, not the route's JSON shape: the route never + // ran. The grant it names is the point. + expect(await res.text()).toContain("nodes:mcp"); + expect((await mcpAudit()).size).toBe(auditsBefore); + }); + + it("a key WITH nodes:mcp flips its own owner's agent node", async () => { + // The positive half, and the nodeManagementGuard/bearer door: a scoped + // system key resolves as the `system` service account, so this key may + // only manage AGENT NODES OWNED BY `system`. Minting it on this file's + // foreign node would answer 404 (invisible), so the cell proves the grant + // passes authGuard's check by showing the REFUSAL is the ownership one, + // not the permission one. + const auditsBefore = (await mcpAudit()).size; + const key = await mintKey(["nodes:mcp"]); + const res = await put(NODE, undefined, false, key); + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("nodes:mcp"); + // The owner cells above have already written audit rows for this node by + // now, so emptiness is the wrong assertion; what must hold is that no + // KEY added a row. + expect((await mcpAudit()).size).toBe(auditsBefore); // still nothing from any key + }); + + it("refuses a valid bearer credential outright", async () => { + // Machine credentials never manage a machine. The key is a LIVE one + // belonging to the node's own owner, so this 403 is `nodeManagementGuard` + // classifying the actor, not authentication failing. It is also the only + // cell here that pins the guard's MOUNT: dropping `.use(nodeManagementGuard)` + // would let authGuard resolve a pane's token as its OWNER, flip + // `canManage` on the owner's own node, and leave every other test green. + await subshells.create({ + id: KEYED, + userId: ownerId, + presetId: "p", + harnessId: "claude-code", + name: KEYED, + workingDir: "/tmp", + tmuxSocket: null, + }); + const key = await issueSubshellToken(KEYED, ownerId); + const auditsBefore = (await mcpAudit()).size; + + const res = await put(NODE, undefined, false, key); + expect(res.status).toBe(403); + expect(await res.text()).toContain("cannot manage nodes"); + expect((await mcpAudit()).size).toBe(auditsBefore); + }); + + it("a stranger gets 404, never 403: an invisible node stays invisible", async () => { + expect((await put(NODE, otherCookie, false)).status).toBe(404); + }); + + it("answers 404 for an unknown node", async () => { + expect((await put(`n-missing-${crypto.randomUUID()}`, ownerCookie, false)).status).toBe(404); + }); + + it("a non-boolean body is refused before anything is written", async () => { + const before = await mcpAudit(); + const res = await nodesRoutes.fetch( + new Request(`http://localhost:3080/api/nodes/${NODE}/mcp-enabled`, { + method: "PUT", + headers: { "content-type": "application/json", cookie: `better-auth.session_token=${ownerCookie}` }, + body: JSON.stringify({ on: "no" }), + }), + ); + expect([400, 422]).toContain(res.status); + expect((await mcpAudit()).size).toBe(before.size); + const row = await db.selectFrom("nodes").select("mcpEnabled").where("id", "=", NODE).executeTakeFirstOrThrow(); + expect(row.mcpEnabled).toBe(1); + }); + + it("an admin is refused on a node they do not own, and allowed on `local`", async () => { + // The admin boost is instance-wide EDIT, never ownership: `canManage` is + // the real owner, plus admins on the seeded host alone. + expect((await put(NODE, adminCookie, false)).status).toBe(403); + // A member sees `local` through the seeded Everyone/edit grant but holds + // no management there: refused, not invisible. + expect((await put(LOCAL_NODE_ID, otherCookie, false)).status).toBe(403); + // Like SSH and unlike maintenance, this flip stops nothing, so OFF is + // safe to test on the shared host row; `afterAll` restores it. + const res = await put(LOCAL_NODE_ID, adminCookie, false); + expect(res.status).toBe(200); + expect(((await res.json()) as { mcpEnabled: boolean }).mcpEnabled).toBe(false); + }); +}); diff --git a/apps/server/api/src/api/nodes/index.ts b/apps/server/api/src/api/nodes/index.ts index de812e4da..777ff8b2f 100644 --- a/apps/server/api/src/api/nodes/index.ts +++ b/apps/server/api/src/api/nodes/index.ts @@ -20,6 +20,7 @@ import { serviceNodeRoute } from "@/api/nodes/service-node.route.js"; import { setNodeAllowedDirsRoute } from "@/api/nodes/set-node-allowed-dirs.route.js"; import { setNodeCrossAgentRoute } from "@/api/nodes/set-node-cross-agent.route.js"; import { setNodeMaintenanceRoute } from "@/api/nodes/set-node-maintenance.route.js"; +import { setNodeMcpEnabledRoute } from "@/api/nodes/set-node-mcp-enabled.route.js"; import { setNodeServerUrlRoute } from "@/api/nodes/set-node-server-url.route.js"; import { setNodeSharesRoute } from "@/api/nodes/set-node-shares.route.js"; import { setNodeSshEnabledRoute } from "@/api/nodes/set-node-ssh-enabled.route.js"; @@ -59,6 +60,7 @@ const nodeRegistryRoutes = new Elysia() const nodeOperationsRoutes = new Elysia() .use(setNodeMaintenanceRoute) .use(setNodeSshEnabledRoute) + .use(setNodeMcpEnabledRoute) .use(setNodeViewsEnabledRoute) .use(displayReadinessRoute) .use(repairNodeMachinePinRoute) diff --git a/apps/server/api/src/api/nodes/node-view.ts b/apps/server/api/src/api/nodes/node-view.ts index e7fd100c9..e30edf118 100644 --- a/apps/server/api/src/api/nodes/node-view.ts +++ b/apps/server/api/src/api/nodes/node-view.ts @@ -193,6 +193,10 @@ export const NodeViewSchema = t.Object({ description: "Whether SSH port forwards on this machine may bind non-loopback addresses. Off by default: forwards bind loopback only, and widening turns the machine into a router for whatever network reaches the bound port. The owner's decision, refused at launch while false", }), + mcpEnabled: t.Boolean({ + description: + "Whether panes launched on this machine may be wired with the subshell MCP. On by default: MCP wiring predates the switch, so an unwritten row keeps today's behavior. Off outranks every account preference and is enforced on the machine itself; running panes keep their live MCP child", + }), held: t.Nullable( t.Object({ reason: t.Union([t.Literal("below-floor"), t.Literal("protocol-mismatch"), t.Literal("encryption-required")], { @@ -496,6 +500,12 @@ function nodeViewBase( virtualDisplayProvider: row.virtualDisplayProvider ?? DEFAULT_VIRTUAL_DISPLAY_PROVIDER, virtualDisplayProviders: storedDisplayProviderIds(row.virtualDisplayProviders), sshWideForwards: row.sshWideForwards === 1, + // The DEFAULT-ALLOW polarity is deliberate and is the opposite of the + // default-deny switches above (ssh, web previews, virtual displays): an + // unwritten column (a pre-switch row, or one whose migration added it + // with DEFAULT 1) must keep wiring the MCP, because that was every + // pane's behavior before the switch existed. + mcpEnabled: row.mcpEnabled !== 0, // Read from the LIVE registry rather than the row, for the same reason // `runtime` is: being held is a fact about a socket that exists right now, // and a column would go stale the moment the process ends. It is NOT diff --git a/apps/server/api/src/api/nodes/set-node-mcp-enabled.route.ts b/apps/server/api/src/api/nodes/set-node-mcp-enabled.route.ts new file mode 100644 index 000000000..fcad73f4b --- /dev/null +++ b/apps/server/api/src/api/nodes/set-node-mcp-enabled.route.ts @@ -0,0 +1,81 @@ +import { BackendErrorCodes } from "@internal/backend-errors"; +import { Elysia, t } from "elysia"; +import { apiErrorBody } from "@/api/api-error.js"; +import { nodeManagementGuard } from "@/api/nodes/node-management-guard.js"; +import { NodeViewSchema, toNodeView } from "@/api/nodes/node-view.js"; +import { apiModels } from "@/api/schemas/index.js"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { loadNodeGate } from "@/modules/nodes/gate.js"; +import { setNodeMcpEnabled } from "@/modules/nodes/mcp-enabled.js"; +import { ForbiddenError } from "@/shared/errors.js"; + +const McpEnabledBodySchema = t.Object({ + on: t.Boolean({ + description: + "Allow the subshell MCP in panes launched on this machine. On by default: turning it off strips the MCP from every new launch here, whoever the pane's owner is and whatever their own preference says. Owner only; admin on the control-plane host.", + }), +}); + +/** + * `PUT /api/nodes/:id/mcp-enabled` `{on}`: allow or refuse the subshell MCP on + * one machine. Browser session or an API key holding the `nodes:mcp` grant. + * + * **OWNER-only** (`gate.canManage`, with the seeded-`local` admin exception), + * the same gate as SSH and previews: an `edit` grantee may restart the agent + * or read its log, but deciding whether the MCP may run on this machine is not + * theirs. The grant is its OWN catalog id: a key scoped to SSH must not be + * able to flip MCP access, because the two answer different questions. + * + * Off here outranks every account preference: the machine's owner is saying + * nothing of that tool surface runs on their hardware, and the node enforces + * it on itself (mirror file, launch-frame strip, `subshell mcp` refusal), so + * the refusal holds even for a harness the plane never wired per-launch. + * + * The route writes nothing and audits nothing itself: `setNodeMcpEnabled` is + * the one change-site (row + audit + best-effort push), so no caller can + * perform half the act. + * + * It answers on an OFFLINE node rather than refusing: the flag is the plane's + * record, and the machine learns the value at its next `ready` reconcile. + * Refusing would make a gate that exists to be closable openable only while + * the machine happens to be reachable. + */ +export const setNodeMcpEnabledRoute = new Elysia() + .use(nodeManagementGuard) + .use(apiModels) + .put( + "/:id/mcp-enabled", + async ({ params, body, user, actor, status }) => { + const gate = await loadNodeGate(user.id, params.id, actor); + if (!gate) { + return status(404, apiErrorBody({ code: BackendErrorCodes.NOT_FOUND_ERROR, message: "Node not found" })); + } + if (!gate.canManage) throw new ForbiddenError(); + + await setNodeMcpEnabled(gate.row.id, user.id, body.on); + // Re-read rather than patching the gate's snapshot: the service wrote + // the row, and the view must render what is stored, not what this + // handler believes it asked for. + const row = (await new NodesRepository(db).findById(gate.row.id)) ?? gate.row; + return await toNodeView(row, gate.access, gate.isAdmin, gate.granted); + }, + { + params: t.Object({ id: t.String({ description: "Node id" }) }), + body: McpEnabledBodySchema, + response: { + 200: NodeViewSchema, + 400: "ApiErrorResponse", + 401: "ApiErrorResponse", + 403: "ApiErrorResponse", + 404: "ApiErrorResponse", + }, + systemPermission: "nodes:mcp", + detail: { + operationId: "setNodeMcpEnabled", + tags: ["nodes"], + description: + "Allow or refuse the subshell MCP on a node (owner only; admin on the control-plane host). On by default; off strips the MCP from every new launch on this machine and outranks any account preference.", + }, + }, + ); diff --git a/apps/server/api/src/api/settings.route.ts b/apps/server/api/src/api/settings.route.ts index 91faaa18d..455e9e876 100644 --- a/apps/server/api/src/api/settings.route.ts +++ b/apps/server/api/src/api/settings.route.ts @@ -129,6 +129,19 @@ const TerminalHistorySchema = t.Object({ ), }); +/** + * Per-user subshell MCP preference. On by default — MCP wiring is today's + * behavior, and an upgrade must not silently strip comms. Off, the plane wires + * no MCP into this account's NEW launches and restarts here, and the pane's + * token is minted narrowed to self-reporting only. + */ +const McpSettingsSchema = t.Object({ + mcpEnabled: t.Boolean({ + description: + "Whether the subshell MCP is wired into this account's panes (default true). Off, new launches and restarts carry no MCP registration and a report-only token; running panes keep theirs; a node switched off still refuses everything regardless of this preference", + }), +}); + /** Public read gains the break-glass flag (spec 2026-08-31 §6) — it leaks * only that the hatch is armed, which the banner itself broadcasts. */ const PublicSettingsSchema = t.Object({ @@ -526,6 +539,43 @@ export const settingsRoutes = new Elysia({ prefix: "/api/settings" }) description: "Set the caller's terminal attach history cap (1–200; null = instance default)", }, }, + ) + .get( + "/mcp", + async ({ user, actor }) => { + // Self-service, cookie actor only (the terminal-history and + // notifications precedent), and in THIS module rather than a new + // `.use()` layer — the composed-`App` depth ceiling again. No audit + // row: a personal preference, and `notify_enabled` writes none either. + requireCookieActor(actor, "MCP settings require a cookie session"); + return { mcpEnabled: await new UserMetaRepository(db).getMcpEnabled(user.id) } as const; + }, + { + response: McpSettingsSchema, + detail: { + operationId: "getMcpSettings", + tags: ["settings"], + description: "Whether the subshell MCP is wired into this account's panes (default true)", + }, + }, + ) + .patch( + "/mcp", + async ({ body, user, actor }) => { + requireCookieActor(actor, "MCP settings require a cookie session"); + await new UserMetaRepository(db).setMcpEnabled(user.id, body.mcpEnabled); + return { mcpEnabled: body.mcpEnabled } as const; + }, + { + body: McpSettingsSchema, + response: McpSettingsSchema, + detail: { + operationId: "updateMcpSettings", + tags: ["settings"], + description: + "Set whether the subshell MCP is wired into this account's panes. Off, new launches and restarts carry no MCP and a report-only token; running panes keep theirs", + }, + }, ); /** diff --git a/apps/server/api/src/api/subshells/__tests__/subshells-extend-token.test.ts b/apps/server/api/src/api/subshells/__tests__/subshells-extend-token.test.ts index 6184a9df5..e4fc0ad30 100644 --- a/apps/server/api/src/api/subshells/__tests__/subshells-extend-token.test.ts +++ b/apps/server/api/src/api/subshells/__tests__/subshells-extend-token.test.ts @@ -1,12 +1,15 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { generateKeypair } from "@internal/mcp-core"; import { hashPassword } from "better-auth/crypto"; +import { channelRoutes } from "@/api/channels/index.js"; +import { identityRoutes } from "@/api/identities.route.js"; import { subshellRoutes } from "@/api/subshells/index.js"; import { db } from "@/db/index.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { UsersRepository } from "@/db/repositories/users.repository.js"; import { getAuth } from "@/modules/auth/auth.js"; import { authDatabase } from "@/modules/auth/database.js"; -import { issueSubshellToken } from "@/modules/subshells/tokens.js"; +import { issueSubshellToken, reportingTokenPermissions } from "@/modules/subshells/tokens.js"; import { authedRequest, deleteUserByEmailOrId, setupAuthTables, signIn } from "../../../testing/api/auth-tables.js"; /** @@ -143,4 +146,119 @@ describe("POST /api/subshells/:id/extend-token", () => { ); expect(term.status).toBe(403); // write not granted }); + + /** + * The depth decision of the MCP-off switch, made visible at the HTTP + * boundary. A pane whose MCP was refused (by account or node preference) is + * minted `reportingTokenPermissions()`: the four harness self-report verbs + * survive, and the sibling surface §4 of docs/security.md accounts for + * disappears from the key itself, not just from the tool list a disabled + * pane would never load. The full-scope control in the same test is what + * makes the refusals mean SOMETHING: without it, a blanket 403 from a broken + * fixture would pass for the wrong reason. + */ + describe("a report-only pane token (MCP switched off)", () => { + // `fetch` on a composed Elysia instance returns MaybePromise; + // awaiting it here is what the route tests already do inline. + async function bearer( + route: { fetch(req: Request): Response | Promise }, + path: string, + init?: RequestInit, + key?: string, + ) { + const res = await route.fetch( + new Request(`http://localhost:3080${path}`, { + ...init, + headers: { "content-type": "application/json", ...(key ? { authorization: `Bearer ${key}` } : {}) }, + }), + ); + return res.status; + } + + it("passes all four hook doors: bell, exit, session id, roster", async () => { + // One POST per door the `subshell report` verbs hit, because a per-file + // revert of any ONE of them to requirePerm silently 403s an opted-out + // pane's reporting for that verb (runReport swallows failures as "a lost + // report") while every other suite stays green. `exit` goes LAST on + // purpose: its report parks the row (alive 0, status still running), + // and the roster door 409s any row not alive — while the session door + // deliberately still records a parked row, the transcript a pending + // auto-restart should resume. + const s = await subshellWithScopedToken(reportingTokenPermissions()); + expect( + await bearer( + subshellRoutes, + `/api/subshells/${s.id}/attention`, + { method: "POST", body: '{"kind":"turn_complete"}' }, + s.key, + ), + ).toBe(200); + expect( + await bearer( + subshellRoutes, + `/api/subshells/${s.id}/harness-session`, + { method: "POST", body: JSON.stringify({ sessionId: crypto.randomUUID() }) }, + s.key, + ), + ).toBe(200); + expect( + await bearer( + subshellRoutes, + `/api/subshells/${s.id}/subagents/report`, + { method: "POST", body: '{"event":"snapshot","agents":[]}' }, + s.key, + ), + ).toBe(200); + expect( + await bearer(subshellRoutes, `/api/subshells/${s.id}/exit`, { method: "POST", body: '{"exitCode":0}' }, s.key), + ).toBe(200); + }); + + it("still extends its own token: the door is ownership-only, so the 7-day wall never lands", async () => { + // Every `subshell report` verb but `exit` rides this extend, and a + // report-only pane launches no MCP child to arm the periodic one. If a + // future pass gates this route on a scope (plausibly `subshells:write`, + // which every pre-switch pane key held), this cell goes red now rather + // than leaving a long-lived opted-out pane's hooks to silently 403 a + // week in. + const s = await subshellWithScopedToken(reportingTokenPermissions()); + expect(await bearer(subshellRoutes, `/api/subshells/${s.id}/extend-token`, { method: "POST" }, s.key)).toBe(200); + }); + + it("cannot read a channel or register an identity: channels is absent from its map", async () => { + const s = await subshellWithScopedToken(reportingTokenPermissions()); + // The channels read is the plain scope check. + expect(await bearer(channelRoutes, "/api/channels", undefined, s.key)).toBe(403); + // Identity registration is gated on channels:write, so it is the one door + // that discriminates a narrowed pane from a full one even when the + // legacy concession is in view. The map carries NEITHER `channels` nor + // `prompts`, so it is not a recognizable pre-prompts shape and cannot + // claim that pass. + const { publicJwk } = await generateKeypair(); + expect( + await bearer( + identityRoutes, + "/api/identities", + { method: "POST", body: JSON.stringify({ publicKey: publicJwk }) }, + s.key, + ), + ).toBe(403); + }); + + it("control: the SAME doors accept a full-scope pane token", async () => { + // Without this cell the two above only prove "some scoped token is + // refused"; this proves the refusal is the narrowing, not the fixture. + const s = await subshellWithScopedToken({ channels: ["read", "write"], subshells: ["read", "write"] }); + expect(await bearer(channelRoutes, "/api/channels", undefined, s.key)).toBe(200); + const { publicJwk } = await generateKeypair(); + expect( + await bearer( + identityRoutes, + "/api/identities", + { method: "POST", body: JSON.stringify({ publicKey: publicJwk }) }, + s.key, + ), + ).toBe(200); + }); + }); }); diff --git a/apps/server/api/src/api/subshells/subshell-attention.route.ts b/apps/server/api/src/api/subshells/subshell-attention.route.ts index 9d1902d2f..47e1d4968 100644 --- a/apps/server/api/src/api/subshells/subshell-attention.route.ts +++ b/apps/server/api/src/api/subshells/subshell-attention.route.ts @@ -1,5 +1,5 @@ import { Elysia, t } from "elysia"; -import { authGuard, requirePerm } from "@/api/auth-guard.js"; +import { authGuard, requireReportPerm } from "@/api/auth-guard.js"; import { contextPlugin } from "@/api/middleware/context.plugin.js"; import { apiModels } from "@/api/schemas/index.js"; import { HttpError } from "@/shared/errors.js"; @@ -27,11 +27,11 @@ export const subshellAttentionRoute = new Elysia() .post( "/:id/attention", async ({ params, body, actor, principal, apiKeyPermissions, ctx }) => { - requirePerm({ actor, apiKeyPermissions }, "subshells", "write"); - // requirePerm is scope-wide, not per-row: a subshell key holds - // subshells:write for ITS subshell, so the cross-row check lives here — - // and unlike /name, the cookie actor is turned away entirely: this is - // the harness talking, not a browser. + requireReportPerm({ actor, apiKeyPermissions }); + // The grant is scope-wide, not per-row: a pane holds this door for ITS + // own subshell, so the cross-row check lives here. And unlike /name, the + // cookie actor is turned away entirely: this is the harness talking, not + // a browser. if (actor !== "subshell-key" || principal !== `sess:${params.id}`) { throw new HttpError(403, "Only a subshell's own key may report attention"); } diff --git a/apps/server/api/src/api/subshells/subshell-exit.route.ts b/apps/server/api/src/api/subshells/subshell-exit.route.ts index 558d6cb76..c12a82b4b 100644 --- a/apps/server/api/src/api/subshells/subshell-exit.route.ts +++ b/apps/server/api/src/api/subshells/subshell-exit.route.ts @@ -1,5 +1,5 @@ import { Elysia, t } from "elysia"; -import { authGuard, requirePerm } from "@/api/auth-guard.js"; +import { authGuard, requireReportPerm } from "@/api/auth-guard.js"; import { contextPlugin } from "@/api/middleware/context.plugin.js"; import { apiModels } from "@/api/schemas/index.js"; import { getPreviewBroker } from "@/modules/previews/preview-broker.js"; @@ -45,9 +45,10 @@ export const subshellExitRoute = new Elysia() .post( "/:id/exit", async ({ params, body, actor, principal, apiKeyPermissions, ctx }) => { - requirePerm({ actor, apiKeyPermissions }, "subshells", "write"); - // requirePerm is scope-wide, not per-row: a subshell key holds - // subshells:write for ITS subshell, so the cross-row check lives here. + requireReportPerm({ actor, apiKeyPermissions }); + // The grant is scope-wide, not per-row: a pane holds this door for ITS + // own subshell (report:write, or the subshells:write an MCP-enabled pane + // carries), so the cross-row check lives here. if (actor !== "subshell-key" || principal !== `sess:${params.id}`) { throw new HttpError(403, "Only a subshell's own key may report its exit"); } diff --git a/apps/server/api/src/api/subshells/subshell-harness-session.route.ts b/apps/server/api/src/api/subshells/subshell-harness-session.route.ts index 688717223..1cc27b664 100644 --- a/apps/server/api/src/api/subshells/subshell-harness-session.route.ts +++ b/apps/server/api/src/api/subshells/subshell-harness-session.route.ts @@ -1,5 +1,5 @@ import { Elysia, t } from "elysia"; -import { authGuard, requirePerm } from "@/api/auth-guard.js"; +import { authGuard, requireReportPerm } from "@/api/auth-guard.js"; import { contextPlugin } from "@/api/middleware/context.plugin.js"; import { apiModels } from "@/api/schemas/index.js"; import { HttpError } from "@/shared/errors.js"; @@ -31,11 +31,11 @@ export const subshellHarnessSessionRoute = new Elysia() .post( "/:id/harness-session", async ({ params, body, actor, principal, apiKeyPermissions, ctx }) => { - requirePerm({ actor, apiKeyPermissions }, "subshells", "write"); - // requirePerm is scope-wide, not per-row: a subshell key holds - // subshells:write for ITS subshell, so the cross-row check lives here — - // and the cookie actor is turned away entirely: this is the harness - // talking, not a browser (same rule as /attention). + requireReportPerm({ actor, apiKeyPermissions }); + // The grant is scope-wide, not per-row: a pane holds this door for ITS + // own subshell, so the cross-row check lives here. And the cookie actor + // is turned away entirely: this is the harness talking, not a browser + // (same rule as /attention). if (actor !== "subshell-key" || principal !== `sess:${params.id}`) { throw new HttpError(403, "Only a subshell's own key may report its harness session"); } diff --git a/apps/server/api/src/api/subshells/subshell-subagents.route.ts b/apps/server/api/src/api/subshells/subshell-subagents.route.ts index 7a91cafa2..de02f14d1 100644 --- a/apps/server/api/src/api/subshells/subshell-subagents.route.ts +++ b/apps/server/api/src/api/subshells/subshell-subagents.route.ts @@ -1,6 +1,6 @@ import { normalizeLabel } from "@internal/subshell-protocol"; import { Elysia, t } from "elysia"; -import { authGuard, requirePerm } from "@/api/auth-guard.js"; +import { authGuard, requirePerm, requireReportPerm } from "@/api/auth-guard.js"; import { contextPlugin } from "@/api/middleware/context.plugin.js"; import { apiModels } from "@/api/schemas/index.js"; import { HarnessSubagents } from "@/modules/subshells/harness-subagents.js"; @@ -194,10 +194,17 @@ export const subshellSubagentsRoute = new Elysia() }, }, ) + // The reporting door stays open to a report-only token, which is what an + // MCP-disabled pane holds. WHO walks through is the dialect story: hook- + // driven dialects report regardless of the switch, while opencode and pi + // ship their reporter as an extension inside the MCP registration, + // so a switched-off pane of those dialects loads no bridge and never calls + // this route. Shipping the bridge without the wiring would need a + // reporting-only registration variant in each plugin. .post( "/:id/subagents/report", async ({ params, body, actor, principal, apiKeyId, apiKeyPermissions, ctx }) => { - requirePerm({ actor, apiKeyPermissions }, "subshells", "write"); + requireReportPerm({ actor, apiKeyPermissions }); if (actor !== "subshell-key" || principal !== `sess:${params.id}`) { throw new HttpError(403, "Only a subshell's own key may report its subagents"); } diff --git a/apps/server/api/src/api/ws/__tests__/node-ws-handler.test.ts b/apps/server/api/src/api/ws/__tests__/node-ws-handler.test.ts index 41a6bb10f..bf8fb37d4 100644 --- a/apps/server/api/src/api/ws/__tests__/node-ws-handler.test.ts +++ b/apps/server/api/src/api/ws/__tests__/node-ws-handler.test.ts @@ -776,6 +776,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => }, onSshEnabled: () => {}, onPreviewEnabled: () => {}, + onMcpEnabled: () => {}, }); try { const ws = fakeSocket("n1"); @@ -834,6 +835,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => }, onSshEnabled: () => {}, onPreviewEnabled: () => {}, + onMcpEnabled: () => {}, }); return { seen, off: () => resetNodeEventsForTests() }; } @@ -917,6 +919,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => seen.push([nodeId, reported]); }, onPreviewEnabled: () => {}, + onMcpEnabled: () => {}, }); return { seen, off: () => resetNodeEventsForTests() }; } @@ -1001,6 +1004,7 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => onPreviewEnabled: (nodeId, reported) => { seen.push([nodeId, reported]); }, + onMcpEnabled: () => {}, }); return { seen, off: () => resetNodeEventsForTests() }; } @@ -1069,6 +1073,90 @@ describe("handleNodeMessage (inbound unsigned events, spec §3.3/§5.3)", () => }); }); + /** + * The MCP mirror walks the same two frames into the same shape of hook, + * and the rows pin the DOOR for the third switch: both carriers, the + * socket's nodeId, the lenient field. What the reconciler decides lives in + * `mcp-enabled.test.ts`; what matters here is that the arms EXIST and pass + * the parsed payload, because the switch has no default branch and a + * mis-wired or missing `mcp_enabled` arm passes every other suite. + */ + describe("mcp_enabled → the lifecycle hook", () => { + /** Install a recording MCP hook and return both the log and the uninstaller. */ + function recordMcpEnabled(): { seen: [string, unknown][]; off: () => void } { + const seen: [string, unknown][] = []; + setNodeLifecycleHooks({ + onExit: () => {}, + onSubshellsReport: () => {}, + onMaintenance: () => {}, + onSshEnabled: () => {}, + onPreviewEnabled: () => {}, + onMcpEnabled: (nodeId, reported) => { + seen.push([nodeId, reported]); + }, + }); + return { seen, off: () => resetNodeEventsForTests() }; + } + + it("a ready CARRYING mcpEnabled reconciles it", async () => { + const { seen, off } = recordMcpEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify(readyFrame({ mcpEnabled: { on: false, changedAt: "2026-11-01T10:00:00.000Z" } })), + ); + expect(seen).toEqual([["n1", { on: false, changedAt: "2026-11-01T10:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready WITHOUT it still reconciles, because silence is the ALLOWED answer here", async () => { + // The one inversion against the ssh and preview cells beside it: an + // absent mirror agrees with a default-ON row, so the hook hearing + // `undefined` is the healthy case, not a skipped beat. + const { seen, off } = recordMcpEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame())); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + + it("the mcp_enabled EVENT reaches the same hook, under the SOCKET's nodeId", async () => { + const { seen, off } = recordMcpEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage( + h.deps, + fakeSocket("n1"), + JSON.stringify({ type: "mcp_enabled", on: true, changedAt: "2026-11-01T11:00:00.000Z" }), + ); + expect(seen).toEqual([["n1", { on: true, changedAt: "2026-11-01T11:00:00.000Z" }]]); + } finally { + off(); + } + }); + + it("a ready whose mcpEnabled field is malformed keeps the ready and drops the field", async () => { + // Lenient like the twins: the plane learns the drop as silence, and + // silence on THIS flag reads allowed toward a default row. + const { seen, off } = recordMcpEnabled(); + try { + const h = makeHarness(); + await handleNodeMessage(h.deps, fakeSocket("n1"), JSON.stringify(readyFrame({ mcpEnabled: { on: "yes" } }))); + expect(h.ready).toHaveLength(1); + expect(seen).toEqual([["n1", undefined]]); + } finally { + off(); + } + }); + }); + it("ready stashes homeDir and selfInvoke on the live facts; env is NOT a ready field", async () => { // Spec 2026-09-10 §5 as amended by the final review: `ready` reports the // home (resume defaults hang off it) and the agent's `selfInvoke` diff --git a/apps/server/api/src/api/ws/node-ws-handler.ts b/apps/server/api/src/api/ws/node-ws-handler.ts index 356cb710b..17a0ef1e5 100644 --- a/apps/server/api/src/api/ws/node-ws-handler.ts +++ b/apps/server/api/src/api/ws/node-ws-handler.ts @@ -1112,6 +1112,12 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: // fire-and-forget push of the row's own value. if (hooks) await hooks.onPreviewEnabled(nodeId, event.previewEnabled); else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile preview_enabled`); + // The MCP-use mirror rides the same door, with its own reading of + // silence: an absent mirror means wiring continues, so the reconcile is + // a no-op unless the row says no (then the file comes back) or the file + // says no while the row says yes (then the row is pushed again). + if (hooks) await hooks.onMcpEnabled(nodeId, event.mcpEnabled); + else logger.debug(`node ws: ready from ${nodeId} with no lifecycle hook to reconcile mcp_enabled`); // The §4.3 signing bootstrap (spec 2026-10-08): an agent whose identity // record has no signing key is ASKED ONCE, and the answer files itself. // Fire-and-forget for the same reason the detect kick above is: the @@ -1209,6 +1215,19 @@ export async function handleNodeMessage(deps: NodeWsDeps, ws: NodeWsSocket, raw: else logger.warn(`node ws: preview_enabled from ${nodeId} with no lifecycle hook`); return; } + case "mcp_enabled": { + const hooks = getNodeLifecycleHooks(); + // The machine's MCP-use mirror moved mid-session, the same story the ssh + // and preview arms tell: no node-side writer, so what arrives is a file + // that changed by other means, and the plane's answer is a push of its + // row. One difference worth stating: silence here means ALLOWED rather + // than refused, which is why an absent mirror is never announced and an + // `off` one always is. Same hook as `ready`, one reconciler, so a + // mid-session report and a connect-time state answer alike. + if (hooks) await hooks.onMcpEnabled(nodeId, { on: event.on, changedAt: event.changedAt }); + else logger.warn(`node ws: mcp_enabled from ${nodeId} with no lifecycle hook`); + return; + } case "result": { // Connection-scoped settle: the frame can only resolve pendings on the // socket it arrived on (same record the close path drains). Fall back to diff --git a/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts b/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts index e527dc09c..72f3c5f2b 100644 --- a/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts +++ b/apps/server/api/src/cli/commands/__tests__/backup-cli-subprocess.test.ts @@ -395,21 +395,21 @@ test("real entry: available older upgrade snapshot restores and migrates before const previewSettingsSchema = db .query("SELECT sql FROM sqlite_master WHERE type='table' AND name='preview_proxy_settings'") .get() as { sql: string }; - // WHY 0047..0070 together: these migrations are the tail of the + // WHY 0047..0073 together: these migrations are the tail of the // registered ledger, so the prefix runs contiguously through 0046 and boot // re-applies the whole tail. Advancing LATEST_BACKUP_MIGRATION means the strip // set moves with it in the same commit. const migrationRows = db .query( - "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings','0071-service-certificate-validation','0072-service-preview-configuration') ORDER BY name", + "SELECT * FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings','0071-service-certificate-validation','0072-service-preview-configuration','0073-mcp-enabled') ORDER BY name", ) .all() as { name: string; timestamp: string }[]; db.exec( - "DROP TABLE preview_proxy_settings; ALTER TABLE presets DROP COLUMN enabled; ALTER TABLE nodes DROP COLUMN virtual_display_providers; ALTER TABLE presets DROP COLUMN screen_config; ALTER TABLE nodes DROP COLUMN virtual_display_provider; ALTER TABLE subshells DROP COLUMN screen_config; ALTER TABLE nodes DROP COLUMN virtual_display_enabled; DROP TABLE subshell_services; ALTER TABLE subshells DROP COLUMN parent_subshell_id; ALTER TABLE nodes DROP COLUMN system_specs; ALTER TABLE presets DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN recommended_starting_dirs; ALTER TABLE workspaces ADD COLUMN draft integer NOT NULL DEFAULT 0; CREATE UNIQUE INDEX idx_workspaces_user_name ON workspaces (user_id, name) WHERE draft = 0; DROP INDEX idx_workspace_panes_workspace_subshell; DROP TABLE preset_shares; DROP TABLE ssh_preset_shares; ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN preview_enabled_at; ALTER TABLE nodes DROP COLUMN web_previews_enabled; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings','0071-service-certificate-validation','0072-service-preview-configuration'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", + "DROP TABLE preview_proxy_settings; ALTER TABLE presets DROP COLUMN enabled; ALTER TABLE nodes DROP COLUMN virtual_display_providers; ALTER TABLE presets DROP COLUMN screen_config; ALTER TABLE nodes DROP COLUMN virtual_display_provider; ALTER TABLE subshells DROP COLUMN screen_config; ALTER TABLE nodes DROP COLUMN virtual_display_enabled; DROP TABLE subshell_services; ALTER TABLE subshells DROP COLUMN parent_subshell_id; ALTER TABLE nodes DROP COLUMN system_specs; ALTER TABLE presets DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN cross_comm_notes; ALTER TABLE nodes DROP COLUMN recommended_starting_dirs; ALTER TABLE workspaces ADD COLUMN draft integer NOT NULL DEFAULT 0; CREATE UNIQUE INDEX idx_workspaces_user_name ON workspaces (user_id, name) WHERE draft = 0; DROP INDEX idx_workspace_panes_workspace_subshell; DROP TABLE preset_shares; DROP TABLE ssh_preset_shares; ALTER TABLE nodes DROP COLUMN ssh_wide_forwards; ALTER TABLE nodes DROP COLUMN preview_enabled_at; ALTER TABLE nodes DROP COLUMN web_previews_enabled; ALTER TABLE nodes DROP COLUMN ssh_enabled_at; ALTER TABLE nodes DROP COLUMN ssh_enabled; DROP TABLE ssh_presets; DROP TABLE ssh_recent_destinations; ALTER TABLE subshells DROP COLUMN ssh; ALTER TABLE identities DROP COLUMN signing_public_key; ALTER TABLE nodes DROP COLUMN ssh_fingerprint; ALTER TABLE subshells DROP COLUMN key_home_node_id; DROP TABLE ssh_host_pins; ALTER TABLE nodes DROP COLUMN mcp_enabled_at; ALTER TABLE nodes DROP COLUMN mcp_enabled; ALTER TABLE user_meta DROP COLUMN mcp_enabled; DELETE FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings','0071-service-certificate-validation','0072-service-preview-configuration','0073-mcp-enabled'); CREATE TABLE migration_restore_probe(value TEXT); INSERT INTO migration_restore_probe VALUES ('older snapshot');", ); db.query("VACUUM INTO ?").run(snapshot); db.exec( - "ALTER TABLE nodes ADD COLUMN virtual_display_providers text NOT NULL DEFAULT '[\"xvfb-x11vnc\"]'; ALTER TABLE presets ADD COLUMN screen_config text; ALTER TABLE subshells ADD COLUMN screen_config text; ALTER TABLE subshells ADD COLUMN parent_subshell_id text REFERENCES subshells(id) ON DELETE SET NULL; ALTER TABLE nodes ADD COLUMN system_specs text; ALTER TABLE presets ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN recommended_starting_dirs text; ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE ssh_presets ADD COLUMN forwards text; ALTER TABLE nodes ADD COLUMN ssh_wide_forwards integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN web_previews_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN preview_enabled_at text; ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE presets ADD COLUMN enabled integer NOT NULL DEFAULT 1; ALTER TABLE ssh_presets ADD COLUMN enabled integer NOT NULL DEFAULT 1; UPDATE migration_restore_probe SET value='newer destination';", + "ALTER TABLE nodes ADD COLUMN virtual_display_providers text NOT NULL DEFAULT '[\"xvfb-x11vnc\"]'; ALTER TABLE presets ADD COLUMN screen_config text; ALTER TABLE subshells ADD COLUMN screen_config text; ALTER TABLE subshells ADD COLUMN parent_subshell_id text REFERENCES subshells(id) ON DELETE SET NULL; ALTER TABLE nodes ADD COLUMN system_specs text; ALTER TABLE presets ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN cross_comm_notes text; ALTER TABLE nodes ADD COLUMN recommended_starting_dirs text; ALTER TABLE nodes ADD COLUMN ssh_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN ssh_enabled_at text; CREATE TABLE ssh_recent_destinations(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, alias TEXT, node_id TEXT NOT NULL, last_connect_at TEXT NOT NULL); CREATE TABLE ssh_presets(id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id), name TEXT NOT NULL, description TEXT, node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, destination TEXT NOT NULL, alias TEXT, authentication TEXT NOT NULL, key_home TEXT REFERENCES nodes(id) ON DELETE SET NULL, fingerprints TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE ssh_presets ADD COLUMN forwards text; ALTER TABLE nodes ADD COLUMN ssh_wide_forwards integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN web_previews_enabled integer NOT NULL DEFAULT 0; ALTER TABLE nodes ADD COLUMN preview_enabled_at text; ALTER TABLE subshells ADD COLUMN ssh text; ALTER TABLE identities ADD COLUMN signing_public_key text; ALTER TABLE nodes ADD COLUMN ssh_fingerprint text; ALTER TABLE subshells ADD COLUMN key_home_node_id text; CREATE TABLE ssh_host_pins(id TEXT PRIMARY KEY, owner_user_id TEXT NOT NULL REFERENCES user(id), destination TEXT NOT NULL, host_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); ALTER TABLE presets ADD COLUMN enabled integer NOT NULL DEFAULT 1; ALTER TABLE ssh_presets ADD COLUMN enabled integer NOT NULL DEFAULT 1; ALTER TABLE user_meta ADD COLUMN mcp_enabled integer NOT NULL DEFAULT 1; ALTER TABLE nodes ADD COLUMN mcp_enabled integer NOT NULL DEFAULT 1; ALTER TABLE nodes ADD COLUMN mcp_enabled_at text; UPDATE migration_restore_probe SET value='newer destination';", ); db.exec(previewSettingsSchema.sql); for (const row of migrationRows) @@ -424,7 +424,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( offline .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings','0071-service-certificate-validation','0072-service-preview-configuration')", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings','0071-service-certificate-validation','0072-service-preview-configuration','0073-mcp-enabled')", ) .all(), ).toEqual([]); @@ -434,7 +434,7 @@ test("real entry: available older upgrade snapshot restores and migrates before expect( migrated .query( - "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings','0071-service-certificate-validation','0072-service-preview-configuration') ORDER BY name", + "SELECT name FROM kysely_migration WHERE name IN ('0047-node-ssh-enabled','0048-ssh-launch-and-saved-hosts','0049-ssh-relay-identity','0050-ssh-grants','0051-ssh-request-destination','0052-subshell-key-home','0053-drop-ssh-grants','0054-ssh-presets','0055-node-ssh-wide-forwards','0056-ssh-preset-forwards','0057-preset-sharing','0058-workspace-first','0059-cross-agent-guidance','0060-subshell-parent','0061-node-system-specs','0062-node-preview-enabled','0063-subshell-services','0064-node-virtual-display-enabled','0065-subshell-screen-config','0066-node-display-provider','0067-display-provider-choices','0068-preset-enabled','0069-auth-account-provider-key','0070-preview-proxy-settings','0071-service-certificate-validation','0072-service-preview-configuration','0073-mcp-enabled') ORDER BY name", ) .all(), ).toEqual([ @@ -464,6 +464,7 @@ test("real entry: available older upgrade snapshot restores and migrates before { name: "0070-preview-proxy-settings" }, { name: "0071-service-certificate-validation" }, { name: "0072-service-preview-configuration" }, + { name: "0073-mcp-enabled" }, ]); expect(migrated.query("SELECT value FROM migration_restore_probe").get()).toEqual({ value: "older snapshot" }); expect( diff --git a/apps/server/api/src/db/migrate.ts b/apps/server/api/src/db/migrate.ts index cc7121d58..826b7bea5 100644 --- a/apps/server/api/src/db/migrate.ts +++ b/apps/server/api/src/db/migrate.ts @@ -66,6 +66,7 @@ import * as authAccountProviderKeyMigration from "@/db/migrations/0069-auth-acco import * as previewProxySettingsMigration from "@/db/migrations/0070-preview-proxy-settings.js"; import * as serviceCertificateValidationMigration from "@/db/migrations/0071-service-certificate-validation.js"; import * as servicePreviewConfigurationMigration from "@/db/migrations/0072-service-preview-configuration.js"; +import * as mcpEnabledMigration from "@/db/migrations/0073-mcp-enabled.js"; import * as dropSshGrantsMigration from "./migrations/0053-drop-ssh-grants.js"; import * as presetSharingMigration from "./migrations/0057-preset-sharing.js"; import * as nodeVirtualDisplayEnabledMigration from "./migrations/0064-node-virtual-display-enabled.js"; @@ -199,6 +200,7 @@ export async function runMigrations(): Promise { "0070-preview-proxy-settings": previewProxySettingsMigration, "0071-service-certificate-validation": serviceCertificateValidationMigration, "0072-service-preview-configuration": servicePreviewConfigurationMigration, + "0073-mcp-enabled": mcpEnabledMigration, }; }, }, diff --git a/apps/server/api/src/db/migrations/0073-mcp-enabled.ts b/apps/server/api/src/db/migrations/0073-mcp-enabled.ts new file mode 100644 index 000000000..7593e645f --- /dev/null +++ b/apps/server/api/src/db/migrations/0073-mcp-enabled.ts @@ -0,0 +1,31 @@ +import type { Kysely } from "kysely"; + +// Adds the two switches that stop wiring the subshell MCP into a pane: one per +// account, one per machine. +// +// Both default to 1, which is the deliberate inversion of the SSH (0047) and +// preview (0062) gates beside them. Those features are opt-in, so an upgrade +// leaves the fleet exactly as it was by switching them off. MCP wiring already +// happens on every launch, so "as it was" here means ON: a DEFAULT 0 would +// strip cross-agent comms from every account and machine the moment the +// migration ran, silently, and nothing else in the suite would notice. +// +// The node column is the plane's record; the machine's mirror file is written +// from it and only ever read to refuse what the row already refused. +export async function up(db: Kysely): Promise { + await db.schema + .alterTable("user_meta") + .addColumn("mcp_enabled", "integer", (col) => col.notNull().defaultTo(1)) + .execute(); + await db.schema + .alterTable("nodes") + .addColumn("mcp_enabled", "integer", (col) => col.notNull().defaultTo(1)) + .execute(); + await db.schema.alterTable("nodes").addColumn("mcp_enabled_at", "text").execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("nodes").dropColumn("mcp_enabled_at").execute(); + await db.schema.alterTable("nodes").dropColumn("mcp_enabled").execute(); + await db.schema.alterTable("user_meta").dropColumn("mcp_enabled").execute(); +} diff --git a/apps/server/api/src/db/migrations/__tests__/0073-mcp-enabled.test.ts b/apps/server/api/src/db/migrations/__tests__/0073-mcp-enabled.test.ts new file mode 100644 index 000000000..9fb4d583b --- /dev/null +++ b/apps/server/api/src/db/migrations/__tests__/0073-mcp-enabled.test.ts @@ -0,0 +1,115 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { Kysely, sql } from "kysely"; +import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import * as initMigration from "@/db/migrations/0001-init.js"; +import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; +import * as profileDefaultFlagMigration from "@/db/migrations/0010-profile-default-flag.js"; +import * as nodesMigration from "@/db/migrations/0017-nodes.js"; +import * as mcpEnabledMigration from "@/db/migrations/0073-mcp-enabled.js"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +interface MigrationDatabase { + [table: string]: Record; + nodes: Record; + user_meta: Record; +} + +/** + * The two MCP-use switch columns. Default 1 INVERTS 0047/0062's doctrine, and + * that inversion is the whole upgrade story this test guards: MCP wiring + * already happens on every launch, so an upgrade must leave it on. A DEFAULT 0 + * slipping in would strip cross-agent comms from every existing account and + * machine on the next boot, and no other suite would notice, because the + * launch path reads the column with the same default. + */ +describe("migration 0073-mcp-enabled", () => { + let dbFile: string; + let db: Kysely; + + /** Insert a node row the way the pre-0073 repository did - no MCP fields at all. */ + const legacyNode = (id: string) => + db + .insertInto("nodes") + .values({ + id, + owner_user_id: "u1", + name: id, + kind: "agent", + created_at: "2026-11-01T00:00:00.000Z", + updated_at: "2026-11-01T00:00:00.000Z", + }) + .execute(); + + /** A user_meta row written before the column existed. */ + const legacyMeta = (userId: string) => db.insertInto("user_meta").values({ user_id: userId }).execute(); + + beforeAll(async () => { + dbFile = `/tmp/subshell-0073-${Math.random().toString(36).slice(2)}.db`; + db = new Kysely({ dialect: new BunSqliteDialect({ database: openSqliteDatabase(dbFile) }) }); + await initMigration.up(db); + await remoteOpsMigration.up(db); + await profileDefaultFlagMigration.up(db); + await nodesMigration.up(db); + // Written BEFORE the columns exist - the upgrade case this test is about. + await legacyNode("pre-existing"); + await legacyMeta("u1"); + await mcpEnabledMigration.up(db); + }); + + afterAll(async () => { + await db.destroy(); + }); + + it("adds the three columns", async () => { + const nodeCols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const nodeNames = nodeCols.rows.map((c) => c.name); + expect(nodeNames).toContain("mcp_enabled"); + expect(nodeNames).toContain("mcp_enabled_at"); + const metaCols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('user_meta')`.execute(db); + expect(metaCols.rows.map((c) => c.name)).toContain("mcp_enabled"); + }); + + it("leaves every pre-existing node and account ENABLED (default-on doctrine)", async () => { + const r = await sql<{ + mcp_enabled: number; + mcp_enabled_at: string | null; + }>`SELECT mcp_enabled, mcp_enabled_at FROM nodes WHERE id = 'pre-existing'`.execute(db); + expect(r.rows[0]).toEqual({ mcp_enabled: 1, mcp_enabled_at: null }); + const m = await sql<{ mcp_enabled: number }>`SELECT mcp_enabled FROM user_meta WHERE user_id = 'u1'`.execute(db); + expect(m.rows[0].mcp_enabled).toBe(1); + }); + + it("defaults a row written without the columns to 1", async () => { + await legacyNode("fresh"); + const r = await sql<{ + mcp_enabled: number; + }>`SELECT mcp_enabled FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0].mcp_enabled).toBe(1); + }); + + it("stores a refusal with its stamp", async () => { + await db + .updateTable("nodes") + .set({ mcp_enabled: 0, mcp_enabled_at: "2026-11-01T10:00:00.000Z" }) + .where("id", "=", "fresh") + .execute(); + const r = await sql<{ + mcp_enabled: number; + mcp_enabled_at: string; + }>`SELECT mcp_enabled, mcp_enabled_at FROM nodes WHERE id = 'fresh'`.execute(db); + expect(r.rows[0]).toEqual({ mcp_enabled: 0, mcp_enabled_at: "2026-11-01T10:00:00.000Z" }); + }); + + it("down removes all three columns and leaves the rows", async () => { + await mcpEnabledMigration.down(db); + const nodeCols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('nodes')`.execute(db); + const nodeNames = nodeCols.rows.map((c) => c.name); + expect(nodeNames).not.toContain("mcp_enabled"); + expect(nodeNames).not.toContain("mcp_enabled_at"); + const metaCols = await sql<{ name: string }>`SELECT name FROM pragma_table_info('user_meta')`.execute(db); + expect(metaCols.rows.map((c) => c.name)).not.toContain("mcp_enabled"); + const left = await sql<{ id: string }>`SELECT id FROM nodes ORDER BY id`.execute(db); + expect(left.rows.map((r) => r.id)).toEqual(["fresh", "pre-existing"]); + await mcpEnabledMigration.up(db); // leave the DB on the current shape + }); +}); diff --git a/apps/server/api/src/db/repositories/nodes.repository.ts b/apps/server/api/src/db/repositories/nodes.repository.ts index 2545671b1..552359686 100644 --- a/apps/server/api/src/db/repositories/nodes.repository.ts +++ b/apps/server/api/src/db/repositories/nodes.repository.ts @@ -69,6 +69,12 @@ export class NodesRepository extends BaseRepository { virtualDisplayEnabled: input.virtualDisplayEnabled ?? 0, previewEnabledAt: input.previewEnabledAt ?? null, sshWideForwards: 0, + // A new node wires the MCP: `1` is the column default (migration 0073), + // because wiring predates the switch. Mirroring it here keeps the read + // back complete, and it is the opposite of the two flags above: an + // enrolled machine starts exactly as every machine already behaves. + mcpEnabled: input.mcpEnabled ?? 1, + mcpEnabledAt: input.mcpEnabledAt ?? null, createdAt: input.createdAt ?? now, updatedAt: now, }) @@ -340,6 +346,35 @@ export class NodesRepository extends BaseRepository { .executeTakeFirst(); } + /** + * Write the MCP-use switch and its stamp. + * + * Same shape as {@link setSshEnabled} and {@link setPreviewEnabled}: both + * fields move together, and the caller supplies `changedAt`, because the + * stamp is the reconciliation protocol between this row and the machine's + * mirror file. A relay never re-stamps the fact it is carrying. + */ + async setMcpEnabled( + id: string, + state: { + /** False = the subshell MCP is refused in every pane launched here. */ + on: boolean; + /** ISO 8601 of the write that produced `on`. Never re-stamped on relay. */ + changedAt: string; + }, + ): Promise { + return await this.db + .updateTable("nodes") + .set({ + mcpEnabled: state.on ? 1 : 0, + mcpEnabledAt: state.changedAt, + updatedAt: new Date().toISOString(), + }) + .where("id", "=", id) + .returningAll() + .executeTakeFirst(); + } + /** * Write the SSH wide-forwards allowance (spec 2026-10-15 §6). No stamp * column: unlike {@link setSshEnabled} there is no node-side mirror to diff --git a/apps/server/api/src/db/repositories/user-meta.repository.ts b/apps/server/api/src/db/repositories/user-meta.repository.ts index 0d84c7eef..feda96020 100644 --- a/apps/server/api/src/db/repositories/user-meta.repository.ts +++ b/apps/server/api/src/db/repositories/user-meta.repository.ts @@ -231,6 +231,36 @@ export class UserMetaRepository extends BaseRepository { .execute(); } + /** + * Whether this account's panes may be wired with the subshell MCP. A missing + * row or the column default reads as enabled: wiring is what every account + * got before the switch existed, so silence must not mean refusal — the same + * reading {@link getNotifyEnabled} gives. + * @param userId - better-auth user id + */ + async getMcpEnabled(userId: string): Promise { + const row = await this.db + .selectFrom("userMeta") + .select("mcpEnabled") + .where("userId", "=", userId) + .executeTakeFirst(); + return (row?.mcpEnabled ?? 1) === 1; + } + + /** + * Sets the account's MCP switch. Upserts for the same reason as + * {@link setNotifyEnabled}: an account whose `user_meta` row was never + * created still has to be able to answer, and `role` is used only if the row + * is being created. + */ + async setMcpEnabled(userId: string, on: boolean): Promise { + await this.db + .insertInto("userMeta") + .values({ userId, role: "user", mcpEnabled: on ? 1 : 0 }) + .onConflict((oc) => oc.column("userId").doUpdateSet({ mcpEnabled: on ? 1 : 0 })) + .execute(); + } + /** * The user's terminal attach history cap, or null when they have no * preference (the instance default applies). A missing row reads as "no diff --git a/apps/server/api/src/db/types/nodes.db-types.ts b/apps/server/api/src/db/types/nodes.db-types.ts index 03da9c769..f208db553 100644 --- a/apps/server/api/src/db/types/nodes.db-types.ts +++ b/apps/server/api/src/db/types/nodes.db-types.ts @@ -135,6 +135,23 @@ export interface NodeTable { * per machine and belongs to the owner, never to a connection's author. */ sshWideForwards: number; + /** + * 1 = the subshell MCP may be wired into panes launched on this machine. + * Default 1, the inversion of {@link sshEnabled} and {@link webPreviewsEnabled}: + * those features are opt-in, while MCP wiring predates its own switch, so an + * upgrade has to leave every machine wiring exactly what it wired before. A + * 0 here refuses for everyone: it outranks any account's own preference, + * because the machine's owner gets the last word on what runs there. + */ + mcpEnabled: number; + /** + * ISO 8601 of the write that last changed {@link mcpEnabled} (the wire's + * `changedAt`), or null when the flag was never written. Same role + * {@link sshEnabledAt} plays for SSH: the stamp rides the flag on every push + * so the two copies end byte-identical. Null is a real answer here, and it + * means "wiring is allowed, and no one has pushed a value yet". + */ + mcpEnabledAt: string | null; /** * JSON mirror of the ssh-fingerprint block from this node's last runtime * report (spec 2026-10-08 §4.5), so the trust card survives an offline node diff --git a/apps/server/api/src/db/types/user-meta.db-types.ts b/apps/server/api/src/db/types/user-meta.db-types.ts index 8548fbc0f..00a85c1d0 100644 --- a/apps/server/api/src/db/types/user-meta.db-types.ts +++ b/apps/server/api/src/db/types/user-meta.db-types.ts @@ -17,6 +17,16 @@ export interface UserMetaTable { * reads always yield a number. */ notifyEnabled: Generated; + /** + * 1 = panes this account launches may be wired with the subshell MCP; 0 = + * they launch without it, and the pane's token is minted with the reporting + * scope only. `Generated` mirrors the column's `NOT NULL DEFAULT 1`, and an + * absent `user_meta` row reads the same way: MCP wiring is what every + * account got before the switch existed, so an upgrade must not quietly + * strip it. `Generated` also means registration can omit it, exactly as + * {@link notifyEnabled} does. + */ + mcpEnabled: Generated; /** * Per-user terminal attach history cap (trailing lines replayed before the * live tail). NULL = no preference → instance default @@ -54,16 +64,24 @@ export interface UserMetaTable { } /** - * Insert shape. `notifyEnabled`, `disabled`, `setupStep`, `approvalState` and - * `pendingArrivedAt` are optional so registration (which knows only id + role) - * stays valid; an omitted value takes the DB default (1 = notifications on, - * 0 = not disabled, NULL = no wizard in progress, "approved", NULL). + * Insert shape. `notifyEnabled`, `mcpEnabled`, `disabled`, `setupStep`, + * `approvalState` and `pendingArrivedAt` are optional so registration (which + * knows only id + role) stays valid; an omitted value takes the DB default + * (1 = notifications on, 1 = MCP wiring on, 0 = not disabled, NULL = no wizard + * in progress, "approved", NULL). */ export type NewUserMeta = Omit< UserMetaTable, - "notifyEnabled" | "terminalReplayLines" | "disabled" | "setupStep" | "approvalState" | "pendingArrivedAt" + | "notifyEnabled" + | "mcpEnabled" + | "terminalReplayLines" + | "disabled" + | "setupStep" + | "approvalState" + | "pendingArrivedAt" > & { notifyEnabled?: number; + mcpEnabled?: number; terminalReplayLines?: number | null; disabled?: number; approvalState?: string; diff --git a/apps/server/api/src/index.ts b/apps/server/api/src/index.ts index 2f7229d0c..fd75118c3 100644 --- a/apps/server/api/src/index.ts +++ b/apps/server/api/src/index.ts @@ -55,6 +55,7 @@ import { prepareLocalPlugins } from "@/modules/plugins/store.js"; import { reconcileMaintenance } from "@/modules/nodes/maintenance.js"; import { setNodeLifecycleHooks } from "@/modules/nodes/node-events.js"; import { listOnline } from "@/modules/nodes/node-registry.js"; +import { reconcileMcpEnabled } from "@/modules/nodes/mcp-enabled.js"; import { reconcilePreviewEnabled } from "@/modules/nodes/preview-enabled.js"; import { ensureLocalNode } from "@/modules/nodes/seed-local.js"; import { reconcileSshEnabled } from "@/modules/nodes/ssh-enabled.js"; @@ -334,6 +335,11 @@ async function bootServer(): Promise { // The preview switch's sibling (pane-preview spec Task 3): same door, same // direction rule: the row is the record, so this reconcile only pushes. onPreviewEnabled: (nodeId, reported) => reconcilePreviewEnabled(nodeId, reported), + // The MCP-use switch's sibling: same door, same direction rule (the row is + // the record, so this only pushes), and one different default: an + // unreported mirror means the machine is still wiring, which is agreement + // with a default row rather than the refusal the other two gates read. + onMcpEnabled: (nodeId, reported) => reconcileMcpEnabled(nodeId, reported), }); // Restore alive/exit state at boot: a backend restart mid-subshell must not // leave stale alive=1 rows (tmux subshells died with the old process). diff --git a/apps/server/api/src/modules/backups/database.ts b/apps/server/api/src/modules/backups/database.ts index 9594ec6fb..5ec97c3ed 100644 --- a/apps/server/api/src/modules/backups/database.ts +++ b/apps/server/api/src/modules/backups/database.ts @@ -11,7 +11,7 @@ import { assertSafeHostPath } from "./paths.js"; import type { BackupAdmin } from "./types.js"; /** Latest supported application migration; its registration is pinned by the engine tests. */ -export const LATEST_BACKUP_MIGRATION = "0072-service-preview-configuration"; +export const LATEST_BACKUP_MIGRATION = "0073-mcp-enabled"; /** Snapshot a live SQLite database using VACUUM INTO, including committed WAL state. */ export async function snapshotDatabase(sourcePath: string, destinationPath: string): Promise { diff --git a/apps/server/api/src/modules/nodes/__tests__/mcp-enabled.test.ts b/apps/server/api/src/modules/nodes/__tests__/mcp-enabled.test.ts new file mode 100644 index 000000000..b5d38b8d8 --- /dev/null +++ b/apps/server/api/src/modules/nodes/__tests__/mcp-enabled.test.ts @@ -0,0 +1,334 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { hashPassword } from "better-auth/crypto"; +import { db } from "@/db/index.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; +import { UsersRepository } from "@/db/repositories/users.repository.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; +import { + decideMcpEnabled, + getNodeMcpEnabled, + pushSetMcpEnabled, + reconcileMcpEnabled, + setNodeMcpEnabled, +} from "@/modules/nodes/mcp-enabled.js"; +import { resetNodeRegistryForTests } from "@/modules/nodes/node-registry.js"; +import { deleteUserByEmailOrId, setupAuthTables } from "@/testing/api/auth-tables.js"; +import { attachScriptedNode, ok } from "@/testing/scripted-node.js"; + +/** + * The MCP switch's plane half: the decide table (pure, the ssh/preview + * suites' pattern) and the act on a real row plus a scripted node (its seam: + * no RPC mocks; the push travels the real `sendCommand` chain and lands in the + * scripted wire, which is what proves the `set_mcp_enabled` frame parses + * against the shipped parser). + * + * Two properties this suite exists to pin, and they pull in opposite + * directions. Like its siblings, the plane is the sole writer: a reconcile + * NEVER adopts, every disagreement ends with the row's value pushed down. + * UNLIKE its siblings, silence reads ALLOWED: MCP wiring predates the switch, + * so the pre-existing row and the never-pushed machine agree by default, and + * only an explicit refusal splits them. Read the silence rule off the ssh + * clone and every unpinned machine starts refusing. + */ + +const nodes = new NodesRepository(db); +const nodeIds: string[] = []; +const emails: string[] = []; +let ownerId: string; + +async function mkUser(): Promise { + const email = `mcp-on-${crypto.randomUUID()}@subshell.local`; + emails.push(email); + return await new UsersRepository(db).createUser({ + email, + name: email, + passwordHash: await hashPassword("mcp-pass-1"), + role: "user", + }); +} + +/** An enrolled agent node with no live socket. Every command on it fails offline. */ +async function mkNode(): Promise { + const id = crypto.randomUUID(); + nodeIds.push(id); + await nodes.create({ id, ownerUserId: ownerId, name: `mcp-${id.slice(0, 8)}`, kind: "agent", status: "online" }); + return id; +} + +/** Audit rows for one target, newest last. */ +async function auditFor(targetId: string): Promise<{ actorUserId: string | null; meta: unknown }[]> { + const rows = await db + .selectFrom("auditEvents") + .select(["actorUserId", "metadataJson"]) + .where("targetId", "=", targetId) + .where("action", "=", "node.mcp_enabled.update") + .orderBy("createdAt", "asc") + .execute(); + return rows.map((r) => ({ actorUserId: r.actorUserId, meta: JSON.parse(r.metadataJson ?? "null") })); +} + +/** Let the void-fired pushes land (the maintenance-apply posture). */ +const settle = () => new Promise((r) => setTimeout(r, 25)); + +/** + * Narrow a stamp read back from the row to the `string` the wire shape wants. + * A null here IS a failure of the test's own subject, so it throws loudly + * rather than asserting `!` over a value that could be null. + */ +function stamped(value: string | null | undefined): string { + if (typeof value !== "string") throw new Error(`the row carries no mcp stamp: ${String(value)}`); + return value; +} + +beforeAll(async () => { + await setupAuthTables(); + ownerId = await mkUser(); +}); + +afterAll(async () => { + resetNodeRegistryForTests(); + for (const id of nodeIds) await nodes.deleteById(id); + for (const mail of emails) await deleteUserByEmailOrId(mail); +}); + +/* ------------------ the pure table -------------------------- */ + +describe("decideMcpEnabled: silence allows, and the plane is still the only author", () => { + const row = (mcpEnabled: 0 | 1, mcpEnabledAt: string | null) => ({ mcpEnabled, mcpEnabledAt }); + + it("agreement is a noop: reported matches the row, either value", () => { + expect( + decideMcpEnabled(row(1, "2026-11-01T10:00:00.000Z"), { on: true, changedAt: "2026-11-01T10:00:00.000Z" }), + ).toBe("noop"); + expect( + decideMcpEnabled(row(0, "2026-11-01T10:00:00.000Z"), { on: false, changedAt: "2026-11-01T09:00:00.000Z" }), + ).toBe("noop"); + // Differing stamps under an agreeing value are a relayed fact, not a + // fight: there is no node-side writer to bargain with. + expect( + decideMcpEnabled(row(1, "2026-11-01T12:00:00.000Z"), { on: true, changedAt: "2026-11-01T09:00:00.000Z" }), + ).toBe("noop"); + }); + + it("silence means ALLOWED here: a default or on row agrees; a refused row pushes", () => { + // THE inverted line, pinned. An absent mirror reads ALLOWED (the + // pre-switch status quo), so a never-written or on row and a silent node + // already answer alike. A row the owner refused faces a machine that would + // wire anyway, and the row wins: that push is the repair. + expect(decideMcpEnabled(row(1, null), undefined)).toBe("noop"); + expect(decideMcpEnabled(row(1, "2026-11-01T10:00:00.000Z"), undefined)).toBe("noop"); + expect(decideMcpEnabled(row(0, "2026-11-01T10:00:00.000Z"), undefined)).toBe("push-row"); + }); + + it("disagreement pushes the row, in both directions, and never adopts: not even a NEWER report", () => { + // With no node-side writer, even a future-dated report is just bytes the + // row overwrites. This is the direction rule the ssh suite also pins. + expect( + decideMcpEnabled(row(0, "2026-11-01T10:00:00.000Z"), { on: true, changedAt: "2099-01-01T00:00:00.000Z" }), + ).toBe("push-row"); + expect( + decideMcpEnabled(row(1, "2026-11-01T10:00:00.000Z"), { on: false, changedAt: "2026-11-01T09:00:00.000Z" }), + ).toBe("push-row"); + }); + + it("the one disagreement with nothing to relay is a repair, not a silence", () => { + // Row never written + a mirror saying OFF (a data dir hand-edited or + // carried over): unlike SSH this disagreement is NOT inert, because the + // mirror itself strips wiring. The row's default allowance wins with a + // freshly minted stamp. + expect(decideMcpEnabled(row(1, null), { on: false, changedAt: "2026-11-01T09:00:00.000Z" })).toBe("push-repair"); + }); +}); + +/* ------------------ the act, on a real row -------------------------- */ + +describe("setNodeMcpEnabled: write, audit, push, one function", () => { + it("records nothing at all for a node that is no longer there", async () => { + // The trail records changed that happened. A row that vanished between + // the route's gate read and this UPDATE must not leave an audit line + // behind (the skipped push is unobservable here by nature: no row, no + // socket). Pinning the guard, because moving the audit above the UPDATE + // (or dropping the guard) would otherwise pass every other cell. + const ghost = crypto.randomUUID(); + await expect(setNodeMcpEnabled(ghost, "no-such-actor", false)).resolves.toBeUndefined(); + expect(await auditFor(ghost)).toEqual([]); + }); + + it("writes the row, audits EXACTLY ONE row naming the actor, and pushes the stamped value", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_mcp_enabled: ok }); + try { + await setNodeMcpEnabled(nodeId, ownerId, false); + const row = await nodes.findById(nodeId); + expect(row?.mcpEnabled).toBe(0); + expect(row?.mcpEnabledAt).not.toBeNull(); + await settle(); + // Exactly one audit row: the push adds nothing, and a second writer of + // this act is precisely what the one-function shape exists to prevent. + const rows = await auditFor(nodeId); + expect(rows).toHaveLength(1); + expect(rows[0].actorUserId).toBe(ownerId); + expect(rows[0].meta).toEqual({ on: false }); + // The push is the row's OWN bytes: the wire frame the shipped parser + // accepted, the stored stamp included, not a re-stamped copy. + expect(scripted.cmdsOf("set_mcp_enabled")).toEqual([ + { type: "set_mcp_enabled", on: false, changedAt: stamped(row?.mcpEnabledAt) }, + ]); + } finally { + scripted.detach(); + } + }); + + it("an ON flip lands too: the mirror is written as a value, not deleted", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_mcp_enabled: ok }); + try { + await setNodeMcpEnabled(nodeId, ownerId, true); + const row = await nodes.findById(nodeId); + expect(row?.mcpEnabled).toBe(1); + expect(row?.mcpEnabledAt).not.toBeNull(); + await settle(); + expect(scripted.cmdsOf("set_mcp_enabled")).toEqual([ + { type: "set_mcp_enabled", on: true, changedAt: stamped(row?.mcpEnabledAt) }, + ]); + } finally { + scripted.detach(); + } + }); + + it("survives an offline node: the push is best-effort; the row already refuses", async () => { + const nodeId = await mkNode(); + await expect(setNodeMcpEnabled(nodeId, ownerId, false)).resolves.toBeUndefined(); + expect((await nodes.findById(nodeId))?.mcpEnabled).toBe(0); + }); +}); + +/* ------------------ the default-allow read -------------------------- */ + +describe("getNodeMcpEnabled: the row is the record, read default-ALLOW", () => { + it("a row that does not exist reads ON", async () => { + await expect(getNodeMcpEnabled("no-such-node")).resolves.toBe(true); + }); + + it("a fresh row reads ON until someone flips it off", async () => { + const nodeId = await mkNode(); + await expect(getNodeMcpEnabled(nodeId)).resolves.toBe(true); + await setNodeMcpEnabled(nodeId, ownerId, false); + await expect(getNodeMcpEnabled(nodeId)).resolves.toBe(false); + }); +}); + +/* ------------------ the hook body -------------------------- */ + +describe("reconcileMcpEnabled: decide, push the row, write nothing", () => { + it("agreement pushes NOTHING: the steady state must not echo a command per beat", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_mcp_enabled: ok }); + await setNodeMcpEnabled(nodeId, ownerId, false); + const changedAt = stamped((await nodes.findById(nodeId))?.mcpEnabledAt); + await settle(); + try { + // The `ready` that follows the push carries exactly what was written. + await reconcileMcpEnabled(nodeId, { on: false, changedAt }); + await settle(); + expect(scripted.countOf("set_mcp_enabled")).toBe(1); // only the flip's own push + } finally { + scripted.detach(); + } + }); + + it("disagreement pushes the ROW's value verbatim, and the row never moves", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_mcp_enabled: ok }); + await setNodeMcpEnabled(nodeId, ownerId, false); + const changedAt = stamped((await nodes.findById(nodeId))?.mcpEnabledAt); + await settle(); + try { + // The machine reports ALLOWED (its mirror corrupted, say) under an OFF + // row: the answer is the row pushed down, never the report pulled up. + await reconcileMcpEnabled(nodeId, { on: true, changedAt: "2026-11-01T13:00:00.000Z" }); + await settle(); + const pushes = scripted.cmdsOf("set_mcp_enabled"); + expect(pushes).toHaveLength(2); + expect(pushes[1]).toEqual({ type: "set_mcp_enabled", on: false, changedAt }); // the row's bytes, stamp included + expect((await nodes.findById(nodeId))?.mcpEnabledAt).toBe(changedAt); // unmoved: never adopted, never re-stamped + } finally { + scripted.detach(); + } + }); + + it("row OFF + node silent → the refusal is handed back, and the reconcile itself writes no row and no audit", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_mcp_enabled: ok }); + await setNodeMcpEnabled(nodeId, ownerId, false); + const changedAt = stamped((await nodes.findById(nodeId))?.mcpEnabledAt); + await settle(); + const auditsBefore = (await auditFor(nodeId)).length; + try { + // A wiped data dir: no mirror, no stamp to offer, the row's refusal is + // re-armed by the push (the decide table's same cell). + await reconcileMcpEnabled(nodeId, undefined); + await settle(); + expect(scripted.cmdsOf("set_mcp_enabled")).toEqual([ + { type: "set_mcp_enabled", on: false, changedAt }, + { type: "set_mcp_enabled", on: false, changedAt }, + ]); + expect((await auditFor(nodeId)).length).toBe(auditsBefore); // pushes are not acts; only flips audit + } finally { + scripted.detach(); + } + }); + + it("a mirror refusing under an unwritten row repairs with a fresh stamp and leaves the row allowed", async () => { + const nodeId = await mkNode(); + const scripted = attachScriptedNode(nodeId, { set_mcp_enabled: ok }); + try { + // Nothing was ever written here, so there is no stamp to relay: the + // repair mints one for the PUSH only (the row stays unwritten), and the + // row's allowance is what travels. + await reconcileMcpEnabled(nodeId, { on: false, changedAt: "2026-10-01T00:00:00.000Z" }); + await settle(); + const pushes = scripted.cmdsOf("set_mcp_enabled"); + expect(pushes).toHaveLength(1); + expect(pushes[0]).toEqual({ type: "set_mcp_enabled", on: true, changedAt: expect.any(String) }); + const row = await nodes.findById(nodeId); + expect(row?.mcpEnabled).toBe(1); + expect(row?.mcpEnabledAt).toBeNull(); // the repair writes the machine, never the record + } finally { + scripted.detach(); + } + }); + + it("does nothing for a node that is not enrolled here", async () => { + await expect( + reconcileMcpEnabled("not-a-node", { on: false, changedAt: "2026-11-01T10:00:00.000Z" }), + ).resolves.toBeUndefined(); + }); +}); + +/* ------------------ `local` -------------------------- */ + +describe("`local` has no mirror to push to", () => { + it("pushSetMcpEnabled is a NO-OP for local: resolves without a socket, where an agent would fail offline", async () => { + // The proof is the contrast: `sendCommand` to a node with no live socket + // rejects (the offline case above swallows exactly that). Local must + // RESOLVE: its answer to the gate is the DB row, and there is no second + // copy to tell. + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + await expect( + pushSetMcpEnabled(LOCAL_NODE_ID, { on: false, changedAt: "2026-11-01T10:00:00.000Z" }), + ).resolves.toBeUndefined(); + }); + + it("setNodeMcpEnabled works on local: row + audit, and the flip completes", async () => { + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: ownerId, name: "Server", kind: "local" }).catch(() => {}); + await setNodeMcpEnabled(LOCAL_NODE_ID, ownerId, false); + expect((await nodes.findById(LOCAL_NODE_ID))?.mcpEnabled).toBe(0); + // Restore the shipped default so no later expectation in this file's DB + // inherits a closed server row (each test file gets its own database, and + // audit-counting tests filter by targetId, so the extra audit rows here + // are harmless by design). + await setNodeMcpEnabled(LOCAL_NODE_ID, ownerId, true); + expect((await nodes.findById(LOCAL_NODE_ID))?.mcpEnabled).toBe(1); + }); +}); diff --git a/apps/server/api/src/modules/nodes/__tests__/node-events.test.ts b/apps/server/api/src/modules/nodes/__tests__/node-events.test.ts index 94edc5bc7..584d8980d 100644 --- a/apps/server/api/src/modules/nodes/__tests__/node-events.test.ts +++ b/apps/server/api/src/modules/nodes/__tests__/node-events.test.ts @@ -257,6 +257,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { onMaintenance: () => {}, onSshEnabled: () => {}, onPreviewEnabled: () => {}, + onMcpEnabled: () => {}, }); const h = makeHarness(); const ws = fakeSocket("n1"); @@ -284,6 +285,7 @@ describe("exit / subshells_report → lifecycle hooks (spec §3.3)", () => { onMaintenance: () => {}, onSshEnabled: () => {}, onPreviewEnabled: () => {}, + onMcpEnabled: () => {}, onSubshellsReport: (nodeId, report) => { seen.push({ nodeId, report }); }, diff --git a/apps/server/api/src/modules/nodes/__tests__/remote-launcher.test.ts b/apps/server/api/src/modules/nodes/__tests__/remote-launcher.test.ts index a3c2a892e..d405e6b1e 100644 --- a/apps/server/api/src/modules/nodes/__tests__/remote-launcher.test.ts +++ b/apps/server/api/src/modules/nodes/__tests__/remote-launcher.test.ts @@ -177,6 +177,8 @@ function nodeRow(over: Partial = {}): NodeTable { sshEnabled: 0, sshWideForwards: 0, sshEnabledAt: null, + mcpEnabled: 1, + mcpEnabledAt: null, webPreviewsEnabled: 0, virtualDisplayEnabled: 0, previewEnabledAt: null, diff --git a/apps/server/api/src/modules/nodes/mcp-enabled.ts b/apps/server/api/src/modules/nodes/mcp-enabled.ts new file mode 100644 index 000000000..7d1a1adad --- /dev/null +++ b/apps/server/api/src/modules/nodes/mcp-enabled.ts @@ -0,0 +1,166 @@ +import type { NodeMcpEnabledWire } from "@internal/subshell-protocol"; +import { getRequestlessContext } from "@/app/context.js"; +import { LOCAL_NODE_ID, type NodeTable } from "@/db/types/nodes.db-types.js"; +import { audit } from "@/modules/audit.js"; +import { sendCommand } from "@/modules/nodes/node-rpc.js"; +import { logger } from "@/shared/logger.js"; + +/** + * The per-node MCP-use switch, written in exactly one place: the posture + * `ssh-enabled.ts` and `preview-enabled.ts` established. One change site, the + * plane as sole author, the machine holding a mirror it never votes on, and a + * reconcile that ends every disagreement by pushing the row. + * + * **It differs from both of them in one place, and that place is the whole + * point of reading this file before copying one.** This flag defaults to + * ALLOWED. SSH and previews gate features that are opt-in, so a machine that + * has never been pushed a value refuses, and silence is safely read as + * refusal. MCP wiring predates its own switch, so an upgrade must leave every + * machine wiring exactly what it wired before: an absent mirror reads allowed, + * and only an explicit `on: false` refuses. Read this the other way round and + * the first boot after the migration strips cross-agent comms from a fleet + * that never opted out. + * + * The row is still the record. The mirror exists so a machine can refuse what + * a stale or buggy plane tries to wire anyway, which is the difference between + * "the plane skips it" and "it isn't available on this node". + */ + +/** + * Read the switch the way the launch gate reads it. An absent row or an + * unwritten column reads as ALLOWED, which is the whole fleet's status quo; + * only an explicit 0 refuses. + */ +export async function getNodeMcpEnabled(nodeId: string): Promise { + const row = await getRequestlessContext().repos.nodes.findById(nodeId); + return (row?.mcpEnabled ?? 1) !== 0; +} + +/** + * Flip one node's MCP switch: row, audit, and the best-effort push, all in + * this one function, so no caller can perform half the act. The stamp is + * minted HERE (one change site mints once); the route supplies the acting + * human, never a time. + * + * Nothing is torn down on the way through. A pane whose MCP child is already + * running keeps it: the switch reaches launches and restarts, and severing a + * tool set from under a mid-turn agent is a worse surprise than the delay. + */ +export async function setNodeMcpEnabled(nodeId: string, actorUserId: string, on: boolean): Promise { + const changedAt = new Date().toISOString(); + const { repos } = getRequestlessContext(); + const row = await repos.nodes.setMcpEnabled(nodeId, { on, changedAt }); + // The trail records changed that happened. If the row vanished between the + // gate's read and this UPDATE, nothing changed, so nothing is recorded or + // pushed. + if (!row) return; + await audit({ + actorUserId, + action: "node.mcp_enabled.update", + targetType: "node", + targetId: nodeId, + metadataJson: JSON.stringify({ on }), + }); + // The row is written and audited BEFORE the push, so a node that answers the + // command cannot be looking at a mirror the record has not caught up to, and + // a push that fails (the node is offline) costs nothing: the value re-lands + // at the node's next `ready` through {@link reconcileMcpEnabled}. + pushSetMcpEnabledBestEffort(nodeId, { on: row.mcpEnabled !== 0, changedAt }); +} + +/** The node-row fields {@link decideMcpEnabled} reads. Nothing else is relevant. */ +type McpRow = Pick; + +/** What reconciliation decided about one node's two copies of the flag. */ +export type McpDecision = + /** The two ends agree, or there is nothing coherent to send. */ + | "noop" + /** Relay the row's value with the row's own stamp, byte-identical. */ + | "push-row" + /** + * The row has never been written and the machine is refusing anyway. Push + * the row's allowance with a stamp minted now: a mirror that came apart or + * was hand-edited must not strand a machine on the side nobody chose, and + * there is no existing stamp to relay. + */ + | "push-repair"; + +/** + * Decide what to do when the plane's row and the node's report disagree. + * PURE: it reads two values and answers; the caller performs. + * + * | node reports | plane row | → | + * |---|---|---| + * | nothing | allowed (default or written) | `noop`: silence means ALLOW here, and that matches the row | + * | the row's value | any value | `noop`: agreement, the steady state; a push would echo forever | + * | nothing | refused | `push-row`: the machine lost its file while the owner said no; hand it back | + * | the other value | with a stamp | `push-row`: the row is the record; the machine is told, never asked | + * | the other value | never written | `push-repair`: nothing exists to relay, and unlike SSH this disagreement is NOT inert, because the mirror itself strips wiring. The row's allowance wins with a fresh stamp | + * + * Stamps are never compared, and nothing ever adopts: an old, corrupt, or + * hand-edited mirror does not move the row. That direction is the rule. + * + * @param row - the plane's node row (the two MCP fields only) + * @param reported - what the machine says its mirror answers, or undefined + * when it reported none (no file, or a malformed `ready` field the lenient + * parser dropped) + */ +export function decideMcpEnabled(row: McpRow, reported: NodeMcpEnabledWire | undefined): McpDecision { + const rowOn = (row.mcpEnabled ?? 1) !== 0; + // Silence reads ALLOWED. This is the inverted line: ssh and preview read it + // as refusal because those features are opt-in. + if ((reported?.on ?? true) === rowOn) return "noop"; + if (row.mcpEnabledAt) return "push-row"; + return "push-repair"; +} + +/** + * Send one node the MCP state the plane holds, so both copies end + * byte-identical. + * + * `local` is skipped: the control-plane host has no agent socket and no mirror + * file, so its answer to the gate is the DB row itself. + */ +export async function pushSetMcpEnabled(nodeId: string, state: NodeMcpEnabledWire): Promise { + if (nodeId === LOCAL_NODE_ID) return; + await sendCommand(nodeId, { type: "set_mcp_enabled", on: state.on, changedAt: state.changedAt }); +} + +/** + * {@link pushSetMcpEnabled}, fire-and-forget with the failure logged. An + * offline node simply misses it: the plane already refuses to compose wiring + * for a row that says no, and the machine re-learns the value on its next + * `ready` through {@link reconcileMcpEnabled}. + */ +export function pushSetMcpEnabledBestEffort(nodeId: string, state: NodeMcpEnabledWire): void { + void pushSetMcpEnabled(nodeId, state).catch((err: unknown) => { + logger + .withError(err) + .warn(`mcp-enabled push failed for node ${nodeId}; its mirror stays stale until it reconnects`); + }); +} + +/** + * Reconcile one node's report against the plane's row: the body of the + * `onMcpEnabled` lifecycle hook, fed by `ready.mcpEnabled` and by the + * `mcp_enabled` event a machine sends when its mirror moved underneath it. + * + * One read and at most one fire-and-forget push. Nothing to stop (an MCP flip + * severs no live pane), nothing to write, nothing to await. A row that + * disagrees with the machine pushes; never the other direction. + */ +export async function reconcileMcpEnabled(nodeId: string, reported: NodeMcpEnabledWire | undefined): Promise { + const { repos } = getRequestlessContext(); + const row = await repos.nodes.findById(nodeId); + if (!row) return; + const on = (row.mcpEnabled ?? 1) !== 0; + const decision = decideMcpEnabled(row, reported); + if (decision === "noop") return; + if (decision === "push-repair") { + pushSetMcpEnabledBestEffort(nodeId, { on, changedAt: new Date().toISOString() }); + return; + } + const stamp = row.mcpEnabledAt; + if (stamp === null) return; // `decideMcpEnabled` only answers push-row when a stamp exists. + pushSetMcpEnabledBestEffort(nodeId, { on, changedAt: stamp }); +} diff --git a/apps/server/api/src/modules/nodes/node-events.ts b/apps/server/api/src/modules/nodes/node-events.ts index 7756ad438..009e6eb28 100644 --- a/apps/server/api/src/modules/nodes/node-events.ts +++ b/apps/server/api/src/modules/nodes/node-events.ts @@ -1,6 +1,7 @@ import type { NodeEvent, NodeMaintenanceWire, + NodeMcpEnabledWire, NodePreviewEnabledWire, NodeSshEnabledWire, } from "@internal/subshell-protocol"; @@ -133,6 +134,22 @@ export interface NodeLifecycleHooks { * field the lenient parser dropped, all of which mean REFUSED there) */ onPreviewEnabled(nodeId: string, reported: NodePreviewEnabledWire | undefined): Promise | void; + /** + * The machine's own copy of its MCP-use switch: from `ready.mcpEnabled` at + * connect, or from the `mcp_enabled` event when the mirror moved underneath + * the connection (in practice: corruption). + * + * Same door and same direction rule as {@link onPreviewEnabled}: the plane is + * this flag's only writer, so reconciliation pushes and never adopts. What + * differs is the value of silence. An absent mirror reads ALLOWED here rather + * than refused, because MCP wiring is older than the switch, so an + * unreported mirror agrees with a default row and the reconcile stays quiet. + * + * @param nodeId - the SOCKET's authenticated identity, never a frame's claim + * @param reported - the machine's mirror answer, or undefined when it + * reported none, which means ALLOWED here + */ + onMcpEnabled(nodeId: string, reported: NodeMcpEnabledWire | undefined): Promise | void; } let lifecycleHooks: NodeLifecycleHooks | undefined; diff --git a/apps/server/api/src/modules/subshells/__tests__/subshell-manager-mcp.test.ts b/apps/server/api/src/modules/subshells/__tests__/subshell-manager-mcp.test.ts index 7f1202b34..d5ac0b6b0 100644 --- a/apps/server/api/src/modules/subshells/__tests__/subshell-manager-mcp.test.ts +++ b/apps/server/api/src/modules/subshells/__tests__/subshell-manager-mcp.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { TmuxRunner } from "@internal/pane-runtime"; import { CamelCasePlugin, Kysely } from "kysely"; import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { db as sharedDb } from "@/db/index.js"; import * as initMigration from "@/db/migrations/0001-init.js"; import * as operatorUxMigration from "@/db/migrations/0002-operator-ux.js"; import * as remoteOpsMigration from "@/db/migrations/0003-remote-ops.js"; @@ -26,13 +27,17 @@ import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import * as subshellParentMigration from "@/db/migrations/0060-subshell-parent.js"; import * as presetEnabledMigration from "@/db/migrations/0068-preset-enabled.js"; import { openSqliteDatabase } from "@/db/open-database.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; +import { UserMetaRepository } from "@/db/repositories/user-meta.repository.js"; import type { Database } from "@/db/types/index.js"; +import { LOCAL_NODE_ID } from "@/db/types/nodes.db-types.js"; import type { SubshellTable, SubshellUpdate } from "@/db/types/subshells.db-types.js"; import { prepareLocalPlugins } from "@/modules/plugins/store.js"; import { subshellMcpConfigPath } from "@/modules/subshells/mcp-launch.js"; import { SubshellManagerService, type SubshellTokenProvider } from "@/modules/subshells/subshell-manager.service.js"; +import type { PaneTokenPermissions } from "@/modules/subshells/tokens.js"; import { seedPreset } from "@/testing/seed-preset.js"; import { ensureMigratedTestDb } from "@/testing/test-database.js"; @@ -93,8 +98,11 @@ class MockTmux extends TmuxRunner { class StubTokens implements SubshellTokenProvider { issued: string[] = []; revoked: string[] = []; - async issue(subshellId: string): Promise { + /** The grant map each issue call carried, so the MCP-off narrowing is visible. */ + issuedPermissions: (PaneTokenPermissions | undefined)[] = []; + async issue(subshellId: string, _userId?: string, _ceiling?: unknown, perms?: PaneTokenPermissions): Promise { this.issued.push(subshellId); + this.issuedPermissions.push(perms); return `subshell_stub_${subshellId.slice(0, 8)}`; } async revoke(subshellId: string): Promise { @@ -329,6 +337,93 @@ describe("createSubshell MCP integration", () => { }); }); +/** + * The MCP-use switch at the launch. The decision reads the SHARED singleton + * (the same context the allowlist and auto-restart gates read), so these + * tests write their state through `sharedDb` and restore the shipped default + * after themselves. What OFF changes is the REGISTRATION and the token scope, + * never the credential env: `subshellMcpEnv` is the harness hooks' credential + * too, and a pane that reports its exit code still needs a key to report with. + */ +describe("the subshell MCP switch: an OFF launch carries no wiring", () => { + const userMeta = new UserMetaRepository(sharedDb); + const nodes = new NodesRepository(sharedDb); + + it("account off: no registration, no config file, and STILL a minted token and baked env", async () => { + await userMeta.setMcpEnabled("u1", false); + try { + const created = await manager.createSubshell({ + userId: "u1", + harnessId: "claude-code", + presetId, + workingDir: testDir, + }); + // The token is minted anyway (the hooks ride it) but narrowed to + // self-reporting: the depth decision, made visible at the seam. + expect(tokens.issued).toEqual([created.id]); + expect(tokens.issuedPermissions.at(-1)).toEqual({ report: ["write"] }); + const cmd = tmux.newSubshellCmds[0] ?? ""; + expect(cmd).toContain(`SUBSHELL_API_KEY='subshell_stub_${created.id.slice(0, 8)}'`); + expect(cmd).toContain(`SUBSHELL_ID='${created.id}'`); + expect(cmd).not.toContain("--mcp-config"); + expect(existsSync(subshellMcpConfigPath(created.id))).toBe(false); + await manager.terminateSubshell("u1", created.id); + } finally { + await userMeta.setMcpEnabled("u1", true); + } + }); + + it("account off on restart: the relaunched pane is unwired, and the flip destroys nothing that is running", async () => { + const created = await manager.createSubshell({ + userId: "u1", + harnessId: "claude-code", + presetId, + workingDir: testDir, + }); + expect(existsSync(subshellMcpConfigPath(created.id))).toBe(true); // the ON launch wrote it + await userMeta.setMcpEnabled("u1", false); + try { + // The flip itself touches no live pane: the switch reaches launches and + // restarts, and severing a tool set from under a mid-turn agent is the + // worse surprise. + expect(existsSync(subshellMcpConfigPath(created.id))).toBe(true); + const restarted = await manager.restartSubshell("u1", created.id); + if (!restarted) throw new Error("restart returned null"); + const cmd = tmux.newSubshellCmds.at(-1) ?? ""; + expect(cmd).not.toContain("--mcp-config"); + expect(cmd).toContain("SUBSHELL_API_KEY="); + expect(tokens.issuedPermissions.at(-1)).toEqual({ report: ["write"] }); + await manager.terminateSubshell("u1", created.id); + } finally { + await userMeta.setMcpEnabled("u1", true); + } + }); + + it("node row off (local): the machine outranks an account that says yes", async () => { + if (!(await nodes.findById(LOCAL_NODE_ID))) { + await nodes.create({ id: LOCAL_NODE_ID, ownerUserId: "system", name: "Server", kind: "local" }); + } + await nodes.setMcpEnabled(LOCAL_NODE_ID, { on: false, changedAt: new Date().toISOString() }); + try { + expect(await userMeta.getMcpEnabled("u1")).toBe(true); // the account says yes... + const created = await manager.createSubshell({ + userId: "u1", + harnessId: "claude-code", + presetId, + workingDir: testDir, + }); + // ...and the node's refusal wins: no wiring, narrowed token, no file. + const cmd = tmux.newSubshellCmds[0] ?? ""; + expect(cmd).not.toContain("--mcp-config"); + expect(existsSync(subshellMcpConfigPath(created.id))).toBe(false); + expect(tokens.issuedPermissions.at(-1)).toEqual({ report: ["write"] }); + await manager.terminateSubshell("u1", created.id); + } finally { + await nodes.setMcpEnabled(LOCAL_NODE_ID, { on: true, changedAt: new Date().toISOString() }); + } + }); +}); + describe("createSubshell MCP registration per harness dialect", () => { /** Create a preset for an arbitrary harness and a PATH-override stub binary. */ async function harnessPreset(harnessId: string, pathEnv: string, env?: Record): Promise { diff --git a/apps/server/api/src/modules/subshells/__tests__/subshell-manager-remote.test.ts b/apps/server/api/src/modules/subshells/__tests__/subshell-manager-remote.test.ts index 08ef21adc..84ba0a4d3 100644 --- a/apps/server/api/src/modules/subshells/__tests__/subshell-manager-remote.test.ts +++ b/apps/server/api/src/modules/subshells/__tests__/subshell-manager-remote.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { CamelCasePlugin, Kysely } from "kysely"; import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; +import { runAuthMigrations } from "@/db/auth-migrations.js"; +import { db as sharedDb } from "@/db/index.js"; import { runMigrations } from "@/db/migrate.js"; import * as initMigration from "@/db/migrations/0001-init.js"; import * as operatorUxMigration from "@/db/migrations/0002-operator-ux.js"; @@ -25,6 +27,7 @@ import * as keyHomeMigration from "@/db/migrations/0052-subshell-key-home.js"; import * as subshellParentMigration from "@/db/migrations/0060-subshell-parent.js"; import * as presetEnabledMigration from "@/db/migrations/0068-preset-enabled.js"; import { openSqliteDatabase } from "@/db/open-database.js"; +import { NodesRepository } from "@/db/repositories/nodes.repository.js"; import { PresetsRepository } from "@/db/repositories/presets.repository.js"; import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import type { Database } from "@/db/types/index.js"; @@ -72,6 +75,13 @@ beforeAll(async () => { // the shared DB in the same process, green in the full suite, red in any // narrower run. await runMigrations(); + // The MCP-switch cells create and delete node rows on the SHARED + // singleton, and a nodes DELETE makes SQLite resolve the cross-table FK + // graph (`ssh_presets` points at both `nodes` and the better-auth + // `user`). A boot-migrator-only DB has no auth tables, so the delete + // throws "no such table: main.user". A real boot runs both migrators; + // mirroring that here is what makes the cleanup work. + await runAuthMigrations(); dbHandle = new Kysely({ dialect: new BunSqliteDialect({ database: async () => openSqliteDatabase(":memory:") }), plugins: [new CamelCasePlugin()], @@ -256,6 +266,77 @@ describe("manager createSubshell on an agent node (test launcher wins for all no }); }); +describe("manager createSubshell on a node whose MCP switch is off", () => { + // The switch reads the SHARED singleton (`mcpWiringAllowed` goes through the + // requestless context), so the refusal lives on a real row in that DB, not + // on this file's private in-memory handle. + const sharedNodes = new NodesRepository(sharedDb); + + it("produces a frame byte-identical to the capability-skip, with the reporter still present", async () => { + const fake = new FakeNodeLauncher(testDir); + const manager = new SubshellManagerService({ + subshells: subshellsRepo, + presets: presetsRepo, + launcher: fake, + tokens, + audit: async () => {}, + }); + const nodeId = `rmgr-mcp-off-${crypto.randomUUID().slice(0, 8)}`; + await sharedNodes.create({ id: nodeId, ownerUserId: "u1", name: nodeId, kind: "agent" }); + await sharedNodes.setMcpEnabled(nodeId, { on: false, changedAt: new Date().toISOString() }); + const off = nodeOnline(nodeId, ["mcp"]); // the capability is advertised; the switch still refuses + try { + const created = await manager.createSubshell({ + userId: "u1", + harnessId: "claude-code", + presetId, + workingDir: "/tmp", + nodeId, + }); + expect(fake.plans).toHaveLength(1); + // Same shape as the no-capability agent: no registration, no config path. + expect(fake.plans[0].mcp).toBeUndefined(); + expect(fake.plans[0].mcpConfigPath).toBeUndefined(); + // The reporter is NOT gated on the MCP switch (hooks are not an MCP + // feature): it must still name the node's binary. + expect(fake.plans[0].reporter).toEqual({ command: "/usr/bin/subshell", args: ["report"] }); + // The credential env is the hooks' too, so it still lands. + expect(fake.plans[0].subshellEnv.SUBSHELL_API_KEY).toBe("subshell_stub"); + await subshellsRepo.delete(created.id); + } finally { + off(); + await sharedNodes.deleteById(nodeId); + } + }); + + it("the offline throw still fires while off: a gone node is not a switch refusal", async () => { + const fake = new FakeNodeLauncher(testDir); + const manager = new SubshellManagerService({ + subshells: subshellsRepo, + presets: presetsRepo, + launcher: fake, + tokens, + audit: async () => {}, + }); + const nodeId = `rmgr-mcp-off-nolive-${crypto.randomUUID().slice(0, 8)}`; + await sharedNodes.create({ id: nodeId, ownerUserId: "u1", name: nodeId, kind: "agent" }); + await sharedNodes.setMcpEnabled(nodeId, { on: false, changedAt: new Date().toISOString() }); + // No nodeOnline: the agent is not connected. The switch says off AND the + // node is gone; the launch must roll back with the offline error, not + // quietly start a pane on a machine that cannot host it. + try { + const err = await manager + .createSubshell({ userId: "u1", harnessId: "claude-code", presetId, workingDir: "/tmp", nodeId }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(NodeRpcError); + expect((err as NodeRpcError).code).toBe("offline"); + expect(fake.plans).toHaveLength(0); + } finally { + await sharedNodes.deleteById(nodeId); + } + }); +}); + describe("manager restartSubshell on an offline agent row (real launcher path)", () => { it("revive throws the offline-flavored error and rolls the parked row back (mapping to 409 is the service's)", async () => { // NO injected launcher: the manager must resolve the real per-node diff --git a/apps/server/api/src/modules/subshells/mcp-availability.ts b/apps/server/api/src/modules/subshells/mcp-availability.ts new file mode 100644 index 000000000..519c77d20 --- /dev/null +++ b/apps/server/api/src/modules/subshells/mcp-availability.ts @@ -0,0 +1,37 @@ +import { getRequestlessContext } from "@/app/context.js"; +import { getNodeMcpEnabled } from "@/modules/nodes/mcp-enabled.js"; + +/** + * The one question the launch asks about the subshell MCP: may this account's + * pane, on this machine, be wired with it? + * + * Two switches answer, and **off wins on either of them**. An account saying + * no is that person deciding their panes should not carry the tool surface or + * pay its context cost; a node saying no is the machine's owner deciding + * nothing of the MCP runs on their hardware, and that refusal is not something + * a tenant can talk past. So there is no precedence to reason about, only a + * veto: an enabled account on a refused node gets nothing, and the node's + * owner never has to trust a plane that forgot to ask. + * + * Both defaults are ON, which is what makes an upgrade quiet: a row that was + * never written reads allowed at both levels, and every pane lands exactly as + * it did before the switches existed. + * + * This answers the plane's half. A node whose mirror says no also strips + * wiring material out of a launch frame that carries it anyway + * (`apps/node/agent/src/commands/launch.ts`), and refuses to start `subshell + * mcp` at all, which is what makes the machine's refusal true for a harness + * that registered the server on its own months ago. + * + * @param userId - the pane's OWNER, read from the row, never the caller's + * preference substituted: a restart reaches this from an edit-grantee or the + * auto-restart sweep, and the owner's switch is what gets asked every time + * @param nodeId - the machine the pane will run on, `local` included + */ +export async function mcpWiringAllowed(userId: string, nodeId: string): Promise { + const accountEnabled = await getRequestlessContext().repos.userMeta.getMcpEnabled(userId); + // The account's own refusal needs no second read, and skipping the node read + // keeps the refusal cheap on a path that runs for every launch. + if (!accountEnabled) return false; + return getNodeMcpEnabled(nodeId); +} diff --git a/apps/server/api/src/modules/subshells/subshell-manager.service.ts b/apps/server/api/src/modules/subshells/subshell-manager.service.ts index b903f8fc0..f606a7186 100644 --- a/apps/server/api/src/modules/subshells/subshell-manager.service.ts +++ b/apps/server/api/src/modules/subshells/subshell-manager.service.ts @@ -61,6 +61,7 @@ import { sshCanonicalDestination } from "@/modules/ssh/ssh-destination.js"; import { sweepLocalSshDir } from "@/modules/ssh/ssh-launch.service.js"; import type { Access } from "@/modules/subshells/access.js"; import { type LogCursorRequest, readLogCursor } from "@/modules/subshells/log-tail.js"; +import { mcpWiringAllowed } from "@/modules/subshells/mcp-availability.js"; import { nodeSelfInvoke, planRemoteSubshellMcp, @@ -70,7 +71,14 @@ import { } from "@/modules/subshells/mcp-launch.js"; import { probeReporterLaunch } from "@/modules/subshells/mcp-resolve.js"; import { subshellLogPath } from "@/modules/subshells/paths.js"; -import { issueSubshellToken, revokeSubshellToken, subshellSystemPermissions } from "@/modules/subshells/tokens.js"; +import { + issueSubshellToken, + type PaneTokenPermissions, + paneTokenPermissions, + reportingTokenPermissions, + revokeSubshellToken, + subshellSystemPermissions, +} from "@/modules/subshells/tokens.js"; import { HttpError } from "@/shared/errors.js"; import { logger } from "@/shared/logger.js"; @@ -78,7 +86,12 @@ import { logger } from "@/shared/logger.js"; * for test isolation; defaults to the real better-auth-backed functions. */ export interface SubshellTokenProvider { /** Mints the subshell's MCP API key; returns the plaintext (once). */ - issue(subshellId: string, userId: string, ceiling?: ApiKeyPermission[] | null): Promise; + issue( + subshellId: string, + userId: string, + ceiling?: ApiKeyPermission[] | null, + permissions?: PaneTokenPermissions, + ): Promise; /** Reads the previous ceiling before rotation, when the provider persists credentials. */ permissions?(subshellId: string, userId: string): Promise; /** Disables the subshell's token (no-op when absent). */ @@ -306,23 +319,43 @@ export class SubshellManagerService { } /** - * Compose the MCP registration for one launch on one node (spec §6.4). + * Compose the MCP registration for one launch on one node (spec §6.4), or + * nothing when this launch's MCP decision says off. + * * Local rows keep today's write-the-file path; agent rows get the PURE * remote plan (dialect computed control-side, content shipped with the * launch command) — gated on the node advertising the `"mcp"` capability, * and on the node still having live `ready` facts (its connection dropped * between resolution and launch ⇒ the same offline-flavored throw * `sendCommand` produces, so rollback and the 409 mapping treat it alike). + * + * The offline throw stays AHEAD of the off-decision on purpose. A node that + * is gone is a launch that must roll back and tell the caller so, whatever + * the switches say; a node that is merely refusing the MCP is a launch that + * succeeds without it. Answering the second with the first's 409 would make + * an owner's toggle look like an outage. + * + * An `mcp`-capable agent whose node row says off returns the SAME shape the + * capability skip returns (a bare `{ facts }`), so nothing downstream has to + * learn a third case, and the reporter stays composed either way: harness + * hooks are not an MCP feature (`#planReporter` owns that rule). * @throws NodeRpcError code "offline" for an agent node with no live facts */ - #planMcp( + async #planMcp( harness: HarnessPlugin, subshellId: string, nodeId: string, - ): { mcp?: McpRegistration; mcpConfigPath?: string; facts?: NodeFacts } { - if (nodeId === LOCAL_NODE_ID) return { mcp: registerSubshellMcp(harness, subshellId) }; + mcpAllowed: boolean, + ): Promise<{ mcp?: McpRegistration; mcpConfigPath?: string; facts?: NodeFacts }> { + if (nodeId === LOCAL_NODE_ID) return mcpAllowed ? { mcp: registerSubshellMcp(harness, subshellId) } : {}; const facts = getLive(nodeId)?.agent; if (!facts) throw new NodeRpcError("offline", `node "${nodeId}" has no live connection`, nodeId); + if (!mcpAllowed) { + logger.debug( + `subshell ${subshellId}: the subshell MCP is switched off for this account or node; not registered for this launch`, + ); + return { facts }; + } if (!facts.capabilities.includes("mcp")) { logger.debug( `subshell ${subshellId}: node "${nodeId}" did not advertise the "mcp" capability; subshell mcp not registered for this launch`, @@ -611,13 +644,20 @@ export class SubshellManagerService { } } + // The MCP decision, asked ONCE per launch. It picks both the credential + // scope and whether the harness gets a registration at all, and a decision + // read in two places is two places that drift: a pane wired with no + // registration but a full-scope key is the attack surface with the tool + // list left behind, and one narrowed but still wired is a broken pane. + const mcpAllowed = await mcpWiringAllowed(userId, targetNode); // The token is minted AFTER the row exists (issueSubshellToken writes the // api-key id back onto it) but BEFORE tmux starts, so the plaintext key is // baked into the harness env. + const tokenPermissions = mcpAllowed ? paneTokenPermissions() : reportingTokenPermissions(); const apiKey = systemKeyPermissions === null - ? await this.#tokens.issue(id, userId) - : await this.#tokens.issue(id, userId, systemKeyPermissions); + ? await this.#tokens.issue(id, userId, null, tokenPermissions) + : await this.#tokens.issue(id, userId, systemKeyPermissions, tokenPermissions); let promptDelivered = false; try { @@ -629,7 +669,7 @@ export class SubshellManagerService { // rows get the PURE plan (no local file; content ships with the launch // command to the node's own path). INSIDE the try: an agent that // dropped offline between resolution and here must roll back too. - const { mcp, mcpConfigPath, facts } = this.#planMcp(harness, id, targetNode); + const { mcp, mcpConfigPath, facts } = await this.#planMcp(harness, id, targetNode, mcpAllowed); const reporter = this.#planReporter(targetNode, facts); const subshellEnv = subshellMcpEnv(apiKey, id, subshellName); if (facts) { @@ -1530,11 +1570,18 @@ export class SubshellManagerService { const previousPermissions = (await this.#tokens.permissions?.(row.id, row.userId)) ?? null; const ceiling = intersectSystemPermissions(previousPermissions, systemKeyPermissions); await this.#revokeTokenOrUnlink(row.id); + // The MCP decision is re-asked on every revive rather than inherited from + // the row: a pane restarted after its owner switched the MCP off comes back + // without it, and so does one restarted after the machine's owner refused + // it. Running panes are the ones that keep what they were given, which is + // the whole reach of both switches. + const mcpAllowed = await mcpWiringAllowed(row.userId, row.nodeId); + const tokenPermissions = mcpAllowed ? paneTokenPermissions() : reportingTokenPermissions(); const apiKey = ceiling === null - ? await this.#tokens.issue(row.id, row.userId) - : await this.#tokens.issue(row.id, row.userId, ceiling); - const { mcp, mcpConfigPath, facts } = this.#planMcp(harness, row.id, row.nodeId); + ? await this.#tokens.issue(row.id, row.userId, null, tokenPermissions) + : await this.#tokens.issue(row.id, row.userId, ceiling, tokenPermissions); + const { mcp, mcpConfigPath, facts } = await this.#planMcp(harness, row.id, row.nodeId, mcpAllowed); const reporter = this.#planReporter(row.nodeId, facts); const subshellEnv = subshellMcpEnv(apiKey, row.id, row.name); if (facts) { diff --git a/apps/server/api/src/modules/subshells/tokens.ts b/apps/server/api/src/modules/subshells/tokens.ts index 7dd96da94..e517df84b 100644 --- a/apps/server/api/src/modules/subshells/tokens.ts +++ b/apps/server/api/src/modules/subshells/tokens.ts @@ -11,6 +11,60 @@ import { logger } from "@/shared/logger.js"; /** Per-subshell MCP token lifetime. Self-extension resets it; death revokes it. */ const SUBSHELL_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days (plugin min is 1 day) +/** A pane token's grant map, as better-auth stores it. */ +export type PaneTokenPermissions = Record; + +/** + * The grants a pane holds when its MCP wiring is ON: everything the MCP tools + * drive, and the `subshells` scope the harness hooks ride as well. + * + * `prompts` joined the map with the saved-prompt library (spec 2026-09-28); a + * pane may full-CRUD its OWNER's prompts over MCP. `transfers` joined with + * node-to-node archive transfer (spec 2026-10-01 §5) and, unlike prompts, gets + * NO legacy pass in `requirePerm`: pre-feature maps 403 on transfers until the + * pane restarts and re-mints. Grandfathering old tokens into a file-move grant + * is exactly the mistake the prompts concession would be to copy. + */ +export function paneTokenPermissions(): PaneTokenPermissions { + return { + channels: ["read", "write"], + subshells: ["read", "write"], + prompts: ["read", "write"], + transfers: ["read", "write"], + }; +} + +/** + * The grants a pane holds when the MCP is switched off for it, by its owner's + * account or by the node it launched on: `report`, and nothing else. + * + * That is exactly the four `subshell report` verbs the harness hooks run, so + * the pane still says when its turn ended, still rings and clears its own + * waiting bell, still reports its exit code and session id, and still + * self-extends its token (that door checks ownership, not a scope, though a + * derived execution key still meets its `subshells:execute` grant, exactly as + * the MCP child did; and every report verb but `exit` rides the extend in + * parallel, so a hook-live pane never reaches the TTL wall even with no MCP + * child to arm the periodic one). What + * it cannot do is read another pane, send input to one, create or terminate + * one, post to a channel, touch the prompt library, or move a file: the + * sibling surface §4 of docs/security.md accounts for is gone from this key + * rather than merely left without a tool that names it. + * + * One dialect caveat: for opencode and pi the CHILD ROSTER reporter + * is a harness extension that ships inside the MCP registration, not a hook + * command line, so a switched-off pane of those dialects loads no bridge and + * reports no roster. The door stays open for it (a report-only key passes + * `requireReportPerm`); nothing walks through. Dialects whose roster rides the + * hook reporter keep reporting. + * + * A fresh object per call, like the full map: the auth plugin treats the map + * as mutable. + */ +export function reportingTokenPermissions(): PaneTokenPermissions { + return { report: ["write"] }; +} + /** * Mints the per-subshell MCP API key: a better-auth api-key bound to the * subshell via `metadata.subshellId`, its id recorded on the subshell row for @@ -18,13 +72,22 @@ const SUBSHELL_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days (plugin min is 1 * the subshell's env; never stored server-side in the clear — the plugin * hashes it). * + * `permissions` is the launch's MCP decision in credential form, and the caller + * that read the two switches passes it: a pane with the wiring switched off + * gets {@link reportingTokenPermissions} and no registration, so the two halves + * of one decision cannot be taken in two places and drift. The default is the + * full map, which is what every caller that predates the switch means. + * * @param subshellId - the subshell this token authenticates * @param userId - owning user (the api-key reference) + * @param ceiling - inherited system-key grants, or null for an ordinary pane + * @param permissions - the grant map to mint; the full MCP set by default */ export async function issueSubshellToken( subshellId: string, userId: string, ceiling: ApiKeyPermission[] | null = null, + permissions: PaneTokenPermissions = paneTokenPermissions(), ): Promise { const metadata: SubshellKeyMetadata = { kind: "subshell", @@ -37,21 +100,7 @@ export async function issueSubshellToken( userId, expiresIn: SUBSHELL_TOKEN_TTL_SECONDS, metadata, - // Least-privilege grants every subshell token carries; a fresh object - // per call because the plugin treats it as mutable. - // `prompts` joined the map with the saved-prompt library (spec - // 2026-09-28); a pane may full-CRUD its OWNER's prompts over MCP. - // `transfers` joined with node-to-node archive transfer (spec - // 2026-10-01 §5) and, unlike prompts, gets NO legacy pass in - // `requirePerm`: pre-feature maps 403 on transfers until the pane - // restarts and re-mints. Grandfathering old tokens into a file-move - // grant is exactly the mistake the prompts concession would be to copy. - permissions: { - channels: ["read", "write"], - subshells: ["read", "write"], - prompts: ["read", "write"], - transfers: ["read", "write"], - }, + permissions, }, })) as unknown as CreatedApiKey; await new SubshellsRepository(db).update(subshellId, { apiKeyId: created.id }); diff --git a/apps/server/web/src/components/__tests__/mcp-card.test.tsx b/apps/server/web/src/components/__tests__/mcp-card.test.tsx new file mode 100644 index 000000000..d88483779 --- /dev/null +++ b/apps/server/web/src/components/__tests__/mcp-card.test.tsx @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { McpCard } from "@/components/mcp-card"; + +/** + * The account-wide MCP switch. Same one-boolean read/write cycle as the + * notifications master card it clones, but the two default-on fallbacks are + * its OWN: this switch's safe state is ON, because wiring predates it, so a + * probe that fails must not answer "off" (that would silently strip tools + * from panes nobody opted out of). These cells pin that polarity, the inert + * unknown state, and the write-failure rollback. + */ + +const switch_ = () => screen.getByRole("switch", { name: "Wire the subshell MCP into my panes" }); + +afterEach(() => cleanup()); + +describe("McpCard", () => { + it("is inert and labelled Unknown until the server has answered", async () => { + let resolve: (on: boolean) => void = () => {}; + render( new Promise((r) => (resolve = r))} setEnabled={async (on) => on} />); + expect(screen.getByText("Unknown")).toBeDefined(); + expect(switch_().hasAttribute("data-disabled")).toBe(true); + resolve(true); + await waitFor(() => expect(switch_().getAttribute("aria-checked")).toBe("true")); + expect(screen.getByText("On")).toBeDefined(); + }); + + it("clicking an on switch writes false and the server echo lands 'Off'", async () => { + const writes: boolean[] = []; + render( + true} + setEnabled={async (on) => { + writes.push(on); + return on; + }} + />, + ); + await waitFor(() => expect(switch_().getAttribute("aria-checked")).toBe("true")); + fireEvent.click(switch_()); + await waitFor(() => expect(switch_().getAttribute("aria-checked")).toBe("false")); + expect(writes).toEqual([false]); + expect(screen.getByText("Off")).toBeDefined(); + }); + + it("a failing probe falls back to the safe default: on", async () => { + render( + { + throw new Error("offline"); + }} + setEnabled={async (on) => on} + />, + ); + await waitFor(() => expect(switch_().getAttribute("aria-checked")).toBe("true")); + }); + + it("a failing write rolls the switch back and shows an alert", async () => { + render( + true} + setEnabled={async () => { + throw new Error("API 500: boom"); + }} + />, + ); + await waitFor(() => expect(switch_().getAttribute("aria-checked")).toBe("true")); + fireEvent.click(switch_()); + await waitFor(() => expect(switch_().getAttribute("aria-checked")).toBe("true")); + expect(await screen.findByRole("alert")).toBeDefined(); + }); +}); diff --git a/apps/server/web/src/components/mcp-card.tsx b/apps/server/web/src/components/mcp-card.tsx new file mode 100644 index 000000000..09c66e898 --- /dev/null +++ b/apps/server/web/src/components/mcp-card.tsx @@ -0,0 +1,87 @@ +import { + apiFetch, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Label, + Switch, +} from "@internal/node-admin"; +import { useBooleanSetting } from "@/hooks/use-boolean-setting"; + +/** + * Account → Preferences: the per-user subshell MCP switch. One value stored in + * `user_meta`, so it follows the account across devices. Off means new panes + * and restarts launch with no MCP registration and a report-only token: the + * agent keeps its self-reporting (the attention bell, exit codes, session id; + * the subagent roster survives only on the hook-driven dialects, because + * opencode and pi ship their reporter inside the registration a + * switched-off pane never writes) but loses the sibling-coordination tools. + * The two endpoints are injectable so the read/write cycle is testable without + * stubbing fetch, and the state machine is the shared {@link useBooleanSetting} + * the notifications master switch also runs. + */ + +/** Module-level so the default prop is a STABLE reference: an inline arrow + * would re-fire the load effect on every render (refetch loop). */ +const getMcpEnabled = () => apiFetch<{ mcpEnabled: boolean }>("/api/settings/mcp").then((r) => r.mcpEnabled); +const setMcpEnabled = (on: boolean) => + apiFetch<{ mcpEnabled: boolean }>("/api/settings/mcp", { + method: "PATCH", + body: JSON.stringify({ mcpEnabled: on }), + }).then((r) => r.mcpEnabled); + +/** Props exist so tests can drive the switch without a live server. */ +export type McpCardProps = { + /** Defaults to the real `GET /api/settings/mcp`. */ + getEnabled?: () => Promise; + /** Defaults to the real `PATCH /api/settings/mcp`. */ + setEnabled?: (on: boolean) => Promise; +}; + +/** Settings card for "wire the subshell MCP into my panes at all". */ +export function McpCard({ getEnabled = getMcpEnabled, setEnabled = setMcpEnabled }: McpCardProps) { + // The probe-fails-ON fallback and the optimistic rollback live in the shared + // hook the notifications master switch also runs, so a fix there lands here. + const { enabled, busy, error, toggle } = useBooleanSetting({ + get: getEnabled, + set: setEnabled, + errorMessage: "The MCP setting could not be changed.", + }); + + return ( + + + Subshell MCP + + Wire the subshell MCP into your panes so their agents can coordinate with your other subshells. Turning this + off removes those tools from panes you launch or restart, which narrows what a misled agent can reach and + frees the context its tool list costs. + + + +
+ {/* Unknown ≠ On: the switch only claims a state the server reported, + and is inert until it has. */} + void toggle()} + disabled={busy || enabled === null} + aria-label="Wire the subshell MCP into my panes" + /> + +
+

+ Panes already running keep their MCP until they restart. A node whose owner has turned the MCP off refuses it + for every account, including yours. +

+ {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/apps/server/web/src/components/nodes/__tests__/node-mcp-card.test.tsx b/apps/server/web/src/components/nodes/__tests__/node-mcp-card.test.tsx new file mode 100644 index 000000000..d2e0c8823 --- /dev/null +++ b/apps/server/web/src/components/nodes/__tests__/node-mcp-card.test.tsx @@ -0,0 +1,113 @@ +import { afterEach, expect, it } from "bun:test"; +import type { NodeDetail } from "@internal/node-admin"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { NodeMcpCard } from "@/components/nodes/node-mcp-card"; + +/** + * The node's MCP-use card. The SSH card beside it is opt-in, so an absent + * flag reads OFF there; THIS one reads ALLOWED, because MCP wiring predates + * the switch and a payload cached before the field existed must not strip + * comms. The first cells below pin both halves of that polarity, and a future + * `node.mcpEnabled === true` edit fails them. + */ + +function node(over: Partial = {}): NodeDetail { + return { + id: "node1", + name: "box", + kind: "agent", + os: "linux", + arch: "x64", + hostname: "box", + status: "online", + lastSeenAt: null, + agentVersion: "3.2.0", + protocolVersion: 27, + access: "owner", + canManage: true, + canRetire: true, + canLaunch: true, + capabilities: [], + allowedDirs: [], + harnesses: [], + inventoryStale: false, + maintenance: false, + maintenanceAt: null, + maintenanceSource: null, + sshEnabled: false, + sshWideForwards: false, + held: null, + ...over, + }; +} + +const originalFetch = globalThis.fetch; +afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; +}); + +function mount(n: NodeDetail) { + const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } }); + return render( + + + , + ); +} + +it("reads an ABSENT mcpEnabled as allowed: a pre-switch payload is not a refusal", () => { + // The whole inversion in one assertion. The ssh field's cache-tolerance + // reads the opposite way; flip this card to `=== true` and every node + // cached before protocol 27 starts rendering a false refusal. + mount(node({ mcpEnabled: undefined })); + expect(screen.getByRole("switch", { name: "Allow the subshell MCP" }).getAttribute("aria-checked")).toBe("true"); +}); + +for (const enabled of [false, true]) { + it(`writes the opposite MCP setting when currently ${enabled}`, async () => { + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (url, init) => { + calls.push({ url: String(url), init }); + return new Response("{}"); + }) as unknown as typeof fetch; + mount(node({ mcpEnabled: enabled })); + const sw = screen.getByRole("switch", { name: "Allow the subshell MCP" }); + expect(sw.getAttribute("aria-checked")).toBe(String(enabled)); + fireEvent.click(sw); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0].url).toContain("/api/nodes/node1/mcp-enabled"); + expect(calls[0].init?.method).toBe("PUT"); + expect(JSON.parse(String(calls[0].init?.body))).toEqual({ on: !enabled }); + }); +} + +it("shows the refusal state to a viewer who cannot manage it, with the owner as the reason", () => { + mount(node({ mcpEnabled: false, access: "edit", canManage: false })); + expect(screen.getByRole("switch", { name: "Allow the subshell MCP" }).getAttribute("aria-checked")).toBe("false"); + expect(screen.getByRole("switch").hasAttribute("data-disabled")).toBe(true); + const reason = "Only this node’s owner can change this setting."; + expect(screen.getByText(reason)).toBeTruthy(); + // A disabled control must explain itself to keyboard and touch too, not just + // print a paragraph: the switch points at the reason row, and a focusable + // wrapper carries the tooltip (the preview card's shape). A bare switch edit + // fails both lines. + const describedBy = screen.getByRole("switch").getAttribute("aria-describedby"); + expect(describedBy ? document.getElementById(describedBy)?.textContent : null).toBe(reason); + expect(screen.getByRole("button", { name: reason })).toBeTruthy(); +}); + +it("names the admin as the changer for the built-in node", () => { + mount(node({ kind: "local", id: "local", canManage: false })); + expect(screen.getByText("An admin can change this setting.")).toBeTruthy(); +}); + +it("shows failed writes without changing the reported state", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ message: "Permission changed." }), { status: 403 })) as unknown as typeof fetch; + mount(node({ mcpEnabled: true })); + fireEvent.click(screen.getByRole("switch", { name: "Allow the subshell MCP" })); + await screen.findByRole("alert"); + expect(screen.getByRole("switch").getAttribute("aria-checked")).toBe("true"); +}); diff --git a/apps/server/web/src/components/nodes/node-mcp-card.tsx b/apps/server/web/src/components/nodes/node-mcp-card.tsx new file mode 100644 index 000000000..21638285e --- /dev/null +++ b/apps/server/web/src/components/nodes/node-mcp-card.tsx @@ -0,0 +1,95 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, + errMessage, + Label, + type NodeDetail, + Switch, +} from "@internal/node-admin"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useSetMcpEnabled } from "@/hooks/use-nodes"; + +/** + * The node's own MCP-use switch. Off here outranks every account preference: + * the machine's owner is saying nothing of that tool surface runs here, and + * the node enforces that on itself. The mirror is absent-tolerant in the + * client the same way it is on the machine: `undefined` means a payload cached + * before the switch existed, and that read is ALLOWED, not refused. + * + * A disabled switch explains itself the way the preview card beside it does: + * one reason string feeding the tooltip, the focusable wrapper, and the gold + * line under the control, so a keyboard user tabbing past a locked switch + * still learns who holds the change. + */ +export function NodeMcpCard({ node }: { node: NodeDetail }) { + const update = useSetMcpEnabled(); + const allowed = node.mcpEnabled !== false; + const reasonId = "node-mcp-enabled-reason"; + const disabledReason = !node.canManage + ? node.kind === "local" + ? "An admin can change this setting." + : "Only this node’s owner can change this setting." + : update.isPending + ? "Saving the subshell MCP…" + : undefined; + const toggle = ( + update.mutate({ id: node.id, on })} + /> + ); + return ( + + + Subshell MCP + + +
+ {disabledReason ? ( + + + } + > + {toggle} + + {disabledReason} + + ) : ( + toggle + )} + +
+ {node.canManage ? ( +

+ {allowed + ? "Turning this off means panes started or restarted here get no subshell MCP and no sibling-coordination tools, whatever their owners prefer. Panes already running keep their live MCP until they restart." + : "Turning this on wires new launches here with the subshell MCP, unless the pane's owner has switched it off in their preferences. New launches and restarts pick up the change."} +

+ ) : ( +

+ {disabledReason} +

+ )} + {update.isError && ( +

+ {errMessage(update.error, "Couldn't update the MCP setting.")} +

+ )} +
+
+ ); +} diff --git a/apps/server/web/src/components/notifications-master-card.tsx b/apps/server/web/src/components/notifications-master-card.tsx index 051d9b306..fd0860314 100644 --- a/apps/server/web/src/components/notifications-master-card.tsx +++ b/apps/server/web/src/components/notifications-master-card.tsx @@ -1,14 +1,5 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - errMessage, - Label, - Switch, -} from "@internal/node-admin"; -import { useEffect, useState } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle, Label, Switch } from "@internal/node-admin"; +import { useBooleanSetting } from "@/hooks/use-boolean-setting"; import { getMasterSwitch, setMasterSwitch } from "@/lib/notifications"; /** @@ -16,8 +7,9 @@ import { getMasterSwitch, setMasterSwitch } from "@/lib/notifications"; * 2026-08-31). One value shared across every device, stored in `user_meta`; * off means NO subshell push is ever sent to this user, whatever the per-device * opt-ins or per-subshell bells say. It complements (does not replace) the - * per-device {@link NotificationsCard}. The two hooks are injectable so the - * read/write cycle is testable without stubbing fetch. + * per-device {@link NotificationsCard}. The two endpoints are injectable so + * the read/write cycle is testable without stubbing fetch, and the state + * machine is {@link useBooleanSetting}, shared with the MCP account card. */ /** Props exist so tests can drive the switch without a live server. */ @@ -33,39 +25,11 @@ export function NotificationsMasterCard({ getEnabled = getMasterSwitch, setEnabled = setMasterSwitch, }: NotificationsMasterCardProps) { - const [enabled, setEnabledState] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - let live = true; - getEnabled() - .then((on) => live && setEnabledState(on)) - // A probe that fails (offline, 500) must not leave a broken switch: - // fall back to on (the documented default) rather than a false "off". - .catch(() => live && setEnabledState(true)); - return () => { - live = false; - }; - }, [getEnabled]); - - async function toggle() { - if (enabled === null) return; - const next = !enabled; - setBusy(true); - setError(null); - // Optimistic: move the switch now, roll back only if the write fails. The - // server echoes the persisted value, so on success we adopt that. - setEnabledState(next); - try { - setEnabledState(await setEnabled(next)); - } catch (err) { - setEnabledState(enabled); - setError(errMessage(err, "The notification setting could not be changed.")); - } finally { - setBusy(false); - } - } + const { enabled, busy, error, toggle } = useBooleanSetting({ + get: getEnabled, + set: setEnabled, + errorMessage: "The notification setting could not be changed.", + }); return ( diff --git a/apps/server/web/src/hooks/use-boolean-setting.ts b/apps/server/web/src/hooks/use-boolean-setting.ts new file mode 100644 index 000000000..d1fb9f1dc --- /dev/null +++ b/apps/server/web/src/hooks/use-boolean-setting.ts @@ -0,0 +1,78 @@ +import { errMessage } from "@internal/node-admin"; +import { useEffect, useState } from "react"; + +/** Arguments for {@link useBooleanSetting}. */ +export interface BooleanSettingOptions { + /** Reads the stored value once on mount; must be a stable reference. */ + get: () => Promise; + /** Writes a new value and resolves with what the server persisted. */ + set: (on: boolean) => Promise; + /** + * What a failed probe falls back to. The account switches default their + * features ON, so the fallback is ON too: a flaky read must never render a + * false "Off" that makes a person think they opted out. + */ + fallbackOn?: boolean; + /** Fallback line shown when the write refuses and carries no message. */ + errorMessage: string; +} + +/** The live state a boolean-settings card renders from. */ +export interface BooleanSettingState { + /** null until the probe answers; the switch stays inert and reads Unknown. */ + enabled: boolean | null; + /** A write is in flight. */ + busy: boolean; + /** The last refused write, rendered as an alert until the next attempt. */ + error: string | null; + /** Optimistic flip: move now, adopt the server's echo or roll back. */ + toggle: () => Promise; +} + +/** + * The state machine behind an account-wide boolean settings card, extracted + * from the notifications master switch this replaced two copies of: probe + * once, inert-until-answered, optimistic toggle with rollback and a visible + * refusal. Callers inject their own endpoints (the cards' props keep the + * read/write cycle testable without stubbing fetch), and `get` must be a + * module-level stable reference so the load effect does not refetch every + * render. + */ +export function useBooleanSetting({ + get, + set, + fallbackOn = true, + errorMessage, +}: BooleanSettingOptions): BooleanSettingState { + const [enabled, setEnabledState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let live = true; + get() + .then((on) => live && setEnabledState(on)) + .catch(() => live && setEnabledState(fallbackOn)); + return () => { + live = false; + }; + }, [get, fallbackOn]); + + async function toggle() { + if (enabled === null) return; + const next = !enabled; + setBusy(true); + setError(null); + setEnabledState(next); + try { + setEnabledState(await set(next)); + } catch (err) { + setEnabledState(enabled); + setError(errMessage(err, errorMessage)); + } finally { + setBusy(false); + } + } + + return { enabled, busy, error, toggle }; +} diff --git a/apps/server/web/src/hooks/use-nodes.ts b/apps/server/web/src/hooks/use-nodes.ts index 4440dff4d..aa80e4a87 100644 --- a/apps/server/web/src/hooks/use-nodes.ts +++ b/apps/server/web/src/hooks/use-nodes.ts @@ -237,3 +237,27 @@ export function useSetViewsEnabled() { }, }); } + +/** + * Flip one machine's subshell MCP allowance. Owner-only at the route (the same + * gate as the SSH and preview switches), and it answers on an offline node too: + * the flag is the plane's record, and the machine learns the value at its next + * connect. Off here outranks every account preference; running panes keep + * their live MCP child either way. + */ +export function useSetMcpEnabled() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ id, on }: { id: string; on: boolean }) => + apiFetch(`/api/nodes/${encodeURIComponent(id)}/mcp-enabled`, { + method: "PUT", + body: JSON.stringify({ on }), + }), + onSuccess: async (_data, { id }) => { + await Promise.all([ + client.invalidateQueries({ queryKey: [...NODE_QUERY_KEY, id] }), + client.invalidateQueries({ queryKey: NODES_QUERY_KEY }), + ]); + }, + }); +} diff --git a/apps/server/web/src/routes/nodes_.$id.tsx b/apps/server/web/src/routes/nodes_.$id.tsx index 46dd002f7..3dc50979b 100644 --- a/apps/server/web/src/routes/nodes_.$id.tsx +++ b/apps/server/web/src/routes/nodes_.$id.tsx @@ -23,6 +23,7 @@ import { EditableText } from "@/components/editable-text"; import { DeleteNodeDialog } from "@/components/nodes/delete-node-dialog"; import { NodeCrossAgentCard } from "@/components/nodes/node-cross-agent-card"; import { NodeHarnessCard } from "@/components/nodes/node-harness-card"; +import { NodeMcpCard } from "@/components/nodes/node-mcp-card"; import { NodePageShell } from "@/components/nodes/node-page-shell"; import { NodePreviewCard } from "@/components/nodes/node-preview-card"; import { NodeReregister } from "@/components/nodes/node-reregister"; @@ -242,6 +243,10 @@ function NodeDetailPage() { the same owner-only posture, the same remedy lines. */} + {/* The MCP-use switch, same owner-only family again. Off here beats + every account preference, and the node enforces it on itself. */} + + {/* The same owner-or-edit-on-an-agent rule as the daemon sections and the route's gate. `local` updates with the server, which the page already cannot ask here. */} {managesNodeSections(n) && } diff --git a/apps/server/web/src/routes/preferences.tsx b/apps/server/web/src/routes/preferences.tsx index c301b8b6d..46088a767 100644 --- a/apps/server/web/src/routes/preferences.tsx +++ b/apps/server/web/src/routes/preferences.tsx @@ -2,6 +2,7 @@ import { FormSectionTitle } from "@internal/node-admin"; import { createFileRoute } from "@tanstack/react-router"; import { DashboardPage } from "@/components/dashboard-page"; import { DeviceNameCard } from "@/components/device-name-card"; +import { McpCard } from "@/components/mcp-card"; import { MouseSamplingCard } from "@/components/mouse-sampling-card"; import { NotificationsCard } from "@/components/notifications-card"; import { NotificationsMasterCard } from "@/components/notifications-master-card"; @@ -48,6 +49,7 @@ function PreferencesPage() {
+
diff --git a/bun.lock b/bun.lock index 93afeae9d..76a11df73 100644 --- a/bun.lock +++ b/bun.lock @@ -128,7 +128,7 @@ }, "apps/node/agent": { "name": "@internal/node", - "version": "3.1.4", + "version": "3.2.0", "dependencies": { "@clack/prompts": "1.8.1", "@internal/backend-errors": "workspace:*", diff --git a/docs/security.md b/docs/security.md index 190c6f924..4de5bb825 100644 --- a/docs/security.md +++ b/docs/security.md @@ -85,7 +85,7 @@ principals; the fourth is a websocket credential and nothing else. | `principal` | `user:` | `user:` | `sess:` | none | | Minted by | sign-up / sign-in / passkey | admin, Server Settings → API keys | the server at subshell start | `POST /api/nodes/enroll` | | Scope | the user's own data; **the only actor admin surfaces accept**; the only actor that may mint an UNBOUND WS attach token | explicit versioned grants on new keys; legacy keys retain their previous non-node access. Scoped keys need `subshells:execute` to mint a WS attach token, restricted to a system-owned subshell; legacy keys may bind to any single subshell | pane permission map plus any inherited system-key ceiling; may mint a WS attach token only for its OWN subshell | open `/ws/node` as that node | -| Lifetime | better-auth session | until disabled or deleted | 7 days, self-extending while the MCP child runs | long-lived | +| Lifetime | better-auth session | until disabled or deleted | 7 days, self-extending while the MCP child runs and while the pane's report hooks fire (every report but `exit` rides an extend) | long-lived | | Revocation | sign-out / password change / account disable (see "Disabling an account" below) | instant | **instant** on terminate/delete; rotated on auto-restart | delete the node | | Stored as | session row | hash only | hash only | hash only | @@ -762,9 +762,17 @@ also visible to any local process that can read `/proc//environ`, `ps` output, or tmux pane metadata. Accepted (§11). **What that token actually carries.** `permissions: { channels: ["read", -"write"], subshells: ["read", "write"], prompts: ["read", "write"] }` -(`services/subshell-tokens.ts:33`): -the map is a coarse scope gate, not a row fence. The MCP tools self-restrict +"write"], subshells: ["read", "write"], prompts: ["read", "write"], +transfers: ["read", "write"] }` (`modules/subshells/tokens.ts:28`): +the map is a coarse scope gate, not a row fence. That is the map when the +pane's MCP wiring is ON, which is still the default at both levels; with the +account or node switch off, the key is minted as `{ report: ["write"] }` +instead, so the harness-hook verbs and token self-extension keep working and +none of the sibling surface below rides the key at all (§6). One caveat +travels with that sentence: on opencode and pi the child-roster +reporter is a harness extension bundled into the MCP registration rather than +a hook command line, so a disabled pane of those dialects simply reports no +roster, the door for it notwithstanding. The MCP tools self-restrict the pane to its own row for reporting, but the raw REST surface resolves the token as its OWNER with shares switched off (§3), which means a prompt-injected pane can create, restart and **terminate** the owner's OTHER @@ -1122,6 +1130,25 @@ consequences are: `preview-display-unsupported`) instead of half-serving silently. Browser document isolation, bearer-link exposure, and local-user limits are accounted separately in §11.15. +- **The subshell MCP wiring is switchable per account and per node, both + defaulting ON.** Off on either switch and a new launch or restart gets no MCP + wiring and a report-only token, so §4's sibling surface is gone from the key + rather than merely left without a tool naming it; a running pane keeps the + MCP child and token it already has. The node's half is enforced on the + machine: the row pushes to a local mirror, a launch frame that still carries + wiring material has it stripped, and `subshell mcp` refuses at the command + itself; a child whose node never learned the mirror, or whose account alone + is off, instead exits at identity registration. Either way the MCP never + acts on a report-only key (a disabled child whose plane is momentarily + unreachable may still enumerate its tools, but every call is refused at the + guard), and the node owner's no is not something a tenant account can talk + past. A disabled pane still holds its credential: + the four report verbs and token self-extension answer to it, and that is its + whole scope-gated reach now, since reading even its own log asks for the + `subshells` scope it no longer carries (the log stays readable on disk by + whoever owns that OS account, §11). The §3 deliberately-public bearer reads + answer to any valid credential, this one included; the switch is not a + per-pane instance of that posture, and it does not narrow it either. - **Node shares and subshell shares are independent axes.** Sharing a node does not hand out subshell tokens, but anything launched there trusts the machine. - **Whoever owns the node's OS user owns every pane launched there**, including @@ -2878,6 +2905,7 @@ Audit events are written, grouped by family: `node.allowed_dirs.update`, `node.config.update`, `node.maintenance.update`, `node.ssh_enabled.update`, `node.ssh_wide_forwards.update` (the boolean itself; which node, who flipped it), + `node.mcp_enabled.update` (the boolean itself; which node, who flipped it), `node.ssh_identity.register` (spec 2026-10-08 §4.3: the signing-key bootstrap lands when the link's answer fills the EMPTY slot, `via: "link"` and nothing else in the metadata - never the key value; the idempotent diff --git a/packages/mcp-core/src/__tests__/report.test.ts b/packages/mcp-core/src/__tests__/report.test.ts index dec844172..3ffa790f4 100644 --- a/packages/mcp-core/src/__tests__/report.test.ts +++ b/packages/mcp-core/src/__tests__/report.test.ts @@ -17,14 +17,21 @@ describe("runReport", () => { SUBSHELL_ID: "sub_42", } as NodeJS.ProcessEnv; - /** Captures the single request a report makes. */ + /** + * Captures the requests a report makes: the report POST in `seen`, and the + * self-extension that rides every verb (except `exit`) in `extended`, split + * so the report-shape cells below keep reading exactly as written. + */ function recorder() { const seen: Request[] = []; + const extended: Request[] = []; const fetchImpl = (async (input: string | URL, init?: RequestInit) => { - seen.push(new Request(String(input), init)); + const req = new Request(String(input), init); + if (req.url.endsWith("/extend-token")) extended.push(req); + else seen.push(req); return new Response("{}", { status: 200 }); }) as unknown as typeof fetch; - return { seen, fetchImpl }; + return { seen, extended, fetchImpl }; } it("reports child lifecycle metadata without forwarding prompts or transcripts", async () => { @@ -315,7 +322,7 @@ describe("runReport", () => { }); it("sends nothing when the SessionStart payload carries no session_id", async () => { - const { seen, fetchImpl } = recorder(); + const { seen, extended, fetchImpl } = recorder(); await runReport(["session"], { env: paneEnv, @@ -323,7 +330,64 @@ describe("runReport", () => { readStdin: async () => JSON.stringify({ cwd: "/x" }), }); + // Nothing reported means nothing happened in the pane worth extending + // the token for. expect(seen).toHaveLength(0); + expect(extended).toHaveLength(0); + }); + + it("rides every report with a token extend, and `exit` alone with none", async () => { + // The extend the MCP child arms at its boot: a pane whose MCP is switched + // off launches no child, so without this its hooks would silently start + // 401-ing once the 7-day TTL ran out under a pane in daily use. + const reporting: Array<[string[], () => Promise]> = [ + [["attention", "turn_complete"], async () => "{}"], + [["attention", "resumed"], async () => "{}"], + [["attention", "needs_attention"], async () => "{}"], + // Every REPORT_VERBS member that earns a POST, so a future verb that + // forgets its extend shows up here. + [ + ["subagents"], + async () => + JSON.stringify({ hook_event_name: "subagent_start", extra: { child_session_id: "c1", child_goal: "x" } }), + ], + [["session"], async () => JSON.stringify({ session_id: "sess-1" })], + ]; + for (const [argv, readStdin] of reporting) { + const { extended, fetchImpl } = recorder(); + await runReport(argv, { env: paneEnv, fetch: fetchImpl, readStdin }); + expect(extended).toHaveLength(1); + expect(extended[0]?.url).toBe("http://h:3080/api/subshells/sub_42/extend-token"); + expect(extended[0]?.method).toBe("POST"); + expect(extended[0]?.headers.get("authorization")).toBe("Bearer subshell_key123"); + } + const { seen, extended, fetchImpl } = recorder(); + await runReport(["exit", "7"], { env: paneEnv, fetch: fetchImpl }); + expect(seen).toHaveLength(1); // the exit report itself still lands + expect(extended).toHaveLength(0); // the token dies with the pane anyway + }); + + it("reports fine when the extend is refused, and a refused extend never reaches the pane", async () => { + // Best-effort like the report itself: a dead or expired token must not + // turn the hook's stdout into a server complaint. + const seen: Request[] = []; + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { + const req = new Request(String(input), init); + if (!req.url.endsWith("/extend-token")) seen.push(req); + return req.url.endsWith("/extend-token") + ? new Response("no", { status: 401 }) + : new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + const consoleErrors: unknown[] = []; + const origError = console.error; + console.error = (...args: unknown[]) => consoleErrors.push(args); + try { + await runReport(["attention", "turn_complete"], { env: paneEnv, fetch: fetchImpl }); + } finally { + console.error = origError; + } + expect(seen).toHaveLength(1); + expect(consoleErrors).toHaveLength(0); }); it("swallows a failing transport rather than throwing into the hook", async () => { @@ -446,7 +510,9 @@ describe("subagent report: encoding and numeric edges (review probe F-V2-report) /** Captures raw bodies so the test can assert the WIRE is valid JSON, not just the parse. */ function rawRecorder() { const bodies: string[] = []; - const fetchImpl = (async (_input: string | URL, init?: { body?: string | null }) => { + const fetchImpl = (async (input: string | URL, init?: { body?: string | null }) => { + // The riding extend has no body and belongs to a different cell. + if (String(input).endsWith("/extend-token")) return new Response("{}", { status: 200 }); bodies.push(String(init?.body)); return new Response("{}", { status: 200 }); }) as unknown as typeof fetch; @@ -733,8 +799,10 @@ describe("turn_complete gating on the Stop payload (spec 2026-09-23)", () => { await runReport(argv, { env: paneEnv, readStdin: stdin, - fetch: (async () => { - calls += 1; + fetch: (async (input: string | URL) => { + // The riding extend is not a POST of the report's own; the gating + // cells below count what the Stop payload earned. + if (!String(input).endsWith("/extend-token")) calls += 1; return new Response("{}", { status: 200 }); }) as never, }); diff --git a/packages/mcp-core/src/__tests__/server.test.ts b/packages/mcp-core/src/__tests__/server.test.ts index 964c5fc36..6eedac17a 100644 --- a/packages/mcp-core/src/__tests__/server.test.ts +++ b/packages/mcp-core/src/__tests__/server.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "bun:test"; import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/server"; +import { ApiError } from "../api-client.js"; import type { IdentityKeyPair } from "../crypto.js"; -import { createSubshellMcpServer } from "../server.js"; +import { createSubshellMcpServer, McpDisabledError, registerPaneIdentity } from "../server.js"; import type { ToolApi } from "../tools.js"; const api: ToolApi = { @@ -165,3 +166,46 @@ describe("subshell mcp tool surface (tools/list, spec 2026-09-25)", () => { expect(byName.transfer_files.description).toContain("Never restart your own pane"); }); }); + +/** + * The 403-fatal reading of the MCP-off switch. A host-registered `subshell mcp` + * child on a narrowed (report-only) token still starts and reaches + * POST /api/identities; a 403 there is the backend refusing this pane's MCP use, + * and it must be fatal so the off switch cannot be answered by a child that + * serves its `subshells`-scoped tools anyway. A 409 keeps today's + * degraded-but-live reading: it never gated registration, only create/join. + */ +describe("registerPaneIdentity", () => { + const own = { publicJwk: "{}", privateJwk: "{}" } as unknown as IdentityKeyPair; + + function apiThrowing(err: unknown): ToolApi { + return { + async req() { + throw err; + }, + }; + } + + it("a 403 is fatal: it throws McpDisabledError rather than serving anyway", async () => { + await expect( + registerPaneIdentity(apiThrowing(new ApiError(403, "no channels:write")), own, "p"), + ).rejects.toBeInstanceOf(McpDisabledError); + }); + + it("a 409 degrades live: it logs and resolves (read-only tools still serve)", async () => { + const errWrite = process.stderr.write; + let saw = ""; + process.stderr.write = ((chunk: string) => { + saw += chunk; + return true; + }) as typeof process.stderr.write; + try { + await expect( + registerPaneIdentity(apiThrowing(new ApiError(409, "identity_required")), own, "p"), + ).resolves.toBeUndefined(); + expect(saw).toInclude("identity registration failed"); + } finally { + process.stderr.write = errWrite; + } + }); +}); diff --git a/packages/mcp-core/src/report.ts b/packages/mcp-core/src/report.ts index 2a8ac1005..f03a27aaa 100644 --- a/packages/mcp-core/src/report.ts +++ b/packages/mcp-core/src/report.ts @@ -109,6 +109,13 @@ export interface ReportIo { const POST_TIMEOUT_MS = 2000; const RESUMED_POST_TIMEOUT_MS = 750; const STDIN_TIMEOUT_MS = 2000; +/** + * The self-extension that rides every report, held WITHIN every per-verb POST + * budget (the verbs run on 750 or 2000; the extend equals the faster one) so + * the parallel never lengthens a hook's worst case: the two run together and + * the process lives for the max, which stays the verb's own number. + */ +const EXTEND_TIMEOUT_MS = 750; /** * The POST budget for one report argv — the whole per-verb decision, as a @@ -148,7 +155,7 @@ export async function runReport(argv: string[], io: ReportIo = {}): Promise {}); + // The extend the MCP child arms at its boot, the report verbs arm at + // theirs: an MCP-disabled pane launches no child, and without this its + // hooks would quietly 401 once the 7-day TTL ran out under a pane in + // daily use. Same door, same ownership check, still best-effort; `exit` + // skips it because the token dies with the pane anyway. + const extend = + argv[0] === "exit" + ? undefined + : doFetch(`${baseUrl}/api/subshells/${subshellId}/extend-token`, { + method: "POST", + headers: { authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(EXTEND_TIMEOUT_MS), + }).catch(() => {}); + await post; + await extend; } catch { // Every failure is a lost report: an unreachable server, a refused POST, // a malformed payload, an incomplete env. None of them is the hook's diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index 16634e238..d38fb5cf9 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -13,7 +13,7 @@ import { z } from "zod"; void z.custom(() => true); import { McpServer } from "@modelcontextprotocol/server"; import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -import { SubshellApi } from "./api-client.js"; +import { ApiError, SubshellApi } from "./api-client.js"; import type { IdentityKeyPair } from "./crypto.js"; import { readMcpEnv } from "./env.js"; import { loadOrCreateIdentity } from "./identity-store.js"; @@ -555,29 +555,69 @@ export function createSubshellMcpServer(deps: { api: ToolApi; own: IdentityKeyPa return server; } +/** + * The fatal reading of a 403 at identity registration: this pane's token was + * minted without the `channels` scope, which happens exactly one way — the + * subshell MCP is switched off for the pane, by its owner's account preference + * or by the node it runs on. A child that reaches this point did so because a + * harness registered `subshell mcp` on its host, not because the plane wired + * it, and serving the `subshells`-scoped tools on a report-only key would make + * the off switch a lie. The two CLI entries print this message and exit; the + * stdio server never connects. + */ +export class McpDisabledError extends Error { + constructor(message: string) { + super(message); + this.name = "McpDisabledError"; + } +} + +/** + * Register/rotate this subshell's channel identity so members can seal + * replies to us. Best-effort for everything EXCEPT a 403: a 409 (the + * server-side `identity_required` only gates create/join) must not stop us + * from serving read-only tools, but a 403 means the backend already refused + * this pane's MCP use, and serving anyway would be serving on a token whose + * owner said no. + * + * @internal exported for tests; callers use runSubshellMcp. + */ +export async function registerPaneIdentity( + api: ToolApi, + own: IdentityKeyPair, + displayName: string | null, +): Promise { + try { + await api.req("/api/identities", { + method: "POST", + body: { publicKey: own.publicJwk, displayName }, + }); + } catch (err) { + if (err instanceof ApiError && err.status === 403) { + throw new McpDisabledError( + "the subshell MCP is switched off for this pane, so nothing is served: its account preference or the node's setting refuses it, and this token cannot register an identity. A person can allow the MCP again under Preferences or the node's settings, then restart the pane", + ); + } + process.stderr.write(`subshell mcp: identity registration failed: ${describeToolError(err).message}\n`); + } +} + /** * Boots the `subshell mcp` stdio server: reads env, persists this subshell's * identity, registers its public key with the backend, extends this pane's * token (now and on an interval), and serves the tools over stdio until the * client disconnects. * + * A refused registration ({@link McpDisabledError}) exits from here before + * anything connects, so a disabled pane's child never serves. + * * Never writes to stdout (the MCP channel) — diagnostics go to stderr. */ export async function runSubshellMcp(): Promise { const env = readMcpEnv(); const api = new SubshellApi({ baseUrl: env.baseUrl, apiKey: env.apiKey }); const own = await loadOrCreateIdentity(env.dataDir, `sess:${env.subshellId}`); - // Register/rotate our key so members can seal replies to us. Best-effort: a - // 409 (identity_required handled server-side only gates create/join) must - // not stop us from serving read-only tools. - try { - await api.req("/api/identities", { - method: "POST", - body: { publicKey: own.publicJwk, displayName: env.subshellName }, - }); - } catch (err) { - process.stderr.write(`subshell mcp: identity registration failed: ${describeToolError(err).message}\n`); - } + await registerPaneIdentity(api, own, env.subshellName); startTokenRenewal(api, env.subshellId); diff --git a/packages/node-admin/src/types/node.ts b/packages/node-admin/src/types/node.ts index 27985d7d9..0b8325103 100644 --- a/packages/node-admin/src/types/node.ts +++ b/packages/node-admin/src/types/node.ts @@ -200,6 +200,14 @@ export interface Node { * older than the field must keep typechecking, and absent reads closed. */ webPreviewsEnabled?: boolean; + /** + * Whether panes launched on this machine may be wired with the subshell MCP. + * On by default: MCP wiring predates its own switch, so a payload cached by + * a client older than the field must read ALLOWED here, the opposite + * polarity of `webPreviewsEnabled`. Off strips the MCP from every new launch + * on this machine and outranks any account preference. + */ + mcpEnabled?: boolean; /** Whether subshells may request a virtual display on this node. */ virtualDisplayEnabled?: boolean; /** Provider selected for new virtual displays on this node. */ diff --git a/packages/subshell-protocol/src/__tests__/node-frames.test.ts b/packages/subshell-protocol/src/__tests__/node-frames.test.ts index 84991a0c1..9f07f6808 100644 --- a/packages/subshell-protocol/src/__tests__/node-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-frames.test.ts @@ -427,7 +427,15 @@ describe("parseNodeCommandBody", () => { // page's `title`, and only ports the document probe accepts are listed at // all. An older node answers the pre-26 shape, and the same pane would // offer a different picker depending on which host ran it. - expect(NODE_PROTOCOL_VERSION).toBe(26); + // 27 is the MCP-use switch: `set_mcp_enabled` carries the plane's value, + // `ready.mcpEnabled` states the node's mirror, the `mcp_enabled` event + // reports a mirror that moved underneath the connection, and a launch's + // MCP wiring may be stripped by the node itself. Unlike ssh (16) a silent + // mirror here reads ALLOWED, not refused, because MCP wiring predates the + // switch; the exact-match bump still stands, because a pre-27 agent has + // neither the strip nor the mirror reader: it would refuse the push and + // launch with wiring the switch already refused. + expect(NODE_PROTOCOL_VERSION).toBe(27); }); it("accepts set_allowed_dirs and rejects a missing or non-array dirs", () => { @@ -1129,6 +1137,62 @@ describe("ssh gate (arrived at protocol 16)", () => { }); }); +// The MCP switch's three carriers, cloned from the ssh set one-for-one. The +// one difference lives in the SEMANTICS, not the grammar: an absent mirror +// reads ALLOWED here (MCP wiring predates its own switch), where ssh reads it +// as refusal. That inversion lives in the reconciler and the node's +// `mcpAllowed`, not in these parsers, which stay polarity-agnostic. +describe("mcp switch (arrived at protocol 27)", () => { + const base = { + type: "ready", + agentVersion: MIN_NODE_VERSION, + protocolVersion: NODE_PROTOCOL_VERSION, + os: "linux", + arch: "x64", + hostname: "h", + dataDir: "/d", + capabilities: [], + }; + const state = { on: true, changedAt: "2026-11-01T10:00:00.000Z" }; + + it("carries the mirror on ready, and drops a malformed one without refusing the ready", () => { + // Lenient exactly like `sshEnabled`: the rest of the frame brings the node + // online, and the plane's answer to silence is its own row. + expect(parseNodeEvent({ ...base, mcpEnabled: state })).toMatchObject({ type: "ready", mcpEnabled: state }); + expect(parseNodeEvent(base)).toMatchObject({ type: "ready" }); + const bad = parseNodeEvent({ ...base, mcpEnabled: { on: "yes" } }); + expect(bad?.type).toBe("ready"); + expect(bad && "mcpEnabled" in bad ? bad.mcpEnabled : undefined).toBeUndefined(); + }); + + it("parses the standalone event strictly — it is the mid-session carrier", () => { + expect(parseNodeEvent({ type: "mcp_enabled", ...state })).toEqual({ type: "mcp_enabled", ...state }); + expect(parseNodeEvent({ type: "mcp_enabled", on: false, changedAt: state.changedAt })).toEqual({ + type: "mcp_enabled", + on: false, + changedAt: state.changedAt, + }); + expect(parseNodeEvent({ type: "mcp_enabled", on: true })).toBeNull(); + expect(parseNodeEvent({ type: "mcp_enabled", changedAt: state.changedAt })).toBeNull(); + expect(parseNodeEvent({ type: "mcp_enabled", on: "yes", changedAt: state.changedAt })).toBeNull(); + }); + + it("parses the set_mcp_enabled command and refuses a partial one", () => { + expect(parseNodeCommandBody({ type: "set_mcp_enabled", ...state })).toEqual({ + type: "set_mcp_enabled", + ...state, + }); + expect(parseNodeCommandBody({ type: "set_mcp_enabled", on: false, changedAt: state.changedAt })).toEqual({ + type: "set_mcp_enabled", + on: false, + changedAt: state.changedAt, + }); + expect(parseNodeCommandBody({ type: "set_mcp_enabled", on: true })).toBeNull(); + expect(parseNodeCommandBody({ type: "set_mcp_enabled", changedAt: state.changedAt })).toBeNull(); + expect(parseNodeCommandBody({ type: "set_mcp_enabled", on: 1, changedAt: state.changedAt })).toBeNull(); + }); +}); + describe("ssh command arms and the launch ssh block", () => { it("ssh discovery/resolve arms delegate to the ssh grammar", () => { expect(parseNodeCommandBody({ type: "ssh_discover_aliases" })).toEqual({ type: "ssh_discover_aliases" }); diff --git a/packages/subshell-protocol/src/api-key-permissions.ts b/packages/subshell-protocol/src/api-key-permissions.ts index 6da25a240..c3874b910 100644 --- a/packages/subshell-protocol/src/api-key-permissions.ts +++ b/packages/subshell-protocol/src/api-key-permissions.ts @@ -42,6 +42,11 @@ export const API_KEY_PERMISSION_GROUPS = [ label: "Change apps and screens access", description: "Enable or disable apps and screens on a node.", }, + { + id: "nodes:mcp", + label: "Change subshell MCP access", + description: "Enable or disable the subshell MCP in panes launched on a node.", + }, { id: "nodes:ssh-wide-forwards", label: "Allow network-accessible forwards", diff --git a/packages/subshell-protocol/src/index.ts b/packages/subshell-protocol/src/index.ts index 1838b7e26..273075608 100644 --- a/packages/subshell-protocol/src/index.ts +++ b/packages/subshell-protocol/src/index.ts @@ -112,6 +112,7 @@ export { type NodeCommandBody, type NodeEvent, type NodeMaintenanceWire, + type NodeMcpEnabledWire, type NodePreviewEnabledWire, type NodeRuntimeReport, type NodeServiceVerb, @@ -132,6 +133,7 @@ export { parseNodeCommandBody, parseNodeEvent, parseNodeMaintenance, + parseNodeMcpEnabled, parseNodePreviewEnabled, parseNodeRuntimeReport, parseNodeSshEnabled, diff --git a/packages/subshell-protocol/src/node-frames.ts b/packages/subshell-protocol/src/node-frames.ts index d361c0930..b84d7987d 100644 --- a/packages/subshell-protocol/src/node-frames.ts +++ b/packages/subshell-protocol/src/node-frames.ts @@ -235,8 +235,26 @@ import { * the title and lists every listener it finds, so the same pane would offer a * different picker depending on which host ran it; the exact-match handshake * rejects one before discovery is ever asked. + * + * **26 -> 27:** the MCP-use switch. `set_mcp_enabled` mirrors a per-machine + * owner switch into the node's mirror file, `ready.mcpEnabled` states that + * mirror at connect, and the `mcp_enabled` event reports a mirror that moved + * underneath the connection (ssh's `ssh_enabled` twin). A launch frame that + * still carries `mcp` while the mirror says off drops the wiring material and + * launches the pane anyway, so a stale plane cannot wire what the machine's + * owner refused. Additive in shape, and breaking anyway because the gate is + * exact-match: what the bump buys is that the plane never sends a command an + * attached agent cannot parse. + * + * Note the polarity difference from the ssh gate it otherwise clones. There an + * absent mirror reads REFUSED, because pane-watching is opt-in and has no + * before to keep behaving like. Here an absent mirror reads ALLOWED: MCP + * wiring predates the switch, so a node nobody has pushed yet must stay + * exactly as it was. The row is still the record either way, and an upgrade + * that read silence as refusal would strip comms from a fleet that never + * opted out. */ -export const NODE_PROTOCOL_VERSION = 26; +export const NODE_PROTOCOL_VERSION = 27; /** * The FIRST protocol whose agents verify the publisher signature on an @@ -1151,6 +1169,27 @@ export type NodeCommandBody = /** The plane's stamp for this value; stored verbatim. */ changedAt: string; } + | { + /** + * Write this node's MCP-use mirror file. + * + * The plane's only writing copy of the flag, so the `set_ssh_enabled` + * posture carries over whole: there is no keyboard verb to race, and the + * command writes the file and nothing else. Turning it off severs no + * running pane, because the switch reaches new launches and restarts, and + * a pane whose MCP child is already alive keeps that child until it + * stops. + * + * Carries the plane's own `changedAt` rather than letting the node stamp + * its own: both copies must end byte-identical, so a later `ready` report + * reads as agreement rather than a disagreement this process invented. + */ + type: "set_mcp_enabled"; + /** False = the subshell MCP is refused in every pane launched here. */ + on: boolean; + /** The plane's stamp for this value; stored verbatim. */ + changedAt: string; + } | { /** * Replace this agent's own binary and restart into it (spec 2026-09-15 §5.2). @@ -1419,6 +1458,31 @@ export function parseNodePreviewEnabled(value: unknown): NodePreviewEnabledWire }; } +/** + * Whether this machine's owner permits the subshell MCP to run in panes launched + * here, as it travels in either direction. Same `{ on, changedAt }` shape as the + * ssh and preview gates, and the same division of authorship: only the plane + * writes it, and `changedAt` rides verbatim so a relay can never outrank the + * decision it carries. Nothing compares stamps; the row is the record. + * + * What differs is what silence means. An absent mirror reads ALLOWED, not + * refused: MCP wiring predates this switch, and a node that has never been + * pushed a value must carry on exactly as it did. The refusal is carried by an + * explicit `on: false`. + */ +export interface NodeMcpEnabledWire { + /** False = nothing of the subshell MCP runs in a pane launched on this machine. */ + on: boolean; + /** ISO 8601 stamp of the write that produced this value. */ + changedAt: string; +} + +/** A {@link NodeMcpEnabledWire}, or null when the value is not one. */ +export function parseNodeMcpEnabled(value: unknown): NodeMcpEnabledWire | null { + if (!isRecord(value) || !isBool(value.on) || !isStr(value.changedAt)) return null; + return { on: value.on, changedAt: value.changedAt }; +} + /** Agent → control events, unsigned (socket-authed; spec §3.3). */ /** * One field of a plugin's preset-editor schema, as it travels. @@ -1855,6 +1919,17 @@ export type NodeEvent = * the switch. */ previewEnabled?: NodePreviewEnabledWire; + /** + * This machine's MCP-use mirror, as its file reads at connect. Absent + * means the node has no file, which for MCP reads as ALLOWED, not + * refused: wiring predates the switch, so silence is agreement with the + * row's own default rather than a refusal to obey it. + * + * Dropped rather than fatal when malformed, exactly like `previewEnabled` + * and `sshEnabled`: the plane reconciles any disagreement from its own + * row on the very same `ready`. + */ + mcpEnabled?: NodeMcpEnabledWire; } | { /** @@ -1889,6 +1964,23 @@ export type NodeEvent = /** ISO 8601 stamp from the file (its own mtime when the file is unreadable). */ changedAt: string; } + | { + /** + * This machine's MCP-use mirror moved underneath the connection, the + * heartbeat belt's twin of `ssh_enabled`. + * + * There is no node-side writer, so what this carries is a file that + * changed by some other means: a repair, a hand edit, or corruption that + * flipped the machine from wiring panes to refusing them. The plane's + * answer to any disagreement is a push of its row, which is also the + * repair. + */ + type: "mcp_enabled"; + /** The refusal/allowance this machine is acting on, as its mirror reads. */ + on: boolean; + /** ISO 8601 stamp from the file (its own mtime when the file is unreadable). */ + changedAt: string; + } | { type: "inventory"; harnesses: { @@ -2235,6 +2327,10 @@ export function parseNodeCommandBody(value: unknown): NodeCommandBody | null { const state = parseNodeSshEnabled(value); return state ? { type: "set_ssh_enabled", ...state } : null; } + case "set_mcp_enabled": { + const state = parseNodeMcpEnabled(value); + return state ? { type: "set_mcp_enabled", ...state } : null; + } case "update": { // Shape only, like `set_server_url` above: WHICH url is acceptable is // the plane's to decide, and the digest is checked against the bytes @@ -2486,18 +2582,21 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { maintenance: rawMaintenance, sshEnabled: rawSshEnabled, previewEnabled: rawPreviewEnabled, + mcpEnabled: rawMcpEnabled, ...rest } = value as Record & { runtime?: unknown; maintenance?: unknown; sshEnabled?: unknown; previewEnabled?: unknown; + mcpEnabled?: unknown; }; const systemSpecs = parseNodeSystemSpecs(rawSystemSpecs); const runtime = rawRuntime === undefined ? null : parseNodeRuntimeReport(rawRuntime); const maintenance = rawMaintenance === undefined ? null : parseNodeMaintenance(rawMaintenance); const sshEnabled = rawSshEnabled === undefined ? null : parseNodeSshEnabled(rawSshEnabled); const previewEnabled = rawPreviewEnabled === undefined ? null : parseNodePreviewEnabled(rawPreviewEnabled); + const mcpEnabled = rawMcpEnabled === undefined ? null : parseNodeMcpEnabled(rawMcpEnabled); return { ...(rest as unknown as Extract), ...(systemSpecs ? { systemSpecs } : {}), @@ -2505,6 +2604,7 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { ...(maintenance ? { maintenance } : {}), ...(sshEnabled ? { sshEnabled } : {}), ...(previewEnabled ? { previewEnabled } : {}), + ...(mcpEnabled ? { mcpEnabled } : {}), }; } case "inventory": { @@ -2529,6 +2629,12 @@ export function parseNodeEvent(raw: string | object): NodeEvent | null { const state = parseNodeSshEnabled(value); return state ? { type: "ssh_enabled", ...state } : null; } + case "mcp_enabled": { + // Strict for the same reason as `ssh_enabled`: the frame is the machine + // reporting what it is acting on, and a half-state is not an answer. + const state = parseNodeMcpEnabled(value); + return state ? { type: "mcp_enabled", ...state } : null; + } case "heartbeat": { if (!isStr(value.ts)) return null; const runtime = parseNodeRuntimeReport(value.runtime); diff --git a/packages/subshell-protocol/src/versions.ts b/packages/subshell-protocol/src/versions.ts index 6ab1ccc39..1481d3d00 100644 --- a/packages/subshell-protocol/src/versions.ts +++ b/packages/subshell-protocol/src/versions.ts @@ -132,8 +132,15 @@ export function semverLt(a: string, b: string): boolean { * agent below this answers the ports scan in the old shape, and the exact-match * gate already refuses it: the floor is what turns that refusal into * "install subshell >= 3.1.0" wherever a version gets compared. + * + * Raised to 3.2.0 with protocol 27 (the MCP-use switch). The published agent is + * 3.1.4, so the floor goes one minor above it, same as the last two bumps, and + * `apps/node/agent/package.json` moves with it in this commit. An agent below + * this understands no `set_mcp_enabled`, so it would hold a stale mirror file + * and wire an MCP its owner refused; the exact-match gate already refuses it, + * and the floor is what makes that refusal read as an installable instruction. */ -export const MIN_NODE_VERSION = "3.1.0"; +export const MIN_NODE_VERSION = "3.2.0"; /** * Whether an agent reporting `version` may connect.