diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md index 51153b48853a..02fc2276bf87 100644 --- a/.downstream/native-domains.md +++ b/.downstream/native-domains.md @@ -216,6 +216,12 @@ Optional `comparison: encoding` keeps the same current body, tier, identity, cookie and exit, and compares plaintext probes with the configured business codec. It changes no global setting or business qualification; actual per-shot encoding distinguishes an enabled zstd comparison from two plaintext samples. +The same probe-only scenario can select a configured source for one plaintext, +no-tier Astra pair against its own current live candidate. It cannot generate, +test another source model, acquire a replacement or substitute a different +candidate owner. `probe_context.subject_role` distinguishes source self-checks +from target checks. Self-check results do not qualify business traffic or mutate +the source's health/status. Ranxi reference: v2.10.2 (`d3e43f2de33af9e987cffa511d76dfabbcd749da`). Its five fingerprint, gateway-cookie, target-probe and automatic-setup core files are diff --git a/backend/internal/service/codex_gateway_borrow_diagnose.go b/backend/internal/service/codex_gateway_borrow_diagnose.go index 7d0cfa296856..cd5e980772c5 100644 --- a/backend/internal/service/codex_gateway_borrow_diagnose.go +++ b/backend/internal/service/codex_gateway_borrow_diagnose.go @@ -146,7 +146,11 @@ func (s *CodexGatewayBorrowService) Diagnose(ctx context.Context, request CodexB return errors.New("request_limit must be between 1 and 8") } cfg := s.ConfigSnapshot() - if !cfg.Enabled || !slices.Contains(cfg.TargetAccountIDs, request.AccountID) || !slices.Contains(cfg.Models, request.Model) { + sourceProbe := request.Scenario == "probe_contract" && request.Model == codexGatewayBorrowSourceModel && slices.Contains(cfg.SourceAccountIDs, request.AccountID) + if sourceProbe && (request.ServiceTier != "" || request.Comparison == "encoding") { + return errors.New("source self-check uses the current plaintext Astra template without a service tier") + } + if !cfg.Enabled || (!sourceProbe && (!slices.Contains(cfg.TargetAccountIDs, request.AccountID) || !slices.Contains(cfg.Models, request.Model))) { return errors.New("select a saved, enabled target and model") } account, err := s.accounts.GetByID(ctx, request.AccountID) @@ -166,7 +170,7 @@ func (s *CodexGatewayBorrowService) Diagnose(ctx context.Context, request CodexB ctx, cancel := borrowRevisionContext(WithAccountObservation(ctx), revisionCtx, 10*time.Minute) defer cancel() if request.Scenario == "probe_contract" { - return s.diagnoseProbeContract(ctx, revision, revisionCtx, request, limit, emit) + return s.diagnoseProbeContract(ctx, revision, revisionCtx, request, sourceProbe, limit, emit) } var requests atomic.Int32 modes := []bool{false, true} diff --git a/backend/internal/service/codex_gateway_borrow_diagnose_contract.go b/backend/internal/service/codex_gateway_borrow_diagnose_contract.go index 665cbe97dcbe..ea7ee4f12b42 100644 --- a/backend/internal/service/codex_gateway_borrow_diagnose_contract.go +++ b/backend/internal/service/codex_gateway_borrow_diagnose_contract.go @@ -27,6 +27,7 @@ const ( // a selected Sol model here is explicitly the downstream model extension. type CodexBorrowProbeContext struct { SourceAccountID int64 `json:"source_account_id"` + SubjectRole string `json:"subject_role"` SourceRequestEncoding string `json:"source_request_encoding,omitempty"` TargetProxyID *int64 `json:"target_proxy_id"` CookieFingerprint string `json:"cookie_fingerprint"` @@ -52,7 +53,7 @@ func borrowRanxiLiteralPayload(model, tier string) []byte { return []byte(body + `}`) } -func (s *CodexGatewayBorrowService) diagnoseProbeContract(ctx context.Context, revision uint64, revisionCtx context.Context, request CodexBorrowDiagnosticRequest, limit int, emit func(CodexBorrowDiagnosticEvent)) error { +func (s *CodexGatewayBorrowService) diagnoseProbeContract(ctx context.Context, revision uint64, revisionCtx context.Context, request CodexBorrowDiagnosticRequest, sourceProbe bool, limit int, emit func(CodexBorrowDiagnosticEvent)) error { var requests atomic.Int32 d := &codexBorrowDiagnostic{limit: int32(limit), requests: &requests, borrow: true, session: uuid.NewString(), serviceTier: request.ServiceTier, onRequest: func(count int32) { @@ -69,10 +70,16 @@ func (s *CodexGatewayBorrowService) diagnoseProbeContract(ctx context.Context, r return err // A blocked local tier must not consume a source or probe request. } finalTier := gjson.GetBytes(policyBody, "service_tier").String() + if sourceProbe && finalTier != "" { + return errors.New("local tier policy changes the source self-check template; no model request was sent") + } s.mu.Lock() candidate := s.candidate current := s.candidateCurrentLocked(revision, candidate) s.mu.Unlock() + if sourceProbe && (!current || candidate.sourceID != account.ID) { + return errors.New("source self-check requires its current live candidate; no different source is prepared or substituted") + } if !current { if err := s.prepareSource(ctx, revision, revisionCtx); err != nil { return err @@ -85,6 +92,10 @@ func (s *CodexGatewayBorrowService) diagnoseProbeContract(ctx context.Context, r if !current { return ErrCodexGatewayBorrowChanged } + role := "target" + if sourceProbe { + role = "source" + } // The snapshot cannot be silently replaced halfway through the comparison. ctx, cancel := context.WithDeadline(ctx, candidate.expires) defer cancel() @@ -106,6 +117,9 @@ func (s *CodexGatewayBorrowService) diagnoseProbeContract(ctx context.Context, r if request.Comparison == "encoding" { shapes = []string{codexBorrowProbePlain, codexBorrowProbeConfigured} } + if sourceProbe { + shapes = []string{codexBorrowProbeCurrent} + } for _, shape := range shapes { if err := ctx.Err(); err != nil { return err @@ -131,6 +145,7 @@ func (s *CodexGatewayBorrowService) diagnoseProbeContract(ctx context.Context, r result := CodexBorrowDiagnosticResult{Scenario: "probe_contract", Mode: "borrowed", ProbeOnly: true, Verification: &verification, DurationMS: time.Since(started).Milliseconds(), ReportedModel: verification.ReportedModel, ProbeContext: &CodexBorrowProbeContext{SourceAccountID: candidate.sourceID, TargetProxyID: account.ProxyID, + SubjectRole: role, SourceRequestEncoding: candidate.sourceRequestEncoding, CookieFingerprint: borrowHash(candidate.cookie.Value), CandidateExpiresAt: candidate.expires, TemplateFingerprint: identity, RequestedTier: request.ServiceTier, FinalTier: finalTier, RoutingHint: template.Header.Get(openAICodexRoutingHintHeader), diff --git a/backend/internal/service/codex_gateway_borrow_source_self_test.go b/backend/internal/service/codex_gateway_borrow_source_self_test.go new file mode 100644 index 000000000000..263dec23df6d --- /dev/null +++ b/backend/internal/service/codex_gateway_borrow_source_self_test.go @@ -0,0 +1,51 @@ +package service + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestCodexBorrowSourceSelfCheckUsesOnlyCurrentOwner(t *testing.T) { + var calls int + source := borrowCoreAccount(1) + s := newBorrowCoreTest(t, func(req *http.Request, _ string, id int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + calls++ + assert.EqualValues(t, 1, id) + assert.Equal(t, codexGatewayBorrowSourceModel, gjson.GetBytes(borrowCoreBody(t, req), "model").String()) + assert.Empty(t, req.Header.Get("Content-Encoding")) + return borrowCoreResponse(codexGatewayBorrowSourceModel, "OK", "source-state"), nil + }, source) + borrowCoreCandidate(s, time.Now().Add(time.Minute)) + var results []*CodexBorrowDiagnosticResult + err := s.Diagnose(context.Background(), CodexBorrowDiagnosticRequest{Scenario: "probe_contract", AccountID: 1, Model: codexGatewayBorrowSourceModel, Transport: "http"}, func(e CodexBorrowDiagnosticEvent) { + if e.Result != nil { + results = append(results, e.Result) + } + }) + require.NoError(t, err) + require.Equal(t, 2, calls) + for _, r := range results { + require.Equal(t, "source", r.ProbeContext.SubjectRole) + require.True(t, r.ProbeOnly) + require.False(t, r.Applied || r.Dispatched || r.Completed) + } + require.Empty(t, s.qualifications) + require.Empty(t, s.Status().RecentUsage) + for _, request := range []CodexBorrowDiagnosticRequest{ + {AccountID: 1, Model: codexGatewayBorrowSourceModel, Transport: "http", Mode: "borrowed"}, + {Scenario: "probe_contract", AccountID: 1, Model: "gpt-6.1-sol", Transport: "http"}, + } { + require.Error(t, s.Diagnose(context.Background(), request, func(CodexBorrowDiagnosticEvent) {})) + } + require.Equal(t, 2, calls, "source selection cannot enter generation or another model") + s.candidate = nil + require.Error(t, s.Diagnose(context.Background(), CodexBorrowDiagnosticRequest{Scenario: "probe_contract", AccountID: 1, Model: codexGatewayBorrowSourceModel, Transport: "http"}, func(CodexBorrowDiagnosticEvent) {})) + require.Equal(t, 2, calls, "a self-check does not silently prepare a replacement candidate") +}