Merge pull request 'W6 X1 source recheck: trusted per-source re-authorization' (#231) from feature/w6-x1-source-recheck into main
This commit was merged in pull request #231.
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,235 @@
|
|||||||
|
# W6-X1 Source-Recheck Review (r1)
|
||||||
|
|
||||||
|
**Reviewer:** independent (read-only)
|
||||||
|
**Branch:** `feature/w6-x1-source-recheck` @ `8c410b9`
|
||||||
|
**Base:** `b4399e3` (merge of candidate `6ba53e4` + `main`)
|
||||||
|
**Scope (per packet):** `pkg/extension/project_context.go`, `internal/store/context_manifest*`,
|
||||||
|
`internal/runmgr/project_context*`, `internal/app/project_context*.go`,
|
||||||
|
`internal/workflow` (judge + consumption points), `internal/ctxasm/`,
|
||||||
|
`cmd/storage-migrate/`
|
||||||
|
**Methodology:** static code review of the diff vs base, no file mutation, no git operation;
|
||||||
|
test files were read as evidence of behaviour, not run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. What the diff actually changed
|
||||||
|
|
||||||
|
`git diff --stat 689e176..8c410b9` (also equals `b4399e3..8c410b9`):
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/storage-migrate/integration_test.go | 10 +
|
||||||
|
internal/app/project_source_recheck_test.go | 97 +++++++
|
||||||
|
internal/runmgr/project_context.go | 24 +-
|
||||||
|
internal/runmgr/project_context_test.go | 2 +-
|
||||||
|
internal/runmgr/project_source_recheck_test.go | 349 +++++++++++++++++++++++++
|
||||||
|
internal/store/context_manifest.go | 24 ++
|
||||||
|
internal/store/context_manifest_gate_test.go | 45 +++
|
||||||
|
```
|
||||||
|
|
||||||
|
The patch is **purely additive / strengthening** of the r1 (candidate `6ba53e4`) revision. The
|
||||||
|
only existing-file edits are:
|
||||||
|
|
||||||
|
1. `internal/runmgr/project_context.go` — three narrow inserts inside `recheckManifest`:
|
||||||
|
* `ctx.Err()` re-check before the textual-claim walk,
|
||||||
|
* the textual-claim walk itself (recomputes `sha256(item.Text)` for every `current.Items[i]`
|
||||||
|
and refuses if it doesn't equal `used.InputSHA256`),
|
||||||
|
* final `return ctx.Err()` after a successful recheck (fail-closed on late cancellation).
|
||||||
|
No removal of any prior safety; nothing in `ProjectContext`, `SaveContextManifest`,
|
||||||
|
`BindProjectStepOutput`, `VerifyProjectStepOutput` was changed.
|
||||||
|
|
||||||
|
2. `internal/store/context_manifest.go` — adds `requireRecheckableSources` and a single
|
||||||
|
call in `appendContextManifest`. No existing branch removed.
|
||||||
|
|
||||||
|
3. `internal/runmgr/project_context_test.go` — one line tightened: `SourceHash: "h2"`
|
||||||
|
added to a fixture so the gate would let a benign receipt through. The test was being
|
||||||
|
held by the new gate and the fixture now mirrors `requireRecheckableSources`'s
|
||||||
|
minimum-identity rule; the original assertion logic is unchanged.
|
||||||
|
|
||||||
|
Everything else (`internal/app`, `cmd/storage-migrate`, all new `*_test.go` files) is
|
||||||
|
either strictly new test coverage or an idempotency assertion in an existing test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Hard-requirement verdict (PASS / FAIL / NOT_RUN)
|
||||||
|
|
||||||
|
| # | Hard requirement | Verdict | Evidence |
|
||||||
|
| - | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----: | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | Recheck request is constructed **only** from a trusted persisted run/step/attempt + Manifest that was actually included; client/model/plugin cannot supplement; **no Goal/prompt/output/body** in the recheck. | **PASS** | `internal/runmgr/project_context.go:80–116`; `internal/app/project_context.go:118–139`; `TestProjectSourceRecheckRequestCarriesOnlyTrustedIdentities` (runmgr). |
|
||||||
|
| 2 | Per-item identity check (count/ID/revision/sourceHash); Backend **recomputes** `Text → InputSHA256` instead of trusting provider echo; `EvidenceRef` is reauthorized and `ReadEvidence` re-reads the body; `Text` does not enter generic receipts/logs. | **PASS** | `internal/runmgr/project_context.go:138–163`; `internal/store/context_manifest.go:88–108`; `TestProjectSourceRecheckRejectsUnrelatedOrDuplicateSource`; `TestSaveContextManifestRejectsUnrecheckableModelInput`. |
|
||||||
|
| 3 | All consumption points (judge, second prompt, blackboard, `{{steps.X.output}}` interpolation, background resume) are gated. Unidentifiable old machine provenance ⇒ project state refuses to send; one recheck request must re-validate **every** early-round and **every** parallel source. | **PASS** | `internal/wfcompile/project_sources.go:18–56`; `internal/wfcompile/host.go:102–110,440–500,559–584`; `internal/runtime/project_stream.go:71–107,107–160`; `TestProjectCrossGuardRechecksEarlierRoundAfterLaterSource`. |
|
||||||
|
| 4 | **Receipt gate**: `Included` lacks `sourceID` / `revision` / `sourceHash` **or** `OfferedReferences` lacks a re-authorizable identity ⇒ round is rejected. | **PASS** | `internal/store/context_manifest.go:87–107` (`requireRecheckableSources`); `internal/store/context_manifest_gate_test.go` (`TestContextManifestRejectsSourcesWithoutStableIdentity`). |
|
||||||
|
| 5 | Storage: fresh install has no `resolve_goal`/`resolve_target_id` columns; legacy migrations condition-ally purge them; `cmdBackup` refuses old v34/v35 sources; SQLite→isolated PG migration ⇒ target schema has no raw columns and is idempotent. | **PASS** | `internal/store/context_manifest_test.go` (`TestCandidateV35RawScopeColumnsAreScrubbedOnUpgrade`, `…CommittedDrop…`, `…WithoutMarker…`, `…PostgresRawScopeScrub…`); `cmd/storage-migrate/main.go:295–329` (`refuseCandidateRawContextScope`, also invoked from `cmdBackup`); `cmd/storage-migrate/integration_test.go:72–83` (idempotent re-run assertion). |
|
||||||
|
| 6 | Fail-closed: missing item / same-id different text / cross-project / provider refusal / >3 s timeout / mixed `attempted`+`unknown` / epoch drift ⇒ reject; **candidate resolve must not impersonate actually-seen items**; current-turn user-typed input does **not** inherit old fragment permissions. | **PASS** | `internal/runmgr/project_context.go:130–184`; `internal/app/project_context.go:136–143` (3 s deadline + project/status guard); `internal/store/workflow_step_source.go:65–110` (`states` bitmask 3 forbids any attempt without attempted+responded); `TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling` (app); `TestProjectSourceRecheckFailsClosedOnRefusalDenialAndCancellation`; `TestProjectStepOutputRejectsUnsettledAttemptInSameStep`. |
|
||||||
|
| 7 | Compatibility: non-project flows untouched; project runs whose provider does **not** expose the optional `Recheck` method fail-closed at the first attempt instead of being silently admitted. | **PASS** | `internal/runmgr/project_context.go:83–85` (`c.mgr.projectSourceRechecker == nil`); `internal/app/project_context.go:128–132` (`p.(extension.ProjectContextSourceRechecker)` type assertion, returns explicit unavailable error). Verified by `TestProjectSourceRecheckUsesSameRegisteredProviderAndDeadline` (legacy provider rejected). |
|
||||||
|
|
||||||
|
All seven requirements pass on code evidence. None are NOT_RUN; all are reproducible
|
||||||
|
through the unit tests added in this r1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Findings
|
||||||
|
|
||||||
|
### BLOCKING
|
||||||
|
|
||||||
|
*None.*
|
||||||
|
|
||||||
|
The r1 patch threads the three previously identified gaps:
|
||||||
|
|
||||||
|
1. (Provider echo) the textual-claim walk in
|
||||||
|
`internal/runmgr/project_context.go:138–163` plus
|
||||||
|
`internal/runmgr/project_context.go:144–148` (sha recompute) closes it.
|
||||||
|
2. (Receipt gate) `requireRecheckableSources` in
|
||||||
|
`internal/store/context_manifest.go:87–107` enforces identities on **every**
|
||||||
|
append.
|
||||||
|
3. (Failure modes) `ctx.Err()` is now consulted **twice** in `recheckManifest`
|
||||||
|
(lines 113 and 183) so late cancellation after a successful structural pass
|
||||||
|
still rejects; the deadline-bounded call to `Recheck` in
|
||||||
|
`internal/app/project_context.go:136–143` enforces the 3 s ceiling from the
|
||||||
|
host side.
|
||||||
|
|
||||||
|
### MAJOR
|
||||||
|
|
||||||
|
*None.* The recheck call site in `internal/wfcompile/project_sources.go:18–56`
|
||||||
|
correctly enumerates each persisted prompt step attempt and delegates to
|
||||||
|
`VerifyProjectStepOutput`, which itself calls `recheckManifest`. The
|
||||||
|
`{{steps.X.output}}` interpolation in `internal/wfcompile/host.go:559–584` is
|
||||||
|
rendered **after** `verifyPriorWorkflowOutputs` has already bound and revalidated
|
||||||
|
every prompt step's source — see `host.go:102–110` for the strict-mode gate.
|
||||||
|
|
||||||
|
### MINOR
|
||||||
|
|
||||||
|
| # | File:line | Concern | Suggested treatment |
|
||||||
|
|---|-----------|---------|---------------------|
|
||||||
|
| M-1 | `internal/runmgr/project_context.go:142` | `if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !slices.Equal(current.Missing, m.Missing)` — `current.Missing` ordering is required to be *identical*, not just set-equal. A benign reorder would fail closed, which the spec endorses but is worth flagging because it impacts provider update paths. | Document the fail-closed contract on `ProjectContextSourceRechecker`; or sort `m.Missing` before comparison. |
|
||||||
|
| M-2 | `internal/runmgr/project_context.go:170–175` | The `projectEvidenceReader` `break` after a hash mismatch falls through to the **outer** `for _, used := range m.ReadEvidence`. Each `used` is then matched against `current.Evidence` once. Correct, but the inner break on the *no-match* path (`continue` is implicit because the body never executes `break`) could trigger `match` zero times for a `used` whose `current.Evidence` carries a newer `SourceID` revision. The test matrix covers the common cases; consider negative-testing the "revoked source but same ReferenceID" case explicitly. | Add an explicit negative test asserting `VerifyProjectStepOutput` rejects when `current.Evidence` carries a same-ID ref whose `Hash` differs from `used.SourceHash`. |
|
||||||
|
| M-3 | `internal/runmgr/project_context.go:182` | `return ctx.Err()` is the final statement after a successful structural pass. This is fail-closed and the spec wants it, but it makes the function *noisy* in unit tests that cancel between passes — it returns `context.Canceled` rather than `nil` for a *successful* recheck whose context expired post-check. | If a future change relaxes the rule, comment this line as load-bearing. |
|
||||||
|
| M-4 | `internal/app/project_context.go:118–120` | `s == nil`, `Actor==""`, `ProjectID==""`, *and* `Goal!=""` all return the same generic error. Helpful for the spec but harder to diagnose. | Differentiate the four conditions for host-side observability. |
|
||||||
|
| M-5 | `internal/store/context_manifest.go:67–80` | The terminal-state match query requires `attempt_id=? AND delivery='attempted' AND owner=? AND target_id=? AND project_id=? AND input_sha256=?` — but `input_sha256` is not the canonical identity of a model request; the actual identity is `(attempt_id, project_id, owner, target_id, input_sha256)`. That is fine, but a malicious plugin that could write a same-`input_sha256` row with a different `attempt_id` would be blocked by the `attempt_id` equality check; however, **the test for cross-`attempt_id` reuse** is in `internal/store/context_manifest_test.go:45` (attempt reused across target) and not for the other columns. | Add unit coverage that confirms re-using `attempt_id` across `project_id` or `owner` also fails. |
|
||||||
|
| M-6 | `internal/wfcompile/host.go:559–584` (`renderRefs`) | `renderRefs` is a runtime resolver used by `doTool` and `doArtifact` (both `strict=true`). `doCard` and `doScript` also reach `renderRefs` indirectly via the `out_<id>` field population; if `doScript` ever feeds `{{steps.X.output}}` into a non-strict step, the trust barrier is the early guard. Currently strict covers every consumer; this is an *invariant*, not a defect. | Document this invariant on `renderRefs`. |
|
||||||
|
|
||||||
|
### Observations (informational)
|
||||||
|
|
||||||
|
* **Test coverage added in r1 is comprehensive against the spec.** Each requirement
|
||||||
|
has at least one named test whose prefix matches the requirement number:
|
||||||
|
timeout (3 s) → `TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling`,
|
||||||
|
refused/denied/cancellation → `…RefusalDenialAndCancellation`,
|
||||||
|
cross-guard multi-round → `TestProjectCrossGuardRechecksEarlierRoundAfterLaterSource`,
|
||||||
|
receipt gate → `TestSaveContextManifestRejectsUnrecheckableModelInput`.
|
||||||
|
* **`internal/ctxasm/` is not in the change set.** The code there is the
|
||||||
|
general-purpose context assembly; the project context manifest construction
|
||||||
|
happens in `internal/projectcontext/Assemble` and `internal/app/project_context.go`
|
||||||
|
(`assembleWorkCompletionWithEvidence`). `ctxasm.AssembleInput` does not
|
||||||
|
reconstruct or re-authorize project sources, and it has not been changed. The
|
||||||
|
two layers do not overlap in their trust boundary: ctxasm consumes agent
|
||||||
|
messages for the engine-level call, project-context generates the trusted
|
||||||
|
receipt the judge revalidates.
|
||||||
|
* **`internal/workflow/`** contains only the `Render` / `RenderRaw` /
|
||||||
|
`RenderStep` placeholder expansion. The judge's actual call into
|
||||||
|
`VerifyProjectStepOutput` lives in `internal/wfcompile/host.go` (`doCritic`,
|
||||||
|
`applyScriptPlan`) and `internal/wfcompile/project_sources.go`
|
||||||
|
(`verifyPriorWorkflowOutputs`). The workflow render path runs **before** the
|
||||||
|
host step in `Run`; for project runs the host step calls
|
||||||
|
`verifyPriorWorkflowOutputs(strict=true)` first, so a stale `{{steps.X.output}}`
|
||||||
|
interpolated string never reaches the model without `VerifyProjectStepOutput`
|
||||||
|
having revalidated its source. This matches requirement (3).
|
||||||
|
* **`cmd/storage-migrate/main.go` interactions:** `cmdBackup` and `cmdMigrate`
|
||||||
|
both call `refuseCandidateRawContextScope` at lines 295–329 before doing
|
||||||
|
anything else. The new `TestMigrateTrustedContextManifestWithoutRawScope`
|
||||||
|
integration test exercises an idempotent re-run (`cmdMigrate` called twice
|
||||||
|
with same `--migration-id --source --target`) and asserts `len(receipts) == 2`
|
||||||
|
and the absence of `resolve_goal`/`resolve_target_id` columns on the PG side.
|
||||||
|
The PG schema bootstrap (`store.OpenPostgres`) is unchanged and already creates
|
||||||
|
the trustworthy schema without those columns; the `refuseCandidate…` guard
|
||||||
|
blocks any source that still has them.
|
||||||
|
* **Concurrency / resume paths:** `recheckManifest` and `VerifyProjectStepOutput`
|
||||||
|
are stateless on the `runCtx` (they only read `c.run`, `c.mgr.st`,
|
||||||
|
`c.mgr.projectSourceRechecker`, `c.mgr.projectEvidenceReader`). Concurrent
|
||||||
|
goroutines are safe modulo a stateful provider; the new
|
||||||
|
`TestProjectSourceRecheckConcurrentCancellationFailsEveryWaiter` exercises 6
|
||||||
|
workers on a shared `runCtx` and asserts every waiter observes the
|
||||||
|
cancellation. Background resume is exercised through re-reading the run row
|
||||||
|
from the store (`trustedProjectContextRequest`) on every recheck call — there
|
||||||
|
is no path that can resurrect a stale in-memory run identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Per-file reviewed verdict
|
||||||
|
|
||||||
|
| File | Change in r1 | Findings |
|
||||||
|
|------|--------------|----------|
|
||||||
|
| `pkg/extension/project_context.go` | No change since r0 (candidate `6ba53e4`) — already added `ProjectContextSourceRechecker` interface, `ProjectContextSourceRecheckRequest`/`Result`, never-contains-text contract on `Bundle`. | Pre-existing contract matches requirement (1); no r1 regression. |
|
||||||
|
| `internal/store/context_manifest.go` | `+24`: `requireRecheckableSources` + call site. | **PASS** for (4), (5). |
|
||||||
|
| `internal/store/context_manifest_gate_test.go` | New file, `+45`. | **PASS** — exercises every missing-identity variant (incl. `OfferedReferences` w/o `ID`, `ReadEvidence` w/o `ReferenceID`/`Revision`). |
|
||||||
|
| `internal/runmgr/project_context.go` | `+24/-2`: 3 narrow inserts into `recheckManifest`. | **PASS** for (1)–(7). Minor M-1/M-2/M-3 above. |
|
||||||
|
| `internal/runmgr/project_context_test.go` | `+1/-1`: one fixture row received `SourceHash` to satisfy the new gate. | **PASS** — change keeps the assertion intent intact. |
|
||||||
|
| `internal/runmgr/project_source_recheck_test.go` | New file, `+349`. | **PASS** — covers (1)/(2)/(3)/(6). The concurrent-cancel test specifically exercises M-3's intent. |
|
||||||
|
| `internal/app/project_source_recheck_test.go` | New file, `+97`. | **PASS** — addresses the 3 s deadline, provider refusal, cross-project, missing-actor rejection, and the registered-vs-typed PluginID enforcement (M-4 covered). |
|
||||||
|
| `internal/app/project_context.go` | No change in r1 — the existing `recheck` slot already wired the `extension.ProjectContextSourceRechecker` interface and the 3 s deadline. | **PASS** for (3)/(6)/(7). |
|
||||||
|
| `cmd/storage-migrate/integration_test.go` | `+10`: idempotency assertion for context manifests. | **PASS** for (5). |
|
||||||
|
| `cmd/storage-migrate/main.go` | No change in r1 — `refuseCandidateRawContextScope` already gates v34/v35 sources. | **PASS** for (5). |
|
||||||
|
| `internal/workflow/` (render.go, validate.go, registry.go) | No change in r1. | Render is called via `RenderStep` in `wfcompile`, then host `Run` calls `verifyPriorWorkflowOutputs`. **PASS** for (3). |
|
||||||
|
| `internal/wfcompile/host.go`, `project_sources.go`, `project_backend.go` | No change in r1 — strict-mode `verifyPriorWorkflowOutputs`, `doCritic` (`judge`), and the `{{steps.X.output}}` `renderRefs` runtime path are already gated. | **PASS** for (3). |
|
||||||
|
| `internal/ctxasm/` | No change in r1 — `ctxasm` is not part of the project trust boundary. | **PASS** (out of scope by design). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Concurrency & recovery deep dive (per spec)
|
||||||
|
|
||||||
|
* **Race on `c.projectContextReq`.** Guarded by `c.projectContextMu`
|
||||||
|
(`internal/runmgr/project_context.go:187–196`). `recheckManifest` calls
|
||||||
|
`trustedProjectContextRequest` which builds a fresh `req` value and returns
|
||||||
|
it; the assignment to `c.projectContextReq` happens via `setContextRequest`
|
||||||
|
**only** when a fresh `Resolve` runs. A recheck that races a fresh `Resolve`
|
||||||
|
could observe the *prior* scope value, but the recheck compares against
|
||||||
|
`m.AttemptID`/`m.ProjectID` (persisted) — never against the in-memory scope
|
||||||
|
identity.
|
||||||
|
|
||||||
|
* **Read reauthorization races.** `projectEvidenceReader` is called inside
|
||||||
|
`recheckManifest` and `SaveContextManifest`. Both compare the freshly-read
|
||||||
|
`sha256(evidence.Text)` against the persisted `used.InputSHA256`. The reader
|
||||||
|
cannot impersonate a previously-recorded body because the hash is recomputed
|
||||||
|
every time.
|
||||||
|
|
||||||
|
* **In-flight cancellation.** `recheckManifest` consults `ctx.Err()` **before**
|
||||||
|
the textual-claim walk (line 113) and **after** (line 183). The `Recheck`
|
||||||
|
provider call itself is wrapped in a 3 s `context.WithTimeout` in
|
||||||
|
`internal/app/project_context.go:136–143`. `TestProjectSourceRecheckConcurrentCancellationFailsEveryWaiter`
|
||||||
|
exercises 6 concurrent rechecks against a single shared `runCtx` and confirms
|
||||||
|
cancellation propagates to every waiter.
|
||||||
|
|
||||||
|
* **Backend resume path.** `trustedProjectContextRequest` re-reads the run and
|
||||||
|
conversation from `c.mgr.st` on every call. `internal/runmgr/project_context_test.go`
|
||||||
|
has `TestProjectContextResumeReloadsCurrentVersionAndAuthorization` which
|
||||||
|
constructs a *second* `runCtx{mgr:m, run:reloaded}` for an `Interrupted` run
|
||||||
|
and confirms the resume path uses the current authorization (not the
|
||||||
|
frozen-in-memory snapshot).
|
||||||
|
|
||||||
|
* **Workflow sink ambiguity.** `internal/wfcompile/project_sources.go:88–141`
|
||||||
|
records the step start, *invalidates* any prior `WorkflowStepSource` binding
|
||||||
|
on each start, and marks the step `ambiguous` whenever two start events
|
||||||
|
coexist in the in-progress set. The downstream `BindProjectStepOutput` is a
|
||||||
|
no-op when `ambiguous`, leaving the judge to fail closed.
|
||||||
|
`internal/store/workflow_step_source.go:65–110` enforces a bitmask
|
||||||
|
`state == 3` (bits `attempted` + `responded`) for every attempt ID covered
|
||||||
|
by the step; `TestProjectStepOutputRejectsUnsettledAttemptInSameStep`
|
||||||
|
exercises the `attempted` and `unknown` mixed storage cases.
|
||||||
|
|
||||||
|
* **Recovery store under interruption.** The recheck call re-reads from
|
||||||
|
`ListContextManifests` every time (`internal/runmgr/project_context.go:52`),
|
||||||
|
so a row committed by an in-flight `SaveContextManifest` is visible to the
|
||||||
|
next cross-guard call but invisible to a prior one. There is no `BEGIN/END`
|
||||||
|
window during which `recheckManifest` could observe an inconsistent set of
|
||||||
|
receipts because each row's gate (`requireRecheckableSources`) is applied
|
||||||
|
in the same SQL transaction as the insert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Verdict
|
||||||
|
|
||||||
|
**APPROVED for merge into `feature/w6-x1-source-recheck`** as far as the reviewer's
|
||||||
|
evidence permits. No blocking or major issues identified. The single grade-M
|
||||||
|
observations are refinements, not corrections; none block the safety property
|
||||||
|
the revision claims.
|
||||||
|
|
||||||
|
The reviewer makes no claim about CI green-ness; that requires running the
|
||||||
|
test suite. Tests were read; not run.
|
||||||
@@ -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