返回 DeepSeek-Reasonix
dispatch_test.go
根目录 / internal / extension / dispatch / dispatch_test.go
1 package dispatch
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "reflect"
9 "strings"
10 "sync"
11 "testing"
12
13 "reasonix/internal/extension"
14 "reasonix/internal/extension/protocol"
15 "reasonix/internal/extension/sidecar"
16 )
17
18 // The production Client interface exists so the real sidecar client drops in
19 // without an adapter; pin that here so a signature drift fails the build.
20 var _ Client = (*sidecar.Client)(nil)
21
22 // testSecret is a credential shape secrets.RedactCredentials reliably masks
23 // (it appears in internal/secrets' own tests).
24 const testSecret = "sk-real-secret-value-123456"
25
26 func interceptor(pluginID string, point extension.InterceptorPoint, priority int) extension.Contribution {
27 return extension.Contribution{
28 Kind: extension.KindInterceptor,
29 ID: string(point),
30 Source: extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: pluginID, Origin: "extension-runtime"},
31 Priority: priority,
32 }
33 }
34
35 func owner(pluginID string) extension.ContributionSource {
36 return extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: pluginID, Origin: "extension-runtime"}
37 }
38
39 // buildDispatcher wires a dispatcher over the given fakes; a plugin absent
40 // from fakes resolves to a nil (untyped) client, mirroring the documented
41 // adapter contract.
42 func buildDispatcher(chain map[extension.InterceptorPoint][]extension.Contribution, replacements map[extension.Slot]extension.ContributionSource, fakes map[string]*fakeClient, required map[string]bool, warns *warnRecorder) *Dispatcher {
43 clients := func(pluginID string) Client {
44 if client := fakes[pluginID]; client != nil {
45 return client
46 }
47 return nil
48 }
49 return New(chain, replacements, clients, required, Options{Warn: warns.warn})
50 }
51
52 func timeoutError(pluginID string, point extension.InterceptorPoint) error {
53 return &protocol.ProtocolError{
54 Reason: protocol.ErrInterceptTimeout,
55 Message: fmt.Sprintf("extension %s did not answer %s within 5s", pluginID, point),
56 }
57 }
58
59 // pointCase describes one intercept point for the dispatch matrix.
60 type pointCase struct {
61 point extension.InterceptorPoint
62 sample func() any
63 replaceJSON string
64 checkReplaced func(t *testing.T, payload any)
65 violateJSON string
66 }
67
68 func userMessage(content string) protocol.ProviderMessage {
69 return protocol.ProviderMessage{Role: protocol.ProviderRoleUser, Content: content}
70 }
71
72 func pointCases() []pointCase {
73 cases := []pointCase{
74 {
75 point: extension.PointInputReceive,
76 sample: func() any { return &InputPayload{Text: "hello"} },
77 replaceJSON: `{"text":"rewritten"}`,
78 checkReplaced: func(t *testing.T, payload any) {
79 t.Helper()
80 if got := payload.(*InputPayload).Text; got != "rewritten" {
81 t.Fatalf("Text = %q, want %q", got, "rewritten")
82 }
83 },
84 violateJSON: `{"text":""}`,
85 },
86 {
87 point: extension.PointAgentBeforeStart,
88 sample: func() any { return &AgentStartPayload{Model: "openai/gpt-5", ToolCount: 3, SessionID: "s1"} },
89 replaceJSON: `{"model":"other/model","toolCount":7,"sessionId":"s1"}`,
90 checkReplaced: func(t *testing.T, payload any) {
91 t.Helper()
92 got := payload.(*AgentStartPayload)
93 if got.Model != "other/model" || got.ToolCount != 7 {
94 t.Fatalf("payload = %+v, want model other/model with 7 tools", got)
95 }
96 },
97 violateJSON: `{"model":"m"}`,
98 },
99 {
100 point: extension.PointSystemPromptBuild,
101 sample: func() any { return &SystemPromptPayload{Prompt: "base prompt", WorkspaceRoot: "/ws"} },
102 replaceJSON: `{"prompt":"owned prompt","workspaceRoot":"/ws"}`,
103 checkReplaced: func(t *testing.T, payload any) {
104 t.Helper()
105 if got := payload.(*SystemPromptPayload).Prompt; got != "owned prompt" {
106 t.Fatalf("Prompt = %q, want %q", got, "owned prompt")
107 }
108 },
109 violateJSON: `{"prompt":"x"}`,
110 },
111 {
112 point: extension.PointContextPrepare,
113 sample: func() any { return &ContextPayload{Messages: []protocol.ProviderMessage{userMessage("hi")}} },
114 replaceJSON: `{"messages":[{"role":"user","content":"replaced"}]}`,
115 checkReplaced: func(t *testing.T, payload any) {
116 t.Helper()
117 got := payload.(*ContextPayload)
118 if len(got.Messages) != 1 || got.Messages[0].Content != "replaced" {
119 t.Fatalf("Messages = %+v, want one replaced message", got.Messages)
120 }
121 },
122 violateJSON: `{}`,
123 },
124 {
125 point: extension.PointProviderRequest,
126 sample: func() any {
127 return &ProviderRequestPayload{Request: protocol.ProviderRequest{
128 Messages: []protocol.ProviderMessage{userMessage("q")},
129 Tools: []protocol.ProviderToolSchema{},
130 }}
131 },
132 replaceJSON: `{"request":{"messages":[{"role":"user","content":"q2"}],"tools":[],"maxTokens":99}}`,
133 checkReplaced: func(t *testing.T, payload any) {
134 t.Helper()
135 got := payload.(*ProviderRequestPayload)
136 if got.Request.MaxTokens != 99 || got.Request.Messages[0].Content != "q2" {
137 t.Fatalf("Request = %+v, want maxTokens 99 and replaced message", got.Request)
138 }
139 },
140 // tool parameters must be a JSON object, not an array.
141 violateJSON: `{"request":{"messages":[],"tools":[{"name":"t","parameters":[1]}]}}`,
142 },
143 {
144 point: extension.PointProviderResponse,
145 sample: func() any {
146 return &ProviderResponsePayload{Text: "answer", Usage: &protocol.ProviderUsage{PromptTokens: 1, TotalTokens: 2}}
147 },
148 replaceJSON: `{"text":"changed","calls":[{"id":"c1","name":"bash","arguments":"{}"}]}`,
149 checkReplaced: func(t *testing.T, payload any) {
150 t.Helper()
151 got := payload.(*ProviderResponsePayload)
152 if got.Text != "changed" || len(got.Calls) != 1 {
153 t.Fatalf("payload = %+v, want changed text with one call", got)
154 }
155 // Whole-value assignment: fields absent from the replacement
156 // must not leak the previous value through.
157 if got.Usage != nil {
158 t.Fatalf("Usage = %+v, want nil (replacement omitted it)", got.Usage)
159 }
160 },
161 violateJSON: `{"calls":[{"id":"","name":"x"}]}`,
162 },
163 {
164 point: extension.PointToolBefore,
165 sample: func() any { return &ToolBeforePayload{Name: "bash", Arguments: `{"cmd":"ls"}`} },
166 replaceJSON: `{"name":"bash","arguments":"{\"cmd\":\"pwd\"}"}`,
167 checkReplaced: func(t *testing.T, payload any) {
168 t.Helper()
169 if got := payload.(*ToolBeforePayload).Arguments; !strings.Contains(got, "pwd") {
170 t.Fatalf("Arguments = %q, want a pwd command", got)
171 }
172 },
173 violateJSON: `{"name":"bash","arguments":"not json"}`,
174 },
175 {
176 point: extension.PointToolAfter,
177 sample: func() any { return &ToolAfterPayload{Name: "bash", Arguments: `{"cmd":"ls"}`, Result: "out"} },
178 replaceJSON: `{"name":"bash","result":"new out"}`,
179 checkReplaced: func(t *testing.T, payload any) {
180 t.Helper()
181 got := payload.(*ToolAfterPayload)
182 if got.Result != "new out" || got.Arguments != "" {
183 t.Fatalf("payload = %+v, want new result and cleared arguments", got)
184 }
185 },
186 violateJSON: `{}`,
187 },
188 {
189 point: extension.PointPermissionDecision,
190 sample: func() any {
191 return &PermissionPayload{Name: "bash", Arguments: `{"cmd":"rm -rf x"}`, HostDecision: "deny"}
192 },
193 replaceJSON: `{"name":"bash","arguments":"{\"cmd\":\"ls\"}","hostDecision":"deny"}`,
194 checkReplaced: func(t *testing.T, payload any) {
195 t.Helper()
196 if got := payload.(*PermissionPayload).Arguments; !strings.Contains(got, "ls") {
197 t.Fatalf("Arguments = %q, want an ls command", got)
198 }
199 },
200 violateJSON: `{"name":"bash","hostDecision":"maybe"}`,
201 },
202 {
203 point: extension.PointCompactionPrepare,
204 sample: func() any {
205 return &CompactionPreparePayload{Messages: []protocol.ProviderMessage{userMessage("m")}, Guidance: "g"}
206 },
207 replaceJSON: `{"messages":[],"guidance":"new guidance"}`,
208 checkReplaced: func(t *testing.T, payload any) {
209 t.Helper()
210 got := payload.(*CompactionPreparePayload)
211 if got.Guidance != "new guidance" || got.Messages == nil || len(got.Messages) != 0 {
212 t.Fatalf("payload = %+v, want new guidance with an empty non-nil messages array", got)
213 }
214 },
215 violateJSON: `{}`,
216 },
217 {
218 point: extension.PointCompactionComplete,
219 sample: func() any { return &CompactionCompletePayload{Summary: "summary"} },
220 replaceJSON: `{"summary":"new summary"}`,
221 checkReplaced: func(t *testing.T, payload any) {
222 t.Helper()
223 if got := payload.(*CompactionCompletePayload).Summary; got != "new summary" {
224 t.Fatalf("Summary = %q, want %q", got, "new summary")
225 }
226 },
227 violateJSON: `{}`,
228 },
229 {
230 point: extension.PointFrontendEvent,
231 sample: func() any { return &FrontendEventPayload{Kind: "notice", Text: "t", Detail: "d"} },
232 replaceJSON: `{"kind":"notice","text":"replaced text"}`,
233 checkReplaced: func(t *testing.T, payload any) {
234 t.Helper()
235 if got := payload.(*FrontendEventPayload).Text; got != "replaced text" {
236 t.Fatalf("Text = %q, want %q", got, "replaced text")
237 }
238 },
239 violateJSON: `{}`,
240 },
241 }
242 for _, phase := range []string{PhaseStart, PhaseEnd, PhaseLoad, PhaseSave, PhaseRotate} {
243 phase := phase
244 point := extension.InterceptorPoint("session." + phase)
245 cases = append(cases, pointCase{
246 point: point,
247 sample: func() any { return &SessionPayload{SessionPath: "/tmp/s.json", Phase: phase} },
248 replaceJSON: fmt.Sprintf(`{"sessionPath":"/tmp/other.json","phase":%q}`, phase),
249 checkReplaced: func(t *testing.T, payload any) {
250 t.Helper()
251 got := payload.(*SessionPayload)
252 if got.SessionPath != "/tmp/other.json" || got.Phase != phase {
253 t.Fatalf("payload = %+v, want replaced path at phase %q", got, phase)
254 }
255 },
256 violateJSON: fmt.Sprintf(`{"sessionPath":"/x","phase":%q}`, "bogus"),
257 })
258 }
259 return cases
260 }
261
262 // TestInterceptMatrixContinue verifies all 17 points: a continue ruling
263 // passes the payload through unchanged.
264 func TestInterceptMatrixContinue(t *testing.T) {
265 for _, tc := range pointCases() {
266 t.Run(string(tc.point), func(t *testing.T) {
267 fake := &fakeClient{}
268 warns := &warnRecorder{}
269 d := buildDispatcher(
270 map[extension.InterceptorPoint][]extension.Contribution{tc.point: {interceptor("p1", tc.point, 0)}},
271 nil, map[string]*fakeClient{"p1": fake}, nil, warns)
272 payload := tc.sample()
273 result, err := d.Intercept(context.Background(), tc.point, payload)
274 if err != nil {
275 t.Fatalf("Intercept: %v", err)
276 }
277 if result.Blocked || result.Permission != nil || len(result.Applied) != 0 {
278 t.Fatalf("result = %+v, want a clean pass-through", result)
279 }
280 if !reflect.DeepEqual(payload, tc.sample()) {
281 t.Fatalf("payload = %+v, want unchanged %+v", payload, tc.sample())
282 }
283 if fake.interceptCount() != 1 {
284 t.Fatalf("intercept calls = %d, want 1", fake.interceptCount())
285 }
286 if warns.count() != 0 {
287 t.Fatalf("warns = %v, want none", warns.msgs)
288 }
289 })
290 }
291 }
292
293 // TestInterceptMatrixBlock verifies all 17 points: a block ruling stops the
294 // operation and the reason is credential-redacted.
295 func TestInterceptMatrixBlock(t *testing.T) {
296 for _, tc := range pointCases() {
297 t.Run(string(tc.point), func(t *testing.T) {
298 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
299 return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "denied, token " + testSecret}, nil
300 }}
301 warns := &warnRecorder{}
302 d := buildDispatcher(
303 map[extension.InterceptorPoint][]extension.Contribution{tc.point: {interceptor("p1", tc.point, 0)}},
304 nil, map[string]*fakeClient{"p1": fake}, nil, warns)
305 payload := tc.sample()
306 result, err := d.Intercept(context.Background(), tc.point, payload)
307 if err != nil {
308 t.Fatalf("Intercept: %v", err)
309 }
310 if !result.Blocked {
311 t.Fatalf("result = %+v, want blocked", result)
312 }
313 if strings.Contains(result.BlockReason, testSecret) {
314 t.Fatalf("BlockReason %q leaks the credential", result.BlockReason)
315 }
316 if !strings.Contains(result.BlockReason, "denied, token") {
317 t.Fatalf("BlockReason %q lost the human-readable reason", result.BlockReason)
318 }
319 if result.BlockReason == "denied, token "+testSecret {
320 t.Fatalf("BlockReason was not redacted at all")
321 }
322 })
323 }
324 }
325
326 // TestInterceptMatrixReplace verifies all 17 points: a replace ruling
327 // substitutes the payload and the caller observes the new value.
328 func TestInterceptMatrixReplace(t *testing.T) {
329 for _, tc := range pointCases() {
330 t.Run(string(tc.point), func(t *testing.T) {
331 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
332 return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(tc.replaceJSON)}, nil
333 }}
334 warns := &warnRecorder{}
335 d := buildDispatcher(
336 map[extension.InterceptorPoint][]extension.Contribution{tc.point: {interceptor("p1", tc.point, 0)}},
337 nil, map[string]*fakeClient{"p1": fake}, nil, warns)
338 payload := tc.sample()
339 result, err := d.Intercept(context.Background(), tc.point, payload)
340 if err != nil {
341 t.Fatalf("Intercept: %v", err)
342 }
343 tc.checkReplaced(t, payload)
344 if !reflect.DeepEqual(result.Applied, []string{"p1"}) {
345 t.Fatalf("Applied = %v, want [p1]", result.Applied)
346 }
347 if warns.count() != 0 {
348 t.Fatalf("warns = %v, want none", warns.msgs)
349 }
350 })
351 }
352 }
353
354 // TestInterceptMatrixInvalidReplace verifies all 17 points: a replacement
355 // with unknown fields or one that fails Validate is a protocol violation —
356 // optional extensions are warned about once and skipped (payload unchanged),
357 // required extensions fail the operation.
358 func TestInterceptMatrixInvalidReplace(t *testing.T) {
359 badPayloads := map[string]string{
360 "unknown field": `{"bogusField":1}`,
361 "failed validate": "", // filled per point from violateJSON
362 }
363 for _, tc := range pointCases() {
364 for name, bad := range badPayloads {
365 if name == "failed validate" {
366 bad = tc.violateJSON
367 }
368 t.Run(string(tc.point)+"/"+name+"_optional", func(t *testing.T) {
369 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
370 return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(bad)}, nil
371 }}
372 warns := &warnRecorder{}
373 d := buildDispatcher(
374 map[extension.InterceptorPoint][]extension.Contribution{tc.point: {interceptor("p1", tc.point, 0)}},
375 nil, map[string]*fakeClient{"p1": fake}, nil, warns)
376 payload := tc.sample()
377 result, err := d.Intercept(context.Background(), tc.point, payload)
378 if err != nil {
379 t.Fatalf("Intercept: optional violation must not fail, got %v", err)
380 }
381 if result.Blocked || len(result.Applied) != 0 {
382 t.Fatalf("result = %+v, want the ruling skipped", result)
383 }
384 if !reflect.DeepEqual(payload, tc.sample()) {
385 t.Fatalf("payload = %+v, want unchanged %+v", payload, tc.sample())
386 }
387 if warns.count() != 1 || !warns.contains("p1") {
388 t.Fatalf("warns = %v, want one warning naming p1", warns.msgs)
389 }
390 })
391 t.Run(string(tc.point)+"/"+name+"_required", func(t *testing.T) {
392 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
393 return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(bad)}, nil
394 }}
395 warns := &warnRecorder{}
396 d := buildDispatcher(
397 map[extension.InterceptorPoint][]extension.Contribution{tc.point: {interceptor("p1", tc.point, 0)}},
398 nil, map[string]*fakeClient{"p1": fake}, map[string]bool{"p1": true}, warns)
399 payload := tc.sample()
400 _, err := d.Intercept(context.Background(), tc.point, payload)
401 var violation *ViolationError
402 if !errors.As(err, &violation) {
403 t.Fatalf("err = %v (%T), want *ViolationError", err, err)
404 }
405 if violation.Plugin != "p1" || violation.Point != tc.point {
406 t.Fatalf("violation = %+v, want p1 at %s", violation, tc.point)
407 }
408 })
409 }
410 }
411 }
412
413 // TestInterceptMatrixAllowDenyRejected verifies the 16 non-permission points:
414 // allow/deny rulings there are a protocol violation.
415 func TestInterceptMatrixAllowDenyRejected(t *testing.T) {
416 for _, tc := range pointCases() {
417 if tc.point == extension.PointPermissionDecision {
418 continue
419 }
420 for _, decision := range []protocol.InterceptDecision{protocol.DecisionAllow, protocol.DecisionDeny} {
421 t.Run(string(tc.point)+"/"+string(decision)+"_optional", func(t *testing.T) {
422 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
423 return protocol.InterceptResult{Decision: decision}, nil
424 }}
425 warns := &warnRecorder{}
426 d := buildDispatcher(
427 map[extension.InterceptorPoint][]extension.Contribution{tc.point: {interceptor("p1", tc.point, 0)}},
428 nil, map[string]*fakeClient{"p1": fake}, nil, warns)
429 payload := tc.sample()
430 result, err := d.Intercept(context.Background(), tc.point, payload)
431 if err != nil {
432 t.Fatalf("Intercept: optional violation must not fail, got %v", err)
433 }
434 if result.Permission != nil {
435 t.Fatalf("Permission = %v, want nil outside permission.decision", *result.Permission)
436 }
437 if warns.count() != 1 || !warns.contains("only legal") {
438 t.Fatalf("warns = %v, want one warning about the illegal decision", warns.msgs)
439 }
440 })
441 t.Run(string(tc.point)+"/"+string(decision)+"_required", func(t *testing.T) {
442 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
443 return protocol.InterceptResult{Decision: decision}, nil
444 }}
445 warns := &warnRecorder{}
446 d := buildDispatcher(
447 map[extension.InterceptorPoint][]extension.Contribution{tc.point: {interceptor("p1", tc.point, 0)}},
448 nil, map[string]*fakeClient{"p1": fake}, map[string]bool{"p1": true}, warns)
449 payload := tc.sample()
450 _, err := d.Intercept(context.Background(), tc.point, payload)
451 var violation *ViolationError
452 if !errors.As(err, &violation) {
453 t.Fatalf("err = %v (%T), want *ViolationError", err, err)
454 }
455 if !strings.Contains(violation.Detail, "only legal") {
456 t.Fatalf("violation detail = %q, want the legality explanation", violation.Detail)
457 }
458 })
459 }
460 }
461 }
462
463 // TestInterceptChainOrder verifies three extensions observe replaced payloads
464 // in exact chain order (priority ascending dominates plugin ID).
465 func TestInterceptChainOrder(t *testing.T) {
466 point := extension.PointInputReceive
467 // Deliberately unordered, with priority order opposite to plugin-ID order.
468 contribs := extension.SortInterceptors([]extension.Contribution{
469 interceptor("zeta", point, 5),
470 interceptor("alpha", point, -3),
471 interceptor("mid", point, 0),
472 })
473 appendSelf := func(pluginID string) func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
474 return func(_ protocol.InterceptEvent, raw json.RawMessage) (protocol.InterceptResult, error) {
475 var payload InputPayload
476 if err := json.Unmarshal(raw, &payload); err != nil {
477 return protocol.InterceptResult{}, err
478 }
479 replacement, _ := json.Marshal(InputPayload{Text: payload.Text + ">" + pluginID})
480 return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: replacement}, nil
481 }
482 }
483 fakes := map[string]*fakeClient{
484 "alpha": {interceptFn: appendSelf("alpha")},
485 "mid": {interceptFn: appendSelf("mid")},
486 "zeta": {interceptFn: appendSelf("zeta")},
487 }
488 warns := &warnRecorder{}
489 d := buildDispatcher(map[extension.InterceptorPoint][]extension.Contribution{point: contribs}, nil, fakes, nil, warns)
490
491 payload := &InputPayload{Text: "start"}
492 result, err := d.Intercept(context.Background(), point, payload)
493 if err != nil {
494 t.Fatalf("Intercept: %v", err)
495 }
496 if want := "start>alpha>mid>zeta"; payload.Text != want {
497 t.Fatalf("Text = %q, want %q", payload.Text, want)
498 }
499 if want := []string{"alpha", "mid", "zeta"}; !reflect.DeepEqual(result.Applied, want) {
500 t.Fatalf("Applied = %v, want %v", result.Applied, want)
501 }
502 // Each extension observed exactly the value its predecessor produced.
503 wantSeen := map[string]string{"alpha": "start", "mid": "start>alpha", "zeta": "start>alpha>mid"}
504 for pluginID, want := range wantSeen {
505 observed := fakes[pluginID].observedPayloads()
506 if len(observed) != 1 {
507 t.Fatalf("%s observed %d payloads, want 1", pluginID, len(observed))
508 }
509 var seen InputPayload
510 if err := json.Unmarshal(observed[0], &seen); err != nil {
511 t.Fatalf("%s observed payload: %v", pluginID, err)
512 }
513 if seen.Text != want {
514 t.Fatalf("%s observed %q, want %q", pluginID, seen.Text, want)
515 }
516 }
517 }
518
519 func TestPermissionAllowOverridesHostDeny(t *testing.T) {
520 point := extension.PointPermissionDecision
521 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
522 return protocol.InterceptResult{Decision: protocol.DecisionAllow}, nil
523 }}
524 warns := &warnRecorder{}
525 d := buildDispatcher(
526 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("ext-sec", point, 0)}},
527 nil, map[string]*fakeClient{"ext-sec": fake}, nil, warns)
528 payload := &PermissionPayload{Name: "bash", Arguments: `{"cmd":"rm -rf x"}`, HostDecision: "deny"}
529 result, err := d.Intercept(context.Background(), point, payload)
530 if err != nil {
531 t.Fatalf("Intercept: %v", err)
532 }
533 if result.Permission == nil || !*result.Permission {
534 t.Fatalf("Permission = %v, want allow", result.Permission)
535 }
536 if len(result.Audit) != 1 || !strings.Contains(result.Audit[0], "ext-sec") || !strings.Contains(result.Audit[0], "host deny") {
537 t.Fatalf("Audit = %v, want one override note naming ext-sec", result.Audit)
538 }
539 }
540
541 func TestPermissionDeny(t *testing.T) {
542 point := extension.PointPermissionDecision
543 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
544 return protocol.InterceptResult{Decision: protocol.DecisionDeny}, nil
545 }}
546 warns := &warnRecorder{}
547 d := buildDispatcher(
548 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("ext-sec", point, 0)}},
549 nil, map[string]*fakeClient{"ext-sec": fake}, nil, warns)
550 payload := &PermissionPayload{Name: "bash", HostDecision: "allow"}
551 result, err := d.Intercept(context.Background(), point, payload)
552 if err != nil {
553 t.Fatalf("Intercept: %v", err)
554 }
555 if result.Permission == nil || *result.Permission {
556 t.Fatalf("Permission = %v, want deny", result.Permission)
557 }
558 if len(result.Audit) != 0 {
559 t.Fatalf("Audit = %v, want none for a deny", result.Audit)
560 }
561 }
562
563 func TestPermissionContinueLeavesHostDecision(t *testing.T) {
564 point := extension.PointPermissionDecision
565 fake := &fakeClient{}
566 warns := &warnRecorder{}
567 d := buildDispatcher(
568 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("ext-sec", point, 0)}},
569 nil, map[string]*fakeClient{"ext-sec": fake}, nil, warns)
570 payload := &PermissionPayload{Name: "bash", HostDecision: "deny"}
571 result, err := d.Intercept(context.Background(), point, payload)
572 if err != nil {
573 t.Fatalf("Intercept: %v", err)
574 }
575 if result.Permission != nil {
576 t.Fatalf("Permission = %v, want nil (host decision stands)", *result.Permission)
577 }
578 }
579
580 // TestPermissionFirstRulingTerminal verifies the first allow/deny ends the
581 // extension phase: later interceptors are never called.
582 func TestPermissionFirstRulingTerminal(t *testing.T) {
583 point := extension.PointPermissionDecision
584 first := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
585 return protocol.InterceptResult{Decision: protocol.DecisionAllow}, nil
586 }}
587 second := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
588 return protocol.InterceptResult{Decision: protocol.DecisionDeny}, nil
589 }}
590 warns := &warnRecorder{}
591 d := buildDispatcher(
592 map[extension.InterceptorPoint][]extension.Contribution{point: {
593 interceptor("aaa-first", point, 0), interceptor("zzz-second", point, 1),
594 }},
595 nil, map[string]*fakeClient{"aaa-first": first, "zzz-second": second}, nil, warns)
596 payload := &PermissionPayload{Name: "bash", HostDecision: "deny"}
597 result, err := d.Intercept(context.Background(), point, payload)
598 if err != nil {
599 t.Fatalf("Intercept: %v", err)
600 }
601 if result.Permission == nil || !*result.Permission {
602 t.Fatalf("Permission = %v, want the first ruling (allow)", result.Permission)
603 }
604 if second.interceptCount() != 0 {
605 t.Fatalf("second interceptor called %d times after a terminal ruling", second.interceptCount())
606 }
607 }
608
609 // TestPermissionBlock verifies block remains legal at permission.decision and
610 // reports a redacted reason.
611 func TestPermissionBlock(t *testing.T) {
612 point := extension.PointPermissionDecision
613 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
614 return protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "suspicious, token " + testSecret}, nil
615 }}
616 warns := &warnRecorder{}
617 d := buildDispatcher(
618 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("ext-sec", point, 0)}},
619 nil, map[string]*fakeClient{"ext-sec": fake}, nil, warns)
620 payload := &PermissionPayload{Name: "bash", HostDecision: "allow"}
621 result, err := d.Intercept(context.Background(), point, payload)
622 if err != nil {
623 t.Fatalf("Intercept: %v", err)
624 }
625 if !result.Blocked || result.Permission != nil {
626 t.Fatalf("result = %+v, want blocked with no permission ruling", result)
627 }
628 if strings.Contains(result.BlockReason, testSecret) {
629 t.Fatalf("BlockReason %q leaks the credential", result.BlockReason)
630 }
631 }
632
633 func TestStrategyOwnerReplacesSystemPrompt(t *testing.T) {
634 fake := &fakeClient{interceptFn: func(event protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
635 if event != protocol.EventSystemPromptBuild {
636 t.Errorf("strategy event = %q, want %q", event, protocol.EventSystemPromptBuild)
637 }
638 return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(`{"prompt":"owned","workspaceRoot":"/ws"}`)}, nil
639 }}
640 warns := &warnRecorder{}
641 d := buildDispatcher(nil,
642 map[extension.Slot]extension.ContributionSource{extension.SlotSystemPrompt: owner("prompt-owner")},
643 map[string]*fakeClient{"prompt-owner": fake}, nil, warns)
644 payload := &SystemPromptPayload{Prompt: "host default", WorkspaceRoot: "/ws"}
645 if err := d.RunStrategy(context.Background(), extension.SlotSystemPrompt, extension.PointSystemPromptBuild, payload); err != nil {
646 t.Fatalf("RunStrategy: %v", err)
647 }
648 if payload.Prompt != "owned" {
649 t.Fatalf("Prompt = %q, want the owner's replacement", payload.Prompt)
650 }
651 }
652
653 // TestStrategyOwnerTimeoutIsFatal verifies a strategy owner's timeout always
654 // fails the operation (slot owners are required-class even without
655 // required:true).
656 func TestStrategyOwnerTimeoutIsFatal(t *testing.T) {
657 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
658 return protocol.InterceptResult{}, timeoutError("prompt-owner", extension.PointSystemPromptBuild)
659 }}
660 warns := &warnRecorder{}
661 d := buildDispatcher(nil,
662 map[extension.Slot]extension.ContributionSource{extension.SlotSystemPrompt: owner("prompt-owner")},
663 map[string]*fakeClient{"prompt-owner": fake}, nil, warns)
664 payload := &SystemPromptPayload{Prompt: "host default", WorkspaceRoot: "/ws"}
665 err := d.RunStrategy(context.Background(), extension.SlotSystemPrompt, extension.PointSystemPromptBuild, payload)
666 var failure *FailureError
667 if !errors.As(err, &failure) {
668 t.Fatalf("err = %v (%T), want *FailureError", err, err)
669 }
670 var protocolErr *protocol.ProtocolError
671 if !errors.As(err, &protocolErr) || protocolErr.Reason != protocol.ErrInterceptTimeout {
672 t.Fatalf("err = %v, want the wrapped intercept_timeout protocol error", err)
673 }
674 if payload.Prompt != "host default" {
675 t.Fatalf("Prompt = %q, want the host default untouched on failure", payload.Prompt)
676 }
677 }
678
679 // TestStrategyNoOwnerKeepsHostDefault verifies an unowned slot is a no-op.
680 func TestStrategyNoOwnerKeepsHostDefault(t *testing.T) {
681 warns := &warnRecorder{}
682 d := buildDispatcher(nil, nil, nil, nil, warns)
683 if _, ok := d.Strategy(extension.SlotSystemPrompt); ok {
684 t.Fatalf("Strategy reported an owner for an unowned slot")
685 }
686 payload := &SystemPromptPayload{Prompt: "host default", WorkspaceRoot: "/ws"}
687 if err := d.RunStrategy(context.Background(), extension.SlotSystemPrompt, extension.PointSystemPromptBuild, payload); err != nil {
688 t.Fatalf("RunStrategy: %v", err)
689 }
690 if payload.Prompt != "host default" {
691 t.Fatalf("Prompt = %q, want the host default", payload.Prompt)
692 }
693 }
694
695 // TestStrategyNonOwnerCannotClaim verifies chain membership at a point does
696 // not make an extension the strategy owner: only the Replacements owner gets
697 // the strategy call.
698 func TestStrategyNonOwnerCannotClaim(t *testing.T) {
699 point := extension.PointSystemPromptBuild
700 observer := &fakeClient{}
701 owned := &fakeClient{}
702 warns := &warnRecorder{}
703 d := buildDispatcher(
704 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("observer", point, 0)}},
705 map[extension.Slot]extension.ContributionSource{extension.SlotSystemPrompt: owner("prompt-owner")},
706 map[string]*fakeClient{"observer": observer, "prompt-owner": owned}, nil, warns)
707 client, ok := d.Strategy(extension.SlotSystemPrompt)
708 if !ok {
709 t.Fatalf("Strategy reported no owner")
710 }
711 if client != owned {
712 t.Fatalf("Strategy returned the wrong client: the chain observer must not claim the slot")
713 }
714 payload := &SystemPromptPayload{Prompt: "host default", WorkspaceRoot: "/ws"}
715 if err := d.RunStrategy(context.Background(), extension.SlotSystemPrompt, point, payload); err != nil {
716 t.Fatalf("RunStrategy: %v", err)
717 }
718 if observer.interceptCount() != 0 {
719 t.Fatalf("non-owner received %d strategy calls", observer.interceptCount())
720 }
721 if owned.interceptCount() != 1 {
722 t.Fatalf("owner received %d strategy calls, want 1", owned.interceptCount())
723 }
724 }
725
726 // TestStrategyRulingPolicy verifies strategy owners may only continue or
727 // replace; block is fatal with a redacted reason, allow/deny and invalid
728 // replacements are fatal contract violations.
729 func TestStrategyRulingPolicy(t *testing.T) {
730 point := extension.PointSystemPromptBuild
731 newDispatcher := func(answer protocol.InterceptResult) (*Dispatcher, *SystemPromptPayload) {
732 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
733 return answer, nil
734 }}
735 warns := &warnRecorder{}
736 d := buildDispatcher(nil,
737 map[extension.Slot]extension.ContributionSource{extension.SlotSystemPrompt: owner("prompt-owner")},
738 map[string]*fakeClient{"prompt-owner": fake}, nil, warns)
739 return d, &SystemPromptPayload{Prompt: "host default", WorkspaceRoot: "/ws"}
740 }
741
742 t.Run("continue_keeps_default", func(t *testing.T) {
743 d, payload := newDispatcher(protocol.InterceptResult{Decision: protocol.DecisionContinue})
744 if err := d.RunStrategy(context.Background(), extension.SlotSystemPrompt, point, payload); err != nil {
745 t.Fatalf("RunStrategy: %v", err)
746 }
747 if payload.Prompt != "host default" {
748 t.Fatalf("Prompt = %q, want the host default", payload.Prompt)
749 }
750 })
751 t.Run("block_is_fatal_and_redacted", func(t *testing.T) {
752 d, payload := newDispatcher(protocol.InterceptResult{Decision: protocol.DecisionBlock, Reason: "no, token " + testSecret})
753 err := d.RunStrategy(context.Background(), extension.SlotSystemPrompt, point, payload)
754 var blocked *BlockError
755 if !errors.As(err, &blocked) {
756 t.Fatalf("err = %v (%T), want *BlockError", err, err)
757 }
758 if strings.Contains(err.Error(), testSecret) {
759 t.Fatalf("block error %q leaks the credential", err)
760 }
761 })
762 t.Run("allow_is_violation", func(t *testing.T) {
763 d, payload := newDispatcher(protocol.InterceptResult{Decision: protocol.DecisionAllow})
764 err := d.RunStrategy(context.Background(), extension.SlotSystemPrompt, point, payload)
765 var violation *ViolationError
766 if !errors.As(err, &violation) {
767 t.Fatalf("err = %v (%T), want *ViolationError", err, err)
768 }
769 })
770 t.Run("invalid_replace_is_violation", func(t *testing.T) {
771 d, payload := newDispatcher(protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(`{"bogus":1}`)})
772 err := d.RunStrategy(context.Background(), extension.SlotSystemPrompt, point, payload)
773 var violation *ViolationError
774 if !errors.As(err, &violation) {
775 t.Fatalf("err = %v (%T), want *ViolationError", err, err)
776 }
777 if payload.Prompt != "host default" {
778 t.Fatalf("Prompt = %q, want the host default untouched", payload.Prompt)
779 }
780 })
781 }
782
783 // TestOptionalTimeoutWarnsOnce verifies an optional extension's timeout is
784 // warned about exactly once per process and skipped.
785 func TestOptionalTimeoutWarnsOnce(t *testing.T) {
786 point := extension.PointToolBefore
787 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
788 return protocol.InterceptResult{}, timeoutError("opt", point)
789 }}
790 warns := &warnRecorder{}
791 d := buildDispatcher(
792 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("opt", point, 0)}},
793 nil, map[string]*fakeClient{"opt": fake}, nil, warns)
794 for i := 0; i < 2; i++ {
795 payload := &ToolBeforePayload{Name: "bash", Arguments: `{"cmd":"ls"}`}
796 result, err := d.Intercept(context.Background(), point, payload)
797 if err != nil {
798 t.Fatalf("call %d: optional timeout must not fail, got %v", i, err)
799 }
800 if result.Blocked || len(result.Applied) != 0 {
801 t.Fatalf("call %d: result = %+v, want the extension skipped", i, result)
802 }
803 if payload.Name != "bash" {
804 t.Fatalf("call %d: payload changed to %+v", i, payload)
805 }
806 }
807 if warns.count() != 1 {
808 t.Fatalf("warns = %v, want exactly one warning across two timeouts", warns.msgs)
809 }
810 if !warns.contains("opt") || !warns.contains("skipping") {
811 t.Fatalf("warn %v must name the plugin and the skip", warns.msgs)
812 }
813 }
814
815 // TestRequiredTimeoutFails verifies a required extension's timeout fails the
816 // operation and preserves the frozen protocol error for errors.As.
817 func TestRequiredTimeoutFails(t *testing.T) {
818 point := extension.PointToolBefore
819 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
820 return protocol.InterceptResult{}, timeoutError("req", point)
821 }}
822 warns := &warnRecorder{}
823 d := buildDispatcher(
824 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("req", point, 0)}},
825 nil, map[string]*fakeClient{"req": fake}, map[string]bool{"req": true}, warns)
826 payload := &ToolBeforePayload{Name: "bash"}
827 _, err := d.Intercept(context.Background(), point, payload)
828 var failure *FailureError
829 if !errors.As(err, &failure) {
830 t.Fatalf("err = %v (%T), want *FailureError", err, err)
831 }
832 var protocolErr *protocol.ProtocolError
833 if !errors.As(err, &protocolErr) || protocolErr.Reason != protocol.ErrInterceptTimeout {
834 t.Fatalf("err = %v, want the wrapped intercept_timeout protocol error", err)
835 }
836 }
837
838 // TestSlotOwnerTimeoutFails verifies slot ownership alone (no required:true)
839 // upgrades an extension to required-class error policy.
840 func TestSlotOwnerTimeoutFails(t *testing.T) {
841 point := extension.PointInputReceive
842 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
843 return protocol.InterceptResult{}, timeoutError("ctx-owner", point)
844 }}
845 warns := &warnRecorder{}
846 d := buildDispatcher(
847 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("ctx-owner", point, 0)}},
848 map[extension.Slot]extension.ContributionSource{extension.SlotContext: owner("ctx-owner")},
849 map[string]*fakeClient{"ctx-owner": fake}, nil, warns)
850 payload := &InputPayload{Text: "hi"}
851 if _, err := d.Intercept(context.Background(), point, payload); err == nil {
852 t.Fatalf("slot owner's timeout must fail the operation")
853 }
854 if warns.count() != 0 {
855 t.Fatalf("warns = %v, want none for a required-class failure", warns.msgs)
856 }
857 }
858
859 // TestMissingClientPolicy verifies a chain member with no live sidecar client
860 // follows the same optional/required policy.
861 func TestMissingClientPolicy(t *testing.T) {
862 point := extension.PointInputReceive
863 t.Run("optional", func(t *testing.T) {
864 warns := &warnRecorder{}
865 d := buildDispatcher(
866 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("gone", point, 0)}},
867 nil, nil, nil, warns)
868 payload := &InputPayload{Text: "hi"}
869 if _, err := d.Intercept(context.Background(), point, payload); err != nil {
870 t.Fatalf("optional missing client must not fail, got %v", err)
871 }
872 if warns.count() != 1 {
873 t.Fatalf("warns = %v, want one warning", warns.msgs)
874 }
875 })
876 t.Run("required", func(t *testing.T) {
877 warns := &warnRecorder{}
878 d := buildDispatcher(
879 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("gone", point, 0)}},
880 nil, nil, map[string]bool{"gone": true}, warns)
881 payload := &InputPayload{Text: "hi"}
882 var failure *FailureError
883 if _, err := d.Intercept(context.Background(), point, payload); !errors.As(err, &failure) {
884 t.Fatalf("err = %v, want *FailureError", err)
885 }
886 })
887 }
888
889 // TestSessionPhaseMustMatchPoint verifies a session replacement whose phase
890 // disagrees with the dispatched point is a contract violation.
891 func TestSessionPhaseMustMatchPoint(t *testing.T) {
892 point := extension.PointSessionStart
893 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
894 return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: json.RawMessage(`{"sessionPath":"/x","phase":"end"}`)}, nil
895 }}
896 t.Run("optional", func(t *testing.T) {
897 warns := &warnRecorder{}
898 d := buildDispatcher(
899 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("p1", point, 0)}},
900 nil, map[string]*fakeClient{"p1": fake}, nil, warns)
901 payload := &SessionPayload{SessionPath: "/tmp/s.json", Phase: PhaseStart}
902 if _, err := d.Intercept(context.Background(), point, payload); err != nil {
903 t.Fatalf("optional violation must not fail, got %v", err)
904 }
905 if payload.SessionPath != "/tmp/s.json" {
906 t.Fatalf("payload = %+v, want unchanged", payload)
907 }
908 if !warns.contains("does not match") {
909 t.Fatalf("warns = %v, want the phase-mismatch explanation", warns.msgs)
910 }
911 })
912 t.Run("required", func(t *testing.T) {
913 warns := &warnRecorder{}
914 d := buildDispatcher(
915 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("p1", point, 0)}},
916 nil, map[string]*fakeClient{"p1": fake}, map[string]bool{"p1": true}, warns)
917 payload := &SessionPayload{SessionPath: "/tmp/s.json", Phase: PhaseStart}
918 var violation *ViolationError
919 if _, err := d.Intercept(context.Background(), point, payload); !errors.As(err, &violation) {
920 t.Fatalf("err = %v, want *ViolationError", err)
921 }
922 })
923 }
924
925 // TestEventNotifiesChainAndSlotObservers verifies fire-and-forget delivery to
926 // chain members and slot observers (deduplicated), best-effort on error.
927 func TestEventNotifiesChainAndSlotObservers(t *testing.T) {
928 point := extension.PointSystemPromptBuild
929 p1 := &fakeClient{}
930 p2 := &fakeClient{notifyFn: func(protocol.InterceptEvent, json.RawMessage) error {
931 return errors.New("notify blew up, token " + testSecret)
932 }}
933 p3 := &fakeClient{}
934 warns := &warnRecorder{}
935 d := buildDispatcher(
936 map[extension.InterceptorPoint][]extension.Contribution{point: {
937 interceptor("p1", point, 0), interceptor("p2", point, 1), interceptor("p3", point, 2),
938 }},
939 // p3 is both a chain member and the slot owner: it must be notified once.
940 map[extension.Slot]extension.ContributionSource{extension.SlotSystemPrompt: owner("p3")},
941 map[string]*fakeClient{"p1": p1, "p2": p2, "p3": p3}, nil, warns)
942 d.Event(point, &SystemPromptPayload{Prompt: "p", WorkspaceRoot: "/ws"})
943 for pluginID, fake := range map[string]*fakeClient{"p1": p1, "p2": p2, "p3": p3} {
944 if fake.notifyCount() != 1 {
945 t.Fatalf("%s notifyCount = %d, want 1", pluginID, fake.notifyCount())
946 }
947 }
948 if warns.count() != 1 || !warns.contains("p2") {
949 t.Fatalf("warns = %v, want one warning naming p2", warns.msgs)
950 }
951 if warns.contains(testSecret) {
952 t.Fatalf("warning leaks the credential: %v", warns.msgs)
953 }
954 }
955
956 // TestEventMarshalFailureWarns verifies an unmarshalable payload degrades to
957 // a warning instead of a panic.
958 func TestEventMarshalFailureWarns(t *testing.T) {
959 warns := &warnRecorder{}
960 d := buildDispatcher(nil, nil, nil, nil, warns)
961 d.Event(extension.PointInputReceive, make(chan int))
962 if warns.count() != 1 {
963 t.Fatalf("warns = %v, want one marshal-failure warning", warns.msgs)
964 }
965 }
966
967 // TestConcurrentDispatch hammers one Dispatcher from 32 goroutines; run with
968 // -race to prove the read-only dispatch path and the warn-once dedup are
969 // safe.
970 func TestConcurrentDispatch(t *testing.T) {
971 inputPoint := extension.PointInputReceive
972 toolPoint := extension.PointToolBefore
973 replacer := &fakeClient{interceptFn: func(_ protocol.InterceptEvent, raw json.RawMessage) (protocol.InterceptResult, error) {
974 var payload InputPayload
975 if err := json.Unmarshal(raw, &payload); err != nil {
976 return protocol.InterceptResult{}, err
977 }
978 replacement, _ := json.Marshal(InputPayload{Text: payload.Text + ">p2"})
979 return protocol.InterceptResult{Decision: protocol.DecisionReplace, Replacement: replacement}, nil
980 }}
981 failing := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
982 return protocol.InterceptResult{}, timeoutError("p3", inputPoint)
983 }}
984 fakes := map[string]*fakeClient{"p1": {}, "p2": replacer, "p3": failing}
985 warns := &warnRecorder{}
986 d := buildDispatcher(map[extension.InterceptorPoint][]extension.Contribution{
987 inputPoint: {interceptor("p1", inputPoint, 0), interceptor("p2", inputPoint, 1), interceptor("p3", inputPoint, 2)},
988 toolPoint: {interceptor("p1", toolPoint, 0)},
989 }, nil, fakes, nil, warns)
990
991 var wg sync.WaitGroup
992 errs := make(chan error, 32)
993 for i := 0; i < 32; i++ {
994 wg.Add(1)
995 go func(i int) {
996 defer wg.Done()
997 if i%2 == 0 {
998 payload := &InputPayload{Text: fmt.Sprintf("turn-%d", i)}
999 result, err := d.Intercept(context.Background(), inputPoint, payload)
1000 if err != nil {
1001 errs <- err
1002 return
1003 }
1004 if want := fmt.Sprintf("turn-%d>p2", i); payload.Text != want {
1005 errs <- fmt.Errorf("payload = %q, want %q", payload.Text, want)
1006 }
1007 if !reflect.DeepEqual(result.Applied, []string{"p2"}) {
1008 errs <- fmt.Errorf("Applied = %v, want [p2]", result.Applied)
1009 }
1010 } else {
1011 payload := &ToolBeforePayload{Name: "bash", Arguments: `{}`}
1012 if _, err := d.Intercept(context.Background(), toolPoint, payload); err != nil {
1013 errs <- err
1014 }
1015 }
1016 d.Event(inputPoint, &InputPayload{Text: "observed"})
1017 }(i)
1018 }
1019 wg.Wait()
1020 close(errs)
1021 for err := range errs {
1022 t.Fatal(err)
1023 }
1024 // p3 timed out from 16 goroutines but warns exactly once.
1025 if warns.count() != 1 {
1026 t.Fatalf("warns = %v, want one deduplicated warning", warns.msgs)
1027 }
1028 }
1029
1030 // TestFrozenInputs verifies New deep-copies its inputs: mutating the caller's
1031 // chain, replacements, or required map afterwards cannot change dispatch
1032 // behavior, and per-turn payloads never touch the frozen chain.
1033 func TestFrozenInputs(t *testing.T) {
1034 point := extension.PointInputReceive
1035 real := &fakeClient{}
1036 evil := &fakeClient{}
1037 chain := map[extension.InterceptorPoint][]extension.Contribution{
1038 point: {interceptor("real", point, 0)},
1039 }
1040 replacements := map[extension.Slot]extension.ContributionSource{extension.SlotSystemPrompt: owner("real")}
1041 required := map[string]bool{"real": true}
1042 warns := &warnRecorder{}
1043 d := New(chain, replacements, func(pluginID string) Client {
1044 if pluginID == "evil" {
1045 return evil
1046 }
1047 return real
1048 }, required, Options{Warn: warns.warn})
1049
1050 // Mutate every input after construction.
1051 chain[point][0] = interceptor("evil", point, 0)
1052 chain[point] = append(chain[point], interceptor("evil", point, 1))
1053 replacements[extension.SlotSystemPrompt] = owner("evil")
1054 delete(required, "real")
1055
1056 payload := &InputPayload{Text: "hi"}
1057 if _, err := d.Intercept(context.Background(), point, payload); err != nil {
1058 t.Fatalf("Intercept: %v", err)
1059 }
1060 if real.interceptCount() != 1 || evil.interceptCount() != 0 {
1061 t.Fatalf("intercepts real=%d evil=%d, want 1 and 0", real.interceptCount(), evil.interceptCount())
1062 }
1063 client, ok := d.Strategy(extension.SlotSystemPrompt)
1064 if !ok || client != real {
1065 t.Fatalf("Strategy owner changed after the replacements map was mutated")
1066 }
1067
1068 // The required set is frozen too: "real" still fails rather than warns.
1069 failing := New(
1070 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("real", point, 0)}},
1071 nil, func(string) Client {
1072 return &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
1073 return protocol.InterceptResult{}, timeoutError("real", point)
1074 }}
1075 }, map[string]bool{"real": true}, Options{Warn: warns.warn})
1076 if _, err := failing.Intercept(context.Background(), point, &InputPayload{Text: "hi"}); err == nil {
1077 t.Fatalf("required-class failure must fail the operation")
1078 }
1079 }
1080
1081 // TestPayloadTypeMismatch verifies a host programming error (wrong DTO for
1082 // the point) fails loudly instead of dispatching garbage.
1083 func TestPayloadTypeMismatch(t *testing.T) {
1084 warns := &warnRecorder{}
1085 d := buildDispatcher(nil, nil, nil, nil, warns)
1086 if _, err := d.Intercept(context.Background(), extension.PointInputReceive, &ToolBeforePayload{Name: "bash"}); err == nil {
1087 t.Fatalf("wrong payload type must fail")
1088 }
1089 if err := d.RunStrategy(context.Background(), extension.SlotSystemPrompt, extension.PointSystemPromptBuild, &InputPayload{}); err == nil {
1090 t.Fatalf("wrong strategy payload type must fail")
1091 }
1092 }
1093
1094 // TestRedactionInWarnings verifies sidecar error text surfaced through
1095 // warnings is credential-redacted.
1096 func TestRedactionInWarnings(t *testing.T) {
1097 point := extension.PointToolBefore
1098 fake := &fakeClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
1099 return protocol.InterceptResult{}, errors.New("boom, token " + testSecret)
1100 }}
1101 warns := &warnRecorder{}
1102 d := buildDispatcher(
1103 map[extension.InterceptorPoint][]extension.Contribution{point: {interceptor("opt", point, 0)}},
1104 nil, map[string]*fakeClient{"opt": fake}, nil, warns)
1105 if _, err := d.Intercept(context.Background(), point, &ToolBeforePayload{Name: "bash"}); err != nil {
1106 t.Fatalf("Intercept: %v", err)
1107 }
1108 if warns.contains(testSecret) {
1109 t.Fatalf("warning leaks the credential: %v", warns.msgs)
1110 }
1111 }
1112
1112 lines GO