agent-exec transport 412eff9b36afd457500606d468b89f94/0636a7e1c0f0
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 1m30s

This commit is contained in:
agent-exec-transport
2026-10-20 15:59:51 +00:00
parent b4399e3000
commit 8c410b9ea4
7 changed files with 549 additions and 2 deletions
+10
View File
@@ -69,6 +69,16 @@ func TestMigrateTrustedContextManifestWithoutRawScope(t *testing.T) {
if err := pg.DB().QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil || rawColumns != 0 { if err := pg.DB().QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil || rawColumns != 0 {
t.Fatalf("migrated PG schema has raw prompt columns: count=%d err=%v", rawColumns, err) t.Fatalf("migrated PG schema has raw prompt columns: count=%d err=%v", rawColumns, err)
} }
if err := cmdMigrate([]string{"--source", snapshot, "--target", target, "--migration-id", "context-copy"}); err != nil {
t.Fatalf("idempotent context migration rerun: %v", err)
}
receipts, err = pg.ListContextManifests("alice", "r1")
if err != nil || len(receipts) != 2 {
t.Fatalf("rerun duplicated or lost model receipts: count=%d err=%v", len(receipts), err)
}
if err := pg.DB().QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil || rawColumns != 0 {
t.Fatalf("rerun reintroduced raw prompt columns: count=%d err=%v", rawColumns, err)
}
} }
// U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例): // U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例):
@@ -0,0 +1,97 @@
package app
import (
"context"
"errors"
"testing"
"time"
"agent-system-backend/pkg/extension"
)
type scriptedRecheckProvider struct {
testProjectContextProvider
recheck func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error)
}
func (p scriptedRecheckProvider) Recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return p.recheck(ctx, req)
}
func registeredRecheckSlot(t *testing.T, recheck func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error)) *projectContextSlot {
t.Helper()
slot := &projectContextSlot{}
if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: slot}}).RegisterProjectContextProvider(scriptedRecheckProvider{recheck: recheck}); err != nil {
t.Fatal(err)
}
return slot
}
func TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling(t *testing.T) {
var remaining time.Duration
slot := registeredRecheckSlot(t, func(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
deadline, ok := ctx.Deadline()
if !ok {
return extension.ProjectContextSourceRecheckResult{}, errors.New("unbounded recheck")
}
remaining = time.Until(deadline)
select {
case <-ctx.Done():
case <-time.After(10 * time.Second):
}
// A late success must not authorize old machine context.
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.Scope.ProjectID}}, nil
})
started := time.Now()
_, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}})
if err == nil {
t.Fatal("source recheck past its deadline returned authorized context")
}
if remaining <= 0 || remaining > 3*time.Second {
t.Fatalf("source recheck deadline is not bounded by 3s: %v", remaining)
}
if elapsed := time.Since(started); elapsed > 5*time.Second {
t.Fatalf("source recheck outlived its 3s ceiling: %v", elapsed)
}
}
func TestProjectSourceRecheckRejectsProviderRefusalAndForeignBundles(t *testing.T) {
scope := extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}
answers := map[string]func(extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error){
"refused": func(extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return extension.ProjectContextSourceRecheckResult{}, errors.New("provider refused")
},
"denied": func(req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextDenied, ProjectID: req.Scope.ProjectID}}, nil
},
"incomplete": func(req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextIncomplete, ProjectID: req.Scope.ProjectID}}, nil
},
"other project": func(extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "p2"}}, nil
},
}
for name, answer := range answers {
slot := registeredRecheckSlot(t, func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
return answer(req)
})
if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: scope}); err == nil {
t.Fatalf("%s source recheck accepted", name)
}
}
var sawPlugin string
slot := registeredRecheckSlot(t, func(_ context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
sawPlugin = req.Scope.PluginID
return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.Scope.ProjectID}}, nil
})
forged := scope
forged.PluginID = "forged-plugin"
if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: forged}); err != nil || sawPlugin != "rdm" {
t.Fatalf("recheck scope plugin was not the registered holder: plugin=%q err=%v", sawPlugin, err)
}
for _, missing := range []extension.ProjectContextRequest{{ProjectID: "p1"}, {Actor: "alice"}} {
if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: missing}); err == nil {
t.Fatalf("recheck without trusted actor/project accepted: %+v", missing)
}
}
}
+23 -1
View File
@@ -109,6 +109,9 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
if err != nil { if err != nil {
return err return err
} }
if err := ctx.Err(); err != nil {
return err
}
current := result.Bundle current := result.Bundle
expectedRefs := map[string]extension.ProjectEvidenceRef{} expectedRefs := map[string]extension.ProjectEvidenceRef{}
for _, ref := range m.OfferedReferences { for _, ref := range m.OfferedReferences {
@@ -127,6 +130,25 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !slices.Equal(current.Missing, m.Missing) { if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !slices.Equal(current.Missing, m.Missing) {
return errors.New("project source recheck returned an incomplete source set") return errors.New("project source recheck returned an incomplete source set")
} }
// Each returned item must account for exactly one recorded input; the hash
// is recomputed here, never taken from the provider's echo of the request.
claimed := make([]bool, len(m.Included))
for _, item := range current.Items {
matched := false
for i, used := range m.Included {
if claimed[i] || item.SourceID != used.SourceID || item.Revision != used.Revision || item.Hash != used.SourceHash {
continue
}
if fmt.Sprintf("%x", sha256.Sum256([]byte(item.Text))) != used.InputSHA256 {
return fmt.Errorf("project context source %q text changed under its recorded identity", used.SourceID)
}
claimed[i], matched = true, true
break
}
if !matched {
return errors.New("project source recheck returned an unrelated or duplicate source")
}
}
seenRefs := map[string]bool{} seenRefs := map[string]bool{}
for _, ref := range current.Evidence { for _, ref := range current.Evidence {
if ref != expectedRefs[ref.ID] || seenRefs[ref.ID] { if ref != expectedRefs[ref.ID] || seenRefs[ref.ID] {
@@ -158,7 +180,7 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
return errors.New("project evidence permission or content changed") return errors.New("project evidence permission or content changed")
} }
} }
return nil return ctx.Err()
} }
func (c *runCtx) contextRequest() extension.ProjectContextRequest { func (c *runCtx) contextRequest() extension.ProjectContextRequest {
+1 -1
View File
@@ -466,7 +466,7 @@ func TestContextManifestPersistsByOwnerAndRun(t *testing.T) {
conv := recoveryConversation(t, st) conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunPending) run := recoveryRun(t, st, conv, core.RunPending)
rc := &runCtx{mgr: m, run: run, ctx: context.Background()} rc := &runCtx{mgr: m, run: run, ctx: context.Background()}
manifest := extension.ProjectContextManifest{ProjectID: run.ProjectID, Version: "v3", Model: "model-1", AttemptID: "attempt-1", Delivery: "attempted", InputSHA256: "abc", Included: []extension.ProjectContextManifestEntry{{SourceID: "s1", Revision: "r2", InputSHA256: "def"}}} manifest := extension.ProjectContextManifest{ProjectID: run.ProjectID, Version: "v3", Model: "model-1", AttemptID: "attempt-1", Delivery: "attempted", InputSHA256: "abc", Included: []extension.ProjectContextManifestEntry{{SourceID: "s1", Revision: "r2", SourceHash: "h2", InputSHA256: "def"}}}
if err := rc.SaveContextManifest(context.Background(), manifest); err != nil { if err := rc.SaveContextManifest(context.Background(), manifest); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -0,0 +1,349 @@
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 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)
}
}
+24
View File
@@ -38,6 +38,9 @@ func (s *Store) appendContextManifest(owner, targetID string, m extension.Projec
if m.Delivery != "attempted" && m.Delivery != "responded" && m.Delivery != "unknown" { if m.Delivery != "attempted" && m.Delivery != "responded" && m.Delivery != "unknown" {
return errors.New("context manifest has invalid delivery state") return errors.New("context manifest has invalid delivery state")
} }
if err := requireRecheckableSources(m); err != nil {
return err
}
raw, err := json.Marshal(m) raw, err := json.Marshal(m)
if err != nil { if err != nil {
return err return err
@@ -81,6 +84,27 @@ func (s *Store) appendContextManifest(owner, targetID string, m extension.Projec
return tx.Commit() return tx.Commit()
} }
// requireRecheckableSources refuses a receipt, and therefore the model request
// it gates, when an actual input could not be reauthorized on later reuse.
func requireRecheckableSources(m extension.ProjectContextManifest) error {
for _, e := range m.Included {
if e.SourceID == "" || e.Revision == "" || e.SourceHash == "" || e.InputSHA256 == "" {
return errors.New("context manifest included source lacks stable identity")
}
}
for _, ref := range m.OfferedReferences {
if ref.ID == "" || ref.SourceID == "" || ref.Revision == "" || ref.Hash == "" {
return errors.New("context manifest offered reference lacks reauthorizable identity")
}
}
for _, e := range m.ReadEvidence {
if e.ReferenceID == "" || e.SourceID == "" || e.Revision == "" || e.SourceHash == "" || e.InputSHA256 == "" {
return errors.New("context manifest read evidence lacks reauthorizable identity")
}
}
return nil
}
// ListContextManifests reads receipts only within the authenticated owner's // ListContextManifests reads receipts only within the authenticated owner's
// scope. The target is a run ID for ordinary work, or a conversation ID for // scope. The target is a run ID for ordinary work, or a conversation ID for
// direct WorkCompletionHost calls. // direct WorkCompletionHost calls.
@@ -0,0 +1,45 @@
package store
import (
"path/filepath"
"testing"
"agent-system-backend/pkg/extension"
)
func TestContextManifestRejectsSourcesWithoutStableIdentity(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "gate.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
complete := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "model-1", InputSHA256: "request-hash", Delivery: "attempted",
Included: []extension.ProjectContextManifestEntry{{SourceID: "s1", Revision: "r1", SourceHash: "h1", InputSHA256: "in"}},
OfferedReferences: []extension.ProjectEvidenceRef{{ID: "ref-1", SourceID: "doc", Revision: "r1", Hash: "hd"}},
ReadEvidence: []extension.ProjectContextManifestEntry{{ReferenceID: "ref-1", SourceID: "doc", Revision: "r1", SourceHash: "hd", InputSHA256: "body"}}}
broken := []func(*extension.ProjectContextManifest){
func(m *extension.ProjectContextManifest) { m.Included[0].SourceID = "" },
func(m *extension.ProjectContextManifest) { m.Included[0].SourceHash = "" },
func(m *extension.ProjectContextManifest) { m.Included[0].InputSHA256 = "" },
func(m *extension.ProjectContextManifest) { m.OfferedReferences[0].ID = "" },
func(m *extension.ProjectContextManifest) { m.OfferedReferences[0].Hash = "" },
func(m *extension.ProjectContextManifest) { m.ReadEvidence[0].ReferenceID = "" },
func(m *extension.ProjectContextManifest) { m.ReadEvidence[0].Revision = "" },
}
for i, mutate := range broken {
m := complete
m.Included = append([]extension.ProjectContextManifestEntry(nil), complete.Included...)
m.OfferedReferences = append([]extension.ProjectEvidenceRef(nil), complete.OfferedReferences...)
m.ReadEvidence = append([]extension.ProjectContextManifestEntry(nil), complete.ReadEvidence...)
mutate(&m)
if err := st.AppendContextManifest("alice", "conv-1", m); err == nil {
t.Fatalf("case %d: unrecheckable source admitted as model input", i)
}
}
if got, err := st.ListContextManifests("alice", "conv-1"); err != nil || len(got) != 0 {
t.Fatalf("rejected receipts persisted: %+v err=%v", got, err)
}
if err := st.AppendContextManifest("alice", "conv-1", complete); err != nil {
t.Fatalf("complete source identities rejected: %v", err)
}
}