Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/mcp-opt-out.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .claude/rules/security-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Four credential kinds; `apps/server/api/src/api/auth-guard.ts` is the only place

- **Session cookie**: HttpOnly, `SameSite=Lax` always; `Secure` only when `APP_BASE_URL` is https (the marking follows the base URL, not `NODE_ENV`). The only actor admin surfaces accept, and the only one that mints an **unbound** WS attach token.
- **System key** (`Bearer subshell_…`): new keys carry versioned explicit grants, checked by each route's `systemPermission`; unannotated guarded routes deny scoped keys. Legacy keys retain their existing non-node access. Derived pane credentials inherit the creator's ceiling, and restart cannot broaden it. Terminal token minting requires `subshells:execute` and binds the ticket to one accessible pane. Machine credentials never manage the instance (`requireAdmin` 403s every non-cookie actor).
- **Subshell token**: 7-day TTL, self-extending while its MCP child runs; revoked **instantly** on terminate/delete. The permission map is a scope gate, not a row fence (§4). Raw REST resolves a pane's token as its **OWNER** with boost and shares off, so a prompt-injected pane can act on the owner's OTHER subshells - the accepted sibling-action posture (§4, §11).
- **Subshell token**: 7-day TTL, self-extending while its MCP child runs AND while its `subshell report` hooks fire (every report but `exit` rides an extend in parallel, so a hook-live pane never reaches the wall even with no child); revoked **instantly** on terminate/delete. The permission map is a scope gate, not a row fence (§4). With the per-account or per-node MCP switch off (both default ON; off wins), a new pane's key is minted `{ report: ["write"] }` instead: hook reporting and self-extension survive, the sibling surface and channel/prompt/transfer scopes do not; opencode/pi disabled panes additionally load no roster bridge, since theirs rides the registration (§4, §6). Raw REST resolves a pane's token as its **OWNER** with boost and shares off, so a prompt-injected pane can act on the owner's OTHER subshells - the accepted sibling-action posture (§4, §11).
- **Node key**: `/ws/node` and the encrypted discovery envelope only; **no plaintext bearer access on REST** (permanent guard rejection, §5.5); refused while its owner's account is disabled (`4403`); blast radius = impersonating that one node.

