Files
CLIProxyAPI/internal/pluginhost/affinity_callbacks_test.go
Luis Pater 0796d6d133 feat(plugin): add host session affinity lookup callback
- Decode and validate host affinity lookup requests for provider, model, and session ID.
- Query the active auth manager for session affinity bindings and status.
- Return lookup responses containing the auth index, observation timestamp, and credential availability state.

Closes: #5604
2026-09-09 03:25:42 +08:00

533 lines
20 KiB
Go

package pluginhost
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
)
type mockPluginScheduler struct{}
func (m *mockPluginScheduler) PickAuth(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, error) {
return pluginapi.SchedulerPickResponse{}, false, nil
}
func TestHostAffinityLookupCallback_Contract(t *testing.T) {
host := New()
manager := coreauth.NewManager(nil, nil, nil)
selector := coreauth.NewSessionAffinitySelector(nil)
manager.SetSelector(selector)
host.SetAuthManager(manager)
authA := &coreauth.Auth{
ID: "claude-owner-a@example.com.json",
Provider: "anthropic",
FileName: "claude-owner-a@example.com.json",
Label: "auth-a",
Status: coreauth.StatusActive,
Metadata: map[string]any{
"type": "anthropic",
"api_key": "secret-api-key-a",
"email": "owner-a@example.com",
},
Attributes: map[string]string{
"path": "/etc/secrets/claude-owner-a@example.com.json",
},
}
authA.EnsureIndex()
authB := &coreauth.Auth{
ID: "claude-owner-b@example.com.json",
Provider: "anthropic",
FileName: "claude-owner-b@example.com.json",
Label: "auth-b",
Status: coreauth.StatusActive,
Metadata: map[string]any{
"type": "anthropic",
"api_key": "secret-api-key-b",
"email": "owner-b@example.com",
},
Attributes: map[string]string{
"path": "/etc/secrets/claude-owner-b@example.com.json",
},
}
authB.EnsureIndex()
if _, errReg := manager.Register(context.Background(), authA); errReg != nil {
t.Fatalf("register authA: %v", errReg)
}
if _, errReg := manager.Register(context.Background(), authB); errReg != nil {
t.Fatalf("register authB: %v", errReg)
}
t.Run("absent binding returns unbound", func(t *testing.T) {
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: "non-existent-session",
})
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
if errCall != nil {
t.Fatalf("callFromPlugin error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.Status != pluginapi.HostAffinityStatusUnbound {
t.Fatalf("status = %q, want %q", resp.Status, pluginapi.HostAffinityStatusUnbound)
}
if resp.AuthIndex != "" {
t.Fatalf("unexpected auth in unbound response: %#v", resp)
}
})
t.Run("bound session lookup and payload hygiene without email leak", func(t *testing.T) {
opts := cliproxyexecutor.Options{
Headers: map[string][]string{"X-Claude-Code-Session-Id": {"bound-session-1"}},
Metadata: make(map[string]any),
}
picked, errPick := selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", opts, []*coreauth.Auth{authA})
if errPick != nil || picked.ID != authA.ID {
t.Fatalf("pick failed: auth=%v err=%v", picked, errPick)
}
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: "bound-session-1",
})
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
if errCall != nil {
t.Fatalf("callFromPlugin error = %v", errCall)
}
// Verify no secret, email, credential ID, or physical file path leaked in raw json
rawStr := string(rawResp)
for _, sensitive := range []string{"secret-api-key-a", "owner-a@example.com", "claude-owner-a@example.com.json", "/etc/secrets"} {
if strings.Contains(rawStr, sensitive) {
t.Fatalf("raw response leaked sensitive token %q: %s", sensitive, rawStr)
}
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.Status != pluginapi.HostAffinityStatusBound {
t.Fatalf("status = %q, want %q", resp.Status, pluginapi.HostAffinityStatusBound)
}
if resp.AuthIndex != authA.Index {
t.Fatalf("auth_index = %q, want %q", resp.AuthIndex, authA.Index)
}
if resp.Disabled || resp.Unavailable {
t.Fatalf("unexpected disabled/unavailable flags: disabled=%v unavailable=%v", resp.Disabled, resp.Unavailable)
}
if resp.ObservedAt.IsZero() {
t.Fatalf("observed_at is zero")
}
})
t.Run("thinking suffix normalization", func(t *testing.T) {
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet(high)",
SessionID: "bound-session-1",
})
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
if errCall != nil {
t.Fatalf("callFromPlugin error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.Status != pluginapi.HostAffinityStatusBound || resp.AuthIndex != authA.Index {
t.Fatalf("normalized thinking model lookup failed: %#v", resp)
}
})
t.Run("long session ID normalization (> 256 bytes)", func(t *testing.T) {
longSessionID := strings.Repeat("s", 252) // prefix "claude:" + 252 chars = 259 chars > 256
opts := cliproxyexecutor.Options{
Headers: map[string][]string{"X-Claude-Code-Session-Id": {longSessionID}},
Metadata: make(map[string]any),
}
picked, errPick := selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", opts, []*coreauth.Auth{authA})
if errPick != nil || picked.ID != authA.ID {
t.Fatalf("pick failed: auth=%v err=%v", picked, errPick)
}
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: longSessionID,
})
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
if errCall != nil {
t.Fatalf("callFromPlugin error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.Status != pluginapi.HostAffinityStatusBound || resp.AuthIndex != authA.Index {
t.Fatalf("long session lookup failed: %#v", resp)
}
})
t.Run("rebinding A to B reflects updated binding", func(t *testing.T) {
sessionID := "rebind-session"
opts := cliproxyexecutor.Options{
Headers: map[string][]string{"X-Claude-Code-Session-Id": {sessionID}},
Metadata: make(map[string]any),
}
picked, _ := selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", opts, []*coreauth.Auth{authA})
if picked.ID != authA.ID {
t.Fatalf("initial pick = %s, want %s", picked.ID, authA.ID)
}
// Rebind to authB
picked2, _ := selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", opts, []*coreauth.Auth{authB})
if picked2.ID != authB.ID {
t.Fatalf("second pick = %s, want %s", picked2.ID, authB.ID)
}
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: sessionID,
})
rawResp, _ := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
resp, _ := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if resp.Status != pluginapi.HostAffinityStatusBound || resp.AuthIndex != authB.Index {
t.Fatalf("lookup after rebinding: got index=%q status=%q, want index=%q", resp.AuthIndex, resp.Status, authB.Index)
}
})
t.Run("unknown subagent does not guess parent binding", func(t *testing.T) {
// Parent is bound to authA
parentSession := "parent-main-thread-100"
opts := cliproxyexecutor.Options{
Headers: map[string][]string{"Session-Id": {parentSession}},
Metadata: make(map[string]any),
}
selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", opts, []*coreauth.Auth{authA})
// Query unknown child subagent
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: "codex:" + parentSession + ":subagent-child-unknown",
})
rawResp, _ := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
resp, _ := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if resp.Status != pluginapi.HostAffinityStatusUnbound {
t.Fatalf("unknown subagent lookup status = %q, want %q", resp.Status, pluginapi.HostAffinityStatusUnbound)
}
})
t.Run("ambiguous unqualified session resolves to ambiguous across namespaces", func(t *testing.T) {
// Bind "ambig-sess" under claude to authA, and under codex to authB
optsClaude := cliproxyexecutor.Options{
Headers: map[string][]string{"X-Claude-Code-Session-Id": {"ambig-sess"}},
Metadata: make(map[string]any),
}
selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", optsClaude, []*coreauth.Auth{authA})
optsCodex := cliproxyexecutor.Options{
Headers: map[string][]string{"Session-Id": {"ambig-sess"}},
Metadata: make(map[string]any),
}
selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", optsCodex, []*coreauth.Auth{authB})
// Query bare "ambig-sess" without namespace
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: "ambig-sess",
})
rawResp, _ := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
resp, _ := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if resp.Status != pluginapi.HostAffinityStatusAmbiguous {
t.Fatalf("ambiguous session status = %q, want %q", resp.Status, pluginapi.HostAffinityStatusAmbiguous)
}
// Qualified queries resolve unambiguously
reqClaude, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: "claude:ambig-sess",
})
rawClaude, _ := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqClaude)
respClaude, _ := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawClaude)
if respClaude.Status != pluginapi.HostAffinityStatusBound || respClaude.AuthIndex != authA.Index {
t.Fatalf("qualified claude session status = %q index = %q, want bound %q", respClaude.Status, respClaude.AuthIndex, authA.Index)
}
})
t.Run("extended namespaces resolve bare ID (task, clientreq, geminicache, execution)", func(t *testing.T) {
testCases := []struct {
name string
opts cliproxyexecutor.Options
bareID string
qualID string
auth *coreauth.Auth
}{
{
name: "task header",
opts: cliproxyexecutor.Options{
Headers: map[string][]string{"X-Task-ID": {"task-xyz-1"}},
Metadata: make(map[string]any),
},
bareID: "task-xyz-1",
qualID: "task:task-xyz-1",
auth: authA,
},
{
name: "client request id header",
opts: cliproxyexecutor.Options{
Headers: map[string][]string{"X-Client-Request-Id": {"crid-abc-2"}},
Metadata: make(map[string]any),
},
bareID: "crid-abc-2",
qualID: "clientreq:crid-abc-2",
auth: authB,
},
{
name: "gemini cache payload",
opts: cliproxyexecutor.Options{
OriginalRequest: []byte(`{"cachedContent":"cached-gemini-3"}`),
Metadata: make(map[string]any),
},
bareID: "cached-gemini-3",
qualID: "geminicache:cached-gemini-3",
auth: authA,
},
{
name: "execution session metadata",
opts: cliproxyexecutor.Options{
Metadata: map[string]any{
cliproxyexecutor.ExecutionSessionMetadataKey: "exec-run-4",
},
},
bareID: "exec-run-4",
qualID: "execution:exec-run-4",
auth: authB,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
picked, errPick := selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", tc.opts, []*coreauth.Auth{authA, authB})
if errPick != nil || picked.ID != tc.auth.ID {
t.Fatalf("pick failed: auth=%v err=%v", picked, errPick)
}
// Query via bare ID
reqBare, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: tc.bareID,
})
rawBare, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqBare)
if errCall != nil {
t.Fatalf("callFromPlugin error = %v", errCall)
}
respBare, _ := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawBare)
if respBare.Status != pluginapi.HostAffinityStatusBound || respBare.AuthIndex != tc.auth.Index {
t.Fatalf("bare lookup %q: got status=%q index=%q, want bound index=%q", tc.bareID, respBare.Status, respBare.AuthIndex, tc.auth.Index)
}
// Query via qualified ID
reqQual, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: tc.qualID,
})
rawQual, _ := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqQual)
respQual, _ := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawQual)
if respQual.Status != pluginapi.HostAffinityStatusBound || respQual.AuthIndex != tc.auth.Index {
t.Fatalf("qual lookup %q: got status=%q index=%q, want bound index=%q", tc.qualID, respQual.Status, respQual.AuthIndex, tc.auth.Index)
}
})
}
// Conflict test: bind task:shared-clash to authA and claude:shared-clash to authB
optsClash1 := cliproxyexecutor.Options{
Headers: map[string][]string{"X-Task-ID": {"shared-clash"}},
Metadata: make(map[string]any),
}
selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", optsClash1, []*coreauth.Auth{authA})
optsClash2 := cliproxyexecutor.Options{
Headers: map[string][]string{"X-Claude-Code-Session-Id": {"shared-clash"}},
Metadata: make(map[string]any),
}
selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", optsClash2, []*coreauth.Auth{authB})
reqClash, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: "shared-clash",
})
rawClash, _ := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqClash)
respClash, _ := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawClash)
if respClash.Status != pluginapi.HostAffinityStatusAmbiguous {
t.Fatalf("clash lookup status = %q, want %q", respClash.Status, pluginapi.HostAffinityStatusAmbiguous)
}
})
t.Run("disabled credential returns bound with disabled flag", func(t *testing.T) {
sessionID := "disabled-cred-session"
opts := cliproxyexecutor.Options{
Headers: map[string][]string{"X-Claude-Code-Session-Id": {sessionID}},
Metadata: make(map[string]any),
}
selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", opts, []*coreauth.Auth{authA})
// Disable authA in manager
authA.Disabled = true
manager.Update(context.Background(), authA)
defer func() {
authA.Disabled = false
manager.Update(context.Background(), authA)
}()
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: sessionID,
})
rawResp, _ := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
resp, _ := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if resp.Status != pluginapi.HostAffinityStatusBound {
t.Fatalf("status = %q, want bound", resp.Status)
}
if !resp.Disabled {
t.Fatalf("expected disabled = true, got %v", resp.Disabled)
}
})
t.Run("unsupported when plugin scheduler active", func(t *testing.T) {
managerWithPluginSched := coreauth.NewManager(nil, nil, nil)
managerWithPluginSched.SetSelector(selector)
managerWithPluginSched.SetPluginScheduler(&mockPluginScheduler{})
hostWithPluginSched := New()
hostWithPluginSched.SetAuthManager(managerWithPluginSched)
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: "bound-session-1",
})
rawResp, _ := hostWithPluginSched.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
resp, _ := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if resp.Status != pluginapi.HostAffinityStatusUnsupported {
t.Fatalf("status with plugin scheduler = %q, want %q", resp.Status, pluginapi.HostAffinityStatusUnsupported)
}
})
t.Run("unsupported when selector is not session affinity", func(t *testing.T) {
managerNoAffinity := coreauth.NewManager(nil, nil, nil)
managerNoAffinity.SetSelector(&coreauth.RoundRobinSelector{})
hostNoAffinity := New()
hostNoAffinity.SetAuthManager(managerNoAffinity)
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: "bound-session-1",
})
rawResp, _ := hostNoAffinity.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
resp, _ := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if resp.Status != pluginapi.HostAffinityStatusUnsupported {
t.Fatalf("status with round robin = %q, want %q", resp.Status, pluginapi.HostAffinityStatusUnsupported)
}
})
t.Run("invalid input validation", func(t *testing.T) {
for _, tc := range []struct {
name string
req pluginapi.HostAffinityLookupRequest
}{
{"empty provider", pluginapi.HostAffinityLookupRequest{Provider: "", Model: "m", SessionID: "s"}},
{"empty model", pluginapi.HostAffinityLookupRequest{Provider: "p", Model: "", SessionID: "s"}},
{"empty session", pluginapi.HostAffinityLookupRequest{Provider: "p", Model: "m", SessionID: ""}},
} {
t.Run(tc.name, func(t *testing.T) {
reqPayload, _ := json.Marshal(tc.req)
_, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
if errCall == nil {
t.Fatalf("expected error for %s, got nil", tc.name)
}
})
}
})
t.Run("LCP session binding observation", func(t *testing.T) {
lcpOpts := cliproxyexecutor.Options{
OriginalRequest: []byte(`{"messages":[{"role":"system","content":"host-lcp-sys"},{"role":"user","content":"host-lcp-user"}]}`),
Metadata: map[string]any{
cliproxyexecutor.CallerScopeMetadataKey: "plugin-caller",
},
}
picked, errPick := selector.Pick(context.Background(), "anthropic", "claude-3-7-sonnet", lcpOpts, []*coreauth.Auth{authA})
if errPick != nil || picked.ID != authA.ID {
t.Fatalf("LCP Pick failed: %v", errPick)
}
lcpSessionID, _ := lcpOpts.Metadata[cliproxyexecutor.LCPAffinitySessionIDMetadataKey].(string)
if lcpSessionID == "" {
t.Fatalf("missing LCP session ID in metadata")
}
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: lcpSessionID,
})
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
if errCall != nil {
t.Fatalf("callFromPlugin error = %v", errCall)
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if errDecode != nil {
t.Fatalf("decode response: %v", errDecode)
}
if resp.Status != pluginapi.HostAffinityStatusBound || resp.AuthIndex != authA.Index {
t.Fatalf("LCP observation failed: got status=%q index=%q, want bound %q", resp.Status, resp.AuthIndex, authA.Index)
}
})
t.Run("concurrent reads", func(t *testing.T) {
reqPayload, _ := json.Marshal(pluginapi.HostAffinityLookupRequest{
Provider: "anthropic",
Model: "claude-3-7-sonnet",
SessionID: "bound-session-1",
})
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
rawResp, errCall := host.callFromPlugin(context.Background(), pluginabi.MethodHostAffinityLookup, reqPayload)
if errCall != nil {
t.Errorf("concurrent callFromPlugin error: %v", errCall)
return
}
resp, errDecode := decodeRPCEnvelope[pluginapi.HostAffinityLookupResponse](rawResp)
if errDecode != nil || resp.Status != pluginapi.HostAffinityStatusBound {
t.Errorf("concurrent response error: %#v %v", resp, errDecode)
}
}()
}
wg.Wait()
})
}