diff --git a/cmd/muster-mirror/main.go b/cmd/muster-mirror/main.go index 637b69d5c..ba2108997 100644 --- a/cmd/muster-mirror/main.go +++ b/cmd/muster-mirror/main.go @@ -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 { diff --git a/docs/attention.md b/docs/attention.md index 5cd3aea04..b4ed2fb87 100644 --- a/docs/attention.md +++ b/docs/attention.md @@ -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 @@ -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 diff --git a/docs/design/previews.md b/docs/design/previews.md index 2e1a36c24..46e79c6d3 100644 --- a/docs/design/previews.md +++ b/docs/design/previews.md @@ -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. diff --git a/docs/previews.md b/docs/previews.md index 2d4b632f0..fdb023fb3 100644 --- a/docs/previews.md +++ b/docs/previews.md @@ -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 { diff --git a/internal/mirror/runtime_ledger_test.go b/internal/mirror/runtime_ledger_test.go index 1d221479e..9efc264ae 100644 --- a/internal/mirror/runtime_ledger_test.go +++ b/internal/mirror/runtime_ledger_test.go @@ -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", diff --git a/internal/mirrorhost/mcpproxy.go b/internal/mirrorhost/mcpproxy.go index c1cde0414..7437508f3 100644 --- a/internal/mirrorhost/mcpproxy.go +++ b/internal/mirrorhost/mcpproxy.go @@ -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. @@ -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 @@ -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) @@ -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)) @@ -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) } @@ -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 @@ -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) } diff --git a/internal/mirrorhost/mcpproxy_opencode_test.go b/internal/mirrorhost/mcpproxy_opencode_test.go new file mode 100644 index 000000000..d028c6f98 --- /dev/null +++ b/internal/mirrorhost/mcpproxy_opencode_test.go @@ -0,0 +1,256 @@ +package mirrorhost + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/ZacxDev/muster/internal/mirror" +) + +// AN OPENCODE CALLER OF /preview-mcp (mcpproxy.go, "AN OPENCODE CALLER OF THE +// PREVIEW TOOLS"): opencode's one shared service cannot name a pane, so its +// requests carry X-Muster-Client: opencode and each tools/call names its +// session in params._meta["ai.opencode/sessionID"]; the call is bound to the +// ONE window showing that session (or its nearest ancestor), and fails closed +// otherwise. + +// ocWindows: @1 is a Claude Code window (pane %7); @3 runs opencode on +// ses_launch, bound by its launcher record; @4 on ses_title, a verified title +// binding; @5 on ses_unverified, a title match muster does not send to; @6 +// and @7 both show ses_twice. +type ocWindows struct{} + +func (ocWindows) Windows(_ context.Context) ([]WindowInfo, error) { + oc := func(pane, gen string, send bool, bind string) *AgentPane { + return &AgentPane{Runtime: mirror.RuntimeOpenCode, Pane: pane, Gen: gen, Send: send, Bind: bind} + } + return []WindowInfo{ + {Session: "work", ID: "@1", Pane: "%7", Claude: &ClaudePane{Pane: "%7", Gen: proxySID}}, + {Session: "work", ID: "@3", Pane: "%11", Agent: oc("%11", "ses_launch", true, "")}, + {Session: "work", ID: "@4", Pane: "%12", Agent: oc("%12", "ses_title", false, mirror.AgentBindVerified)}, + {Session: "work", ID: "@5", Pane: "%13", Agent: oc("%13", "ses_unverified", false, mirror.AgentBindStream)}, + {Session: "work", ID: "@6", Pane: "%14", Agent: oc("%14", "ses_twice", true, "")}, + {Session: "work", ID: "@7", Pane: "%15", Agent: oc("%15", "ses_twice", true, "")}, + }, nil +} + +// ocAncestors: ses_child is a subagent's session under ses_launch, ses_grand +// one under ses_child; ses_orphan's parent is no window's. +func ocAncestors(id string) []string { + return map[string][]string{ + "ses_child": {"ses_launch"}, + "ses_grand": {"ses_child", "ses_launch"}, + "ses_orphan": {"ses_gone"}, + }[id] +} + +func ocProxy(base string) *MCPProxy { + p := previewProxy(base) + p.Windows = ocWindows{} + p.OpenCodeAncestors = ocAncestors + return p +} + +// ocHeaders is what opencode's MCP entry sends: the secret and the static +// client header — no pane, no tmux server (the service is in none). +func ocHeaders() map[string]string { + return map[string]string{"Authorization": "Bearer " + proxySecret, MCPClientHeader: MCPClientOpenCode} +} + +func ocRequest(hdr map[string]string) *http.Request { + r := httptest.NewRequest(http.MethodPost, PreviewMCPPath+"?codemode=false", nil) + for k, v := range hdr { + r.Header.Set(k, v) + } + return r +} + +// TestAnOpenCodeCallIsBoundToItsSessionsWindow: the session the call names +// resolves to the window showing it — by a launcher binding or a verified +// title binding — and a subagent's child session (any depth) to its root's. +func TestAnOpenCodeCallIsBoundToItsSessionsWindow(t *testing.T) { + p := ocProxy("http://127.0.0.1:1") + for sid, want := range map[string]string{"ses_launch": "@3", "ses_title": "@4", "ses_child": "@3", "ses_grand": "@3", " ses_title ": "@4"} { + if got, err := p.CallerWindow(ocRequest(ocHeaders()), sid); err != nil || got != want { + t.Errorf("session %q: %q, %v; want %s", sid, got, err, want) + } + } +} + +// TestAnOpenCodeCallNoWindowShowsFailsClosed: no session id, an id no window +// shows, one whose ancestors no window shows, a title match muster does not +// send to, and a session two windows show: each is an error — never the +// Claude Code window, never a guess. +// +// 🔴 REGRESSION TEST for the fail-closed branch: before this change an +// opencode call was resolved by its pane headers alone, which opencode's +// shared service can only fill with ITS pane. +func TestAnOpenCodeCallNoWindowShowsFailsClosed(t *testing.T) { + p := ocProxy("http://127.0.0.1:1") + for sid, why := range map[string]string{ + "": "names no session", + " ": "names no session", + "ses_unknown": "in no window", + "ses_orphan": "in no window", + "ses_unverified": "not verified", + "ses_twice": "shown in 2 windows", + proxySID: "in no window", // a CLAUDE session's id is not an opencode one + } { + got, err := p.CallerWindow(ocRequest(ocHeaders()), sid) + if err == nil || got != "" { + t.Errorf("session %q resolved to %q", sid, got) + continue + } + if !strings.Contains(err.Error(), why) { + t.Errorf("session %q: %v; want it to say %q", sid, err, why) + } + } + // No ancestry at all (a host with no opencode source): a child session is + // not found, and its root still is. + p.OpenCodeAncestors = nil + if got, err := p.CallerWindow(ocRequest(ocHeaders()), "ses_child"); err == nil { + t.Errorf("with no ancestry, ses_child resolved to %q", got) + } + if got, err := p.CallerWindow(ocRequest(ocHeaders()), "ses_launch"); err != nil || got != "@3" { + t.Errorf("control, with no ancestry: %q, %v", got, err) + } +} + +// TestPaneHeadersDecideUnlessTheCallerIsOpenCode: a request WITHOUT the +// opencode marker is resolved by its pane exactly as before, whatever +// session its _meta names (Claude Code's headers win); a request WITH it is +// resolved by the session only — pane headers there can only be opencode's +// service's own, and are ignored. +func TestPaneHeadersDecideUnlessTheCallerIsOpenCode(t *testing.T) { + p := ocProxy("http://127.0.0.1:1") + cc := caller("%7") + if got, err := p.CallerWindow(ocRequest(cc), "ses_launch"); err != nil || got != "@1" { + t.Fatalf("Claude Code headers and an opencode id: %q, %v; want the pane's @1", got, err) + } + if got, err := p.CallerWindow(ocRequest(cc), ""); err != nil || got != "@1" { + t.Fatalf("Claude Code headers alone: %q, %v; want @1", got, err) + } + both := caller("%7") + both[MCPClientHeader] = MCPClientOpenCode + if got, err := p.CallerWindow(ocRequest(both), "ses_launch"); err != nil || got != "@3" { + t.Fatalf("an opencode caller with pane headers: %q, %v; want its session's @3", got, err) + } + if got, err := p.CallerWindow(ocRequest(both), ""); err == nil { + t.Fatalf("an opencode caller with pane headers and no session resolved to %q (the pane's)", got) + } + // The marker is read case-insensitively; any other value is not it. + odd := ocHeaders() + odd[MCPClientHeader] = " OpenCode " + if got, err := p.CallerWindow(ocRequest(odd), "ses_title"); err != nil || got != "@4" { + t.Fatalf("marker %q: %q, %v", odd[MCPClientHeader], got, err) + } + other := caller("%7") + other[MCPClientHeader] = "opencode-ish" + if got, err := p.CallerWindow(ocRequest(other), "ses_launch"); err != nil || got != "@1" { + t.Fatalf("marker %q: %q, %v; want the pane path", other[MCPClientHeader], got, err) + } +} + +// ocCall is a preview_list tools/call naming session sid in its _meta ("": +// no _meta). +func ocCall(sid string) string { + params := map[string]any{"name": "preview_list", "arguments": map[string]any{}} + if sid != "" { + params["_meta"] = map[string]any{"ai.opencode/sessionID": sid, "progressToken": 1} + } + b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": params}) + return string(b) +} + +// TestOpenCodeThroughTheEndpoint: opencode's service — the secret and the +// marker, no tmux server — gets initialize WITH the instructions and the +// three tools (no session needed), a call naming a shown session answers, +// and one naming none, or an unknown one, is the no_window tool error. The +// guard still applies: no secret is 401. +func TestOpenCodeThroughTheEndpoint(t *testing.T) { + up, srv := newUpstream(t, func(w http.ResponseWriter) { _, _ = io.WriteString(w, `{}`) }) + p := ocProxy(srv.URL) + init := decodeReply(t, "initialize", postTo(t, p, PreviewMCPPath+"?codemode=false", ocHeaders(), `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`)) + if !strings.Contains(string(init.Result), `"instructions":"muster's preview tools`) { + t.Fatalf("opencode's initialize carries no instructions: %s", init.Result) + } + if got := toolNames(t, postTo(t, p, PreviewMCPPath, ocHeaders(), listBody)); !slices.Equal(got, []string{"preview_open", "preview_list", "preview_close"}) { + t.Fatalf("opencode lists %v", got) + } + if r := decodeReply(t, "call", postTo(t, p, PreviewMCPPath, ocHeaders(), ocCall("ses_child"))); !strings.Contains(string(r.Result), `{\"previews\":[]}`) { + t.Fatalf("a shown session's preview_list: %s %v", r.Result, r.Error) + } + for _, sid := range []string{"", "ses_unknown", "ses_twice"} { + r := decodeReply(t, "call", postTo(t, p, PreviewMCPPath, ocHeaders(), ocCall(sid))) + if !strings.Contains(string(r.Result), `\"error\":\"no_window\"`) || !strings.Contains(string(r.Result), `"isError":true`) { + t.Errorf("session %q: %s %v; want the no_window tool error", sid, r.Result, r.Error) + } + } + noSecret := ocHeaders() + delete(noSecret, "Authorization") + if w := postTo(t, p, PreviewMCPPath, noSecret, listBody); w.Code != http.StatusUnauthorized { + t.Fatalf("opencode with no secret: %d", w.Code) + } + if up.n() != 0 { + t.Fatalf("opencode's preview requests reached muster %d time(s)", up.n()) + } +} + +// TestTheOpenCodeMarkerOpensNothingElse: the marker is honoured on +// /preview-mcp only. On /mcp — muster's tools — an opencode-marked caller +// outside this host's tmux server is the outsider it always was (no tools, +// nothing forwarded); and on /preview-mcp a caller with no marker and no tmux +// server is still the outsider, whatever session its call names. +func TestTheOpenCodeMarkerOpensNothingElse(t *testing.T) { + up, srv := newUpstream(t, func(w http.ResponseWriter) { _, _ = io.WriteString(w, musterTools) }) + p := ocProxy(srv.URL) + if got := toolNames(t, postTo(t, p, "/mcp", ocHeaders(), listBody)); len(got) != 0 { + t.Fatalf("/mcp lists %v to an opencode-marked outsider", got) + } + if r := decodeReply(t, "call", postTo(t, p, "/mcp", ocHeaders(), proxyCall)); r.Error == nil { + t.Fatalf("/mcp answered an opencode-marked outsider's call: %s", r.Result) + } + if up.n() != 0 { + t.Fatalf("an opencode-marked outsider reached muster %d time(s)", up.n()) + } + bare := map[string]string{"Authorization": "Bearer " + proxySecret} + if got := toolNames(t, postTo(t, p, PreviewMCPPath, bare, listBody)); len(got) != 0 { + t.Fatalf("an unmarked caller outside the tmux server lists %v", got) + } + if r := decodeReply(t, "call", postTo(t, p, PreviewMCPPath, bare, ocCall("ses_launch"))); r.Error == nil { + t.Fatalf("an unmarked outsider's call naming a shown session was answered: %s", r.Result) + } + // The control: the same marked request on /preview-mcp is served. + if got := toolNames(t, postTo(t, p, PreviewMCPPath, ocHeaders(), listBody)); len(got) != 3 { + t.Fatalf("control: an opencode caller on /preview-mcp lists %v", got) + } +} + +// TestAncestorsWalkTheServersSessionList: a child's parents, nearest first, +// from opencode's own session list; a root and an unknown id have none. +func TestAncestorsWalkTheServersSessionList(t *testing.T) { + srv, src := ocServer(t, map[string]string{"ses_root": "Root"}) + srv.PutSession("ses_kid", "Kid", "ses_root") + srv.PutSession("ses_grandkid", "Grandkid", "ses_kid") + if got := src.Ancestors("ses_grandkid"); !slices.Equal(got, []string{"ses_kid", "ses_root"}) { + t.Fatalf("ses_grandkid: %v", got) + } + if got := src.Ancestors("ses_kid"); !slices.Equal(got, []string{"ses_root"}) { + t.Fatalf("ses_kid: %v", got) + } + for _, id := range []string{"ses_root", "ses_nobody", ""} { + if got := src.Ancestors(id); len(got) != 0 { + t.Errorf("%q: %v; want none", id, got) + } + } + var none *OpenCodeSource + if got := none.Ancestors("ses_kid"); got != nil { + t.Fatalf("a nil source: %v", got) + } +} diff --git a/internal/mirrorhost/mcpproxy_preview_test.go b/internal/mirrorhost/mcpproxy_preview_test.go index 603810772..5a703027e 100644 --- a/internal/mirrorhost/mcpproxy_preview_test.go +++ b/internal/mirrorhost/mcpproxy_preview_test.go @@ -223,7 +223,7 @@ func TestThePreviewCallerIsThePanesWindow(t *testing.T) { for pane, want := range map[string]string{"%7": "@1", "%9": "@1", "%10": "@1", "%8": "@2"} { r := httptest.NewRequest(http.MethodPost, PreviewMCPPath, nil) r.Header.Set(MCPPaneHeader, pane) - if got, err := p.CallerWindow(r); err != nil || got != want { + if got, err := p.CallerWindow(r, ""); err != nil || got != want { t.Errorf("pane %s: %q, %v; want %s", pane, got, err, want) } } @@ -236,13 +236,13 @@ func TestThePreviewCallerIsThePanesWindow(t *testing.T) { for _, pane := range []string{"%99", ""} { r := httptest.NewRequest(http.MethodPost, PreviewMCPPath, nil) r.Header.Set(MCPPaneHeader, pane) - if got, err := p.CallerWindow(r); err == nil { + if got, err := p.CallerWindow(r, ""); err == nil { t.Errorf("pane %q resolved to %q", pane, got) } } p.SetPreview(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - win, err := p.CallerWindow(r) + win, err := p.CallerWindow(r, "") if err != nil { win = "error: " + err.Error() } diff --git a/internal/mirrorhost/opencode.go b/internal/mirrorhost/opencode.go index 2653a2ddd..084c746ea 100644 --- a/internal/mirrorhost/opencode.go +++ b/internal/mirrorhost/opencode.go @@ -706,3 +706,39 @@ func (s *OpenCodeSource) markStaleLocked() { s.stale = true s.staleGen++ } + +// Ancestors is session id's parent, that one's parent, and so on — nearest +// first, at most maxOpenCodeAncestors — from the server's session list +// (knownSessions): how a subagent's child session, which a TUI does not show, +// is traced to the root session its window shows (MCPProxy's +// openCodeWindow). An id the list does not hold, or a list that cannot be +// read, has none. A cycle ends the walk. +func (s *OpenCodeSource) Ancestors(id string) []string { + if s == nil || !s.paired() { + return nil + } + sessions, err := s.knownSessions() + if err != nil { + return nil + } + parent := make(map[string]string, len(sessions)) + for _, o := range sessions { + parent[o.ID] = o.ParentID + } + seen := map[string]bool{id: true} + var out []string + for cur := parent[id]; cur != "" && !seen[cur] && len(out) < maxOpenCodeAncestors; cur = parent[cur] { + seen[cur] = true + out = append(out, cur) + } + return out +} + +// OpenCodeSourceOf is b's OpenCodeSource, looking through wrappers; nil for +// none. +func OpenCodeSourceOf(b Backend) *OpenCodeSource { + if s, ok := capabilityOf[OpenCodeSourcer](b); ok { + return s.OpenCodeSource() + } + return nil +} diff --git a/internal/previewhost/mcp.go b/internal/previewhost/mcp.go index c8768fd00..0c0617f45 100644 --- a/internal/previewhost/mcp.go +++ b/internal/previewhost/mcp.go @@ -21,8 +21,10 @@ import ( // THE PREVIEW TOOLS (docs/design/previews.md, "The MCP tools"): a second MCP // server on muster-mirror's loopback MCP endpoint, at /preview-mcp, beside -// /mcp. muster-mirror's MCPProxy guards it exactly as it guards /mcp (the -// per-host secret, no Origin, POST only, the caller's tmux server) and then +// /mcp. muster-mirror's MCPProxy guards it as it guards /mcp (the per-host +// secret, no Origin, POST only, the caller's tmux server — except for an +// opencode-marked caller, which is bound by session id instead: mirrorhost's +// "AN OPENCODE CALLER OF THE PREVIEW TOOLS") and then // hands the request to MCPServer, which answers EVERYTHING itself: nothing on // this path is forwarded to muster, and it holds no muster credential. // @@ -88,7 +90,8 @@ const ( // CodeGatewayError: the gateway refused with something that is not a // well-formed code (gatewayCode). CodeGatewayError = "gateway_error" - // CodeNoWindow: the caller's pane is in no window this host mirrors, so + // CodeNoWindow: the caller's pane — or, for opencode, the session its + // call names — is in no window this host mirrors, so // the preview would belong to no one (not in the design's table: a // session outside a mirrored window has nothing to bind a preview to). CodeNoWindow = "no_window" @@ -104,6 +107,12 @@ const ( // ProbeWait bounds preview_open's probe: a loopback dial and HEAD /, answered. const ProbeWait = 5 * time.Second +// OpenCodeSessionMeta is the key opencode puts the calling session's id +// under in every tools/call's params._meta (packages/core/src/mcp/client.ts +// at 6ec5682): the one per-session signal it sends. Read here and handed to +// Caller; whether it is believed is the caller resolver's decision. +const OpenCodeSessionMeta = "ai.opencode/sessionID" + // mcpMaxBody bounds one JSON-RPC message (the /mcp proxy's own bound). const mcpMaxBody = 64 << 10 @@ -113,10 +122,13 @@ type MCPServer struct { // Host is this host's previews: its allowlist and checks, its record of // acknowledged opens, and its offer stream to the gateway. Host *Host - // Caller is the tmux window ("@12") the request's pane is in, from a - // fresh listing (mirrorhost.MCPProxy.CallerWindow). It needs no Claude - // Code session in the pane: a preview belongs to the window. - Caller func(r *http.Request) (string, error) + // Caller is the tmux window ("@12") the request's caller is in, from a + // fresh listing (mirrorhost.MCPProxy.CallerWindow): by its pane, or — + // for opencode, whose headers cannot name one — by agentSession, the + // session the tools/call itself names (OpenCodeSessionMeta; "" none). It + // needs no Claude Code session in the pane: a preview belongs to the + // window. + Caller func(r *http.Request, agentSession string) (string, error) // Version is what `initialize` names as the server's version; "" = "dev". Version string @@ -190,13 +202,18 @@ func (s *MCPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeRPC(w, req.ID, map[string]any{"tools": toolDefs()}, 0, "") case "tools/call": var p struct { - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + Meta map[string]json.RawMessage `json:"_meta"` } if err := json.Unmarshal(req.Params, &p); err != nil { writeRPC(w, req.ID, nil, -32602, "tools/call params must be {name, arguments}") return } + // A session id that is not a JSON string is none. + var agentSession string + _ = json.Unmarshal(p.Meta[OpenCodeSessionMeta], &agentSession) + c := call{r: r, agentSession: agentSession} var ( res any terr *toolError @@ -204,11 +221,11 @@ func (s *MCPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { ) switch p.Name { case ToolOpen: - res, terr, bad = s.open(r, p.Arguments) + res, terr, bad = s.open(c, p.Arguments) case ToolList: - res, terr, bad = s.list(r, p.Arguments) + res, terr, bad = s.list(c, p.Arguments) case ToolClose: - res, terr, bad = s.close(r, p.Arguments) + res, terr, bad = s.close(c, p.Arguments) default: writeRPC(w, req.ID, nil, -32602, "unknown tool "+p.Name) return @@ -303,11 +320,17 @@ func (s *MCPServer) on() *toolError { return nil } -func (s *MCPServer) window(r *http.Request) (string, *toolError) { +// call is one tools/call: its request, and the agent session it names. +type call struct { + r *http.Request + agentSession string +} + +func (s *MCPServer) window(c call) (string, *toolError) { if s.Caller == nil { return "", refuse(CodeNoWindow, "this host cannot tell which tmux window is calling") } - id, err := s.Caller(r) + id, err := s.Caller(c.r, c.agentSession) if err != nil { return "", refuse(CodeNoWindow, "%v", err) } @@ -327,7 +350,8 @@ func (s *MCPServer) window(r *http.Request) (string, *toolError) { // not_answering); then the gateway, through the offer stream // (Host.RequestOpen: gateway_not_connected, gateway_timeout, or the // gateway's own refusal such as max_open). -func (s *MCPServer) open(r *http.Request, raw json.RawMessage) (any, *toolError, error) { +func (s *MCPServer) open(c call, raw json.RawMessage) (any, *toolError, error) { + r := c.r if terr := s.on(); terr != nil { return nil, terr, nil } @@ -343,7 +367,7 @@ func (s *MCPServer) open(r *http.Request, raw json.RawMessage) (any, *toolError, return nil, nil, err } port := *a.Port - win, terr := s.window(r) + win, terr := s.window(c) if terr != nil { return nil, terr, nil } @@ -513,7 +537,7 @@ func isControl(c rune) bool { return c < ' ' || c == 0x7f } // list is preview_list: the caller's window's acknowledged opens — its // agent's and the owner's click-opens alike (the gateway tells the host of // those unasked). -func (s *MCPServer) list(r *http.Request, raw json.RawMessage) (any, *toolError, error) { +func (s *MCPServer) list(c call, raw json.RawMessage) (any, *toolError, error) { if terr := s.on(); terr != nil { return nil, terr, nil } @@ -521,7 +545,7 @@ func (s *MCPServer) list(r *http.Request, raw json.RawMessage) (any, *toolError, if err := decodeArgs(raw, &a); err != nil { return nil, nil, err } - win, terr := s.window(r) + win, terr := s.window(c) if terr != nil { return nil, terr, nil } @@ -536,7 +560,7 @@ func (s *MCPServer) list(r *http.Request, raw json.RawMessage) (any, *toolError, // close is preview_close: an id the caller's window holds, closed through // the gateway. Another window's id is not_found, exactly like an unknown one. -func (s *MCPServer) close(r *http.Request, raw json.RawMessage) (any, *toolError, error) { +func (s *MCPServer) close(c call, raw json.RawMessage) (any, *toolError, error) { if terr := s.on(); terr != nil { return nil, terr, nil } @@ -549,7 +573,7 @@ func (s *MCPServer) close(r *http.Request, raw json.RawMessage) (any, *toolError if a.ID == "" { return nil, nil, errors.New("id is required") } - win, terr := s.window(r) + win, terr := s.window(c) if terr != nil { return nil, terr, nil } @@ -564,8 +588,8 @@ func (s *MCPServer) close(r *http.Request, raw json.RawMessage) (any, *toolError if !found { return nil, refuse(CodeNotFound, "no preview %q is open for this window", a.ID), nil } - ans, err := s.Host.RequestClose(r.Context(), wire.Request{Window: win, Port: o.Port, Preview: o.Preview}) - if terr := gatewayRefusal(r.Context(), err, "close preview "+o.Preview); terr != nil { + ans, err := s.Host.RequestClose(c.r.Context(), wire.Request{Window: win, Port: o.Port, Preview: o.Preview}) + if terr := gatewayRefusal(c.r.Context(), err, "close preview "+o.Preview); terr != nil { return nil, terr, nil } if ans.Error != "" { diff --git a/internal/previewhost/mcp_test.go b/internal/previewhost/mcp_test.go index 2ae362477..98d3a90a6 100644 --- a/internal/previewhost/mcp_test.go +++ b/internal/previewhost/mcp_test.go @@ -22,13 +22,66 @@ import ( // muster-mirror's CallerWindow resolves it from a listing. var testPanes = map[string]string{"%1": "@1", "%2": "@2", "%3": "@1"} -func testCaller(r *http.Request) (string, error) { +func testCaller(r *http.Request, _ string) (string, error) { if w, ok := testPanes[r.Header.Get("X-Muster-Pane")]; ok { return w, nil } return "", errors.New("pane is in no window this host mirrors") } +// TestTheCallsOpenCodeSessionReachesTheCaller: a tools/call's +// params._meta["ai.opencode/sessionID"] — opencode's one per-session signal — +// is handed to Caller as is, for each tool; a call without one, or with one +// that is not a string, hands "". The window Caller answers is the one the +// tool uses (preview_list of a session-resolved window answers, an unknown +// one is no_window). +func TestTheCallsOpenCodeSessionReachesTheCaller(t *testing.T) { + var got []string + s := &MCPServer{Host: NewHost(HostConfig{Logger: log.New(io.Discard, "", 0)}), Caller: func(r *http.Request, sid string) (string, error) { + got = append(got, sid) + if sid == "ses_root" { + return "@4", nil + } + return "", errors.New("opencode session is in no window this host mirrors") + }} + call := func(name string, args any, meta any) toolOut { + params := map[string]any{"name": name, "arguments": args} + if meta != nil { + params["_meta"] = meta + } + b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params}) + return rpc(t, s, "", string(b)).tool(t) + } + meta := map[string]any{"ai.opencode/sessionID": "ses_root", "progressToken": 3} + if o := call(ToolList, map[string]any{}, meta); o.IsError { + t.Fatalf("preview_list with the session's id: %v", o.Body) + } + if c := call(ToolOpen, map[string]any{"port": 5173}, meta).code(t); c == CodeNoWindow { + t.Fatalf("preview_open with the session's id: no_window") + } + if c := call(ToolClose, map[string]any{"id": "p-1"}, meta).code(t); c != CodeNotFound { + t.Fatalf("preview_close with the session's id: %q; want not_found", c) + } + for name, m := range map[string]any{ + "no _meta": nil, + "another key": map[string]any{"progressToken": 3}, + "a number id": map[string]any{"ai.opencode/sessionID": 7}, + "an unknown id": map[string]any{"ai.opencode/sessionID": "ses_other"}, + "an empty _meta": map[string]any{}, + } { + if c := call(ToolList, map[string]any{}, m).code(t); c != CodeNoWindow { + t.Errorf("%s: %q; want no_window", name, c) + } + } + want := []string{"ses_root", "ses_root", "ses_root"} + if len(got) < 3 || !slices.Equal(got[:3], want) { + t.Fatalf("Caller saw %q; want %q first", got, want) + } + if slices.Contains(got[3:], "ses_root") || !slices.Contains(got[3:], "ses_other") { + t.Fatalf("the refused calls handed %q", got[3:]) + } +} + // rpcReply is one JSON-RPC answer, its tool result decoded. type rpcReply struct { Status int