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