diff --git a/docs/designs/DECISIONS.md b/docs/designs/DECISIONS.md index 01155247..2c7bd800 100644 --- a/docs/designs/DECISIONS.md +++ b/docs/designs/DECISIONS.md @@ -460,5 +460,6 @@ check enforces the mechanical half. Full rationale: | DL-322 | Error/abort emit rule: `reason=error` emits `SessionError(ERROR)` AND the existing `ERRORED` lifecycle transition (additive — board/presence/delivery key off `ERRORED`); `reason=aborted` emits `SessionError(ABORTED)` with NO lifecycle transition (abort is not a crash) and replaces the prior counted-`UnmappedEvent` staging | Active (Matt, 2026-09-02) | [error/abort surfacing](agent/compass-agent-error-abort-surfacing/design.md) | | DL-323 | The `SessionError` trace frame rides the FrameSink never-drop PRIORITY lane (not the bounded drop-oldest trace queue), via an `isSessionError` classifier extending the `frame-sink.ts` priority predicate — matching the `SessionInjection` never-drop carve-out, so surfaced failure content is as durable-on-the-spine as the lifecycle transition it reports | Active (Matt, 2026-09-02) | [error/abort surfacing](agent/compass-agent-error-abort-surfacing/design.md) | | DL-341 | Compass UI visual regression is gated by built-in Playwright `toHaveScreenshot` (not a hosted visual-diff service), with baseline PNGs committed in-repo and regenerated ONLY by a pinned nix Chromium in CI via a `regen-visual-baselines` `workflow_dispatch` lane, never from a developer machine; wired as a `visual-gate` moon task on @playwright/test 1.62.1. Ruled at the design-PR gate: base `maxDiffPixelRatio` 0.001 with per-shot overrides `10/90` (state-dot) and `25/21357` (bridge-card) only; intentional visual changes regenerate baselines by dispatching the lane ON THE FEATURE BRANCH so the bot PR targets that branch; adjudication is GitHub's native committed-PNG image diff (2-up/swipe/onion-skin) on that bot PR, with the CI failure artifact retained only as the diagnostic path for an UNINTENDED red (untracked `e2e/.output` has no committed file to diff); hard gate from first landing, no advisory period; all 11 shots at v1 (RIG-2154) | Active (Matt, 2026-09-07) | [visual regression gate](ui/compass-visual-regression-gate/design.md) | +| DL-347 | Multi-actor comms coverage is tiered: cross-agent conversation, fan-out/isolation, and offline redelivery are asserted at the podman e2e tier with agent authorship carried by the real Runner session→account binding (no per-agent credential — `relay_comms.go:7-15`), and a non-admin observer client proves the credentialed transport path; the D9 visibility/leak matrix stays at the pgtest tier with e2e proving only one transport positive+negative. every agent-reachable relay arm is covered by a unit test at the runnerhub tier (podman is spent on the tool→relay→server JOIN, never on arm dispatch), and each native agent tool has at least one real-server execution. No new build tag or CI tier — new legs ride the existing e2e skip-guard | Active (Matt, 2026-09-08) | [multi-actor comms coverage](server/compass-comms-multi-actor-test-coverage/design.md#resolved-decisions) | | DL-348 | The UI emits the PostHog session id on outbound Connect requests via a `sessionIdInterceptor` in `@compass/client` (sibling to `traceResponseInterceptor`), completing the J1 correlation-key seam whose inbound half stamps semconv `session.id` on backend spans. `X-POSTHOG-SESSION-ID` ONLY — `X-POSTHOG-DISTINCT-ID` is permanently excluded because it identifies a person and backend spans land in Grafana/Tempo, the plane J1 keeps identity out of; identity resolution stays PostHog-side where `identify()` holds it. Supersedes the 2026-09-05 HOLD (RIG-3233), whose two reasons — no inbound consumer, and a lazy-getter-over-nothing boot workaround — are discharged by the server merge and by the boot reorder respectively. posthog-js's own `TracingHeaders` extension is rejected: it monkey-patches global fetch/XHR and also sends the distinct-id header | Active (Matt, 2026-09-07) | [outbound session header](ui/compass-outbound-session-header/design.md#the-outbound-interceptor) | | DL-349 | Boot constructs analytics BEFORE the live clients (`createAnalytics` → `createLiveClients` → `bootCaller` → `identify`), so the transport's session-id getter closes over a real `Analytics` rather than a mutable ref slot or a forward `let`. Rejected alternatives: mirroring the `clients.traceId` sink (a workaround for a construction-order problem the reorder deletes) and a forward `let analytics` (a TDZ-shaped `undefined` window every reader must guard). Accepted trade, stated not silent: on the WhoAmI-failure early return an analytics-enabled deployment now emits an anonymous PostHog session (init-time remote-config egress) where it previously emitted nothing — the reorder is unavoidable while the transport needs the getter at construction, and the egress itself is declined-not-absent (`advanced_disable_flags` would suppress it, at the cost of remote config), with nothing captured either way. The sender-side guard is printable ASCII + `.length ≤ 200`, STRICTER than the server's `≤200 bytes` + valid-UTF-8 pair and NARROWER than `Headers.set` itself, because `Headers.set` is a WebIDL ByteString: a well-formed id above U+00FF THROWS inside the interceptor and would fail the RPC, and U+0080–U+00FF is accepted by `set` but a browser then emits it as a SINGLE RAW HIGH BYTE on the wire, which fails Go's `utf8.ValidString`, so `sessionIDFromHeader` returns `""` and the id is DROPPED — silent loss, the same failure class as every other rejected value, NOT a wrong correlation key the server accepts (measured: raw-TCP wire bytes `736573732de9` from Node/undici, which serializes like a browser; and real Chromium → real Go `net/http` running a verbatim `sessionIDFromHeader` copy — `len` 6, `utf8.ValidString` false, result `""`. A Bun-client-to-`Bun.serve` round-trip measures Bun's own encode/decode pair, not the wire, and is NOT valid evidence here) | Active (Matt, 2026-09-07) | [outbound session header](ui/compass-outbound-session-header/design.md#the-boot-reorder-chosen--approved-by-matt) | diff --git a/go/e2e/cannedmodel.go b/go/e2e/cannedmodel.go index b968bf56..37042ad1 100644 --- a/go/e2e/cannedmodel.go +++ b/go/e2e/cannedmodel.go @@ -135,7 +135,13 @@ func CannedToolCall(toolName, argsJSON string) CannedTurn { // already routed. type cannedMarker struct { marker string - reply string + // turns is the ordered sequence this marker route serves, one per matching + // request. A one-element script is the single-text-reply form + // (newCannedMarker); a longer one is a multi-turn script + // (newCannedMarkerScript) whose TERMINAL element repeats once exhausted — + // see newCannedMarkerScript for why repeating (not 500ing) is the only + // terminating semantics for a marker route. + turns []CannedTurn } // newCannedMarker builds an off-script body-marker route: a request whose body @@ -146,8 +152,36 @@ type cannedMarker struct { // Use it via WithCannedMarkerReply to keep the ordered script drawn only by its // own scripted turns when a shared-backend turn (e.g. a mention-driven steer or // deliver) would otherwise race the counter. +// +// It is exactly the one-element case of newCannedMarkerScript: a single text +// turn whose terminal element repeats, i.e. EVERY matching request settles on +// reply — the pre-script behaviour, unchanged. func newCannedMarker(marker, reply string) cannedMarker { - return cannedMarker{marker: marker, reply: reply} + return cannedMarker{marker: marker, turns: []CannedTurn{CannedText(reply)}} +} + +// newCannedMarkerScript builds an off-script body-marker route that serves an +// ORDERED SEQUENCE of turns rather than one text reply (RIG-3528 T1): matching +// request N of this marker draws turns[N], and once the sequence is exhausted +// the TERMINAL element repeats for every later match. Like newCannedMarker it +// never advances the positional `served` counter, so a marker script and an +// ordered positional script coexist without racing. +// +// The per-marker counter and the repeating terminal element are BOTH +// load-bearing, and this is the design's sharpest hazard. A tool-call turn needs +// TWO model round-trips to settle — the tool-call turn, then the follow-up that +// settles on text — while a marker route matches a substring of the WHOLE +// request body and returns unconditionally. So a naive one-CannedTurn-per-marker +// route DOES NOT TERMINATE: every POST re-matches the marker and re-serves the +// tool call forever. Advancing a per-marker counter is what lets POST 2 draw the +// text settle; repeating the terminal element (rather than 500ing on exhaustion, +// as the positional path does) is what keeps a later re-match — a retry, a +// re-steer, an extra loop iteration — settled instead of erroring the agent out. +// +// Use it via WithCannedMarkerScript. Registering a marker with no turns is a +// construction error (startCannedModelServer): a route that can never settle. +func newCannedMarkerScript(marker string, turns ...CannedTurn) cannedMarker { + return cannedMarker{marker: marker, turns: turns} } // cannedModelServer is a running canned model backend. It owns its listener and @@ -158,10 +192,10 @@ type cannedModelServer struct { ln net.Listener script []CannedTurn // markers are the caller-supplied off-script body-marker routes (see - // cannedMarker / newCannedMarker): a request whose body contains a - // marker settles on its reply WITHOUT advancing the positional counter, - // generalizing setupTurnMarker. Checked after the built-in Setup marker, - // before the positional claim. + // cannedMarker / newCannedMarker / newCannedMarkerScript): a request whose + // body contains a marker draws that route's next turn WITHOUT advancing the + // positional counter, generalizing setupTurnMarker. Checked after the + // built-in Setup marker, before the positional claim. markers []cannedMarker port int closeErr error @@ -173,6 +207,14 @@ type cannedModelServer struct { // but the stub guards the counter anyway. servedMu sync.Mutex served int + // markerServed is the PER-MARKER turn counter, parallel to markers by index: + // markerServed[i] is how many requests marker i has already served, so it + // draws markers[i].turns[markerServed[i]] and clamps to the terminal element + // once exhausted (newCannedMarkerScript). It is deliberately SEPARATE from + // served — a marker route must never consume a positional slot — and guarded + // by its own mutex for the same -race reason served is. + markerMu sync.Mutex + markerServed []int } // setupTurnMarker is a stable substring of the server's root-supervisor Setup @@ -208,12 +250,19 @@ const setupReply = "canned setup turn settled OK" // host-gateway); in the hermetic unit test it is loopback. It returns an error // rather than panicking (rule://go-no-panic-in-lib) so the caller — a test — // decides fatality. markers are optional off-script body-marker routes -// (newCannedMarker): a request whose body carries one settles on its reply -// without consuming a positional slot, additive to the built-in Setup marker. +// (newCannedMarker / newCannedMarkerScript): a request whose body carries one +// draws that route's next turn without consuming a positional slot, additive to +// the built-in Setup marker. A marker with no turns is a construction error, for +// the same reason an empty script is — a route that can never settle a turn. func startCannedModelServer(bindAddr string, script []CannedTurn, markers ...cannedMarker) (*cannedModelServer, error) { if len(script) == 0 { return nil, errors.New("canned model server requires a non-empty script") } + for _, m := range markers { + if len(m.turns) == 0 { + return nil, fmt.Errorf("canned model server marker %q requires at least one turn", m.marker) + } + } ln, err := net.Listen("tcp", bindAddr) if err != nil { return nil, fmt.Errorf("canned model server listen on %q: %w", bindAddr, err) @@ -223,7 +272,15 @@ func startCannedModelServer(bindAddr string, script []CannedTurn, markers ...can _ = ln.Close() // failed construction; release the listener we just opened return nil, fmt.Errorf("canned model server listener has unexpected addr type %T", ln.Addr()) } - c := &cannedModelServer{ln: ln, script: script, markers: markers, port: tcpAddr.Port} + c := &cannedModelServer{ + ln: ln, + script: script, + markers: markers, + // One counter per marker, allocated up front so the handler indexes it + // without ever growing the slice under the lock. + markerServed: make([]int, len(markers)), + port: tcpAddr.Port, + } mux := http.NewServeMux() mux.HandleFunc(cannedChatPath, c.handleChatCompletions) // ReadHeaderTimeout bounds a slow-header client (gosec G112); the canned @@ -327,17 +384,40 @@ type chatToolCallFunction struct { Arguments string `json:"arguments"` } -// handleChatCompletions serves one scripted turn per request. It rejects a -// non-POST with 405 (the provider only ever POSTs) and writes an HTTP error — -// never panics — on any failure (rule://go-no-panic-in-lib). It claims the next -// script index under servedMu; a request past the end of the script is a test -// bug answered with a loud 500 naming exhaustion (never a hang or a default -// turn). The turn's body is either a text turn (a content chunk + a terminal -// finish_reason "stop") or a single-tool-call turn (a chunk whose -// delta.tool_calls carries one entry + a terminal finish_reason "tool_calls"), -// then the literal `data: [DONE]` sentinel. Each event is flushed immediately so -// the client's first-event watchdog sees bytes without waiting on the handler to -// return. +// claimMarkerTurn claims the next turn of marker route i and returns it plus the +// 0-based sequence number of this match (which only disambiguates an emitted +// tool-call id). Once the route's turns are exhausted the TERMINAL element is +// returned for every later match, and the counter keeps climbing so successive +// matches still get distinct call ids. The per-marker counter is guarded by +// markerMu — never servedMu, and never the positional `served` counter, since a +// marker route must not consume a positional slot. Callers hold no lock; the +// index is in range by construction (markerServed is allocated one-per-marker, +// and startCannedModelServer rejects a marker with no turns). +func (c *cannedModelServer) claimMarkerTurn(i int) (CannedTurn, int) { + c.markerMu.Lock() + seq := c.markerServed[i] + c.markerServed[i]++ + c.markerMu.Unlock() + turns := c.markers[i].turns + if seq >= len(turns) { + return turns[len(turns)-1], seq + } + return turns[seq], seq +} + +// handleChatCompletions serves one turn per request. It rejects a non-POST with +// 405 (the provider only ever POSTs) and writes an HTTP error — never panics — +// on any failure (rule://go-no-panic-in-lib). Routing is: the built-in Setup +// marker first, then the caller-supplied marker routes (each advancing its OWN +// counter and clamping to its terminal turn — claimMarkerTurn), then the +// positional script, whose next index is claimed under servedMu; a positional +// request past the end of the script is a test bug answered with a loud 500 +// naming exhaustion (never a hang or a default turn). The turn's body is either +// a text turn (a content chunk + a terminal finish_reason "stop") or a +// single-tool-call turn (a chunk whose delta.tool_calls carries one entry + a +// terminal finish_reason "tool_calls"), then the literal `data: [DONE]` +// sentinel. Each event is flushed immediately so the client's first-event +// watchdog sees bytes without waiting on the handler to return. func (c *cannedModelServer) handleChatCompletions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "canned model backend only serves POST", http.StatusMethodNotAllowed) @@ -368,14 +448,24 @@ func (c *cannedModelServer) handleChatCompletions(w http.ResponseWriter, r *http return } - // Caller-supplied off-script markers (newCannedMarker), checked after the - // built-in Setup marker and BEFORE the positional claim: a request whose body - // carries one settles on its reply without advancing the script counter, so a + // Caller-supplied off-script markers (newCannedMarker / + // newCannedMarkerScript), checked after the built-in Setup marker and BEFORE + // the positional claim: a request whose body carries one draws that route's + // next turn without advancing the positional script counter, so a // shared-backend turn a leg does not want drawn off its ordered script (a // mention-driven steer or deliver) is routed the same way the Setup turn is. - for _, m := range c.markers { + // + // Each marker advances its OWN counter and CLAMPS to its terminal turn once + // exhausted, which is what makes a multi-turn marker route terminate: a + // tool-call turn needs a second round-trip to settle, and that follow-up POST + // re-matches the same marker, so a route that always served turns[0] would + // re-serve the tool call forever (see newCannedMarkerScript). Clamping rather + // than 500ing keeps a later re-match settled — unlike the positional path, a + // marker route has no bounded request count to exhaust against. + for i, m := range c.markers { if strings.Contains(string(body), m.marker) { - c.writeTextTurn(w, flusher, m.reply) + turn, seq := c.claimMarkerTurn(i) + c.writeCannedTurn(w, flusher, turn, seq) return } } @@ -393,36 +483,44 @@ func (c *cannedModelServer) handleChatCompletions(w http.ResponseWriter, r *http return } turn := c.script[idx] + c.writeCannedTurn(w, flusher, turn, idx) +} +// writeCannedTurn serves one CannedTurn on the wire: a pure-text turn (a content +// chunk + a terminal finish_reason "stop") or a single-tool-call turn (a chunk +// whose delta.tool_calls carries one entry + a terminal finish_reason +// "tool_calls", which maps to the stopReason toolUse the agent loop gates tool +// execution on). callSeq only disambiguates the emitted tool-call id, so a +// transcript can tell two served calls apart; it carries no routing meaning. +// Shared by the positional-script path and the marker routes so both settle +// identically on the wire. +func (c *cannedModelServer) writeCannedTurn(w http.ResponseWriter, flusher http.Flusher, turn CannedTurn, callSeq int) { + if !turn.isToolCall { + c.writeTextTurn(w, flusher, turn.text) + return + } const id = "canned-completion" - if turn.isToolCall { - // A single tool-call turn: one delta.tool_calls entry with a unique - // deterministic call id, then a terminal finish_reason "tool_calls" (maps - // to stopReason toolUse the agent loop gates tool execution on). - finish := finishToolCall - callID := fmt.Sprintf("call_%d", idx) - c.writeTurn(w, flusher, []chatChunk{ - {ID: id, Object: chunkObject, Choices: []chatChoice{{ + finish := finishToolCall + callID := fmt.Sprintf("call_%d", callSeq) + c.writeTurn(w, flusher, []chatChunk{ + {ID: id, Object: chunkObject, Choices: []chatChoice{{ + Index: 0, + Delta: chatDelta{Role: "assistant", ToolCalls: []chatToolCall{{ Index: 0, - Delta: chatDelta{Role: "assistant", ToolCalls: []chatToolCall{{ - Index: 0, - ID: callID, - Type: "function", - Function: chatToolCallFunction{ - Name: turn.toolName, - Arguments: turn.toolArgs, - }, - }}}, + ID: callID, + Type: "function", + Function: chatToolCallFunction{ + Name: turn.toolName, + Arguments: turn.toolArgs, + }, }}}, - {ID: id, Object: chunkObject, Choices: []chatChoice{{ - Index: 0, - Delta: chatDelta{}, - FinishReason: &finish, - }}}, - }) - return - } - c.writeTextTurn(w, flusher, turn.text) + }}}, + {ID: id, Object: chunkObject, Choices: []chatChoice{{ + Index: 0, + Delta: chatDelta{}, + FinishReason: &finish, + }}}, + }) } // writeTextTurn serves a pure-text turn: a content chunk carrying reply then a diff --git a/go/e2e/cannedmodel_test.go b/go/e2e/cannedmodel_test.go index a3209f05..58f424a7 100644 --- a/go/e2e/cannedmodel_test.go +++ b/go/e2e/cannedmodel_test.go @@ -587,3 +587,287 @@ func TestCannedCustomMarkerRoutesOffScript(t *testing.T) { t.Fatalf("post-marker normal POST finish_reason = %q, want stop", first.finish) } } + +// TestCannedMarkerScriptAdvancesAndTerminalRepeats is the LOAD-BEARING teeth for +// the marker-routed multi-turn script (newCannedMarkerScript, RIG-3528 T1), and +// it is the assertion the naive one-turn-per-marker implementation cannot pass. +// +// The hazard: a tool-call turn needs TWO model round-trips to settle (the +// tool-call turn, then the follow-up that settles on text), while a marker route +// matches a substring of the WHOLE request body and returns unconditionally. So +// a marker route that always serves its single turn NEVER TERMINATES — every +// POST re-matches and re-serves the tool call forever, and the agent loop spins. +// +// Three consecutive marker-matching POSTs against a [CannedToolCall, CannedText] +// marker script therefore must yield: POST 1 the tool call, POST 2 the text +// SETTLE (not a repeat of the tool call — this is what reddens on the naive +// implementation), and POST 3 still the settle (the terminal element repeats +// once exhausted, so a re-match is never a 500 and never rewinds to the call). +func TestCannedMarkerScriptAdvancesAndTerminalRepeats(t *testing.T) { + const ( + marker = "please post that for me" + toolName = "comms_post_message" + argsJSON = `{"channel":"c1","text":"hi"}` + settle = "posted it" + ) + host, err := hostRoutableAddr() + if err != nil { + t.Fatalf("hostRoutableAddr: %v", err) + } + // The positional script is a single unrelated text turn; the marker script + // carries the two-turn tool-call sequence. Both coexist on one backend, which + // is the shape a leg uses (its own ordered script + a marker-routed peer). + srv, err := startCannedModelServer( + host+":0", + []CannedTurn{CannedText("the one positional turn")}, + newCannedMarkerScript(marker, CannedToolCall(toolName, argsJSON), CannedText(settle)), + ) + if err != nil { + t.Fatalf("startCannedModelServer: %v", err) + } + t.Cleanup(func() { + if err := srv.Close(); err != nil { + t.Errorf("canned model server Close: %v", err) + } + }) + + // context.Background() as the test root (rule://go-thread-context's test + // exemption), matching every sibling test in this file. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + url := srv.BaseURL(host) + "/chat/completions" + markerBody := `{"model":"x","messages":[{"role":"user","content":"` + marker + `"}]}` + + first := readCannedTurnBody(ctx, t, url, markerBody) + if len(first.toolCalls) != 1 || first.toolCalls[0].name != toolName { + t.Fatalf("marker POST#1 = %+v, want a single %q tool call", first, toolName) + } + if first.toolCalls[0].args != argsJSON { + t.Fatalf("marker POST#1 tool args = %q, want %q verbatim", first.toolCalls[0].args, argsJSON) + } + if first.finish != "tool_calls" { + t.Fatalf("marker POST#1 finish_reason = %q, want tool_calls", first.finish) + } + + // THE assertion the naive implementation fails: the SAME marker body, POSTed + // again (exactly what the agent's tool-result follow-up looks like on the + // wire), must draw turns[1] — the text settle — not turns[0] again. + second := readCannedTurnBody(ctx, t, url, markerBody) + if len(second.toolCalls) != 0 { + t.Fatalf("marker POST#2 carried %d tool calls, want the text SETTLE: a marker script that re-serves its tool call never terminates (the agent loop spins forever)", len(second.toolCalls)) + } + if second.content != settle { + t.Fatalf("marker POST#2 content = %q, want the settle %q", second.content, settle) + } + if second.finish != "stop" { + t.Fatalf("marker POST#2 finish_reason = %q, want stop", second.finish) + } + + // The terminal element repeats: a third match stays settled rather than + // 500ing on exhaustion (the positional path's behaviour) or rewinding to the + // tool call. A re-steer or an extra loop iteration must not error the agent + // out. + third := readCannedTurnBody(ctx, t, url, markerBody) + if len(third.toolCalls) != 0 { + t.Fatalf("marker POST#3 carried %d tool calls, want the repeated terminal settle", len(third.toolCalls)) + } + if third.content != settle { + t.Fatalf("marker POST#3 content = %q, want the terminal element repeated (%q)", third.content, settle) + } + if third.finish != "stop" { + t.Fatalf("marker POST#3 finish_reason = %q, want stop", third.finish) + } + + // The marker invariant survives a SCRIPT route as it does a reply route: none + // of the three matches consumed a positional slot, so the single positional + // turn is still at index 0 and an unmarked POST draws it (not a 500). + positional := readCannedTurn(ctx, t, url) + if positional.content != "the one positional turn" { + t.Fatalf("post-marker unmarked POST content = %q, want the positional turn (a marker script must not consume a positional slot)", positional.content) + } + if positional.finish != "stop" { + t.Fatalf("post-marker unmarked POST finish_reason = %q, want stop", positional.finish) + } +} + +// TestCannedMarkerScriptRejectsEmptyTurns pins the construction guard: a marker +// route with no turns can never settle a matching request, so it is a caller bug +// startCannedModelServer refuses rather than a 500 discovered mid-leg. +func TestCannedMarkerScriptRejectsEmptyTurns(t *testing.T) { + host, err := hostRoutableAddr() + if err != nil { + t.Fatalf("hostRoutableAddr: %v", err) + } + srv, err := startCannedModelServer( + host+":0", + []CannedTurn{CannedText("x")}, + newCannedMarkerScript("no-turns-marker"), + ) + if err == nil { + if closeErr := srv.Close(); closeErr != nil { + t.Errorf("canned model server Close: %v", closeErr) + } + t.Fatal("startCannedModelServer accepted a marker with no turns, want a construction error") + } + if !strings.Contains(err.Error(), "no-turns-marker") { + t.Fatalf("error = %v, want it to name the offending marker", err) + } +} + +// TestCannedMarkerScriptsAreIndependentPerMarker pins the PER-MARKER keying of +// the marker-script counter (RIG-3528 T1, review F3). Every other marker test +// registers exactly ONE marker, so markerServed is only ever exercised at i=0 +// and a mis-keyed counter is invisible: with `seq := c.markerServed[0]` hard-coded +// (ignoring the route index) the whole canned suite still passes, while marker B's +// FIRST match silently serves B's SETTLE instead of its opening tool call — the +// design record's named hazard, a marker that "silently serves the wrong agent's +// turn and the test still passes". +// +// Two marker scripts on ONE backend is what the feature is FOR (fixture.go's +// WithCannedMarkerScript: "repeat the option to register several marker +// scripts") — one route per marker-driven agent in a multi-actor leg. So: drive +// marker A through its full [toolcall, settle] pair, then assert marker B's FIRST +// match is still B.turns[0], its own tool call. A shared or mis-keyed counter +// reddens here. +func TestCannedMarkerScriptsAreIndependentPerMarker(t *testing.T) { + const ( + markerA = "agent-a please post that" + toolA = "comms_post_message" + argsA = `{"channel":"a1","text":"from a"}` + settleA = "a-settle" + markerB = "agent-b please post that" + toolB = "comms_deliver_message" + argsB = `{"channel":"b1","text":"from b"}` + settleB = "b-settle" + positiona = "the one positional turn" + ) + host, err := hostRoutableAddr() + if err != nil { + t.Fatalf("hostRoutableAddr: %v", err) + } + srv, err := startCannedModelServer( + host+":0", + []CannedTurn{CannedText(positiona)}, + newCannedMarkerScript(markerA, CannedToolCall(toolA, argsA), CannedText(settleA)), + newCannedMarkerScript(markerB, CannedToolCall(toolB, argsB), CannedText(settleB)), + ) + if err != nil { + t.Fatalf("startCannedModelServer: %v", err) + } + t.Cleanup(func() { + if err := srv.Close(); err != nil { + t.Errorf("canned model server Close: %v", err) + } + }) + + // context.Background() as the test root (rule://go-thread-context's test + // exemption), matching every sibling test in this file. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + url := srv.BaseURL(host) + "/chat/completions" + bodyA := `{"model":"x","messages":[{"role":"user","content":"` + markerA + `"}]}` + bodyB := `{"model":"x","messages":[{"role":"user","content":"` + markerB + `"}]}` + + // Marker A, driven through its whole pair: the tool call, then the settle. + // This is what advances A's counter to 2 and would leak into B's first match + // under a shared counter. + a1 := readCannedTurnBody(ctx, t, url, bodyA) + if len(a1.toolCalls) != 1 || a1.toolCalls[0].name != toolA { + t.Fatalf("marker A POST#1 = %+v, want a single %q tool call", a1, toolA) + } + a2 := readCannedTurnBody(ctx, t, url, bodyA) + if a2.content != settleA { + t.Fatalf("marker A POST#2 content = %q, want A's settle %q", a2.content, settleA) + } + + // THE assertion: marker B's FIRST match draws B.turns[0]. Under a counter + // keyed by a constant index instead of the route index, A's two matches have + // already advanced past B's tool call, so this POST serves b-settle with ZERO + // tool calls and the agent's opening call never happens. + b1 := readCannedTurnBody(ctx, t, url, bodyB) + if len(b1.toolCalls) != 1 { + t.Fatalf("marker B POST#1 carried %d tool calls, want B's OPENING tool call: marker B's counter must be independent of marker A's (A was driven through its full pair first)", len(b1.toolCalls)) + } + if b1.toolCalls[0].name != toolB || b1.toolCalls[0].args != argsB { + t.Fatalf("marker B POST#1 tool call = %q(%q), want B's own %q(%q) — a mis-keyed marker serves the wrong agent's turn", b1.toolCalls[0].name, b1.toolCalls[0].args, toolB, argsB) + } + if b1.finish != "tool_calls" { + t.Fatalf("marker B POST#1 finish_reason = %q, want tool_calls", b1.finish) + } + + // B's own counter then advances on its OWN matches: its second match is B's + // settle (not A's), so the two routes neither share a counter nor cross-serve. + b2 := readCannedTurnBody(ctx, t, url, bodyB) + if b2.content != settleB { + t.Fatalf("marker B POST#2 content = %q, want B's settle %q (not A's %q)", b2.content, settleB, settleA) + } + + // Neither route consumed a positional slot, so the single positional turn is + // still at index 0 — the invariant that keeps this non-vacuous (a 500 here + // would mean the marker routing never engaged at all). + positional := readCannedTurn(ctx, t, url) + if positional.content != positiona { + t.Fatalf("post-marker unmarked POST content = %q, want the positional turn %q", positional.content, positiona) + } +} + +// TestCannedMarkerScriptCallIDsStayDistinct pins the ONLY thing claimMarkerTurn's +// returned seq does (RIG-3528 T1, review F6): keep successive marker-served +// tool-call ids DISTINCT. Nothing else reads it, so passing a constant instead +// (`c.writeCannedTurn(w, flusher, turn, 0)`) is otherwise undetectable — and a +// transcript that cannot tell two served calls apart cannot pin which call a +// tool result answers. +// +// The distinctness the counter guarantees is WITHIN one marker route across +// successive matches, which is exactly why the counter keeps climbing past the +// end of the script (claimMarkerTurn). Two DIFFERENT routes both legitimately +// start at seq 0, so cross-marker ids are equal by design and asserting they +// differ would red on correct production. So: a single-turn tool-call marker +// script, whose terminal element repeats, matched twice. +func TestCannedMarkerScriptCallIDsStayDistinct(t *testing.T) { + const ( + marker = "please call the tool again" + toolName = "comms_post_message" + argsJSON = `{"channel":"c1","text":"hi"}` + ) + host, err := hostRoutableAddr() + if err != nil { + t.Fatalf("hostRoutableAddr: %v", err) + } + srv, err := startCannedModelServer( + host+":0", + []CannedTurn{CannedText("the one positional turn")}, + newCannedMarkerScript(marker, CannedToolCall(toolName, argsJSON)), + ) + if err != nil { + t.Fatalf("startCannedModelServer: %v", err) + } + t.Cleanup(func() { + if err := srv.Close(); err != nil { + t.Errorf("canned model server Close: %v", err) + } + }) + + // context.Background() as the test root (rule://go-thread-context's test + // exemption), matching every sibling test in this file. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + url := srv.BaseURL(host) + "/chat/completions" + body := `{"model":"x","messages":[{"role":"user","content":"` + marker + `"}]}` + + first := readCannedTurnBody(ctx, t, url, body) + second := readCannedTurnBody(ctx, t, url, body) + for i, turn := range []sseTurn{first, second} { + if len(turn.toolCalls) != 1 { + t.Fatalf("marker POST#%d carried %d tool calls, want 1 (the terminal tool-call turn repeats)", i+1, len(turn.toolCalls)) + } + if turn.toolCalls[0].id == "" { + t.Fatalf("marker POST#%d tool-call id is empty", i+1) + } + } + // THE assertion: the second match's id must not repeat the first's. The + // per-marker counter climbs past the end of the script for exactly this. + if first.toolCalls[0].id == second.toolCalls[0].id { + t.Fatalf("both marker-served tool calls carry id %q; successive marker-served call ids must be DISTINCT (claimMarkerTurn's seq), or a transcript cannot tell two served calls apart", first.toolCalls[0].id) + } +} diff --git a/go/e2e/comms_ops.go b/go/e2e/comms_ops.go index 97e47d12..77ad5182 100644 --- a/go/e2e/comms_ops.go +++ b/go/e2e/comms_ops.go @@ -79,6 +79,48 @@ func (f *Fixture) SubscribeComms(ctx context.Context, sinceSeq uint64) (*connect return stream, nil } +// PostMessageAsObserver is PostMessage threaded through an EXPLICIT comms client +// rather than the fixture's admin one, so a leg can post AS a specific +// observer account (the client AsObserver returned) and prove the post is +// authored by — and authorized against — that account rather than the +// bootstrap admin. Identical request shape to PostMessage, including CreateTopic +// (a trusted internal minter); only the credential differs. Returns an error +// rather than panicking so the caller (a test) decides fatality; the per-call +// deadline is threaded from ctx. +func (f *Fixture) PostMessageAsObserver(ctx context.Context, comms commsServiceClient, channelID, topicName, text string) (messageID string, err error) { + rctx, cancel := context.WithTimeout(ctx, rpcTimeout) + defer cancel() + resp, err := comms.PostMessage(rctx, connect.NewRequest(&compassv1.PostMessageRequest{ + Container: &compassv1.PostMessageRequest_ChannelId{ChannelId: channelID}, + Topic: &compassv1.PostMessageRequest_TopicName{TopicName: topicName}, + CreateTopic: true, + Blocks: []*compassv1.MessageBlock{{Block: &compassv1.MessageBlock_Text{Text: text}}}, + })) + if err != nil { + return "", fmt.Errorf("PostMessage RPC (observer): %w", err) + } + return resp.Msg.GetMessage().GetId(), nil +} + +// SubscribeCommsAsObserver is SubscribeComms threaded through an EXPLICIT comms +// client rather than the fixture's admin one — the seam that makes a NEGATIVE +// visibility assertion possible at all, since the admin stream sees everything. +// The per-event D9 filter runs against the STREAM's authenticated account, so an +// observer stream carries only what that account may see. sinceSeq, lifetime, +// and Close ownership are exactly SubscribeComms' (the stream is bound to ctx and +// the caller MUST Close it); AwaitDelivery consumes the returned stream +// unchanged. Returns an error rather than panicking so the caller (a test) +// decides fatality. +func (f *Fixture) SubscribeCommsAsObserver(ctx context.Context, comms commsServiceClient, sinceSeq uint64) (*connect.ServerStreamForClient[compassv1.SubscribeCommsResponse], error) { + stream, err := comms.SubscribeComms(ctx, connect.NewRequest(&compassv1.SubscribeCommsRequest{ + SinceSeq: sinceSeq, + })) + if err != nil { + return nil, fmt.Errorf("SubscribeComms RPC (observer): %w", err) + } + return stream, nil +} + // AwaitDelivery blocks until a MessagePosted whose Message satisfies match fans // onto stream, returning that message; it fails fast on ctx deadline or stream // close. It is FULLY EVENT-GATED: a goroutine pumps stream.Receive() and the diff --git a/go/e2e/fixture.go b/go/e2e/fixture.go index a51c78de..52154d00 100644 --- a/go/e2e/fixture.go +++ b/go/e2e/fixture.go @@ -4,6 +4,7 @@ package e2e import ( "context" + "fmt" "net" "os" "os/exec" //nolint:depguard // e2e harness: LookPath-resolved stack child binaries + podman image probe @@ -13,6 +14,9 @@ import ( "testing" "time" + "connectrpc.com/connect" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" "github.com/RigelBuild/compass/go/internal/stack" "github.com/RigelBuild/compass/go/internal/stack/adapters" "github.com/RigelBuild/compass/go/internal/store" @@ -128,6 +132,25 @@ func WithCannedMarkerReply(marker, reply string) fixtureOption { } } +// WithCannedMarkerScript adds an off-script body-marker route serving an ORDERED +// SEQUENCE of turns (newCannedMarkerScript, RIG-3528 T1) rather than the single +// text reply WithCannedMarkerReply registers: matching request N of this marker +// draws turns[N], and the terminal element repeats once the sequence is +// exhausted. Like WithCannedMarkerReply it never advances the positional script +// counter, so it composes with WithCannedScript and with the built-in Setup +// marker; repeat the option to register several marker scripts. +// +// This is what lets a marker-routed turn issue a TOOL CALL: a tool-call turn +// needs two model round-trips to settle (the call, then the follow-up that +// settles on text), and the follow-up re-matches the same marker — so a +// single-turn marker route would re-serve the tool call forever. Pass +// [CannedToolCall(...), CannedText(...)] and the second round-trip settles. +func WithCannedMarkerScript(marker string, turns ...CannedTurn) fixtureOption { + return func(fc *fixtureConfig) { + fc.cannedMarkers = append(fc.cannedMarkers, newCannedMarkerScript(marker, turns...)) + } +} + // WithSite makes NewFixture reuse a persistent site (root/stateDir/ports) rather // than minting fresh ephemeral ones — the RIG-1790 H6 cross-restart substrate. // Two NewFixture calls over the SAME site drive two stack lifecycles that share @@ -174,6 +197,173 @@ func (f *Fixture) AdminToken() string { return f.adminToken } // child processes rather than matching unrelated host processes. func (f *Fixture) RuntimeDir() string { return f.runtimeDir } +// AsObserver mints a NON-ADMIN bearer for an existing account and returns the +// two Connect clients scoped to it, so a leg can assert what that account CAN +// and CANNOT see over the real TLS door (RIG-3528 T1). Every other fixture RPC +// rides the bootstrap-admin bearer (newAuthedClients), which is why no existing +// leg can prove a NEGATIVE — an admin sees everything. +// +// It mints a CLIENT/observer credential, NOT an agent identity. Agent +// authorship needs no credential at all: the Runner asserts no account and the +// server resolves session_id → account from its own binding +// (runnerhub/relay_comms.go:7-15, the ratified OQ-2 trust model). Do not reach +// for this to author an agent's post — script the agent's turn instead. +// +// IssueToken is admin-gated (server/service.go:407-415), so the mint rides the +// fixture's admin client; the returned clients then dial the SAME door through +// newAuthedClients with the minted bearer, so there is exactly one dial path. +// An account the server cannot resolve is NOT_FOUND, surfaced as the returned +// error (never a panic — the caller, a test, decides fatality). +// +// The argument accepts EITHER spelling — an account id or a handle — because the +// two surfaces disagree: IssueTokenRequest.account_handle is documented as a +// handle (compass.proto:758-763) but the server consumes it as an account ID +// (service.go:420-425 feeds store.AccountID(...) straight into GetAccount, which +// keys on accounts.id), while CommsService's member/owner fields resolve strictly +// through account_handles.handle. So this resolves the ref to an id over +// ListAccounts first. An unresolvable ref is passed through UNCHANGED so the +// SERVER decides the code — that keeps NOT_FOUND the server's answer rather than +// a locally-synthesized one. +func (f *Fixture) AsObserver(ctx context.Context, handle string) (compassServiceClient, commsServiceClient, error) { + // Best-effort id resolution; a miss (unknown ref, or a list error) leaves the + // caller's spelling intact for the server to reject. + target := handle + if acc, err := f.lookupAccount(ctx, handle); err == nil { + target = acc.GetId() + } + rctx, cancel := context.WithTimeout(ctx, rpcTimeout) + defer cancel() + resp, err := f.Compass().IssueToken(rctx, connect.NewRequest(&compassv1.IssueTokenRequest{ + AccountHandle: target, + })) + if err != nil { + return nil, nil, fmt.Errorf("IssueToken RPC: %w", err) + } + token := resp.Msg.GetToken() + if token == "" { + return nil, nil, fmt.Errorf("IssueToken for %q returned an empty token", handle) + } + compass, comms, err := newAuthedClients(f.caPath, f.serverURL, token) + if err != nil { + return nil, nil, fmt.Errorf("observer clients for %q: %w", handle, err) + } + return compass, comms, nil +} + +// lookupAccount resolves an account ref — an id OR a handle — to its Account +// over ListAccounts (the only account read CommsService exposes; there is no +// GetAccount RPC). It exists because the id/handle spelling required differs per +// request field (see AsObserver), so a fixture wrapper taking one spelling has to +// be able to reach the other. An unmatched ref is store-shaped ErrNotFound-like: +// a plain error naming the ref, for the caller to wrap or ignore. +func (f *Fixture) lookupAccount(ctx context.Context, ref string) (*compassv1.Account, error) { + rctx, cancel := context.WithTimeout(ctx, rpcTimeout) + defer cancel() + resp, err := f.Comms().ListAccounts(rctx, connect.NewRequest(&compassv1.ListAccountsRequest{})) + if err != nil { + return nil, fmt.Errorf("ListAccounts RPC: %w", err) + } + for _, acc := range resp.Msg.GetAccounts() { + if acc.GetId() == ref || acc.GetHandle() == ref { + return acc, nil + } + } + return nil, fmt.Errorf("no visible account matching %q (by id or handle)", ref) +} + +// CreateUser creates a human user account over CommsService and returns its +// account id — the owner-tier setup primitive a multi-tenant leg needs (two +// owner users, each with its own agents). Thin client-RPC primitive in the style +// of CreateAgent; returns an error rather than panicking so the caller (a test) +// decides fatality, and the per-call deadline is threaded from ctx. +func (f *Fixture) CreateUser(ctx context.Context, handle, displayName string) (ownerID string, err error) { + rctx, cancel := context.WithTimeout(ctx, rpcTimeout) + defer cancel() + resp, err := f.Comms().CreateUser(rctx, connect.NewRequest(&compassv1.CreateUserRequest{ + Handle: handle, + DisplayName: displayName, + })) + if err != nil { + return "", fmt.Errorf("CreateUser RPC: %w", err) + } + return resp.Msg.GetAccount().GetId(), nil +} + +// CreateChannel creates a plain (kind=CHANNEL) channel over CommsService with +// ownerID as a founding member, and returns its channel id. +// +// private selects the channel's D9 VISIBILITY, which in this schema is a +// property of the channel's GROUP, not of ChannelKind — the ChannelKind enum is +// CHANNEL / DM / (retired) GROUP_DM and carries no private member +// (comms.proto:289-295), and a DM is a two-party conversation the manual create +// path is server-FORBIDDEN from minting (store/channels.go:126-139), so it is +// not the private form of a channel. Both cases are therefore +// CHANNEL_KIND_CHANNEL and differ in placement: +// - private=true → UNGROUPED (empty group_id): membership-only visibility. +// comms.proto:237-239 ("empty for an ungrouped channel, which is +// owner-scoped to its creating caller (the OWNER default), not global"), and +// the read predicate agrees — its group arm requires group_id NOT NULL with +// effective visibility SHARED (store/db/channels.sql.go:495-505), so an +// ungrouped channel is reachable only through channel_members. +// - private=false → created inside a freshly minted SHARED channel group, so +// every account can see it (the globally-visible canary surface). +// +// ownerID is threaded as a MEMBER, not an owner field: CreateChannelRequest has +// NO owner field (name/group_id/kind/member_handles, comms.proto:654-664) and the +// store derives owner scoping from the CALLER plus transitive owner-membership +// (store/channels.go:76-83) — a user is added automatically for any of its agents +// in the member set. Membership is what makes the channel readable by that +// account, which is the property a leg asserts. The creating caller is the +// fixture's admin client, so the admin is a founding member by construction. +// +// member_handles resolves strictly through account_handles.handle (an account id +// never resolves — store/db/accounts.sql.go:248-265), while the signature takes +// an id, so the id is converted to its handle first via lookupAccount. An +// unresolvable ownerID is passed through unchanged so the SERVER answers +// NOT_FOUND rather than a locally-synthesized error. +func (f *Fixture) CreateChannel(ctx context.Context, ownerID, name string, private bool) (channelID string, err error) { + ownerHandle := ownerID + if acc, lookupErr := f.lookupAccount(ctx, ownerID); lookupErr == nil { + ownerHandle = acc.GetHandle() + } + var groupID string + if !private { + groupID, err = f.createSharedGroup(ctx, name+"-group") + if err != nil { + return "", err + } + } + rctx, cancel := context.WithTimeout(ctx, rpcTimeout) + defer cancel() + resp, err := f.Comms().CreateChannel(rctx, connect.NewRequest(&compassv1.CreateChannelRequest{ + Name: name, + GroupId: groupID, + Kind: compassv1.ChannelKind_CHANNEL_KIND_CHANNEL, + MemberHandles: []string{ownerHandle}, + })) + if err != nil { + return "", fmt.Errorf("CreateChannel RPC: %w", err) + } + return resp.Msg.GetChannel().GetId(), nil +} + +// createSharedGroup mints a top-level SHARED-visibility channel group and +// returns its id — the container that makes a channel globally visible (see +// CreateChannel's private=false arm). Top-level, so no parent visibility +// ceiling applies (store/channels.go:13-22). +func (f *Fixture) createSharedGroup(ctx context.Context, name string) (groupID string, err error) { + rctx, cancel := context.WithTimeout(ctx, rpcTimeout) + defer cancel() + resp, err := f.Comms().CreateChannelGroup(rctx, connect.NewRequest(&compassv1.CreateChannelGroupRequest{ + Name: name, + Visibility: compassv1.ChannelGroupVisibility_CHANNEL_GROUP_VISIBILITY_SHARED, + })) + if err != nil { + return "", fmt.Errorf("CreateChannelGroup RPC: %w", err) + } + return resp.Msg.GetGroup().GetId(), nil +} + // NewFixture stands up the real embedded stack over stack.Up with the real // adapter set and returns a Fixture with authenticated Connect clients. It // registers a t.Cleanup that Downs the stack (safe to call twice), so a t.Fatal diff --git a/go/e2e/observer_ops_test.go b/go/e2e/observer_ops_test.go new file mode 100644 index 00000000..b04a7682 --- /dev/null +++ b/go/e2e/observer_ops_test.go @@ -0,0 +1,172 @@ +//go:build podman + +package e2e + +// Podman-tagged teeth for the T1 fixture plumbing (RIG-3528): the observer-scoped +// client mint (AsObserver) and the CreateUser/CreateChannel setup wrappers T3/T4 +// consume. These are the primitives' own proof — the multi-actor legs that USE +// them (T2-T5) land separately, and the marker-script half of T1 is proven in the +// hermetic no-podman lane (cannedmodel_test.go). +// +// The load-bearing case here is the NEGATIVE: AsObserver mints a non-admin bearer +// via IssueToken, which is the seam that makes "what an account CANNOT see" +// assertable at all — every other fixture RPC rides the bootstrap-admin bearer +// (clients.go:63), and an admin sees everything. +// +// podmanUsable-guarded with the byte-identical harness skip literal +// (harness_test.go:29) so the e2e CI guard's skip-string grep matches; no second +// skip condition. Every RPC derives its own deterministic deadline internally +// from the passed-in ctx; the outer ctx is the test root. + +import ( + "context" + "testing" + + "connectrpc.com/connect" + + compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1" +) + +// TestObserverAndSetupPrimitives is the deterministic proof of the three T1 +// podman-tagged primitives over the real stack: CreateUser mints an owner +// account, CreateChannel mints both a private (ungrouped, membership-only) and a +// shared-group (globally visible) channel, and AsObserver mints a working +// NON-ADMIN credential for that owner — proven a real, distinct credential by the +// admin-gate negative below, not merely by an RPC succeeding. +// +// It also carries the record's required member/non-member smoke (design.md:106): +// over the observer's own ListChannels RESPONSE, the shared channel and the +// observer's own private channel are PRESENT and a third account's private +// channel is ABSENT. That single assertion is what gives the `private` flag any +// teeth at all — id-non-empty plus id-distinct hold for ANY two created +// channels, so without reading the response the flag could be dead (or both +// channels shared) and nothing would notice. The present-channel canary keeps +// the negative from going vacuous: an empty list fails the positives rather +// than passing the absence. Only the CROSS-OWNER case is deferred to T4; this +// same-owner smoke is T1's. +func TestObserverAndSetupPrimitives(t *testing.T) { + if !podmanUsable() { + t.Skip("rootless podman cannot run compass-agent:latest here; skipping the real-stack e2e") + } + + ctx := context.Background() // test root, threaded into NewFixture + every primitive + + f := NewFixture(ctx, t) + + ownerID, err := f.CreateUser(ctx, "t1-observer-owner", "T1 Observer Owner") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if ownerID == "" { + t.Fatal("CreateUser returned an empty account id") + } + + privateID, err := f.CreateChannel(ctx, ownerID, "t1-private", true) + if err != nil { + t.Fatalf("CreateChannel (private): %v", err) + } + if privateID == "" { + t.Fatal("CreateChannel (private) returned an empty channel id") + } + + sharedID, err := f.CreateChannel(ctx, ownerID, "t1-shared", false) + if err != nil { + t.Fatalf("CreateChannel (shared): %v", err) + } + if sharedID == "" { + t.Fatal("CreateChannel (shared) returned an empty channel id") + } + if sharedID == privateID { + t.Fatalf("CreateChannel returned the same id %q for both channels", sharedID) + } + // A THIRD channel, private to a DIFFERENT account: the negative surface. Its + // member is a second owner, so the observer is not a member and — being + // ungrouped (private=true) — it is reachable only through channel_members. + // It must therefore be absent from the observer's list. + otherID, err := f.CreateUser(ctx, "t1-observer-other", "T1 Observer Other") + if err != nil { + t.Fatalf("CreateUser (other owner): %v", err) + } + otherPrivateID, err := f.CreateChannel(ctx, otherID, "t1-other-private", true) + if err != nil { + t.Fatalf("CreateChannel (other owner's private): %v", err) + } + if otherPrivateID == "" { + t.Fatal("CreateChannel (other owner's private) returned an empty channel id") + } + + // The observer credential works for an authenticatedOpen CommsService read, + // and the RESPONSE is what carries the visibility teeth — not merely that + // the RPC did not error. + observerCompass, observerComms, err := f.AsObserver(ctx, ownerID) + if err != nil { + t.Fatalf("AsObserver(%s): %v", ownerID, err) + } + listCtx, cancelList := context.WithTimeout(ctx, rpcTimeout) + defer cancelList() + listResp, err := observerComms.ListChannels(listCtx, connect.NewRequest(&compassv1.ListChannelsRequest{})) + if err != nil { + t.Fatalf("observer ListChannels over the real door: %v", err) + } + visible := make(map[string]bool, len(listResp.Msg.GetChannels())) + for _, ch := range listResp.Msg.GetChannels() { + visible[ch.GetId()] = true + } + // The POSITIVE canary first: both of the observer's own channels are present. + // This is what makes the absence below meaningful — an empty or broken list + // reddens here instead of silently satisfying the negative. + if !visible[sharedID] { + t.Fatalf("observer ListChannels omitted the SHARED channel %q; visible ids = %v", sharedID, visible) + } + if !visible[privateID] { + t.Fatalf("observer ListChannels omitted the observer's OWN private channel %q (it is a member); visible ids = %v", privateID, visible) + } + // THE negative: a private (ungrouped) channel the observer is NOT a member of + // must not be reachable. If CreateChannel ignored `private` and grouped every + // channel as SHARED, this id would be globally visible and this reddens. + if visible[otherPrivateID] { + t.Fatalf("observer ListChannels exposed %q — a PRIVATE channel owned by another account the observer is not a member of; the private/shared distinction is not holding", otherPrivateID) + } + + // THE teeth on the credential itself: the observer bearer must be a genuine + // NON-ADMIN identity, not the admin token handed back under a new name. An + // adminOnly RPC (IssueToken, internal/auth/admin_gate.go:65) over the + // observer's own CompassService client must be PermissionDenied. Without this + // the positive read above would also pass if AsObserver silently reused the + // admin bearer — exactly the bug that would make every future + // negative-visibility assertion vacuous. + denyCtx, cancelDeny := context.WithTimeout(ctx, rpcTimeout) + defer cancelDeny() + _, err = observerCompass.IssueToken(denyCtx, connect.NewRequest(&compassv1.IssueTokenRequest{ + AccountHandle: ownerID, + })) + if code := connect.CodeOf(err); code != connect.CodePermissionDenied { + t.Fatalf("observer bearer on the adminOnly IssueToken = %v, want CodePermissionDenied (the observer must NOT be the admin)", code) + } +} + +// TestAsObserverUnknownHandleIsNotFound is the record's named negative: minting +// an observer for a handle no account carries must surface NOT_FOUND from the +// SERVER (service.go:425-430), never a synthesized local error and never a +// silently-admin client. AsObserver passes an unresolvable ref through unchanged +// precisely so the server stays the authority on the code. +func TestAsObserverUnknownHandleIsNotFound(t *testing.T) { + if !podmanUsable() { + t.Skip("rootless podman cannot run compass-agent:latest here; skipping the real-stack e2e") + } + + ctx := context.Background() // test root, threaded into NewFixture + AsObserver + + f := NewFixture(ctx, t) + + compass, comms, err := f.AsObserver(ctx, "t1-no-such-account") + if err == nil { + t.Fatal("AsObserver for an unknown handle succeeded, want NOT_FOUND") + } + if compass != nil || comms != nil { + t.Fatal("AsObserver returned clients alongside its error; a failed mint must yield no usable client") + } + if code := connect.CodeOf(err); code != connect.CodeNotFound { + t.Fatalf("AsObserver(unknown) = %v, want CodeNotFound", code) + } +}