| 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 |