Status: proposed · 2026-10-03 · revision 3 (after two reviews) · supersedes the "one VM at a time" rule of v1
v1 has one slot for the machine with the hands. Whoever attaches last owns it, and the
previous one is closed with 4002 and stops. That was enough to prove the relay, and it
broke the moment there were two computers:
- macmini (instance-mcp 0.8.0, switchboard mode) holds the slot today. Muse's VM attaching as a second set of hands would evict it, and the macmini daemon would stop by contract.
- Testing a second Mac's
wss://path would do the same. - The only workaround is one switchboard per computer, each with its own port,
tailscale serveentry and tokens, and for Muse its own per-address approval.
The owner's trust model is two layers, and v1 only has the first:
- Who may attach. Callers are on the tailnet (the
tailscale servefront) and present a bearer token. Computers present their own secret. - What each caller may use. Which computers, and which tools on each.
instance-mcp #27 (hands-node registry) reaches the same conclusion from the other side: "lend my Mac" must become "lend a hands node", picked from a list. The switchboard is a natural place for that list, since every computer already dials it.
max_inflight = 8 # default for every computer (unchanged from v1)
[[computer]]
name = "macmini"
secret_sha256 = "sha256:…"
[[computer]]
name = "muse-vm"
secret_sha256 = "sha256:…"
max_inflight = 4 # optional override- A computer still dials
GET /vm/attachwithAuthorization: Bearer <secret>. The secret alone selects the slot. The URL does not change, so every v1 daemon (instance-mcp 0.8.0,sb_daemon.py) works as is. - Slot identity is the verifier. The configured name is the label used in policy, routes and audit. A reload that keeps a verifier and changes its name relabels the live socket; nothing is closed (see Reload).
- A name attests possession of a secret, not a particular machine. Whoever holds a computer's secret answers as that computer, so a leaked secret is replaced, not tolerated.
- Anything a computer reports about itself (
serverInfo, hostname, forwarded headers) is shown, never trusted. - No self-registration. Adding a computer is an operator action: generate a pair, add the
verifier, then
SIGHUP. - One live socket per computer. A second attach with the same secret replaces the first
(
4002, unchanged). An attach with another computer's secret takes only that computer's slot.
Config rules, all enforced by check and at load:
- Computer names are 1–64 characters of
[A-Za-z0-9_-]..is not allowed, because a bare TOML keyrpi1.localis a dotted key that makes a nested table. v1 names (.allowed,config.rs:332) keep their rule for clients and pty attaches. - Verifiers are unique across all computers and clients. v1 already refuses reuse
between the VM secret and a client token (
config.rs:244,248); v2 extends that to every pair. [vm]and[[computer]]in the same file is an error.- Every computer named anywhere must exist: in
default_computer, in a client'scomputerstable, or in[[pty_attach]].computer.default_computermust also be one the caller may reach. Otherwise the config is refused. Without this, a typo such asmacmniwould grant nothing today and pre-grant access to any computer given that name later. A consequence: removing a computer fails the reload, keeping the old config (main.rs:134), until every block naming it is edited in the same change. - Computer names are a separate namespace from client and pty-attach names, which stay
unique among themselves as in v1 (
config.rs:240,263). Audit lines carryprincipalandcomputeras distinct fields, somacminias both a computer and a client name is unambiguous.
[[client]]
name = "pahud"
token_sha256 = "sha256:…"
default_computer = "macmini" # required: more than one computer reachable
computers = { "*" = ["*"] } # owner: every computer, every tool
[[client]]
name = "muse"
token_sha256 = "sha256:…"
computers = { macmini = ["sys_info", "screenshot", "browser_navigate", "browser_snapshot",
"browser_click", "browser_type", "browser_press_key",
"browser_wait_for", "browser_tabs", "browser_take_screenshot"] }
max_inflight = 4 # optional, see Fairness
[[client]]
name = "connect"
token_sha256 = "sha256:…"
default_computer = "macmini"
computers = { macmini = ["sys_info", "screenshot"], muse-vm = ["sys_info"] }- Deny by default. A computer missing from
computersdoes not exist for that caller, on every route. "Not allowed" and "unknown" produce byte-identical responses. "*"means every computer, including ones added later. A"*"caller gains access to a new computer the moment it is configured, with no change to its own block. It is for the owner, and startup logs every client that uses it."*"may not be mixed with named computers in onecomputerstable, just as v1 refuses"*"mixed with tool names (config.rs:89-94).computersmay not be empty, and each tool list follows the v1 allowlist rules, including thatvm_statusis always available.- The tool list is still policy, not isolation (see Trust in the README). The computer's own
profile (instance-mcp
observe/desktop/owner) remains the ceiling, and the caller gets the intersection. - A client still carrying v1's top-level
toolsalongside[[computer]]blocks is an error. That combination means a half-migrated config, and guessing which computer it meant would be wrong. checkwarns about any computer that no client or pty attach may use.
POST /mcp/{computer} MCP for one computer; tool names unchanged
POST /mcp exactly /mcp/{default_computer}
GET /computers JSON: the computers this caller may use, and whether each is online
GET /computers returns [{ "name", "default": bool, "attached": bool, "ready": bool }],
one entry per computer the caller may use. The "*"-only fields of /status are not
included.
/mcp never changes meaning when a computer is added. It is always the caller's
default_computer:
- If a caller can reach exactly one computer, that computer is its default and the field is optional.
- If it can reach more than one (including any
"*"caller),default_computeris required andcheckrefuses the config without it.
Adding muse-vm therefore leaves pahud's /mcp pointing at macmini, and Connect or Kiro
configured on /mcp keep working.
Order of checks on every northbound request:
- The bearer token is checked before the path is resolved. A missing or wrong token is
401whatever the path says, so/mcp/{unknown}without a token tells nothing. - The computer is resolved against the caller's allowlist. Not allowed and unknown give
the same
404with the same body. - Tool policy and forwarding work as in v1.
Why a path per computer rather than namespaced tool names (macmini__screenshot) or a
computer argument on every tool:
- Tool names stay exact. Connect matches
sys_infoandscreenshotby name, and models are prompted with instance-mcp's instructions, which name tools unprefixed. - A computer is an MCP server. MCP clients already hold several servers. Adding "macmini" and "muse-vm" as two servers in Kiro, Connect or a Muse skill is the existing UX.
- Policy is evaluated once per request from the path, before any frame is sent.
There is no list_computers MCP tool. Pickers (#27, Connect) are HTTP clients and read
GET /computers, and a model on /mcp/{computer} could not act on such a list anyway.
initialize names the computer in its instructions ("…connected through OpenAB Switchboard
as macmini…"). The always-available tool keeps the name vm_status for compatibility and
gains a computer field.
[[pty_attach]]
name = "kiro-1040"
computer = "muse-vm" # required when more than one computer is configured
url = "wss://…/tools/attach/laptop"
secret_file = "…"
tools = ["*"]This is the switchboard half of #27: "lend muse-vm to session laptop" is one block.
Without computer and with more than one computer configured, the config is refused.
GET /healthzis unchanged: no auth, the switchboard is alive.GET /readyztakes no auth and reveals no names:- With one computer it behaves exactly as in v1: 200 when that computer is attached and ready, else 503.
- With several, it returns 200 if any computer is ready, else 503. The bodies are
v1's (
ok/vm offline), with no counts, so an unauthenticated probe learns neither names nor how many computers exist. An intermittent computer such as a Muse VM does not make it flap.
GET /readyz/{computer}requires a client token and answers only for that caller's computers: 200 ready, 503 offline. Unknown and not-allowed give the same 404 as on/mcp/{computer}, so a tailnet node without a token (an untagged Muse node included) cannot learn which computers exist. Monitoring uses a token scoped to the computers it watches, for examplecomputers = { macmini = ["vm_status"] }(a tool list may not be empty,config.rs:87-89).GET /status(client token) lists only the caller's computers.- The socket peer (
hub.rs:243-252, taken from forwarded identity headers,server.rs:104-123) and the in-flight count are shown only to"*"callers, whatever the number of computers configured. - Other callers see each computer's name,
attached,ready,server,attached_for_secsandmax_in_flight(hub.rs:243-252). Onlypeerandin_flightare hidden. - The same filter applies to the
vm_statustool.
- The socket peer (
- The hub holds one
Mutexover the whole table: verifier → (name, slot). There are no per-slot locks, so install, takeover and reload never interleave across computers. /vm/attachcompares the presented verifier against every computer verifier with no early exit, the same way v1 handles client tokens (config.rs:163-173). Timing does not reveal which computer matched.- Install re-resolves under the lock. v1's protection against an attach authenticated
just before a reload (
hub.rs:341-346) generalises as follows: at install, the socket's verifier is looked up again in the current table; if it is gone, the socket is refused with4003, and if it now maps to another name, it installs under that name. - Each slot has its own pending table,
max_inflight, ping liveness and close handling. A slow or dead computer cannot starve another. generationis per computer (v1's is hub-wide,hub.rs:264). Audit lines carrycomputerand that computer's generation.
max_inflight counts per socket (hub.rs:174-180). Without more, one caller can occupy
every slot on a shared computer, so a long Muse browsing run would starve Connect's
screenshot poll on macmini.
v2 adds a per-client cap on each computer:
- The cap applies to every principal, clients and pty attaches alike. A pod attach is a
caller too, and its own 64-request ceiling (
pty.rs,MAX_CONCURRENT_POD_REQUESTS) is larger than any computer's cap, so without this one pod could starve Connect. [[client]].max_inflightand[[pty_attach]].max_inflightset it, within1..=64.- The default is half of the live socket's
max_inflight(the value it attached with,hub.rs:352), rounded up. A reload that changes the computer's limit does not move the per-caller default until the next attach. - A caller over its cap gets
-32002without the call being sent, exactly like the computer-wide cap.
| Change | Effect |
|---|---|
Client added, removed, token rotated, allowlist or default_computer changed |
from the next request |
| Computer added | its slot exists at once and accepts an attach |
| Computer removed, or its verifier rotated | that socket closes with 4003. Its in-flight calls fail with -32004 and may have run (hub.rs:151-158). Other computers are untouched |
| Same verifier, new name | the live socket is relabelled, and an audit line computer_renamed records it. No close |
| A verifier moved from one name to another | a relabel, not a revoke |
max_inflight (global or per computer) |
new in v2: reloaded, and applied to that computer's next attach. A live socket keeps the value it attached with (hub.rs:352). In v1 limits need a restart (main.rs:130) |
A [[pty_attach]]'s computer, tools or max_inflight changed |
new in v2: swapped on the running attacher, in force from that attach's next request. An attach resolves its policy per request, by attach name, so a computer renamed under it keeps serving the pod — caching it instead answers that pod's CLI "the VM is not connected" for ever, with nothing closed and nothing logged |
[[pty_attach]] added or removed, or its url or secret_file changed, listen, timeouts |
need a restart, as in v1 (server.rs:90-99, main.rs:130). The socket is already dialled, so these cannot be moved under it; each is reported at warn on reload |
Per-caller max_inflight |
from that caller's next call |
Reload stays hub-first, as v1's reload_auth does (server.rs:81-90). The computer table
is swapped before client auth, so a daemon presenting a newly added secret is never
refused with 4003.
A v1 config loads unchanged:
[vm]becomes a computer nameddefault.- A client's
tools = [...]becomescomputers = { default = [...] }, withdefault_computer = "default". [[pty_attach]]withoutcomputertargetsdefault.- Top-level
max_inflightstays the per-computer default.
Nothing a v1 caller or daemon sees changes: /mcp, /readyz, /status and the close codes
behave as before while there is one computer. The one exception is that peer and in_flight
in /status and vm_status are now shown only to callers granted
computers = { "*" = … }, and a v1 client's tools = ["*"] maps to
computers = { default = ["*"] }, which is a named grant — so it no longer sees them.
- One switchboard, one port, one
tailscale serveentry and, for Muse, one approved address serve every computer. - macmini and a Muse VM can both be attached, and neither evicts the other.
- Muse as a caller keeps its token and URL (
/mcpdefaults to macmini). Muse as a computer needs a VM secret and a daemon that dials out. - Moving macmini's config from
defaulttomacminiis a relabel. The attached instance-mcp is not closed, which matters because a daemon that gets4003stops until someone restarts it (instance-mcpReverseAttachClient.swift:125,sb_daemon.py:35). - Memory and audit grow with the number of computers. The expected scale is single digits.
- Routing a call to "any available computer", load balancing, fan-out.
- Computer-to-computer calls through the switchboard.
- Self-registration or enrolment tokens for computers.
- Rate limits beyond per-computer and per-client in-flight caps.
- Persisting anything across restarts.
- One switchboard per computer. Works today. Rejected as the long-term shape: N ports, N configs, N tokens per caller, N approvals for Muse, and no single place to answer "which computers exist".
- Namespaced tool names on one
/mcp. One server entry per caller, but tool names change, Connect breaks, and a model sees three times the tool list for three computers. - A
computerargument on every tool. Same single endpoint, but every tool schema is rewritten, and a forgotten argument becomes a call to the wrong machine. - Computer chosen by URL on attach (
/vm/attach/{name}). It adds nothing, because the secret already identifies the computer, and it would change the URL that instance-mcp 0.8.0 andsb_daemon.pyalready dial. /mcpanswering with only alist_computerstool for multi-computer callers. Rejected: adding a second computer would silently break every client configured on/mcp.
-
Config model and loader. Unit tests:
- a v1 config maps to
defaultexactly; [vm]together with[[computer]]is refused;- a duplicate verifier is refused, between computers and between a computer and a client;
"*"mixed with named computers is refused;- an empty
computerstable is refused; - a
.in a computer name is refused with a message; - a multi-computer or
"*"caller withoutdefault_computeris refused; - a client keeping
toolsbeside[[computer]]is refused; [[pty_attach]]withoutcomputeris refused when there are several computers;- a computer name in
default_computer,computersor[[pty_attach]].computerthat is not configured is refused, and adefault_computerthe caller may not reach is refused; - a per-caller
max_inflightoutside1..=64is refused; checkwarns about a computer no caller may use.
- a v1 config maps to
-
The hub as a map. End-to-end tests:
- two fake computers stay attached together;
- evicting, rotating or removing one leaves the other attached and its calls succeeding;
- a same-verifier rename keeps the socket;
- swapping two verifiers between names evicts nothing;
- an attach authenticated just before its verifier is removed is refused with
4003— covered by a hub unit test ofinstall()(install_refuses_a_secret_rotated_out_after_the_upgrade_check), because theserve()branch that sendsClose(4003)at attach needs a reload to land between the upgrade check and the install and is not deterministically reachable from a client; - per-computer in-flight isolation: one computer full, the other still answers;
- the per-caller cap leaves room for a second caller on the same computer, and applies to a pty attach as well as a client;
- a caller whose HTTP client disconnects mid-call gets its share back at once, rather than when the computer answers or the socket closes;
-
Routes. End-to-end tests:
/mcp/{computer},/computersand/readyz/{computer}work for allowed computers;/mcp/{unknown}and/readyz/{unknown}without a token are401;/readyzbodies carry no names or counts;- unknown and not-allowed give identical responses on every route;
- a
"*"caller's/mcptools/listis identical before and after a computer is added; /readyzis unchanged with one computer, and with several is 200 when any is ready;peeris hidden from non-"*"callers.
-
[[pty_attach]].computer. End-to-end tests: an attach lends the computer it names and not the first one configured; and it keeps serving the pod through a same-verifier rename of that computer, which is the reload path a running attach must follow. -
Deploy on macmini. Swapping the binary is a restart: instance-mcp gets
1001and redials (ReverseAttachClient.swift:126). After that, relabeldefaulttomacminiby reload and confirm instance-mcp stays attached through the reload. -
First second computer: Muse's VM. Open questions for Muse before this step:
- Can a WebSocket upgrade cross its TCP-only
:3130HTTP proxy? That needsCONNECT, and proxy support in the daemon's WebSocket library. - Where does the VM secret live? The Secure Credentials Store injects headers into HTTPS requests; does it do the same for a WebSocket upgrade?
- Does the daemon stay up between conversations?
- Which tools can the VM offer?
sb_daemon.pyneeds X11 forscreenshot,mouseandkey.
On our side: the Linux instance-mcp port has no switchboard mode yet (an instance-mcp follow-up), so step 6 starts with
sb_daemon.py. - Can a WebSocket upgrade cross its TCP-only