From f3680cff3d3c429d1d9340de4eac474a9c061c3d Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Sun, 1 Nov 2026 00:53:17 -0700
Subject: [PATCH 01/10] feat(mcp): per-account and per-node opt-out for the
subshell MCP
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two switches stop wiring the subshell MCP into panes; off wins on either.
An account's Preferences card is the person declining the tool surface and
its context cost; a node's overview card is the machine's owner declining
it, enforced on the machine itself (mirror file, launch-time strip, and a
refusing 'subshell mcp' verb), so a host-registered harness stops with a
clear notice instead of serving anyway.
Both default ON, which inverts the ssh and preview gates beside them: MCP
wiring predates its own switch, so an untouched row reads allowed and an
upgrade strips comms from nobody. A disabled pane's token is minted
{report: [write]} instead of the full map, which drops the sibling surface
docs/security.md §4 accounts for, while the harness hooks (attention, exit,
harness-session, subagents/report) and token self-extension keep working.
A 403 at identity registration is now fatal in the MCP child: on a
narrowed token, serving anyway would make the off switch a lie.
Protocol 27 (set_mcp_enabled frame, ready.mcpEnabled, mcp_enabled event)
raises MIN_NODE_VERSION to 3.2.0 in the same commit as the agent, so
server and node ship together; the release cuts as app=all.
Co-Authored-By: Claude Fable 5
---
.changeset/mcp-opt-out.md | 9 +
.claude/rules/security-context.md | 2 +-
apps/docs/content/docs/mcp/configure.mdx | 6 +
apps/docs/content/docs/mcp/security.mdx | 13 +
.../docs/content/docs/mcp/troubleshooting.mdx | 12 +
apps/docs/content/docs/nodes/manage.mdx | 6 +
apps/node/agent/package.json | 2 +-
apps/node/agent/src/__tests__/cli-mcp.test.ts | 25 ++
.../src/__tests__/commands-launch.test.ts | 142 +++++++-
apps/node/agent/src/cli.ts | 18 +-
apps/node/agent/src/commands/context.ts | 9 +
apps/node/agent/src/commands/index.ts | 3 +
apps/node/agent/src/commands/launch.ts | 61 +++-
apps/node/agent/src/commands/report.ts | 54 +++
.../agent/src/commands/set-mcp-enabled.ts | 31 ++
apps/node/agent/src/daemon.ts | 22 ++
apps/node/agent/src/mcp-enabled.ts | 151 ++++++++
apps/server/api/AGENTS.md | 2 +-
.../src/api/__tests__/settings-route.test.ts | 130 +++++++
apps/server/api/src/api/auth-guard.ts | 25 ++
.../set-node-mcp-enabled.route.test.ts | 285 ++++++++++++++++
apps/server/api/src/api/nodes/index.ts | 2 +
apps/server/api/src/api/nodes/node-view.ts | 9 +
.../api/nodes/set-node-mcp-enabled.route.ts | 81 +++++
apps/server/api/src/api/settings.route.ts | 50 +++
.../__tests__/subshells-extend-token.test.ts | 85 ++++-
.../api/subshells/subshell-attention.route.ts | 12 +-
.../src/api/subshells/subshell-exit.route.ts | 9 +-
.../subshell-harness-session.route.ts | 12 +-
.../api/subshells/subshell-subagents.route.ts | 4 +-
.../api/ws/__tests__/node-ws-handler.test.ts | 4 +
apps/server/api/src/api/ws/node-ws-handler.ts | 19 ++
.../__tests__/backup-cli-subprocess.test.ts | 13 +-
apps/server/api/src/db/migrate.ts | 2 +
.../api/src/db/migrations/0073-mcp-enabled.ts | 31 ++
.../__tests__/0073-mcp-enabled.test.ts | 115 +++++++
.../src/db/repositories/nodes.repository.ts | 35 ++
.../db/repositories/user-meta.repository.ts | 30 ++
.../server/api/src/db/types/nodes.db-types.ts | 17 +
.../api/src/db/types/user-meta.db-types.ts | 28 +-
apps/server/api/src/index.ts | 6 +
.../api/src/modules/backups/database.ts | 2 +-
.../nodes/__tests__/mcp-enabled.test.ts | 323 ++++++++++++++++++
.../nodes/__tests__/node-events.test.ts | 2 +
.../nodes/__tests__/remote-launcher.test.ts | 2 +
.../api/src/modules/nodes/mcp-enabled.ts | 162 +++++++++
.../api/src/modules/nodes/node-events.ts | 17 +
.../__tests__/subshell-manager-mcp.test.ts | 97 +++++-
.../__tests__/subshell-manager-remote.test.ts | 81 +++++
.../src/modules/subshells/mcp-availability.ts | 36 ++
.../subshells/subshell-manager.service.ts | 71 +++-
.../api/src/modules/subshells/tokens.ts | 68 +++-
apps/server/web/src/components/mcp-card.tsx | 112 ++++++
.../src/components/nodes/node-mcp-card.tsx | 59 ++++
apps/server/web/src/hooks/use-nodes.ts | 24 ++
apps/server/web/src/routes/nodes_.$id.tsx | 5 +
apps/server/web/src/routes/preferences.tsx | 2 +
bun.lock | 2 +-
docs/security.md | 18 +-
.../mcp-core/src/__tests__/server.test.ts | 46 ++-
packages/mcp-core/src/server.ts | 64 +++-
packages/node-admin/src/types/node.ts | 8 +
.../src/__tests__/node-frames.test.ts | 65 +++-
.../src/api-key-permissions.ts | 5 +
packages/subshell-protocol/src/index.ts | 2 +
packages/subshell-protocol/src/node-frames.ts | 108 +++++-
packages/subshell-protocol/src/versions.ts | 9 +-
67 files changed, 2871 insertions(+), 91 deletions(-)
create mode 100644 .changeset/mcp-opt-out.md
create mode 100644 apps/node/agent/src/commands/set-mcp-enabled.ts
create mode 100644 apps/node/agent/src/mcp-enabled.ts
create mode 100644 apps/server/api/src/api/nodes/__tests__/set-node-mcp-enabled.route.test.ts
create mode 100644 apps/server/api/src/api/nodes/set-node-mcp-enabled.route.ts
create mode 100644 apps/server/api/src/db/migrations/0073-mcp-enabled.ts
create mode 100644 apps/server/api/src/db/migrations/__tests__/0073-mcp-enabled.test.ts
create mode 100644 apps/server/api/src/modules/nodes/__tests__/mcp-enabled.test.ts
create mode 100644 apps/server/api/src/modules/nodes/mcp-enabled.ts
create mode 100644 apps/server/api/src/modules/subshells/mcp-availability.ts
create mode 100644 apps/server/web/src/components/mcp-card.tsx
create mode 100644 apps/server/web/src/components/nodes/node-mcp-card.tsx
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..7fdfa583e 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; 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: hooks and self-extension survive, the sibling surface and channel/prompt/transfer scopes do not (§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..ad5502b5b 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, open **Settings** → **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. Only the coordination tool surface disappears, along with the context its tool list costs the agent every turn. Harnesses that registered `subshell mcp` on their host through [Manual integration](#manual-integration) refuse to start on a switched-off machine, so their 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..17ec8afaa 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 [Preferences](/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/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/cli.ts b/apps/node/agent/src/cli.ts
index f6432beb5..2996c3a95 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, 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..9d28bbd96 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";
@@ -178,6 +179,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..93370cde4
--- /dev/null
+++ b/apps/node/agent/src/mcp-enabled.ts
@@ -0,0 +1,151 @@
+import { existsSync, 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);
+ if (!existsSync(file)) return { kind: "absent" };
+ 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) {
+ 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..e143fe151 100644
--- a/apps/server/api/AGENTS.md
+++ b/apps/server/api/AGENTS.md
@@ -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
) : (
-
- {node.kind === "local"
- ? "An admin can change this setting."
- : "Only this node’s owner can change this setting."}
+
+ {disabledReason}
)}
{update.isError && (
From d6186b5d1e440dcb9f486e0c182d906e4c4eef23 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Sun, 1 Nov 2026 01:26:45 -0700
Subject: [PATCH 09/10] test: pin report-only passage at all four hook doors
The review caught that the acceptance was pinned for only two of the four
report routes: a per-file revert of the session or roster door to the old
requirePerm would silently 403 an opted-out pane's hooks (runReport swallows
failures as a lost report) with every other suite green. The cell now POSTs
each door with the report-only key, exit last because it writes the death
row, and its comment said every verb rather than every verb but exit.
Co-Authored-By: Claude Fable 5
---
.../__tests__/subshells-extend-token.test.ts | 36 +++++++++++++++----
1 file changed, 30 insertions(+), 6 deletions(-)
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 746cefd0d..6597c8dd6 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
@@ -175,7 +175,14 @@ describe("POST /api/subshells/:id/extend-token", () => {
return res.status;
}
- it("still rings its own bell and reports its own exit", async () => {
+ 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: it writes the death row, and the roster door 409s a row that
+ // is no longer live (the session door would answer 200 and silently
+ // drop it).
const s = await subshellWithScopedToken(reportingTokenPermissions());
expect(
await bearer(
@@ -185,17 +192,34 @@ describe("POST /api/subshells/:id/extend-token", () => {
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 () => {
- // Each `subshell report` verb 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 401 a week in.
+ // 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);
});
From 677dbcf77a380354a97d37572fb66e4533c2a9a9 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Sun, 1 Nov 2026 16:29:06 -0800
Subject: [PATCH 10/10] fix: round-5 full-pass batch on the opt-out
Fresh-eyes full passes on all three slices came back 0 MAJOR with ten
smaller items, all quote-verified and fixed here.
Accuracy: the token-lifetime story in three docs said the report-verb
extend is the switched-off pane's substitute, but it rides every pane's
reports; the four-door comment inherited a clause from round 4 that
round 5 disproved (exit parks the row with status running, and the
session door deliberately still records a parked row; only the roster
door 409s); security section 6's "never serves" became "never acts",
since a disabled child with an unreachable plane enumerates inert tools;
the extend budget comment now states its real inequality (within every
per-verb budget, equal at the fast one); the node-view polarity comment
names the default-deny switches instead of "the two above"; the
mcp-availability param cites the edit-grantee restart that actually
ships; and the refused-mirror notice admits a corrupt file reads the
same as an off one.
Reference page: set_mcp_enabled joins the command table (the only
set_* command the bump added and the page lacked), and the ready
enumeration names all three mirror states.
Structure: the account cards' duplicated probe/optimistic/rollback state
machine is now one hook, useBooleanSetting, run by the notifications
master switch and the MCP card alike, per the dedup rule their own test
comments cited.
Co-Authored-By: Claude Fable 5
---
.claude/rules/security-context.md | 2 +-
.../content/docs/reference/node-protocol.mdx | 4 +-
apps/node/agent/src/cli.ts | 2 +-
apps/server/api/AGENTS.md | 2 +-
apps/server/api/src/api/nodes/node-view.ts | 9 ++-
.../__tests__/subshells-extend-token.test.ts | 7 +-
.../src/modules/subshells/mcp-availability.ts | 5 +-
apps/server/web/src/components/mcp-card.tsx | 50 +++---------
.../components/notifications-master-card.tsx | 56 +++----------
.../web/src/hooks/use-boolean-setting.ts | 78 +++++++++++++++++++
docs/security.md | 8 +-
packages/mcp-core/src/report.ts | 7 +-
12 files changed, 125 insertions(+), 105 deletions(-)
create mode 100644 apps/server/web/src/hooks/use-boolean-setting.ts
diff --git a/.claude/rules/security-context.md b/.claude/rules/security-context.md
index 9c02d4da0..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 (or, with the switch off, while its `subshell report` hooks fire - every report but `exit` rides an extend); 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).
+- **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/reference/node-protocol.mdx b/apps/docs/content/docs/reference/node-protocol.mdx
index 0e7758dca..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, the machine's MCP-use preference, 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`. |
diff --git a/apps/node/agent/src/cli.ts b/apps/node/agent/src/cli.ts
index 2996c3a95..eb4c15525 100644
--- a/apps/node/agent/src/cli.ts
+++ b/apps/node/agent/src/cli.ts
@@ -604,7 +604,7 @@ export async function run(argv: string[], deps: RunDeps = {}): Promise {
// 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: it writes the death row, and the roster door 409s a row that
- // is no longer live (the session door would answer 200 and silently
- // drop it).
+ // 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(
diff --git a/apps/server/api/src/modules/subshells/mcp-availability.ts b/apps/server/api/src/modules/subshells/mcp-availability.ts
index cc4abf9a1..519c77d20 100644
--- a/apps/server/api/src/modules/subshells/mcp-availability.ts
+++ b/apps/server/api/src/modules/subshells/mcp-availability.ts
@@ -23,8 +23,9 @@ import { getNodeMcpEnabled } from "@/modules/nodes/mcp-enabled.js";
* 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 resolved owner (not the actor: a system key
- * launching a pane owned by someone else asks that person's question)
+ * @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 {
diff --git a/apps/server/web/src/components/mcp-card.tsx b/apps/server/web/src/components/mcp-card.tsx
index 02da5a58a..09c66e898 100644
--- a/apps/server/web/src/components/mcp-card.tsx
+++ b/apps/server/web/src/components/mcp-card.tsx
@@ -5,11 +5,10 @@ import {
CardDescription,
CardHeader,
CardTitle,
- errMessage,
Label,
Switch,
} from "@internal/node-admin";
-import { useEffect, useState } from "react";
+import { useBooleanSetting } from "@/hooks/use-boolean-setting";
/**
* Account → Preferences: the per-user subshell MCP switch. One value stored in
@@ -19,9 +18,9 @@ import { useEffect, useState } from "react";
* 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 hooks are
- * injectable so the read/write cycle is testable without stubbing fetch (the
- * notifications master switch is the precedent).
+ * 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
@@ -43,40 +42,13 @@ export type McpCardProps = {
/** Settings card for "wire the subshell MCP into my panes at all". */
export function McpCard({ getEnabled = getMcpEnabled, setEnabled = setMcpEnabled }: McpCardProps) {
- 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 must not leave a broken switch: fall back to on
- // (the documented default) rather than a false "off", which would
- // silently strip tools from panes the user never opted out of.
- .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 MCP setting could not be changed."));
- } finally {
- setBusy(false);
- }
- }
+ // 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 (
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/docs/security.md b/docs/security.md
index a0df62835..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, or (a switched-off pane) while its report hooks fire | 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 |
@@ -1139,8 +1139,10 @@ consequences are:
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
- serves on a report-only key, and the node owner's no is not something a
- tenant account can talk past. A disabled pane still holds its credential:
+ 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
diff --git a/packages/mcp-core/src/report.ts b/packages/mcp-core/src/report.ts
index bfda055f1..f03a27aaa 100644
--- a/packages/mcp-core/src/report.ts
+++ b/packages/mcp-core/src/report.ts
@@ -110,9 +110,10 @@ const POST_TIMEOUT_MS = 2000;
const RESUMED_POST_TIMEOUT_MS = 750;
const STDIN_TIMEOUT_MS = 2000;
/**
- * The self-extension that rides every report, capped BELOW the slowest main
- * POST so it never lengthens a hook's worst case: the two run in parallel and
- * the process lives for the max, which stays the main budget's number.
+ * 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;