agent-exec transport 412eff9b36afd457500606d468b89f94/0636a7e1c0f0
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user