返回 DeepSeek-Reasonix
plugin_test.go
根目录 / internal / plugin / plugin_test.go
1 package plugin
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "path/filepath"
12 "runtime"
13 "strconv"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18
19 "reasonix/internal/event"
20 "reasonix/internal/mcplaunch"
21 "reasonix/internal/sandbox"
22 "reasonix/internal/tool"
23 )
24
25 type countingToolsTransport struct {
26 mu sync.Mutex
27 calls int
28 raw json.RawMessage
29 }
30
31 func (t *countingToolsTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
32 if method != "tools/list" {
33 return json.RawMessage(`{}`), nil
34 }
35 t.mu.Lock()
36 t.calls++
37 t.mu.Unlock()
38 if len(t.raw) > 0 {
39 return t.raw, nil
40 }
41 return json.RawMessage(`{"tools":[{"name":"zed","description":"Sorted after echo.","inputSchema":{"type":"object"}},{"name":"echo","description":"Echo back the message.","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}},"required":["z","msg"]},"annotations":{"readOnlyHint":true}}]}`), nil
42 }
43
44 func (t *countingToolsTransport) notify(ctx context.Context, method string, params any) error {
45 return nil
46 }
47 func (t *countingToolsTransport) close() {}
48
49 func (t *countingToolsTransport) toolsListCalls() int {
50 t.mu.Lock()
51 defer t.mu.Unlock()
52 return t.calls
53 }
54
55 type sequenceToolsTransport struct {
56 mu sync.Mutex
57 calls int
58 raws []json.RawMessage
59 }
60
61 func (t *sequenceToolsTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
62 if method != "tools/list" {
63 return json.RawMessage(`{}`), nil
64 }
65 t.mu.Lock()
66 defer t.mu.Unlock()
67 t.calls++
68 if len(t.raws) == 0 {
69 return json.RawMessage(`{"tools":[]}`), nil
70 }
71 idx := t.calls - 1
72 if idx >= len(t.raws) {
73 idx = len(t.raws) - 1
74 }
75 return t.raws[idx], nil
76 }
77
78 func (t *sequenceToolsTransport) notify(ctx context.Context, method string, params any) error {
79 return nil
80 }
81 func (t *sequenceToolsTransport) close() {}
82
83 func (t *sequenceToolsTransport) toolsListCalls() int {
84 t.mu.Lock()
85 defer t.mu.Unlock()
86 return t.calls
87 }
88
89 type deadlineRecordingTransport struct {
90 mu sync.Mutex
91 deadline []time.Duration
92 methods []string
93 block bool
94 noContext bool
95 }
96
97 func (t *deadlineRecordingTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
98 if d, ok := ctx.Deadline(); ok {
99 t.mu.Lock()
100 t.deadline = append(t.deadline, time.Until(d))
101 t.methods = append(t.methods, method)
102 t.mu.Unlock()
103 } else {
104 t.mu.Lock()
105 t.noContext = true
106 t.methods = append(t.methods, method)
107 t.mu.Unlock()
108 }
109 if t.block {
110 <-ctx.Done()
111 return nil, ctx.Err()
112 }
113 return json.RawMessage(`{}`), nil
114 }
115
116 func (t *deadlineRecordingTransport) notify(ctx context.Context, method string, params any) error {
117 return nil
118 }
119 func (t *deadlineRecordingTransport) close() {}
120
121 func (t *deadlineRecordingTransport) lastDeadline(tst *testing.T) time.Duration {
122 tst.Helper()
123 t.mu.Lock()
124 defer t.mu.Unlock()
125 if len(t.deadline) == 0 {
126 tst.Fatalf("transport recorded no deadline; methods=%v noContext=%v", t.methods, t.noContext)
127 }
128 return t.deadline[len(t.deadline)-1]
129 }
130
131 func assertDeadlineNear(t *testing.T, got, want time.Duration) {
132 t.Helper()
133 if got < want-2*time.Second || got > want+2*time.Second {
134 t.Fatalf("deadline = %v, want near %v", got, want)
135 }
136 }
137
138 func TestMCPRuntimeSpecMatchesExactHostIdentity(t *testing.T) {
139 workspace := filepath.Join(t.TempDir(), "workspace")
140 managerA := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), workspace)
141 managerB := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), workspace)
142 base := Spec{
143 Name: "database", Package: "trusted-package", Type: "http",
144 Command: "launcher", Args: []string{"--serve"}, Env: map[string]string{"TOKEN": "secret-a"},
145 URL: "https://example.invalid/mcp", Headers: map[string]string{"Authorization": "Bearer secret-a"},
146 DefaultStartupTimeout: 30 * time.Second, StartupTimeout: 45 * time.Second,
147 DefaultCallTimeout: 5 * time.Minute, CallTimeout: 30 * time.Second,
148 ToolTimeouts: map[string]time.Duration{"query": 45 * time.Second},
149 Dir: "/work", WorkspaceRoot: workspace, LaunchManager: managerA,
150 ConfigSource: "project_config", Authorized: true, RequireLaunchApproval: true,
151 LaunchArgs: []string{"pkg@1.0.0", "--offline"}, LauncherIdentityArgs: []string{"pkg@1.0.0"},
152 LauncherLocator: "pkg@1.0.0", LauncherResolvedVersion: "1.0.0", LauncherDigest: "digest-a",
153 ProcessMode: MCPProcessConfined,
154 Sandbox: sandbox.Spec{
155 Mode: "enforce", WriteRoots: []string{"/write"}, ReadRoots: []string{"/read"},
156 AppContainerWriteRoots: []string{"/state"}, ForbidReadRoots: []string{"/secret"},
157 Network: true, MinimalWrites: true, Shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: "/bin/bash"},
158 },
159 StateDir: "/state", StripRawPrefix: "db_", LowPriority: true,
160 }
161
162 equivalent := base
163 equivalent.Type = "streamable_http"
164 equivalent.LaunchManager = managerB
165 equivalent.Authorized = false // Authorization is checked separately from runtime identity.
166 equivalent.Stderr = &bytes.Buffer{}
167 if !MCPRuntimeSpecMatches(base, equivalent) {
168 t.Fatal("equivalent runtime specs with separate authorization/stderr handles did not match")
169 }
170
171 emptyA := Spec{Name: "empty", Type: "", Args: nil, Env: nil, Headers: nil, ToolTimeouts: nil}
172 emptyB := Spec{Name: "empty", Type: "stdio", Args: []string{}, Env: map[string]string{}, Headers: map[string]string{}, ToolTimeouts: map[string]time.Duration{}}
173 if !MCPRuntimeSpecMatches(emptyA, emptyB) {
174 t.Fatal("nil and empty runtime collections should be behaviorally equivalent")
175 }
176
177 mutations := []struct {
178 name string
179 mutate func(*Spec)
180 }{
181 {name: "endpoint", mutate: func(s *Spec) { s.URL = "https://other.invalid/mcp" }},
182 {name: "header secret", mutate: func(s *Spec) { s.Headers = map[string]string{"Authorization": "Bearer secret-b"} }},
183 {name: "environment secret", mutate: func(s *Spec) { s.Env = map[string]string{"TOKEN": "secret-b"} }},
184 {name: "default startup timeout", mutate: func(s *Spec) { s.DefaultStartupTimeout = time.Minute }},
185 {name: "startup timeout", mutate: func(s *Spec) { s.StartupTimeout = time.Minute }},
186 {name: "config source", mutate: func(s *Spec) { s.ConfigSource = "user_config" }},
187 {name: "workspace", mutate: func(s *Spec) { s.WorkspaceRoot = "/other-workspace" }},
188 {name: "launcher digest", mutate: func(s *Spec) { s.LauncherDigest = "digest-b" }},
189 {name: "sandbox", mutate: func(s *Spec) { s.Sandbox.Network = false }},
190 {name: "prefix", mutate: func(s *Spec) { s.StripRawPrefix = "other_" }},
191 }
192 for _, tc := range mutations {
193 t.Run(tc.name, func(t *testing.T) {
194 changed := base
195 tc.mutate(&changed)
196 if MCPRuntimeSpecMatches(base, changed) {
197 t.Fatalf("runtime identity ignored %s change", tc.name)
198 }
199 })
200 }
201 }
202
203 func TestClientCallAppliesBuiltInDefaultTimeout(t *testing.T) {
204 for _, transportName := range []string{"stdio", "http"} {
205 t.Run(transportName, func(t *testing.T) {
206 tr := &deadlineRecordingTransport{}
207 c := &Client{name: "maker", t: tr, spec: Spec{Name: "maker"}, transport: transportName}
208 if _, err := c.call(context.Background(), "tools/list", map[string]any{}); err != nil {
209 t.Fatalf("call: %v", err)
210 }
211 assertDeadlineNear(t, tr.lastDeadline(t), defaultCallTimeout)
212 })
213 }
214 }
215
216 func TestClientCallTimeoutPrecedence(t *testing.T) {
217 tr := &deadlineRecordingTransport{}
218 c := &Client{
219 name: "maker",
220 t: tr,
221 spec: Spec{
222 Name: "maker",
223 DefaultCallTimeout: 300 * time.Second,
224 CallTimeout: 600 * time.Second,
225 ToolTimeouts: map[string]time.Duration{"generate_video": 1800 * time.Second},
226 },
227 transport: "stdio",
228 }
229
230 if _, err := c.call(context.Background(), "tools/call", map[string]any{"name": "generate_video"}); err != nil {
231 t.Fatalf("tool override call: %v", err)
232 }
233 assertDeadlineNear(t, tr.lastDeadline(t), 1800*time.Second)
234
235 if _, err := c.call(context.Background(), "tools/call", map[string]any{"name": "search"}); err != nil {
236 t.Fatalf("plugin override call: %v", err)
237 }
238 assertDeadlineNear(t, tr.lastDeadline(t), 600*time.Second)
239
240 if _, err := c.call(context.Background(), "prompts/list", map[string]any{}); err != nil {
241 t.Fatalf("method call: %v", err)
242 }
243 assertDeadlineNear(t, tr.lastDeadline(t), 600*time.Second)
244 }
245
246 func TestClientCallRespectsParentDeadline(t *testing.T) {
247 tr := &deadlineRecordingTransport{}
248 c := &Client{
249 name: "maker",
250 t: tr,
251 spec: Spec{
252 Name: "maker",
253 CallTimeout: 10 * time.Minute,
254 },
255 transport: "http",
256 }
257 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
258 defer cancel()
259 if _, err := c.call(ctx, "tools/call", map[string]any{"name": "generate_video"}); err != nil {
260 t.Fatalf("call: %v", err)
261 }
262 got := tr.lastDeadline(t)
263 if got > 150*time.Millisecond {
264 t.Fatalf("deadline = %v, want caller deadline around 100ms", got)
265 }
266 }
267
268 func TestClientCallTimeoutErrorNamesToolAndConfig(t *testing.T) {
269 tr := &deadlineRecordingTransport{block: true}
270 c := &Client{
271 name: "maker",
272 t: tr,
273 spec: Spec{
274 Name: "maker",
275 CallTimeout: 25 * time.Millisecond,
276 },
277 transport: "stdio",
278 }
279 _, err := c.call(context.Background(), "tools/call", map[string]any{"name": "generate_video"})
280 if err == nil {
281 t.Fatal("timed-out call returned nil error")
282 }
283 if !errors.Is(err, context.DeadlineExceeded) {
284 t.Fatalf("error should wrap context deadline exceeded, got %v", err)
285 }
286 msg := err.Error()
287 if !strings.Contains(msg, `MCP tool "maker.generate_video" timed out after 25ms`) ||
288 !strings.Contains(msg, "tool_timeout_seconds or call_timeout_seconds") {
289 t.Fatalf("timeout error lacks useful guidance: %v", err)
290 }
291 }
292
293 // TestStdioEndToEnd drives a real subprocess (this test binary re-invoked in
294 // helper mode) through the full MCP handshake and a tool call, exercising
295 // StartAll, tools/list, and tools/call over stdio JSON-RPC.
296 func TestStdioEndToEnd(t *testing.T) {
297 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
298 defer cancel()
299
300 spec := Spec{
301 Name: "mock",
302 Command: os.Args[0],
303 Args: []string{"-test.run=TestHelperProcess", "--"},
304 Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
305 }
306
307 host, tools, err := StartAll(ctx, []Spec{spec})
308 if err != nil {
309 t.Fatalf("StartAll: %v", err)
310 }
311 defer host.Close()
312
313 if len(tools) != 2 {
314 t.Fatalf("want 2 tools, got %d", len(tools))
315 }
316 if got := tools[0].Name(); got != "mcp__mock__echo" {
317 t.Fatalf("tool name: want mcp__mock__echo, got %q", got)
318 }
319 if got, want := string(tools[0].Schema()), `{"properties":{"msg":{"type":"string"}},"required":["msg","z"],"type":"object"}`; got != want {
320 t.Fatalf("tool schema = %s, want %s", got, want)
321 }
322
323 out, err := tools[0].Execute(ctx, json.RawMessage(`{"msg":"hi"}`))
324 if err != nil {
325 t.Fatalf("Execute: %v", err)
326 }
327 if out != "echo: hi" {
328 t.Fatalf("result: want %q, got %q", "echo: hi", out)
329 }
330 }
331
332 func TestHostToolsForReusesCachedTools(t *testing.T) {
333 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
334 defer cancel()
335
336 tr := &countingToolsTransport{}
337 host := NewHost()
338 defer host.Close()
339 host.clients = []*Client{{
340 name: "mock",
341 t: tr,
342 spec: Spec{Name: "mock"},
343 transport: "stdio",
344 }}
345
346 first, err := host.ToolsFor(ctx, "mock")
347 if err != nil {
348 t.Fatalf("first ToolsFor: %v", err)
349 }
350 second, err := host.ToolsFor(ctx, "mock")
351 if err != nil {
352 t.Fatalf("second ToolsFor: %v", err)
353 }
354 if got := tr.toolsListCalls(); got != 1 {
355 t.Fatalf("tools/list calls = %d, want 1", got)
356 }
357 if len(first) != 2 || len(second) != 2 {
358 t.Fatalf("ToolsFor lengths = %d and %d, want 2 each", len(first), len(second))
359 }
360 if got := first[0].Name(); got != "mcp__mock__echo" {
361 t.Fatalf("first tool name = %q, want sorted echo first", got)
362 }
363 if got, want := string(second[0].Schema()), string(first[0].Schema()); got != want {
364 t.Fatalf("cached schema changed:\n first=%s\nsecond=%s", want, got)
365 }
366 if !second[0].ReadOnly() {
367 t.Fatal("cached tool lost readOnlyHint")
368 }
369
370 statuses := host.Servers()
371 if len(statuses) != 1 || len(statuses[0].ToolList) != 2 {
372 t.Fatalf("server tool status = %+v, want cached tool metadata", statuses)
373 }
374 if statuses[0].ToolList[0].Name != "echo" || !statuses[0].ToolList[0].ReadOnlyHint {
375 t.Fatalf("tool metadata = %+v, want sorted echo with readOnlyHint", statuses[0].ToolList)
376 }
377 }
378
379 func TestHostToolsForCachesEmptyToolList(t *testing.T) {
380 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
381 defer cancel()
382
383 tr := &countingToolsTransport{raw: json.RawMessage(`{"tools":[]}`)}
384 host := NewHost()
385 defer host.Close()
386 host.clients = []*Client{{
387 name: "empty",
388 t: tr,
389 spec: Spec{Name: "empty"},
390 transport: "stdio",
391 }}
392
393 first, err := host.ToolsFor(ctx, "empty")
394 if err != nil {
395 t.Fatalf("first ToolsFor: %v", err)
396 }
397 second, err := host.ToolsFor(ctx, "empty")
398 if err != nil {
399 t.Fatalf("second ToolsFor: %v", err)
400 }
401 if len(first) != 0 || len(second) != 0 {
402 t.Fatalf("ToolsFor lengths = %d and %d, want 0 each", len(first), len(second))
403 }
404 if got := tr.toolsListCalls(); got != 1 {
405 t.Fatalf("empty tools/list calls = %d, want 1", got)
406 }
407 }
408
409 func TestClientListToolsRetriesAdvertisedEmptyToolList(t *testing.T) {
410 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
411 defer cancel()
412
413 tr := &sequenceToolsTransport{raws: []json.RawMessage{
414 json.RawMessage(`{"tools":[]}`),
415 json.RawMessage(`{"tools":[{"name":"echo","description":"Echo back the message.","inputSchema":{"type":"object"}}]}`),
416 }}
417 c := &Client{
418 name: "race",
419 t: tr,
420 spec: Spec{Name: "race"},
421 transport: "stdio",
422 hasTools: true,
423 }
424
425 tools, err := c.listTools(ctx)
426 if err != nil {
427 t.Fatalf("listTools: %v", err)
428 }
429 if len(tools) != 1 || tools[0].Name() != "mcp__race__echo" {
430 t.Fatalf("tools = %v, want mcp__race__echo", names(tools))
431 }
432 if got := tr.toolsListCalls(); got != 2 {
433 t.Fatalf("tools/list calls = %d, want 2", got)
434 }
435 }
436
437 func TestClientListToolsQuarantinesMalformedSchema(t *testing.T) {
438 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
439 defer cancel()
440
441 tr := &countingToolsTransport{raw: json.RawMessage(`{
442 "tools":[
443 {"name":"echo","description":"Still available.","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}}}},
444 {"name":"generate_yso_bytes","description":"Broken nested schema.","inputSchema":{"type":"object","properties":{"options":{"type":"array","items":{"key":{"type":"string"},"type":{"type":"string"},"value":{"type":"string"}}}}}}
445 ]
446 }`)}
447 c := &Client{name: "yakit", t: tr, spec: Spec{Name: "yakit"}, transport: "stdio"}
448
449 tools, err := c.listTools(ctx)
450 if err != nil {
451 t.Fatalf("listTools: %v", err)
452 }
453 if len(tools) != 1 || tools[0].Name() != "mcp__yakit__echo" {
454 t.Fatalf("tools = %v, want only mcp__yakit__echo", names(tools))
455 }
456 if got := string(tools[0].Schema()); got != `{"properties":{"msg":{"type":"string"}},"type":"object"}` {
457 t.Fatalf("valid sibling schema changed: %s", got)
458 }
459 if len(c.tools) != 2 {
460 t.Fatalf("tool status count = %d, want both advertised tools", len(c.tools))
461 }
462 if c.tools[0].Name != "echo" || c.tools[0].SchemaError != "" {
463 t.Fatalf("valid tool status = %+v", c.tools[0])
464 }
465 if c.tools[1].Name != "generate_yso_bytes" || !strings.Contains(c.tools[1].SchemaError, "/properties/options/items/type") {
466 t.Fatalf("quarantined tool status = %+v", c.tools[1])
467 }
468 }
469
470 func TestClientListToolsQuarantinesNonObjectRootSchemas(t *testing.T) {
471 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
472 defer cancel()
473
474 tr := &countingToolsTransport{raw: json.RawMessage(`{
475 "tools":[
476 {"name":"echo","description":"Still available.","inputSchema":{"type":"object","properties":{"msg":{"type":"string"}}}},
477 {"name":"no_args","description":"Bare empty schema.","inputSchema":{}},
478 {"name":"nullable_root","description":"Union root type.","inputSchema":{"type":["object","null"]}},
479 {"name":"string_root","description":"Non-object root type.","inputSchema":{"type":"string"}}
480 ]
481 }`)}
482 c := &Client{name: "srv", t: tr, spec: Spec{Name: "srv"}, transport: "stdio"}
483
484 tools, err := c.listTools(ctx)
485 if err != nil {
486 t.Fatalf("listTools: %v", err)
487 }
488 if len(tools) != 2 || tools[0].Name() != "mcp__srv__echo" || tools[1].Name() != "mcp__srv__no_args" {
489 t.Fatalf("tools = %v, want echo and normalized no_args", names(tools))
490 }
491 if got := string(tools[1].Schema()); got != `{"properties":{},"type":"object"}` {
492 t.Fatalf("no_args schema = %s, want normalized empty object schema", got)
493 }
494 if len(c.tools) != 4 {
495 t.Fatalf("tool status count = %d, want all advertised tools", len(c.tools))
496 }
497 for _, info := range c.tools {
498 switch info.Name {
499 case "echo", "no_args":
500 if info.SchemaError != "" {
501 t.Fatalf("usable tool status = %+v", info)
502 }
503 case "nullable_root", "string_root":
504 if !strings.Contains(info.SchemaError, `"object"`) {
505 t.Fatalf("quarantined tool status = %+v", info)
506 }
507 default:
508 t.Fatalf("unexpected tool status %+v", info)
509 }
510 }
511 }
512
513 func TestClientListToolsValidatesAfterCompatibilityNormalization(t *testing.T) {
514 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
515 defer cancel()
516
517 tr := &countingToolsTransport{raw: json.RawMessage(`{"tools":[{"name":"legacy","inputSchema":{"type":"object","properties":{"query":{"type":"string","required":true}}}}]}`)}
518 c := &Client{name: "legacy", t: tr, spec: Spec{Name: "legacy"}, transport: "stdio"}
519
520 tools, err := c.listTools(ctx)
521 if err != nil {
522 t.Fatalf("listTools: %v", err)
523 }
524 if len(tools) != 1 {
525 t.Fatalf("tools = %v, want normalized legacy tool", names(tools))
526 }
527 if got := string(tools[0].Schema()); got != `{"properties":{"query":{"type":"string"}},"type":"object"}` {
528 t.Fatalf("normalized schema = %s", got)
529 }
530 }
531
532 func TestClientListToolsPropagatesReadOnlyAndDestructiveHints(t *testing.T) {
533 tr := &countingToolsTransport{raw: json.RawMessage(`{
534 "tools":[{
535 "name":"wipe",
536 "description":"Delete generated state.",
537 "inputSchema":{"type":"object"},
538 "annotations":{"readOnlyHint":true,"destructiveHint":true}
539 }]
540 }`)}
541 c := &Client{name: "srv", t: tr, spec: Spec{Name: "srv"}, transport: "stdio"}
542
543 tools, err := c.listTools(context.Background())
544 if err != nil {
545 t.Fatalf("listTools: %v", err)
546 }
547 if len(tools) != 1 || !tools[0].ReadOnly() {
548 t.Fatalf("tools = %v, want one read-only tool", names(tools))
549 }
550 annotations, ok := tools[0].(tool.MCPAnnotations)
551 if !ok || !annotations.MCPDestructiveHint() {
552 t.Fatalf("tool annotations = (%T, %v), want destructive hint", tools[0], ok)
553 }
554 if len(c.tools) != 1 || !c.tools[0].ReadOnlyHint || !c.tools[0].DestructiveHint {
555 t.Fatalf("tool status = %+v, want both MCP hints", c.tools)
556 }
557 }
558
559 func TestUserAuthorizedMCPHintedReaderIsAuthorizedForSubagents(t *testing.T) {
560 client := &Client{
561 name: "mock", t: &countingToolsTransport{},
562 spec: Spec{Name: "mock", Authorized: true},
563 }
564 tools, err := client.listTools(context.Background())
565 if err != nil {
566 t.Fatalf("listTools: %v", err)
567 }
568 echo := findToolByName(tools, "mcp__mock__echo")
569 if echo == nil || !echo.ReadOnly() {
570 t.Fatalf("installed hinted reader missing or not read-only: %T", echo)
571 }
572 if authority, ok := echo.(tool.MCPServerAuthorization); !ok || !authority.MCPServerAuthorized() {
573 t.Fatalf("installed hinted reader lacks server authorization: %T", echo)
574 }
575 if _, err := echo.Execute(tool.WithReaderExecutionIntent(context.Background()), json.RawMessage(`{"msg":"ok","z":"ok"}`)); err != nil {
576 t.Fatalf("installed hinted reader dispatch: %v", err)
577 }
578 }
579
580 func TestServerAuthorizedUsesResolvedBooleanOnly(t *testing.T) {
581 if !(Spec{Authorized: true}).ServerAuthorized() {
582 t.Fatal("an explicitly authorized server should not require a launch manager")
583 }
584 if (Spec{}).ServerAuthorized() {
585 t.Fatal("an unresolved server should remain unauthorized")
586 }
587 }
588
589 func TestInstalledServerAuthorizationSkipsProjectIdentityDigest(t *testing.T) {
590 installed := Spec{Name: "installed", Authorized: true}
591 resolved, err := resolveProjectLaunchAuthorization(context.Background(), installed)
592 if err != nil || !resolved.ServerAuthorized() {
593 t.Fatalf("installed authorization = (%+v, %v), want authorized without identity resolution", resolved, err)
594 }
595
596 project := Spec{
597 Name: "project", RequireLaunchApproval: true,
598 LaunchManager: mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir()),
599 }
600 if _, err := resolveProjectLaunchAuthorization(context.Background(), project); err == nil || !strings.Contains(err.Error(), "command is required") {
601 t.Fatalf("project authorization did not resolve its exact launch identity: %v", err)
602 }
603 }
604
605 func TestApplyKnownOverridesPinsCodeGraphStdioToWorkspace(t *testing.T) {
606 got := ApplyKnownOverrides(Spec{Name: "codegraph"}, "/workspace")
607 if got.Dir != "/workspace" {
608 t.Fatalf("codegraph stdio Dir = %q, want workspace root", got.Dir)
609 }
610 if got.Env[codeGraphDaemonIdleTimeoutEnv] != codeGraphDaemonIdleTimeoutDefaultMS {
611 t.Fatalf("codegraph daemon idle timeout env = %q, want %s; env=%v", got.Env[codeGraphDaemonIdleTimeoutEnv], codeGraphDaemonIdleTimeoutDefaultMS, got.Env)
612 }
613
614 preset := ApplyKnownOverrides(Spec{Name: "codegraph", Dir: "/custom"}, "/workspace")
615 if preset.Dir != "/custom" {
616 t.Fatalf("existing Dir should be preserved, got %q", preset.Dir)
617 }
618
619 httpSpec := ApplyKnownOverrides(Spec{Name: "codegraph", Type: "http"}, "/workspace")
620 if httpSpec.Dir != "" {
621 t.Fatalf("http codegraph should not receive stdio Dir, got %q", httpSpec.Dir)
622 }
623 if _, ok := httpSpec.Env[codeGraphDaemonIdleTimeoutEnv]; ok {
624 t.Fatalf("http codegraph should not receive daemon idle env, got %+v", httpSpec.Env)
625 }
626
627 other := ApplyKnownOverrides(Spec{Name: "other"}, "/workspace")
628 if other.Dir != "" {
629 t.Fatalf("non-codegraph should not receive Dir, got %q", other.Dir)
630 }
631 if _, ok := other.Env[codeGraphDaemonIdleTimeoutEnv]; ok {
632 t.Fatalf("non-codegraph should not receive daemon idle env, got %+v", other.Env)
633 }
634 }
635
636 func TestApplyKnownOverridesPinsCodebaseMemoryToWorkspace(t *testing.T) {
637 got := ApplyKnownOverrides(Spec{Name: "codebase-memory-mcp"}, "/workspace")
638 if got.Dir != "/workspace" {
639 t.Fatalf("codebase-memory-mcp stdio Dir = %q, want workspace root", got.Dir)
640 }
641 if !got.LowPriority {
642 t.Fatalf("codebase-memory-mcp should run at low priority")
643 }
644
645 preset := ApplyKnownOverrides(Spec{Name: "codebase-memory-mcp", Dir: "/custom"}, "/workspace")
646 if preset.Dir != "/custom" {
647 t.Fatalf("existing Dir should be preserved, got %q", preset.Dir)
648 }
649
650 httpSpec := ApplyKnownOverrides(Spec{Name: "codebase-memory-mcp", Type: "http"}, "/workspace")
651 if httpSpec.Dir != "" {
652 t.Fatalf("http codebase-memory-mcp should not receive stdio Dir, got %q", httpSpec.Dir)
653 }
654
655 npxSpec := ApplyKnownOverrides(Spec{
656 Name: "custom",
657 Command: "npx",
658 Args: []string{"-y", "codebase-memory-mcp@latest"},
659 }, "/workspace")
660 if npxSpec.Dir != "/workspace" || !npxSpec.LowPriority {
661 t.Fatalf("npx codebase-memory-mcp override missing: %+v", npxSpec)
662 }
663 }
664
665 func TestApplyKnownOverridesPreservesConfiguredCodeGraphDaemonIdleTimeout(t *testing.T) {
666 got := ApplyKnownOverrides(Spec{
667 Name: "codegraph",
668 Env: map[string]string{codeGraphDaemonIdleTimeoutEnv: "30000"},
669 }, "/workspace")
670
671 if got.Env[codeGraphDaemonIdleTimeoutEnv] != "30000" {
672 t.Fatalf("configured codegraph daemon idle timeout was overwritten: %+v", got.Env)
673 }
674 }
675
676 func TestStartAvailableKeepsGoodServers(t *testing.T) {
677 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
678 defer cancel()
679
680 good := Spec{
681 Name: "good",
682 Command: os.Args[0],
683 Args: []string{"-test.run=TestHelperProcess", "--"},
684 Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
685 }
686 bad := Spec{Name: "bad", Command: "reasonix-missing-mcp-binary"}
687
688 host, tools := StartAvailable(ctx, []Spec{bad, good})
689 defer host.Close()
690
691 if len(tools) != 2 {
692 t.Fatalf("want tools from the good server, got %d", len(tools))
693 }
694 if got := host.ServerNames(); len(got) != 1 || got[0] != "good" {
695 t.Fatalf("connected servers = %v, want [good]", got)
696 }
697 failures := host.Failures()
698 if len(failures) != 1 || failures[0].Name != "bad" {
699 t.Fatalf("failures = %+v, want bad", failures)
700 }
701 }
702
703 func TestRecordFailurePreservesLaunchApprovalAction(t *testing.T) {
704 host := NewHost()
705 host.RecordFailure(Spec{Name: "project", Type: "stdio"}, fmt.Errorf("connect project MCP: %w", &launchApprovalError{server: "project"}))
706 host.RecordFailure(Spec{Name: "ordinary", Type: "stdio"}, errors.New("connection refused"))
707
708 failures := host.Failures()
709 if len(failures) != 2 {
710 t.Fatalf("failures = %+v, want two", failures)
711 }
712 if !failures[0].RequiresLaunchApproval {
713 t.Fatalf("project launch failure = %+v, want authorization action", failures[0])
714 }
715 if failures[1].RequiresLaunchApproval {
716 t.Fatalf("ordinary failure = %+v, must remain retryable", failures[1])
717 }
718 }
719
720 // TestStartAllAllOrNothingOnFailure pins the strict StartAll contract the
721 // parallel rewrite must preserve: any single plugin failing aborts the whole
722 // set, returns no Host or tools, and tears down every server that did start —
723 // including, under parallel start, a good server whose index sits after the
724 // failing one ([bad, good]). On error the Host is nil, so callers never see a
725 // half-built set; the started servers are closed before StartAll returns.
726 func TestStartAllAllOrNothingOnFailure(t *testing.T) {
727 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
728 defer cancel()
729
730 good := Spec{
731 Name: "good",
732 Command: os.Args[0],
733 Args: []string{"-test.run=TestHelperProcess", "--"},
734 Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
735 }
736 bad := Spec{Name: "bad", Command: "reasonix-missing-mcp-binary"}
737
738 for _, tc := range []struct {
739 name string
740 specs []Spec
741 }{
742 {"failure first", []Spec{bad, good}},
743 {"failure last", []Spec{good, bad}},
744 } {
745 t.Run(tc.name, func(t *testing.T) {
746 host, tools, err := StartAll(ctx, tc.specs)
747 if err == nil {
748 if host != nil {
749 host.Close()
750 }
751 t.Fatal("StartAll should fail when a plugin can't start")
752 }
753 if host != nil || tools != nil {
754 t.Fatalf("failed StartAll must return nil host/tools, got host=%v tools=%d", host, len(tools))
755 }
756 })
757 }
758 }
759
760 func TestStdioFailureCapturesStderr(t *testing.T) {
761 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
762 defer cancel()
763
764 host, _ := StartAvailable(ctx, []Spec{{
765 Name: "stderr",
766 Command: os.Args[0],
767 Args: []string{"-test.run=TestHelperProcess", "--"},
768 Env: map[string]string{"GO_WANT_HELPER_STDERR_EXIT": "1"},
769 }})
770 defer host.Close()
771
772 failures := host.Failures()
773 if len(failures) != 1 {
774 t.Fatalf("failures = %+v, want one", failures)
775 }
776 if !strings.Contains(failures[0].Error, "helper stderr boom") {
777 t.Fatalf("failure should include stderr, got %q", failures[0].Error)
778 }
779 }
780
781 func TestStartupFailureReportsStageElapsedAndRedactedStderr(t *testing.T) {
782 lifeCtx, cancelLife := context.WithCancel(context.Background())
783 defer cancelLife()
784 startupCtx, cancelStartup := context.WithTimeout(lifeCtx, 40*time.Millisecond)
785 defer cancelStartup()
786
787 host := NewHost()
788 defer host.Close()
789 spec := Spec{
790 Name: "slow-stderr",
791 Command: os.Args[0],
792 Args: []string{"-test.run=TestHelperProcess", "--"},
793 Env: map[string]string{
794 "GO_WANT_HELPER_PROCESS": "1",
795 "GO_WANT_HELPER_INIT_MS": "250",
796 "GO_WANT_HELPER_STARTUP_STDERR": "Authorization: Bearer startup-secret-value",
797 },
798 }
799 _, err := host.AddWithLifecycle(lifeCtx, startupCtx, spec)
800 if err == nil {
801 t.Fatal("slow initialize unexpectedly succeeded")
802 }
803 msg := err.Error()
804 for _, want := range []string{"initialize", "after ", "stderr:", "Bearer [redacted]"} {
805 if !strings.Contains(msg, want) {
806 t.Fatalf("startup error missing %q: %v", want, err)
807 }
808 }
809 if strings.Contains(msg, "startup-secret-value") {
810 t.Fatalf("startup error leaked credential: %v", err)
811 }
812
813 host.RecordFailure(spec, err)
814 failures := host.Failures()
815 if len(failures) != 1 || failures[0].Stage != "initialize" || failures[0].Elapsed <= 0 {
816 t.Fatalf("structured startup failure = %+v", failures)
817 }
818 if !strings.Contains(failures[0].Stderr, "Bearer [redacted]") {
819 t.Fatalf("structured stderr was not redacted: %+v", failures[0])
820 }
821 }
822
823 func TestFailureSummaryRedactsCredentials(t *testing.T) {
824 got := summarizeFailureError(errors.New("startup failed: Authorization: Bearer summary-secret-value"))
825 if strings.Contains(got, "summary-secret-value") || !strings.Contains(got, "Bearer [redacted]") {
826 t.Fatalf("failure summary was not redacted: %q", got)
827 }
828 }
829
830 func TestEnsureConnectedInBackgroundSurvivesShortCallerWait(t *testing.T) {
831 lifeCtx, cancelLife := context.WithCancel(context.Background())
832 defer cancelLife()
833 host := NewHost()
834 defer host.Close()
835 spec := Spec{
836 Name: "slow-background",
837 Command: os.Args[0],
838 Args: []string{"-test.run=TestHelperProcess", "--"},
839 StartupTimeout: 2 * time.Second,
840 Env: map[string]string{
841 "GO_WANT_HELPER_PROCESS": "1",
842 "GO_WANT_HELPER_INIT_MS": "150",
843 },
844 }
845 result := host.EnsureConnectedInBackground(lifeCtx, spec)
846 select {
847 case got := <-result:
848 t.Fatalf("background startup settled before the short caller wait: %+v", got)
849 case <-time.After(20 * time.Millisecond):
850 // The caller can return here without cancelling the session-owned startup.
851 }
852 select {
853 case got := <-result:
854 if got.Err != nil || len(got.Tools) != 2 {
855 t.Fatalf("background startup result = %+v", got)
856 }
857 case <-time.After(2 * time.Second):
858 t.Fatal("background startup did not finish")
859 }
860 if !host.HasClient(spec.Name) {
861 t.Fatal("successful background startup did not leave a session-owned client")
862 }
863 }
864
865 func TestEnsureConnectedInBackgroundRemoveDoesNotResurrectServer(t *testing.T) {
866 lifeCtx, cancelLife := context.WithCancel(context.Background())
867 defer cancelLife()
868 host := NewHost()
869 defer host.Close()
870 spec := Spec{
871 Name: "removed-background",
872 Command: os.Args[0],
873 Args: []string{"-test.run=TestHelperProcess", "--"},
874 StartupTimeout: 2 * time.Second,
875 Env: map[string]string{
876 "GO_WANT_HELPER_PROCESS": "1",
877 "GO_WANT_HELPER_INIT_MS": "500",
878 },
879 }
880 result := host.EnsureConnectedInBackground(lifeCtx, spec)
881 deadline := time.Now().Add(2 * time.Second)
882 for {
883 connecting := host.ConnectingServers()
884 if len(connecting) > 0 {
885 if len(connecting) != 1 || connecting[0] != spec.Name {
886 t.Fatalf("ConnectingServers = %v, want exact configured name %q", connecting, spec.Name)
887 }
888 break
889 }
890 if time.Now().After(deadline) {
891 t.Fatal("background startup never entered the in-flight state")
892 }
893 time.Sleep(5 * time.Millisecond)
894 }
895 if _, found := host.Remove(spec.Name); !found {
896 t.Fatal("Host.Remove did not cancel the background generation")
897 }
898 select {
899 case got := <-result:
900 if got.Err == nil {
901 t.Fatalf("removed background startup unexpectedly succeeded: %+v", got)
902 }
903 case <-time.After(2 * time.Second):
904 t.Fatal("removed background startup did not settle")
905 }
906 if host.HasClient(spec.Name) || len(host.ServerNames()) != 0 {
907 t.Fatalf("removed background server was resurrected: %v", host.ServerNames())
908 }
909 }
910
911 func TestStdioUsesConfiguredPATHForCommandLookup(t *testing.T) {
912 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
913 defer cancel()
914
915 dir, command := helperLauncher(t, "mock-mcp")
916 t.Setenv("PATH", "")
917
918 host, tools, err := StartAll(ctx, []Spec{{
919 Name: "path",
920 Command: command,
921 Args: []string{"-test.run=TestHelperProcess", "--"},
922 Env: map[string]string{
923 "GO_WANT_HELPER_PROCESS": "1",
924 "PATH": dir,
925 },
926 }})
927 if err != nil {
928 t.Fatalf("StartAll: %v", err)
929 }
930 defer host.Close()
931 if len(tools) != 2 {
932 t.Fatalf("want helper tools, got %d", len(tools))
933 }
934 }
935
936 func TestStdioFallsBackToShellPATHForCommandLookup(t *testing.T) {
937 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
938 defer cancel()
939
940 dir, command := helperLauncher(t, "shell-mcp")
941 t.Setenv("PATH", "")
942 old := stdioShellPATH
943 stdioShellPATH = func(context.Context) string { return dir }
944 t.Cleanup(func() { stdioShellPATH = old })
945
946 host, tools, err := StartAll(ctx, []Spec{{
947 Name: "shell-path",
948 Command: command,
949 Args: []string{"-test.run=TestHelperProcess", "--"},
950 Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
951 }})
952 if err != nil {
953 t.Fatalf("StartAll: %v", err)
954 }
955 defer host.Close()
956 if len(tools) != 2 {
957 t.Fatalf("want helper tools, got %d", len(tools))
958 }
959 }
960
961 func TestStdioCommandNotFoundSuggestsPATHFix(t *testing.T) {
962 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
963 defer cancel()
964
965 t.Setenv("PATH", "")
966 old := stdioShellPATH
967 stdioShellPATH = func(context.Context) string { return "" }
968 t.Cleanup(func() { stdioShellPATH = old })
969
970 host, _ := StartAvailable(ctx, []Spec{{Name: "missing", Command: "reasonix-missing-mcp-binary"}})
971 defer host.Close()
972
973 failures := host.Failures()
974 if len(failures) != 1 {
975 t.Fatalf("failures = %+v, want one", failures)
976 }
977 msg := failures[0].Error
978 for _, want := range []string{
979 `command "reasonix-missing-mcp-binary" not found on PATH`,
980 "absolute command path",
981 "MCP server env",
982 } {
983 if !strings.Contains(msg, want) {
984 t.Fatalf("failure %q missing %q", msg, want)
985 }
986 }
987 }
988
989 func TestStdioIgnoresRelativePATHEntries(t *testing.T) {
990 dir := t.TempDir()
991 bin := filepath.Join(dir, "bin")
992 if err := os.Mkdir(bin, 0o755); err != nil {
993 t.Fatalf("mkdir bin: %v", err)
994 }
995 name := "mock-mcp"
996 target := filepath.Join(bin, name)
997 env := []string{"PATH=bin"}
998 if runtime.GOOS == "windows" {
999 target += ".cmd"
1000 env = append(env, "PATHEXT=.CMD")
1001 }
1002 if err := os.WriteFile(target, []byte(""), 0o755); err != nil {
1003 t.Fatalf("write fake executable: %v", err)
1004 }
1005 t.Chdir(dir)
1006
1007 if exe, ok := lookPathInEnv(name, env); ok {
1008 t.Fatalf("relative PATH entry resolved to %q; want no match", exe)
1009 }
1010 }
1011
1012 func helperLauncher(t *testing.T, name string) (dir, command string) {
1013 t.Helper()
1014 if runtime.GOOS == "windows" {
1015 t.Skip("shell launcher fixture is POSIX-only")
1016 }
1017 dir = t.TempDir()
1018 command = name
1019 target := filepath.Join(dir, name)
1020 script := "#!/bin/sh\nexec " + shellQuote(os.Args[0]) + " \"$@\"\n"
1021 if err := os.WriteFile(target, []byte(script), 0o755); err != nil {
1022 t.Fatalf("write helper launcher: %v", err)
1023 }
1024 return dir, command
1025 }
1026
1027 func shellQuote(s string) string {
1028 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
1029 }
1030
1031 // TestStartPolicyConcurrencyCap verifies the semaphore-style cap: with
1032 // Concurrency=1 the handshakes must serialise even though every spec runs
1033 // in its own goroutine. We sleep briefly inside each helper's initialize so
1034 // the goroutines have a chance to overlap if the cap is broken, then assert
1035 // that observed max-in-flight never exceeded 1.
1036 func TestStartPolicyConcurrencyCap(t *testing.T) {
1037 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1038 defer cancel()
1039
1040 mk := func(name string) Spec {
1041 return Spec{
1042 Name: name,
1043 Command: os.Args[0],
1044 Args: []string{"-test.run=TestHelperProcess", "--"},
1045 Env: map[string]string{
1046 "GO_WANT_HELPER_PROCESS": "1",
1047 "GO_WANT_HELPER_INIT_MS": "50",
1048 },
1049 }
1050 }
1051 specs := []Spec{mk("a"), mk("b"), mk("c"), mk("d")}
1052 t0 := time.Now()
1053 host, tools, err := Start(ctx, specs, StartPolicy{Concurrency: 1, AbortOnError: true})
1054 if err != nil {
1055 t.Fatalf("Start: %v", err)
1056 }
1057 defer host.Close()
1058 elapsed := time.Since(t0)
1059 // 4 specs × 50ms init each, serialised. Allow generous slack for CI.
1060 if elapsed < 4*50*time.Millisecond {
1061 t.Fatalf("with Concurrency=1, total time should be ≥ Σ(per-spec) but was %v", elapsed)
1062 }
1063 if len(tools) != 4*2 { // helper exposes 2 tools per server
1064 t.Fatalf("want %d tools, got %d", 4*2, len(tools))
1065 }
1066 }
1067
1068 // TestStartPolicyPerPluginTimeout verifies that one slow plugin can't take
1069 // down the whole batch in StartAvailable mode: the slow spec times out and
1070 // gets recorded as a failure while the fast one connects.
1071 func TestStartPolicyPerPluginTimeout(t *testing.T) {
1072 ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
1073 defer cancel()
1074
1075 fast := Spec{
1076 Name: "fast",
1077 Command: os.Args[0],
1078 Args: []string{"-test.run=TestHelperProcess", "--"},
1079 Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"},
1080 }
1081 slow := Spec{
1082 Name: "slow",
1083 Command: os.Args[0],
1084 Args: []string{"-test.run=TestHelperProcess", "--"},
1085 Env: map[string]string{
1086 "GO_WANT_HELPER_PROCESS": "1",
1087 "GO_WANT_HELPER_INIT_MS": "5000", // 5s, well past the 2s budget
1088 },
1089 }
1090 host, tools, err := Start(ctx, []Spec{fast, slow}, StartPolicy{
1091 PerPluginTimeout: 2 * time.Second,
1092 Concurrency: 2,
1093 AbortOnError: false,
1094 })
1095 if err != nil {
1096 t.Fatalf("Start should not return err in record-failure mode: %v", err)
1097 }
1098 defer host.Close()
1099 // Regression: the per-plugin timeout context must NOT bound the long-lived
1100 // stdio child. If transport was bound to cctx instead of the parent ctx, the
1101 // goroutine's deferred cancel would kill `fast`'s subprocess at handshake
1102 // success and this Execute would fail. We invoke it explicitly here so any
1103 // future re-introduction of the bug breaks loudly.
1104 if len(tools) > 0 {
1105 if _, callErr := tools[0].Execute(ctx, json.RawMessage(`{"msg":"hi"}`)); callErr != nil {
1106 t.Fatalf("fast plugin's subprocess was killed by deferred timeout cancel: %v", callErr)
1107 }
1108 }
1109 if len(tools) != 2 { // fast contributes 2 tools
1110 t.Fatalf("want only fast's 2 tools, got %d", len(tools))
1111 }
1112 failures := host.Failures()
1113 if len(failures) != 1 || failures[0].Name != "slow" {
1114 t.Fatalf("failures = %+v, want [slow]", failures)
1115 }
1116 }
1117
1118 func TestStartRecordsTimeoutStats(t *testing.T) {
1119 withTempCache(t)
1120 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1121 defer cancel()
1122
1123 slow := Spec{
1124 Name: "slow-stats",
1125 Command: os.Args[0],
1126 Args: []string{"-test.run=TestHelperProcess", "--"},
1127 Env: map[string]string{
1128 "GO_WANT_HELPER_PROCESS": "1",
1129 "GO_WANT_HELPER_INIT_MS": "300",
1130 },
1131 }
1132 for i := 0; i < 3; i++ {
1133 host, _, err := Start(ctx, []Spec{slow}, StartPolicy{
1134 PerPluginTimeout: 50 * time.Millisecond,
1135 Concurrency: 1,
1136 AbortOnError: false,
1137 })
1138 if err != nil {
1139 t.Fatalf("Start #%d: %v", i, err)
1140 }
1141 host.Close()
1142 }
1143
1144 deadline := time.Now().Add(2 * time.Second)
1145 for {
1146 rec := Recommend("slow-stats", 50*time.Millisecond, 3)
1147 if rec.Demote {
1148 return
1149 }
1150 if time.Now().After(deadline) {
1151 t.Fatalf("timeout samples did not trigger demote; stats=%+v rec=%+v", readStats(t, "slow-stats"), rec)
1152 }
1153 time.Sleep(10 * time.Millisecond)
1154 }
1155 }
1156
1157 // TestStartPhaseAReturnsBeforePhaseB pins the two-phase handshake contract.
1158 // The helper advertises prompts and stalls prompts/list by 200ms; StartAvailable
1159 // must return with tools ready while the prompts surface is still empty, and the
1160 // prompts must only materialise on Host after StartPhaseB has been called and
1161 // drained — proving prompts ride the background phase, not the boot critical path.
1162 func TestStartPhaseAReturnsBeforePhaseB(t *testing.T) {
1163 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1164 defer cancel()
1165
1166 spec := Spec{
1167 Name: "mock",
1168 Command: os.Args[0],
1169 Args: []string{"-test.run=TestHelperProcess", "--"},
1170 Env: map[string]string{
1171 "GO_WANT_HELPER_PROCESS": "1",
1172 "GO_WANT_HELPER_PROMPTS": "1",
1173 "GO_WANT_HELPER_PROMPT_DELAY_MS": "200",
1174 },
1175 }
1176
1177 host, tools := StartAvailable(ctx, []Spec{spec})
1178 defer host.Close()
1179
1180 if len(tools) == 0 {
1181 t.Fatalf("want tools from helper, got 0")
1182 }
1183 // Phase A returns with tools but the prompts surface must still be empty:
1184 // StartAvailable never issues prompts/list (the helper stalls it 200ms), so
1185 // prompts can only appear after StartPhaseB drains them below. We assert this
1186 // deferral directly instead of timing StartAvailable — subprocess spawn plus
1187 // the MCP handshake make a wall-clock threshold flaky on slow CI runners.
1188 if got := host.Prompts(); len(got) != 0 {
1189 t.Fatalf("phase A must not surface prompts yet, got %d", len(got))
1190 }
1191
1192 // Drive phase B and wait for the surface-ready event. Use a buffered channel
1193 // sink so the test never blocks the emitter — the event payload itself is
1194 // our completion signal.
1195 ready := make(chan event.Event, 4)
1196 host.StartPhaseB(ctx, event.FuncSink(func(e event.Event) {
1197 if e.Kind == event.MCPSurfaceReady {
1198 select {
1199 case ready <- e:
1200 default:
1201 }
1202 }
1203 }))
1204
1205 select {
1206 case e := <-ready:
1207 if !strings.Contains(e.Text, "prompts ready") {
1208 t.Fatalf("phase B event text = %q, want it to mention prompts", e.Text)
1209 }
1210 case <-time.After(3 * time.Second):
1211 t.Fatal("phase B never fired MCPSurfaceReady for prompts")
1212 }
1213
1214 if got := host.Prompts(); len(got) != 1 || got[0].Raw != "hello" {
1215 t.Fatalf("after phase B, prompts = %+v, want one named hello", got)
1216 }
1217 }
1218
1219 func TestStartPhaseBDoesNotBlockToolCalls(t *testing.T) {
1220 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1221 defer cancel()
1222
1223 spec := Spec{
1224 Name: "mock",
1225 Command: os.Args[0],
1226 Args: []string{"-test.run=TestHelperProcess", "--"},
1227 Env: map[string]string{
1228 "GO_WANT_HELPER_PROCESS": "1",
1229 "GO_WANT_HELPER_PROMPTS": "1",
1230 "GO_WANT_HELPER_PROMPT_DELAY_MS": "1000",
1231 },
1232 }
1233
1234 host, tools := StartAvailable(ctx, []Spec{spec})
1235 defer host.Close()
1236
1237 var echo tool.Tool
1238 for _, t := range tools {
1239 if t.Name() == "mcp__mock__echo" {
1240 echo = t
1241 break
1242 }
1243 }
1244 if echo == nil {
1245 t.Fatal("missing echo tool")
1246 }
1247
1248 host.StartPhaseB(ctx, event.Discard)
1249 time.Sleep(50 * time.Millisecond)
1250
1251 callCtx, callCancel := context.WithTimeout(ctx, 150*time.Millisecond)
1252 defer callCancel()
1253 out, err := echo.Execute(callCtx, json.RawMessage(`{"msg":"hi"}`))
1254 if err != nil {
1255 t.Fatalf("tool call should not be blocked by background prompts/list: %v", err)
1256 }
1257 if out != "echo: hi" {
1258 t.Fatalf("Execute result = %q, want %q", out, "echo: hi")
1259 }
1260 }
1261
1262 // TestHelperProcess is not a real test; it acts as a minimal MCP stdio server
1263 // when invoked by TestStdioEndToEnd. It exits before the test framework can
1264 // print to stdout, keeping the JSON-RPC channel clean.
1265 //
1266 // GO_WANT_HELPER_INIT_MS optionally injects a sleep before responding to the
1267 // initialize call, used by the timeout / concurrency tests to simulate slow
1268 // handshakes without depending on external processes.
1269 // GO_WANT_HELPER_PROMPTS advertises the prompts capability and registers a
1270 // "hello" prompt; GO_WANT_HELPER_PROMPT_DELAY_MS stalls prompts/list so the
1271 // phase-A vs phase-B split can be exercised.
1272 func TestHelperProcess(t *testing.T) {
1273 if os.Getenv("GO_WANT_HELPER_STDERR_EXIT") == "1" {
1274 os.Stderr.WriteString("helper stderr boom\n")
1275 os.Exit(2)
1276 }
1277 if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
1278 return
1279 }
1280 defer os.Exit(0)
1281 incrementHelperCounter(os.Getenv("GO_WANT_HELPER_START_COUNT"))
1282 if msg := os.Getenv("GO_WANT_HELPER_STARTUP_STDERR"); msg != "" {
1283 _, _ = os.Stderr.WriteString(msg + "\n")
1284 }
1285
1286 var initDelay time.Duration
1287 if ms := os.Getenv("GO_WANT_HELPER_INIT_MS"); ms != "" {
1288 if v, err := time.ParseDuration(ms + "ms"); err == nil {
1289 initDelay = v
1290 }
1291 }
1292
1293 in := bufio.NewReader(os.Stdin)
1294 for {
1295 line, err := in.ReadBytes('\n')
1296 if err != nil {
1297 return
1298 }
1299 line = bytes.TrimSpace(line)
1300 if len(line) == 0 {
1301 continue
1302 }
1303
1304 var req struct {
1305 ID *int `json:"id"`
1306 Method string `json:"method"`
1307 Params json.RawMessage `json:"params"`
1308 }
1309 if err := json.Unmarshal(line, &req); err != nil {
1310 continue
1311 }
1312 if req.ID == nil {
1313 continue // notification: no response
1314 }
1315
1316 var result any
1317 switch req.Method {
1318 case "initialize":
1319 if initDelay > 0 {
1320 time.Sleep(initDelay)
1321 }
1322 caps := map[string]any{}
1323 if os.Getenv("GO_WANT_HELPER_PROMPTS") == "1" {
1324 caps["prompts"] = map[string]any{}
1325 }
1326 result = map[string]any{
1327 "protocolVersion": protocolVersion,
1328 "serverInfo": map[string]any{"name": "mock", "version": "0"},
1329 "capabilities": caps,
1330 }
1331 case "prompts/list":
1332 if ms := os.Getenv("GO_WANT_HELPER_PROMPT_DELAY_MS"); ms != "" {
1333 if v, err := time.ParseDuration(ms + "ms"); err == nil && v > 0 {
1334 time.Sleep(v)
1335 }
1336 }
1337 result = map[string]any{"prompts": []map[string]any{{
1338 "name": "hello",
1339 "description": "say hi",
1340 "arguments": []map[string]any{},
1341 }}}
1342 case "tools/list":
1343 result = map[string]any{"tools": []map[string]any{{
1344 "name": "zed",
1345 "description": "Sorted after echo.",
1346 "inputSchema": map[string]any{"type": "object"},
1347 }, {
1348 "name": "echo",
1349 "description": "Echo back the message.",
1350 "inputSchema": map[string]any{
1351 "type": "object",
1352 "properties": map[string]any{"msg": map[string]any{"type": "string"}},
1353 "required": []string{"z", "msg"},
1354 },
1355 }}}
1356 case "tools/call":
1357 incrementHelperCounter(os.Getenv("GO_WANT_HELPER_CALL_COUNT"))
1358 var p struct {
1359 Arguments struct {
1360 Msg string `json:"msg"`
1361 } `json:"arguments"`
1362 }
1363 _ = json.Unmarshal(req.Params, &p)
1364 result = map[string]any{"content": []map[string]any{
1365 {"type": "text", "text": "echo: " + p.Arguments.Msg},
1366 }}
1367 }
1368
1369 resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID, "result": result}
1370 b, _ := json.Marshal(resp)
1371 os.Stdout.Write(append(b, '\n'))
1372 }
1373 }
1374
1375 func incrementHelperCounter(path string) int {
1376 if strings.TrimSpace(path) == "" {
1377 return 0
1378 }
1379 value := 0
1380 if body, err := os.ReadFile(path); err == nil {
1381 value, _ = strconv.Atoi(strings.TrimSpace(string(body)))
1382 }
1383 value++
1384 _ = os.WriteFile(path, []byte(strconv.Itoa(value)), 0o600)
1385 return value
1386 }
1387
1388 func readHelperCounter(t *testing.T, path string) int {
1389 t.Helper()
1390 body, err := os.ReadFile(path)
1391 if errors.Is(err, os.ErrNotExist) {
1392 return 0
1393 }
1394 if err != nil {
1395 t.Fatal(err)
1396 }
1397 value, err := strconv.Atoi(strings.TrimSpace(string(body)))
1398 if err != nil {
1399 t.Fatalf("parse helper counter %q: %v", body, err)
1400 }
1401 return value
1402 }
1403
1404 func findToolByName(tools []tool.Tool, name string) tool.Tool {
1405 for _, candidate := range tools {
1406 if candidate.Name() == name {
1407 return candidate
1408 }
1409 }
1410 return nil
1411 }
1412
1413 func TestStdioWriterPreservesPersistentProcessByDefault(t *testing.T) {
1414 stateDir := t.TempDir()
1415 startCount := filepath.Join(t.TempDir(), "starts")
1416 callCount := filepath.Join(t.TempDir(), "calls")
1417 spec := Spec{
1418 Name: "stateful-writer", Command: os.Args[0], Args: []string{"-test.run=TestHelperProcess", "--"},
1419 Env: map[string]string{
1420 "GO_WANT_HELPER_PROCESS": "1",
1421 "GO_WANT_HELPER_START_COUNT": startCount,
1422 "GO_WANT_HELPER_CALL_COUNT": callCount,
1423 },
1424 StateDir: stateDir,
1425 }
1426 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1427 defer cancel()
1428 host, tools, err := StartAll(ctx, []Spec{spec})
1429 if err != nil {
1430 t.Fatal(err)
1431 }
1432 defer host.Close()
1433 writer := findToolByName(tools, "mcp__stateful-writer__echo")
1434 if writer == nil {
1435 t.Fatalf("writer tool missing from %v", toolNames(tools))
1436 }
1437 if _, err := writer.Execute(ctx, json.RawMessage(`{"msg":"one","z":"ok"}`)); err != nil {
1438 t.Fatal(err)
1439 }
1440 if _, err := writer.Execute(ctx, json.RawMessage(`{"msg":"two","z":"ok"}`)); err != nil {
1441 t.Fatal(err)
1442 }
1443 if got := readHelperCounter(t, startCount); got != 1 {
1444 t.Fatalf("process starts = %d, want one persistent MCP process", got)
1445 }
1446 if got := readHelperCounter(t, callCount); got != 2 {
1447 t.Fatalf("tool calls = %d, want two calls on the persistent process", got)
1448 }
1449 }
1450
1451 func TestValidateMCPToolNamesRejectsAmbiguousLists(t *testing.T) {
1452 for name, tools := range map[string][]mcpTool{
1453 "empty": {{Name: " "}},
1454 "duplicate": {{Name: "read"}, {Name: "read"}},
1455 } {
1456 t.Run(name, func(t *testing.T) {
1457 if err := validateMCPToolNames(tools); err == nil {
1458 t.Fatalf("validateMCPToolNames(%+v) succeeded", tools)
1459 }
1460 })
1461 }
1462 }
1463
1464 func TestNormalizeIdentityURLPreservesEndpointSemantics(t *testing.T) {
1465 a := normalizeIdentityURL("HTTPS://alice:secret@Example.COM:443/mcp?access_token=abc&workspace=one#fragment")
1466 b := normalizeIdentityURL("https://bob:rotated@example.com/mcp?workspace=two&access_token=xyz")
1467 if a == b {
1468 t.Fatalf("different endpoint credentials/query values collapsed to one identity URL: %q", a)
1469 }
1470 if strings.Contains(a, "#fragment") {
1471 t.Fatalf("identity URL retained non-semantic fragment: %q", a)
1472 }
1473 }
1474
1475 func TestWorkspaceIdentityIgnoresHostPolicyChanges(t *testing.T) {
1476 base := Spec{
1477 Name: "custom", Command: os.Args[0], ConfigSource: "workspace_config",
1478 Sandbox: sandbox.Spec{Mode: "enforce", ForbidReadRoots: []string{"/secret/a"}},
1479 }
1480 changed := base
1481 changed.Sandbox.ForbidReadRoots = []string{"/secret/b"}
1482 a, err := projectLaunchIdentityDigest(context.Background(), base)
1483 if err != nil {
1484 t.Fatal(err)
1485 }
1486 b, err := projectLaunchIdentityDigest(context.Background(), changed)
1487 if err != nil {
1488 t.Fatal(err)
1489 }
1490 if a != b {
1491 t.Fatal("host sandbox policy change altered stable server identity")
1492 }
1493 }
1494
1495 func TestProjectLaunchApprovalBlocksBeforeProcessStart(t *testing.T) {
1496 redirectCache(t)
1497 startCount := filepath.Join(t.TempDir(), "starts")
1498 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
1499 spec := Spec{
1500 Name: "project-server", Command: os.Args[0], Args: []string{"-test.run=TestHelperProcess", "--"},
1501 Env: map[string]string{
1502 "GO_WANT_HELPER_PROCESS": "1",
1503 "GO_WANT_HELPER_START_COUNT": startCount,
1504 },
1505 LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
1506 }
1507 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1508 defer cancel()
1509
1510 if _, _, err := StartAll(ctx, []Spec{spec}); err == nil || !strings.Contains(err.Error(), "until the user authorizes") {
1511 t.Fatalf("unauthorized project start error = %v", err)
1512 }
1513 if got := readHelperCounter(t, startCount); got != 0 {
1514 t.Fatalf("unauthorized project starts = %d, want 0", got)
1515 }
1516 if err := AuthorizeSpecLaunch(ctx, spec); err != nil {
1517 t.Fatal(err)
1518 }
1519 if got := readHelperCounter(t, startCount); got != 0 {
1520 t.Fatalf("launch authorization started project %d times, want 0", got)
1521 }
1522 host, tools, err := StartAll(ctx, []Spec{spec})
1523 if err != nil {
1524 t.Fatal(err)
1525 }
1526 if len(tools) == 0 {
1527 t.Fatal("authorized project server returned no tools")
1528 }
1529 host.Close()
1530 if got := readHelperCounter(t, startCount); got != 1 {
1531 t.Fatalf("post-authorization starts = %d, want 1", got)
1532 }
1533 }
1534
1535 func TestAuthorizeSpecLaunchRecordsInstallConsentWithoutStartingServer(t *testing.T) {
1536 redirectCache(t)
1537 startCount := filepath.Join(t.TempDir(), "starts")
1538 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
1539 spec := Spec{
1540 Name: "installed-project-server", Command: os.Args[0], Args: []string{"-test.run=TestHelperProcess", "--"},
1541 Env: map[string]string{
1542 "GO_WANT_HELPER_PROCESS": "1",
1543 "GO_WANT_HELPER_START_COUNT": startCount,
1544 },
1545 LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
1546 }
1547 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1548 defer cancel()
1549
1550 if err := AuthorizeSpecLaunch(ctx, spec); err != nil {
1551 t.Fatalf("AuthorizeSpecLaunch: %v", err)
1552 }
1553 if resolved := ResolveStoredAuthorization(ctx, spec); !resolved.ServerAuthorized() {
1554 t.Fatal("stored project launch grant did not resolve server authorization")
1555 }
1556 if got := readHelperCounter(t, startCount); got != 0 {
1557 t.Fatalf("install authorization started server %d times, want 0", got)
1558 }
1559 identity, err := projectLaunchIdentityDigest(ctx, spec)
1560 if err != nil {
1561 t.Fatal(err)
1562 }
1563 authorized, changed, err := manager.LaunchAuthorized(spec.Name, spec.ConfigSource, identity)
1564 if err != nil || !authorized || changed {
1565 t.Fatalf("installed launch grant = (authorized=%v changed=%v err=%v)", authorized, changed, err)
1566 }
1567 host, tools, err := StartAll(ctx, []Spec{spec})
1568 if err != nil {
1569 t.Fatalf("start installed project server: %v", err)
1570 }
1571 defer host.Close()
1572 if len(tools) == 0 {
1573 t.Fatal("installed project server returned no tools")
1574 }
1575 }
1576
1577 func TestAuthorizeSpecLaunchDoesNotAddPersistentTransportRestrictions(t *testing.T) {
1578 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
1579 spec := Spec{
1580 Name: "installed-local-http", Type: "http", URL: "http://127.0.0.1:8080/mcp",
1581 LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
1582 }
1583 ctx := context.Background()
1584 if err := AuthorizeSpecLaunch(ctx, spec); err != nil {
1585 t.Fatalf("explicit install authorization: %v", err)
1586 }
1587 identity, err := projectLaunchIdentityDigest(ctx, spec)
1588 if err != nil {
1589 t.Fatal(err)
1590 }
1591 authorized, changed, err := manager.LaunchAuthorized(spec.Name, spec.ConfigSource, identity)
1592 if err != nil || !authorized || changed {
1593 t.Fatalf("installed local HTTP grant = (authorized=%v changed=%v err=%v)", authorized, changed, err)
1594 }
1595 }
1596
1597 func TestAuthorizeProjectSpecLaunchLocksMutableLauncherWithoutStartingServer(t *testing.T) {
1598 manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
1599 launcher := filepath.Join(t.TempDir(), "npx")
1600 if runtime.GOOS == "windows" {
1601 launcher += ".exe"
1602 }
1603 if err := os.WriteFile(launcher, []byte("launcher fixture"), 0o755); err != nil {
1604 t.Fatal(err)
1605 }
1606 commit := "0123456789abcdef0123456789abcdef01234567"
1607 locator := "git+https://example.invalid/server.git@" + commit
1608 spec := Spec{
1609 Name: "repository-server", Command: launcher, Args: []string{locator},
1610 LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true,
1611 }
1612 if err := AuthorizeProjectSpecLaunch(context.Background(), spec); err != nil {
1613 t.Fatalf("AuthorizeProjectSpecLaunch: %v", err)
1614 }
1615 lock, found, err := manager.GetLauncherLock(spec.Name, digestText(locator))
1616 if err != nil || !found || lock.ResolvedVersion != commit {
1617 t.Fatalf("project launcher lock = (%+v, found=%v, err=%v)", lock, found, err)
1618 }
1619 locked, err := applyStoredLauncherLock(spec)
1620 if err != nil {
1621 t.Fatal(err)
1622 }
1623 identity, err := projectLaunchIdentityDigest(context.Background(), locked)
1624 if err != nil {
1625 t.Fatal(err)
1626 }
1627 authorized, changed, err := manager.LaunchAuthorized(spec.Name, spec.ConfigSource, identity)
1628 if err != nil || !authorized || changed {
1629 t.Fatalf("project launch grant = (authorized=%v changed=%v err=%v)", authorized, changed, err)
1630 }
1631 }
1632
1633 func TestReaderIntentRefusesDispatchAfterSafetyDrift(t *testing.T) {
1634 stateDir := t.TempDir()
1635 startCount := filepath.Join(t.TempDir(), "starts")
1636 callCount := filepath.Join(t.TempDir(), "calls")
1637 spec := Spec{
1638 Name: "reader-revoked", Command: os.Args[0], Args: []string{"-test.run=TestHelperProcess", "--"},
1639 Env: map[string]string{
1640 "GO_WANT_HELPER_PROCESS": "1",
1641 "GO_WANT_HELPER_START_COUNT": startCount,
1642 "GO_WANT_HELPER_CALL_COUNT": callCount,
1643 },
1644 StateDir: stateDir, Authorized: true,
1645 LaunchManager: mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), t.TempDir()),
1646 }
1647 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1648 defer cancel()
1649 host, tools, err := StartAll(ctx, []Spec{spec})
1650 if err != nil {
1651 t.Fatal(err)
1652 }
1653 defer host.Close()
1654 target := findToolByName(tools, "mcp__reader-revoked__echo")
1655 if target == nil {
1656 t.Fatalf("tool missing from %v", toolNames(tools))
1657 }
1658 rt, ok := target.(*remoteTool)
1659 if !ok {
1660 t.Fatalf("expected remoteTool adapter, got %T", target)
1661 }
1662
1663 // The installed server is authorized and currently advertises a reader.
1664 rt.client.toolsMu.Lock()
1665 rt.readOnly = true
1666 rt.client.toolsMu.Unlock()
1667 readerCtx := tool.WithReaderExecutionIntent(ctx)
1668 if _, _, err := rt.ExecuteWithImages(readerCtx, json.RawMessage(`{"msg":"ok","z":"ok"}`)); err != nil {
1669 t.Fatalf("authorized reader call failed: %v", err)
1670 }
1671 if got := readHelperCounter(t, startCount); got != 1 {
1672 t.Fatalf("reader call spawned extra processes: starts=%d", got)
1673 }
1674 if got := readHelperCounter(t, callCount); got != 1 {
1675 t.Fatalf("reader call count = %d, want 1", got)
1676 }
1677
1678 // A concurrent read-to-write classification change lands after authorization:
1679 // the reader-authorized call must refuse instead of issuing tools/call.
1680 rt.client.toolsMu.Lock()
1681 rt.readOnly = false
1682 rt.client.toolsMu.Unlock()
1683 if _, _, err := rt.ExecuteWithImages(readerCtx, json.RawMessage(`{"msg":"blocked","z":"ok"}`)); err == nil || !strings.Contains(err.Error(), "changed the authorization or security metadata") {
1684 t.Fatalf("changed reader call = %v, want reader refusal", err)
1685 }
1686 if got := readHelperCounter(t, startCount); got != 1 {
1687 t.Fatalf("revoked reader call started a writer process: starts=%d", got)
1688 }
1689 if got := readHelperCounter(t, callCount); got != 1 {
1690 t.Fatalf("revoked reader call reached tools/call: calls=%d", got)
1691 }
1692
1693 // Schema-only changes do not revoke an installed server or its reader lane.
1694 // The live server owns argument validation; refreshed provider-visible schema
1695 // bytes land in the next session rather than interrupting this call.
1696 rt.client.toolsMu.Lock()
1697 rt.readOnly = true
1698 rt.client.toolsMu.Unlock()
1699 rt.schema = json.RawMessage(`{"type":"object","properties":{"msg":{"type":"number"}}}`)
1700 if _, _, err := rt.ExecuteWithImages(readerCtx, json.RawMessage(`{"msg":"schema-changed","z":"ok"}`)); err != nil {
1701 t.Fatalf("schema-only reader change should execute: %v", err)
1702 }
1703 if got := readHelperCounter(t, callCount); got != 2 {
1704 t.Fatalf("schema-only reader call count = %d, want 2", got)
1705 }
1706
1707 // Without reader intent the ordinary writer path remains on the persistent
1708 // connection.
1709 rt.client.toolsMu.Lock()
1710 rt.readOnly = false
1711 rt.client.toolsMu.Unlock()
1712 if _, _, err := rt.ExecuteWithImages(ctx, json.RawMessage(`{"msg":"writer","z":"ok"}`)); err != nil {
1713 t.Fatalf("authorized writer call failed: %v", err)
1714 }
1715 if got := readHelperCounter(t, startCount); got != 1 {
1716 t.Fatalf("writer call starts = %d, want one persistent process", got)
1717 }
1718 if got := readHelperCounter(t, callCount); got != 3 {
1719 t.Fatalf("writer call count = %d, want 3", got)
1720 }
1721 }
1722
1722 lines GO