Kept posture, in short: the api-key row is lifecycle truth (a key is a subshell principal only when the row, written only by `issueSubshellToken`, matches; self-service api-key endpoints are blocked at the mount); the first registered user becomes admin and the email gate is the **E-mail provider row's** `registration_enabled` (NULL = open only while the instance has no users at all; every OIDC provider answers its own, NULL reads closed); adding a provider is the same trust decision class as installing a plugin, and with `requireLocalEmailVerified: false` the provider's **verified email claim IS the link defense** (§5's inversion); passkeys mint the same session cookie and work only on the configured `APP_BASE_URL` host (rpID); break-glass is a destructive credential rewrite, not a bypass; account disable is one transaction that also kills that person's bearer tokens, live WebSockets, and every node they own; the deliberately public API reads and other conditional-authentication surfaces are inventoried in §3 and `api-surface-auth.test.ts`; WS attach is a 30 s single-use token, scoped tokens bind to one pane and redemption consumes before the bind check (§8); and login backoff (per email, `2^n` s capped at 30) is the only rate limit anywhere.
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/content/docs/mcp/configure.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ Claude Code, Codex, OpenCode, and Oh My Pi receive per-launch MCP configuration.

The compiled server and node binaries both include an `mcp` subcommand. The host resolves which executable exists on the machine where the pane runs.

## Turning the wiring off

Two switches stop Subshell from wiring the MCP into panes, and both default to on. In the server dashboard's sidebar bottom bar, choose **Account** → **Preferences** and find the **Subshell MCP** card to control it for your own account. The node's owner controls the machine-wide switch from that node's **Overview** page, under **Subshell MCP**. Either one off means off: a node whose owner refuses the MCP gets no wiring for any account, and an account that opts out gets none wherever its panes run.

Turning a switch off changes new launches and restarts only; panes already running keep their live MCP until you restart them. A pane launched with the switch off still reports its own state, so its attention bell, exit status, and harness session reporting keep working. The coordination tool surface disappears, along with the context its tool list costs the agent every turn. One extra consequence for OpenCode and pi: their child-agent roster rides the same registration, so a switched-off pane of those agents stops reporting its roster too. The `subshell mcp` command that [Manual integration](#manual-integration) registers on a host refuses to run on a switched-off machine, so those harnesses' panes come up without the tools rather than failing.

## Manual integration

Grok Build, Hermes, and pi require one-time host setup. In the server dashboard's sidebar, choose **Settings** → **Presets** → **New preset** → **New agent preset** and select the agent. Find **Cross-subshell comms** near the bottom of the form for the registration commands. Run those commands on the machine where that agent will execute. See [Grok Build](/agents/grok-build), [Hermes Agent](/agents/hermes) and [pi](/agents/pi).
Expand Down
13 changes: 13 additions & 0 deletions apps/docs/content/docs/mcp/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions apps/docs/content/docs/mcp/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/content/docs/nodes/manage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ The server host's node is named **Server** by default. Its internal ID remains `

Node owners manage the machine's settings. A share may allow launching there without granting ownership of service, directory, or enrollment controls.

## Subshell MCP

In the server dashboard, open **Nodes**, select your node, and find the **Subshell MCP** card on its overview. **Allow the subshell MCP** controls whether Subshell wires its coordination server into panes launched on this machine. It's on by default, and only the node's owner can change it; an administrator manages it for the built-in node on the server host.

Turning it off means new and restarted panes here get no subshell MCP and no sibling-coordination tools, whatever their owners prefer in their own [MCP configuration](/mcp/configure#turning-the-wiring-off). The refusal is enforced on this machine, so it also stops the MCP command for harnesses that registered it on the host directly. Panes already running keep their live MCP until they restart.

## Outgoing SSH

In the server dashboard, open **Nodes**, select your node, and find the **SSH** card on its overview. **Allow SSH** lets Subshell make outgoing SSH connections from this node and use its SSH agent to supply keys to other nodes. It doesn’t enable incoming SSH access to the node.
Expand Down
6 changes: 3 additions & 3 deletions apps/docs/content/docs/reference/node-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Registration is a pairing operation, not normal execution over plaintext. The no

## Readiness and machine reports

The `ready` event identifies the node's release version, protocol version, operating system, architecture, hostname, data directory, and capabilities. It can also report its home directory, invocation path, runtime and service details, maintenance state, and system specs (OS release, CPU model, logical CPU count, total memory, and report time). System specs are optional so older nodes can still connect; the server retains the last report for offline discovery.
The `ready` event identifies the node's release version, protocol version, operating system, architecture, hostname, data directory, and capabilities. It can also report its home directory, invocation path, runtime and service details, maintenance state, the machine's ssh, preview, and MCP-use mirror states, and system specs (OS release, CPU model, logical CPU count, total memory, and report time). System specs are optional so older nodes can still connect; the server retains the last report for offline discovery.

These reports let the server plan launches for the execution machine rather than guessing its paths from the server host. They can disclose machine names, local paths, and selected environment information to the trusted control plane.

Expand Down Expand Up @@ -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`. |

Expand Down Expand Up @@ -173,7 +173,7 @@ The server applies two distinct gates to readiness:
1. The node release must meet the server's minimum supported release version.
2. Its protocol version must match the server's protocol exactly.

The implemented protocol is **26**. Its number is independent of the package release version. Additive wire changes also require a protocol bump and coordinated rollout; there is no per-feature compatibility window.
The implemented protocol is **27**. Its number is independent of the package release version. Additive wire changes also require a protocol bump and coordinated rollout; there is no per-feature compatibility window.

A refused node can remain connected in a **held** state for updates while staying unavailable for launches and ordinary management commands. Only `update` can use that held connection. Older nodes on the legacy path receive that update command in its frozen plaintext recovery format; they aren't admitted for normal plaintext operation. Invalid credentials and disabled owners are authentication failures, not update holds.

Expand Down
19 changes: 19 additions & 0 deletions apps/node/agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,25 @@ owns them. The reporting ORDER is load-bearing:
`maybeReportMaintenance` runs inside `reportDeath` BEFORE the `exit` frame.
**Working on maintenance: read `apps/node/agent/docs/maintenance.md` first.**

## MCP-use gate (`src/mcp-enabled.ts`, protocol 27)

One mirror, `<dataDir>/mcp-enabled.json` = `{ on, changedAt }`, written 0600
by atomic tmp+rename. The plane's `set_mcp_enabled` command is its ONLY
writer: the daemon stores the pushed bytes verbatim and never re-stamps, and
there is no machine-side verb for it. Its polarity INVERTS the maintenance
mirror beside it: an absent file reads ALLOW, because MCP wiring predates its
own switch and a node nobody has pushed stays exactly as it was. The read
answers four ways and `mcpAllowed` maps them: absent/on allow, off refuses,
unreadable refuses-and-announces (a corrupt file must not quietly re-arm
wiring; the plane repairs by pushing its row). Three enforcement points:
`subshell mcp` refuses the command itself; a launch frame that still carries
`mcp` under a refusing mirror drops the wiring material and launches the pane
without it, naming the drop in the node log; and `ready` states the mirror at
connect, with the heartbeat-belt `mcp_enabled` event catching one that moved
underneath the connection (absence stays quiet since silence agrees; an `off`
file always announces). Reach is new launches: a pane already running keeps
its live MCP child until it restarts.

## Pane-log retention (`src/pane-log-retention.ts`, 2026-09-23)

Pane logs (`<dataDir>/subshells/<id>.log`, the verbatim typed transcript) age
Expand Down
2 changes: 1 addition & 1 deletion apps/node/agent/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
25 changes: 25 additions & 0 deletions apps/node/agent/src/__tests__/cli-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 });
}
});
});
Loading
Loading