package runmgr import ( "context" "crypto/sha256" "encoding/json" "errors" "fmt" "slices" "strings" "testing" "time" "agent-system-backend/internal/projectcontext" "agent-system-backend/pkg/extension" core "agent-system-core" ) type recheckFixture struct { rc *runCtx mgr *Manager run core.Run receipt extension.ProjectContextManifest current extension.ProjectContextBundle ref extension.ProjectEvidenceRef } func newRecheckFixture(t *testing.T) recheckFixture { t.Helper() st := openRecoveryStore(t) mgr := newRecoveryManager(t, st) conv := recoveryConversation(t, st) run := recoveryRun(t, st, conv, core.RunPending) mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil }) item := extension.ProjectContextItem{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true} ref := extension.ProjectEvidenceRef{ID: "ref-a", SourceID: "doc-a", Revision: "r1", Hash: "hd"} bundle := extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: run.ProjectID, Version: "v1", Watermark: "w1", AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1", Items: []extension.ProjectContextItem{item}, Evidence: []extension.ProjectEvidenceRef{ref}} _, receipt, err := projectcontext.Assemble(bundle, 900) if err != nil { t.Fatal(err) } receipt.Delivery = "responded" receipt.ReadEvidence = []extension.ProjectContextManifestEntry{{ReferenceID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, SourceHash: ref.Hash, InputSHA256: fmt.Sprintf("%x", sha256.Sum256([]byte("DETAIL_A")))}} mgr.SetProjectEvidenceReader(func(_ context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) { return extension.ProjectEvidence{Reference: req.Reference, Text: "DETAIL_A"}, nil }) return recheckFixture{rc: &runCtx{mgr: mgr, run: run, ctx: context.Background()}, mgr: mgr, run: run, receipt: receipt, current: bundle, ref: ref} } func (f recheckFixture) answer(bundle extension.ProjectContextBundle) { f.mgr.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { return extension.ProjectContextSourceRecheckResult{Bundle: bundle}, nil }) } func TestProjectSourceRecheckRequestCarriesOnlyTrustedIdentities(t *testing.T) { f := newRecheckFixture(t) f.mgr.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { return f.current, nil }) // A live request goal must not leak into the later source recheck. if _, _, err := f.rc.ProjectContext(context.Background(), "SECRET_GOAL_TEXT", 900); err != nil { t.Fatal(err) } var got extension.ProjectContextSourceRecheckRequest f.mgr.SetProjectContextSourceRechecker(func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { got = req return extension.ProjectContextSourceRecheckResult{Bundle: f.current}, nil }) if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err != nil { t.Fatalf("authorized recheck rejected: %v", err) } scope := got.Scope if scope.Actor != string(f.run.Owner) || scope.ProjectID != f.run.ProjectID || scope.ConversationID != f.run.ConversationID || scope.RunID != f.run.ID || scope.StepID != "draft" || scope.Goal != "" || scope.TargetID != "" { t.Fatalf("recheck scope is not the persisted run identity: %+v", scope) } if len(got.Included) != 1 || got.Included[0] != f.receipt.Included[0] || len(got.OfferedReferences) != 1 || got.OfferedReferences[0] != f.ref || len(got.ReadEvidence) != 1 || got.ReadEvidence[0] != f.receipt.ReadEvidence[0] { t.Fatalf("recheck request does not name exactly the recorded inputs: %+v", got) } raw, err := json.Marshal(got) if err != nil { t.Fatal(err) } for _, body := range []string{"SECRET_GOAL_TEXT", "AUTHORIZED_A", "DETAIL_A"} { if strings.Contains(string(raw), body) { t.Fatalf("recheck request carried body text %q", body) } } } func TestProjectSourceRecheckRejectsUnrelatedOrDuplicateSource(t *testing.T) { f := newRecheckFixture(t) receipt := f.receipt receipt.Included = append(receipt.Included, receipt.Included[0]) padded := f.current padded.Items = []extension.ProjectContextItem{f.current.Items[0], {SourceID: "source-x", Revision: "r9", Hash: "hx", Text: "UNRELATED"}} f.answer(padded) if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err == nil { t.Fatal("unrelated source filled the slot of a recorded input") } echoed := f.current echoed.Items = []extension.ProjectContextItem{{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "REPLACED"}} f.answer(echoed) if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err == nil { t.Fatal("provider echoed recorded identity over replaced text") } republished := f.current republished.PublicationEpoch = "pub-2" f.answer(republished) if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err == nil { t.Fatal("publication change accepted") } } func TestProjectSourceRecheckFailsClosedOnRefusalDenialAndCancellation(t *testing.T) { f := newRecheckFixture(t) f.answer(f.current) if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err != nil { t.Fatalf("authorized baseline rejected: %v", err) } f.mgr.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { return extension.ProjectContextSourceRecheckResult{Bundle: f.current}, errors.New("provider refused") }) if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err == nil { t.Fatal("provider refusal accepted") } denied := f.current denied.Status = extension.ProjectContextDenied f.answer(denied) if err := f.rc.recheckManifest(context.Background(), f.receipt, "draft"); err == nil { t.Fatal("denied bundle accepted") } ctx, cancel := context.WithCancel(context.Background()) f.mgr.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { cancel() return extension.ProjectContextSourceRecheckResult{Bundle: f.current}, nil }) if err := f.rc.recheckManifest(ctx, f.receipt, "draft"); !errors.Is(err, context.Canceled) { t.Fatalf("source recheck cancelled in flight was accepted: %v", err) } f.answer(f.current) ctx, cancel = context.WithCancel(context.Background()) f.mgr.SetProjectEvidenceReader(func(_ context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) { cancel() return extension.ProjectEvidence{Reference: req.Reference, Text: "DETAIL_A"}, nil }) if err := f.rc.recheckManifest(ctx, f.receipt, "draft"); !errors.Is(err, context.Canceled) { t.Fatalf("evidence re-read cancelled in flight was accepted: %v", err) } } func TestProjectSourceRecheckConcurrentCancellationFailsEveryWaiter(t *testing.T) { f := newRecheckFixture(t) const workers = 6 entered := make(chan struct{}, workers) release := make(chan struct{}) f.mgr.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { entered <- struct{}{} <-release return extension.ProjectContextSourceRecheckResult{Bundle: f.current}, nil }) ctx, cancel := context.WithCancel(context.Background()) errs := make(chan error, workers) for i := 0; i < workers; i++ { go func() { errs <- f.rc.recheckManifest(ctx, f.receipt, "draft") }() } timeout := time.After(10 * time.Second) for i := 0; i < workers; i++ { select { case <-entered: case <-timeout: t.Fatal("concurrent rechecks did not reach the provider") } } cancel() close(release) for i := 0; i < workers; i++ { if err := <-errs; !errors.Is(err, context.Canceled) { t.Fatalf("concurrent recheck survived cancellation: %v", err) } } } func TestProjectCrossGuardRechecksEarlierRoundAfterLaterSource(t *testing.T) { st := openRecoveryStore(t) mgr := newRecoveryManager(t, st) conv := recoveryConversation(t, st) run := recoveryRun(t, st, conv, core.RunPending) mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil }) items := map[string]extension.ProjectContextItem{ "source-a": {SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true}, "source-b": {SourceID: "source-b", Revision: "r1", Hash: "hb", Text: "PUBLIC_B", Required: true}, } withdrawn := map[string]bool{} bundleWith := func(projectID core.ID, sources ...string) extension.ProjectContextBundle { b := extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: projectID, AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1", Watermark: "w1"} for _, id := range sources { if !withdrawn[id] { b.Items = append(b.Items, items[id]) } } return b } mgr.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { if strings.Contains(req.Goal, "use A") { return bundleWith(req.ProjectID, "source-a"), nil } return bundleWith(req.ProjectID, "source-b"), nil }) var rechecked []string mgr.SetProjectContextSourceRechecker(func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { var ids []string for _, entry := range req.Included { ids = append(ids, entry.SourceID) rechecked = append(rechecked, entry.SourceID) } return extension.ProjectContextSourceRecheckResult{Bundle: bundleWith(req.Scope.ProjectID, ids...)}, nil }) rc := &runCtx{mgr: mgr, run: run, ctx: context.Background()} respond := func(goal, attempt string) extension.ProjectContextBundle { t.Helper() bundle, _, err := rc.ProjectContext(context.Background(), goal, 900) if err != nil { t.Fatal(err) } _, m, err := projectcontext.Assemble(bundle, 900) if err != nil { t.Fatal(err) } m.InputSHA256, m.AttemptID, m.Delivery = "input-"+attempt, attempt, "attempted" if err := rc.SaveContextManifest(context.Background(), m); err != nil { t.Fatal(err) } m.Delivery = "responded" if err := rc.SaveContextManifest(context.Background(), m); err != nil { t.Fatal(err) } return bundle } respond("use A", "round-a") bundleB := respond("now B", "round-b") if err := rc.ValidateProjectCrossGuard(context.Background(), bundleB); err != nil { t.Fatalf("authorized multi-round run rejected: %v", err) } slices.Sort(rechecked) if strings.Join(rechecked, ",") != "source-a,source-b" { t.Fatalf("cross guard did not recheck every earlier round: %q", rechecked) } withdrawn["source-a"] = true if err := rc.ValidateProjectCrossGuard(context.Background(), bundleB); err == nil { t.Fatal("withdrawn early-round source A passed because the last round used B") } } func TestProjectStepOutputRejectsUnsettledAttemptInSameStep(t *testing.T) { st := openRecoveryStore(t) m := newRecoveryManager(t, st) conv := recoveryConversation(t, st) run := recoveryRun(t, st, conv, core.RunPending) m.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil }) bundle := func(projectID core.ID) extension.ProjectContextBundle { return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: projectID, AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1", Watermark: "w1", Items: []extension.ProjectContextItem{{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true}}} } m.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { return bundle(req.ProjectID), nil }) m.SetProjectContextSourceRechecker(func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { return extension.ProjectContextSourceRecheckResult{Bundle: bundle(req.Scope.ProjectID)}, nil }) rc := &runCtx{mgr: m, run: run, ctx: context.Background()} started := time.Now().Add(-time.Second) current, _, err := rc.ProjectContext(context.Background(), "draft", 900) if err != nil { t.Fatal(err) } _, receipt, err := projectcontext.Assemble(current, 900) if err != nil { t.Fatal(err) } receipt.InputSHA256, receipt.AttemptID, receipt.WorkflowStepAttemptID = "input-1", "model-1", "engine-run/draft/1" scoped := projectcontext.WithResolveScope(context.Background(), "draft", "") for _, delivery := range []string{"attempted", "responded"} { receipt.Delivery = delivery if err := rc.SaveContextManifest(scoped, receipt); err != nil { t.Fatal(err) } } output := []byte("AUTHORIZED_A report") if err := rc.BindProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", started, output); err != nil { t.Fatal(err) } if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil { t.Fatalf("settled step rejected: %v", err) } later := receipt later.InputSHA256, later.AttemptID, later.Delivery = "input-2", "model-2", "attempted" if err := rc.SaveContextManifest(scoped, later); err != nil { t.Fatal(err) } if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err == nil { t.Fatal("step with an unanswered model attempt passed") } later.Delivery = "unknown" if err := rc.SaveContextManifest(scoped, later); err != nil { t.Fatal(err) } if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err == nil { t.Fatal("step mixing responded and unknown model attempts passed") } } func TestProjectSourceRecheckComparesMissingSourcesAsASet(t *testing.T) { f := newRecheckFixture(t) receipt := f.receipt receipt.Missing = []string{"source-b", "source-a"} // Order carries no authorization meaning, so a reordered list of the same // unresolved sources stays a pass. reordered := f.current reordered.Missing = []string{"source-a", "source-b"} f.answer(reordered) if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err != nil { t.Fatalf("reordered missing sources treated as a changed source set: %v", err) } if strings.Join(receipt.Missing, ",") != "source-b,source-a" || strings.Join(reordered.Missing, ",") != "source-a,source-b" { t.Fatalf("missing-source comparison mutated a caller slice: recorded=%q current=%q", receipt.Missing, reordered.Missing) } // Any difference other than order still fails closed. for name, missing := range map[string][]string{ "dropped": {"source-a"}, "added": {"source-a", "source-b", "source-c"}, "replaced": {"source-a", "source-z"}, "masked-by-dup": {"source-a", "source-a"}, "withdrawn": nil, } { changed := f.current changed.Missing = missing f.answer(changed) if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err == nil { t.Fatalf("changed missing-source set %q was accepted", name) } } f.answer(reordered) if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err != nil { t.Fatalf("unrelated failure left the authorized baseline broken: %v", err) } } func TestProjectStepOutputRejectsEvidenceRefWithChangedHash(t *testing.T) { st := openRecoveryStore(t) m := newRecoveryManager(t, st) conv := recoveryConversation(t, st) run := recoveryRun(t, st, conv, core.RunPending) m.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil }) ref := extension.ProjectEvidenceRef{ID: "ref-a", SourceID: "doc-a", Revision: "r1", Hash: "hd"} evidenceHash := ref.Hash bundle := func() extension.ProjectContextBundle { return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: run.ProjectID, Version: "v1", Watermark: "w1", AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1", Items: []extension.ProjectContextItem{{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true}}, Evidence: []extension.ProjectEvidenceRef{{ID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, Hash: evidenceHash}}} } m.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { return bundle(), nil }) m.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { return extension.ProjectContextSourceRecheckResult{Bundle: bundle()}, nil }) m.SetProjectEvidenceReader(func(_ context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) { return extension.ProjectEvidence{Reference: req.Reference, Text: "DETAIL_A"}, nil }) rc := &runCtx{mgr: m, run: run, ctx: context.Background()} current, _, err := rc.ProjectContext(context.Background(), "draft", 900) if err != nil { t.Fatal(err) } _, receipt, err := projectcontext.Assemble(current, 900) if err != nil { t.Fatal(err) } receipt.InputSHA256, receipt.AttemptID, receipt.WorkflowStepAttemptID = "input-1", "model-1", "engine-run/draft/1" receipt.ReadEvidence = []extension.ProjectContextManifestEntry{{ReferenceID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, SourceHash: ref.Hash, InputSHA256: fmt.Sprintf("%x", sha256.Sum256([]byte("DETAIL_A")))}} scoped := projectcontext.WithResolveScope(context.Background(), "draft", "") for _, delivery := range []string{"attempted", "responded"} { receipt.Delivery = delivery if err := rc.SaveContextManifest(scoped, receipt); err != nil { t.Fatal(err) } } output := []byte("AUTHORIZED_A report") if err := rc.BindProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", time.Now().Add(-time.Second), output); err != nil { t.Fatalf("unchanged evidence source rejected at binding: %v", err) } if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil { t.Fatalf("unchanged evidence source rejected at the critic boundary: %v", err) } // Same ReferenceID and revision, different source hash: a re-published or // substituted document must not inherit the recorded grant. evidenceHash = "hd2" if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err == nil { t.Fatal("same reference id with a changed source hash passed the critic boundary") } evidenceHash = ref.Hash if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil { t.Fatalf("restoring the recorded hash did not restore the verdict: %v", err) } } func TestSaveContextManifestRejectsUnrecheckableModelInput(t *testing.T) { st := openRecoveryStore(t) m := newRecoveryManager(t, st) conv := recoveryConversation(t, st) run := recoveryRun(t, st, conv, core.RunPending) rc := &runCtx{mgr: m, run: run, ctx: context.Background()} base := extension.ProjectContextManifest{ProjectID: run.ProjectID, AttemptID: "attempt-1", Delivery: "attempted", InputSHA256: "request-hash"} cases := map[string]func(*extension.ProjectContextManifest){ "included without source hash": func(m *extension.ProjectContextManifest) { m.Included = []extension.ProjectContextManifestEntry{{SourceID: "s1", Revision: "r1", InputSHA256: "in"}} }, "included without revision": func(m *extension.ProjectContextManifest) { m.Included = []extension.ProjectContextManifestEntry{{SourceID: "s1", SourceHash: "h1", InputSHA256: "in"}} }, "offered reference without id": func(m *extension.ProjectContextManifest) { m.OfferedReferences = []extension.ProjectEvidenceRef{{SourceID: "doc", Revision: "r1", Hash: "h1"}} }, "read evidence without reference": func(m *extension.ProjectContextManifest) { m.ReadEvidence = []extension.ProjectContextManifestEntry{{SourceID: "doc", Revision: "r1", SourceHash: "h1", InputSHA256: "in"}} }, } for name, mutate := range cases { manifest := base mutate(&manifest) if err := rc.SaveContextManifest(context.Background(), manifest); err == nil { t.Fatalf("%s admitted as recoverable model input", name) } } stored, err := st.ListContextManifests(string(run.Owner), string(run.ID)) if err != nil || len(stored) != 0 { t.Fatalf("unrecheckable model input wrote a receipt: %+v err=%v", stored, err) } }