Files
Agent-System-Backend/internal/runmgr/project_source_recheck_test.go
T
agent-exec-transport 4c2db4c89b
governance / governance (pull_request) Successful in 17s
ci / vet-and-test (pull_request) Successful in 1m27s
agent-exec transport baad876f1320016310be2522c373a71b/9d3ed5593bfb
2026-10-22 03:03:31 +00:00

447 lines
20 KiB
Go

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