Skip to content
Merged
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
3 changes: 3 additions & 0 deletions cmd/muster-mirror/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,9 @@ func startMCP(ctx context.Context, listen, secretFile string, backend mirrorhost
proxy.Secret = secret
proxy.Windows, _ = mirrorhost.WindowListerOf(backend)
proxy.Socket, _ = mirrorhost.ServerSocketOf(backend)
if src := mirrorhost.OpenCodeSourceOf(backend); src != nil {
proxy.OpenCodeAncestors = src.Ancestors
}
logger.Printf("muster-mirror: MCP endpoint for this host's Claude Code sessions at http://%s/mcp (secret in %s)", l.Addr(), secretFile)
go func() {
if err := mirrorhost.ServeMCP(ctx, l, proxy); err != nil {
Expand Down
49 changes: 48 additions & 1 deletion docs/attention.md
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,8 @@ If muster sits behind an auth proxy, `POST /mirror/mcp` must bypass it, as
With [previews](previews.md) on (`-preview` and a valid
`previews.json`), the same endpoint serves a **second MCP server** at
`http://127.0.0.1:47823/preview-mcp`, behind the same secret, the same
`Origin` refusal and the same tmux-server check as `/mcp`. It answers
`Origin` refusal and the same tmux-server check as `/mcp` (opencode's
caller is the one exception, [below](#opencode-sessions)). It answers
everything itself and forwards nothing to muster, so a muster outage still
fails the `muster` server loudly (502) while the preview tools work or fail
on their own. Without previews the path is 404. Add a second entry with the
Expand Down Expand Up @@ -743,6 +744,52 @@ Its `initialize` also carries server instructions (`serverInstructions` in
`internal/previewhost/mcp.go`). A caller from another tmux server still gets
no instructions and no tools.

#### opencode sessions

opencode reaches the same server, but cannot name its pane: its MCP headers
are fixed when its config loads, in the one background service every
opencode TUI on the host is a client of, so `X-Muster-Pane` would name the
service's pane for every session. What opencode sends per session is the
calling session's id, in every `tools/call`'s
`params._meta["ai.opencode/sessionID"]`. So its entry (in opencode's config,
under `mcp`) sends a static `X-Muster-Client: opencode` instead of the pane
headers:

```json
"preview": {
"type": "remote",
"url": "http://127.0.0.1:47823/preview-mcp",
"oauth": false,
"headers": {
"Authorization": "Bearer {file:~/.config/muster-mirror/mcp-secret}",
"X-Muster-Client": "opencode"
}
}
```

A request carrying that header (on `/preview-mcp` only) skips the
tmux-server check, since the service runs in no pane; the secret, `Origin`
and POST checks apply as before. `initialize` and `tools/list` need no
session and answer as for any caller, instructions included. A
`tools/call` is bound to the **one** window whose active pane runs an
opencode TUI on that session, or on its nearest ancestor (a subagent's child
session calls with its own id), through a binding muster also sends on: the
launcher's record, or a verified title binding. Anything else is
`no_window`: no session id, an id no window shows, a window that shows it
only through an unverified title match, or two windows that show it. Pane
headers on such a request are ignored, because they could only be the
service's own. It is never the service's pane, and never a guess.

🔴 The session id is the caller's own claim, so it **selects**; it does not
authenticate. It is believed only among this host's mirrored windows,
behind the same per-host secret, so a forged id can at most bind a preview
to **another of the same owner's windows on this host**, which is what a
Claude Code caller can already do by naming another pane. `/mcp` (muster's
own tools) ignores the header: an opencode-marked caller there is checked
as before. On a host with one `muster-mirror` per tmux server, register the
endpoint of the one whose tmux server runs the opencode TUIs. A call to
another one finds no window (`no_window`).

### Stale tool definitions

Claude Code loads an MCP tool's name at start and fetches its definition only
Expand Down
17 changes: 13 additions & 4 deletions docs/design/previews.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,19 @@ A claim that nothing in the code confirmed is marked **UNVERIFIED**.
capped.
- **An owner-typed port.** Openers are the agent's tool and the host's offer
([decision 9](#decisions)).
- **Gateway-kind agents, the compose driver, opencode sessions** as agent
openers: no in-pod gate for the first two, and opencode has no muster tools
([mirroring.md](../mirroring.md)). An opencode window's listeners are still
**offered** (decision 9 needs no tool).
- **Gateway-kind agents and the compose driver** as agent openers: there is
no in-pod gate for either.
*As built:* **opencode sessions are openers.** opencode's one shared
service cannot name a pane, so its `/preview-mcp` entry sends
`X-Muster-Client: opencode`, and each call is bound by the session id
opencode puts in `params._meta["ai.opencode/sessionID"]`. That id resolves
to the one window showing that session, or its nearest ancestor, through a
binding muster sends on, and anything else is `no_window`. The id is a
selector among this host's own windows behind the same secret, not a
credential ([attention.md](../attention.md#opencode-sessions),
`internal/mirrorhost/mcpproxy.go`). opencode still has no muster tools
(`/mcp`). An opencode window's listeners are also **offered**, as before
(decision 9 needs no tool).
- **Making preview content safe.** It is untrusted, and is kept away.
- **TLS to the upstream.** Plain HTTP on loopback; TLS ends at the edge.

Expand Down
14 changes: 10 additions & 4 deletions docs/previews.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,16 @@ host) and [configuration.md](configuration.md#muster-server-optional-features)
[attention.md](attention.md#the-preview-tools-preview-mcp). A session that
connected before this sees the tools only after it reconnects. The
server's `initialize` carries instructions telling the agent when and how
to use `preview_open`. A runtime that shows MCP server instructions to the
model (Claude Code) needs nothing more; an opencode session is told
through the host's own configuration instead (its opencode MCP entry for
this server, and a line in its AGENTS.md).
to use `preview_open`. Claude Code and opencode both show a server's
instructions to the model, so neither needs more.
**opencode** takes a different entry, in opencode's config under `mcp`:
`"type": "remote"`, the same URL, `"oauth": false`, and two headers, the
secret (`"Authorization": "Bearer {file:~/.config/muster-mirror/mcp-secret}"`)
and the static `"X-Muster-Client": "opencode"`. It sends no pane headers.
Each call is bound to the window showing the opencode session the call
names, and fails with `no_window` when no window shows it
([attention.md](attention.md#opencode-sessions)). Restart opencode's
service after adding it: it reads its MCP config once.

```json
{
Expand Down
4 changes: 4 additions & 0 deletions internal/mirror/runtime_ledger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ func TestEveryPlaceThatNamesARuntimeIsListed(t *testing.T) {
// opencode both report how a turn ended.)
"internal/mirror/wire.go (declarations) ×1",
"internal/mirror/wire.go Window.AgentRuntime ×1",
// A preview tool call from opencode names its session in opencode's
// own _meta key, so only an opencode pane's session can match it
// (mcpproxy.go, "AN OPENCODE CALLER OF THE PREVIEW TOOLS").
"internal/mirrorhost/mcpproxy.go MCPProxy.openCodeWindow ×1",
"internal/mirrorhost/opencode.go ResolveOpenCodePaneStarted ×1",
"internal/mirrorhost/opencode.go openCodeRuntime.LocatePanes ×1",
"internal/mirrorhost/opencode_asks.go hostSide.answerOpenCode ×1",
Expand Down
149 changes: 138 additions & 11 deletions internal/mirrorhost/mcpproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,44 @@ const (
PreviewMCPPath = "/preview-mcp"
// mcpMaxBody bounds one forwarded JSON-RPC message (muster's own bound).
mcpMaxBody = 64 << 10
// MCPClientHeader names the MCP client when its headers cannot name a
// pane; MCPClientOpenCode is opencode's value (OpenCodeCaller).
MCPClientHeader = "X-Muster-Client"
MCPClientOpenCode = "opencode"
)

// AN OPENCODE CALLER OF THE PREVIEW TOOLS (docs/design/previews.md, "opencode
// callers"). opencode resolves a remote MCP server's headers ONCE, when its
// config loads, inside the one background service every opencode TUI on the
// host is a client of — so X-Muster-Pane / X-Muster-Tmux, if it sent them,
// would name the SERVICE's pane for every session. What opencode does send
// per session is the calling session's id, in every tools/call's
// params._meta["ai.opencode/sessionID"]. So its MCP entry sends the static
// header X-Muster-Client: opencode, and a /preview-mcp request carrying it is
// resolved by that session id instead of by pane (CallerWindow):
//
// - the guard's tmux-server check is not applied to it (the service runs
// outside any pane); the secret, Origin and POST checks are, unchanged.
// /mcp — muster's own tools — never takes this path: it needs a Claude
// Code session (session()), and an opencode-marked request there is
// checked exactly as before;
// - initialize and tools/list carry no session id and need none: they are
// answered as for any caller (the instructions and the three tools);
// - a tools/call is bound to the ONE window of this host whose active pane
// runs an opencode TUI bound — by its launcher record, or a verified title
// binding — to that session, or to the nearest ancestor of it (a
// subagent's child session calls with its own id). No session id, an id
// no such window shows, or two windows showing it: no window, and the
// call fails (no_window). Never the service's pane, never a guess.
// - pane headers on an opencode-marked request are IGNORED: they can only
// be the service's own, which is exactly the wrong answer.
//
// 🔴 A SELECTOR, NOT A CREDENTIAL. The session id is the caller's own claim.
// It is believed only among THIS host's mirrored windows, behind the same
// per-host secret, so a forged id can at most bind a preview to ANOTHER of
// the same owner's windows on this host — the reach a Claude Code caller
// already has by naming another pane. An id no window shows binds nothing.

// MCPProxy is the local endpoint's handler.
type MCPProxy struct {
// URL is muster's base URL (as Config.URL); Token this host's mirror token.
Expand Down Expand Up @@ -93,6 +129,11 @@ type MCPProxy struct {
// Version is what an `initialize` this endpoint answers itself names as
// the server's version (answerOutsider); "" reads "dev".
Version string
// OpenCodeAncestors is an opencode session's parent, its parent's, …
// nearest first (OpenCodeSource.Ancestors): how a subagent's child
// session finds the window showing its root. nil: only a session a
// window shows itself is found.
OpenCodeAncestors func(id string) []string

// outsiderLogged: the first caller from another tmux server is logged,
// the rest are not (every Claude Code session on another tenant's server
Expand Down Expand Up @@ -122,12 +163,26 @@ func (p *MCPProxy) previewHandler() http.Handler {
return nil
}

// CallerWindow is the tmux window ("@12") the caller's pane — its
// X-Muster-Pane, ANY pane of the window — is in now, from a FRESH listing.
// Unlike session it needs no Claude Code session in the pane: a preview
// OpenCodeCaller reports whether r is marked as opencode's
// (X-Muster-Client: opencode).
func OpenCodeCaller(r *http.Request) bool {
return strings.EqualFold(strings.TrimSpace(r.Header.Get(MCPClientHeader)), MCPClientOpenCode)
}

// CallerWindow is the tmux window ("@12") a preview tool's caller is in now,
// from a FRESH listing. agentSession is the calling agent session the call
// itself names (opencode's params._meta["ai.opencode/sessionID"]; "" none).
//
// An opencode-marked request (OpenCodeCaller) is resolved by agentSession
// alone (openCodeWindow). Any other is resolved by its pane — its
// X-Muster-Pane, ANY pane of the window — and agentSession is not read:
// unlike session it needs no Claude Code session in the pane, as a preview
// belongs to the window, whatever runs in it. The guard has already checked
// the caller's tmux server (checkServer: pane ids are per server).
func (p *MCPProxy) CallerWindow(r *http.Request) (string, error) {
// such a caller's tmux server (checkServer: pane ids are per server).
func (p *MCPProxy) CallerWindow(r *http.Request, agentSession string) (string, error) {
if OpenCodeCaller(r) {
return p.openCodeWindow(r.Context(), agentSession)
}
pane := strings.TrimSpace(r.Header.Get(MCPPaneHeader))
if pane == "" {
return "", fmt.Errorf("name the calling session: send %s: ${TMUX_PANE:-} from inside the mirrored tmux server", MCPPaneHeader)
Expand Down Expand Up @@ -158,6 +213,71 @@ func windowOfPane(ws []WindowInfo, pane string) (string, bool) {
return "", false
}

// maxOpenCodeAncestors bounds how far up a child session's parents the
// window is looked for.
const maxOpenCodeAncestors = 8

// openCodeWindow is the window of this host showing opencode session sid —
// or, when none shows it, the nearest of its ancestors that one shows
// (OpenCodeAncestors) — through a binding muster sends on (boundOpenCode).
// 🔴 FAILS CLOSED: no id, an id no window shows, or a session two windows
// show is an error, never a fallback.
func (p *MCPProxy) openCodeWindow(ctx context.Context, sid string) (string, error) {
sid = strings.TrimSpace(sid)
if sid == "" {
return "", errors.New(`this opencode call names no session (params._meta["ai.opencode/sessionID"]), so its window is unknown`)
}
if p.Windows == nil {
return "", errors.New("this host cannot resolve an opencode session")
}
ws, err := p.Windows.Windows(ctx)
if err != nil {
return "", fmt.Errorf("list windows: %w", err)
}
ids := []string{sid}
if p.OpenCodeAncestors != nil {
for _, a := range p.OpenCodeAncestors(sid) {
if len(ids) > maxOpenCodeAncestors {
break
}
ids = append(ids, a)
}
}
unbound := ""
for _, id := range ids {
var found []string
for _, w := range ws {
a := w.Agent
if a == nil || a.Runtime != mirror.RuntimeOpenCode || a.Gen != id {
continue
}
if !boundOpenCode(a) {
unbound = w.ID
continue
}
found = append(found, w.ID)
}
switch len(found) {
case 0:
case 1:
return found[0], nil
default:
return "", fmt.Errorf("opencode session %s is shown in %d windows (%s), so which one called is unknown", strconv.Quote(id), len(found), strings.Join(found, ", "))
}
}
if unbound != "" {
return "", fmt.Errorf("window %s shows opencode session %s, but its binding is not verified (muster does not send to it either)", unbound, strconv.Quote(sid))
}
return "", fmt.Errorf("opencode session %s is in no window this host mirrors", strconv.Quote(sid))
}

// boundOpenCode reports whether an opencode pane's session is one muster
// sends to: bound by its launcher record (Send), or by a verified title
// binding. A title match that is only view-only binds no preview either.
func boundOpenCode(a *AgentPane) bool {
return a.Send || a.Bind == mirror.AgentBindVerified
}

// MCPTarget is muster's /mirror/mcp URL for base.
func MCPTarget(base string, allowHTTP bool) (string, error) {
u, err := url.Parse(strings.TrimSpace(base))
Expand Down Expand Up @@ -404,14 +524,14 @@ func derefClaude(c *ClaudePane) ClaudePane {
func (p *MCPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/mcp":
p.guarded(w, r, "muster", p.serveMuster)
p.guarded(w, r, "muster", false, p.serveMuster)
case PreviewMCPPath:
h := p.previewHandler()
if h == nil {
http.NotFound(w, r)
return
}
p.guarded(w, r, "muster-preview", h.ServeHTTP)
p.guarded(w, r, "muster-preview", true, h.ServeHTTP)
default:
http.NotFound(w, r)
}
Expand All @@ -422,7 +542,12 @@ func (p *MCPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// refused (401), anything but a POST gets the transport's 405, and a caller
// from another tmux server is answered as a healthy server with no tools,
// named name (answerOutsider). Only then is serve called.
func (p *MCPProxy) guarded(w http.ResponseWriter, r *http.Request, name string, serve http.HandlerFunc) {
//
// opencodeOK (the preview server only): an opencode-marked request
// (OpenCodeCaller) skips the tmux-server check — opencode's service runs
// outside any pane, and its calls are bound by session id instead
// (openCodeWindow), which only finds this host's own windows.
func (p *MCPProxy) guarded(w http.ResponseWriter, r *http.Request, name string, opencodeOK bool, serve http.HandlerFunc) {
if r.Header.Get("Origin") != "" {
mcpRefuse(w, http.StatusForbidden, "cross-origin requests are refused")
return
Expand All @@ -438,9 +563,11 @@ func (p *MCPProxy) guarded(w http.ResponseWriter, r *http.Request, name string,
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if err := p.checkServer(r.Context(), r); err != nil {
p.answerOutsider(w, r, name, err)
return
if !(opencodeOK && OpenCodeCaller(r)) {
if err := p.checkServer(r.Context(), r); err != nil {
p.answerOutsider(w, r, name, err)
return
}
}
serve(w, r)
}
Expand Down
Loading
Loading