diff --git a/README.md b/README.md index ce073d4..f77841c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ For the full staged roadmap, including contribution points, revenue share, patro - the same bridge URL/token pair lets `tools/dispatch-agent-requests.mjs` record `/dispatch/runs/*` receipts for launched Codex work, so Bifrost keeps a live run trail even when a turn fails - Bifrost-dispatched Codex turns also inject Bifrost-owned GitHub gates through environment config: `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` and mutating `gh` commands stay blocked, and only `tools/bifrost-bridge.mjs` is allowed to authorize the bridge-owned GitHub mutation path - `tools/agent-transport.mjs` also uses that bridge configuration to record `/transport/receipts` entries for queue, claim, release, and close events on the request lane +- `tools/agent-transport.mjs apply-snapshot` is also treated as a mutating import path now: it fails closed without the bridge receipt config and derives request-lane receipts from the imported state delta - `tools/governance-threads.mjs` uses that same bridge configuration to record `/governance/receipts` entries for topic opens, comments, approvals, and promotions - those agent activity scripts now fail closed without the bridge URL/token pair; `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` is the explicit operator-recovery hatch - GitHub bridge mutations now fail closed without those values; `--allow-ungated-github true` is reserved for explicit operator recovery diff --git a/docs/agent-transport.md b/docs/agent-transport.md index a7e35b3..18f60fb 100644 --- a/docs/agent-transport.md +++ b/docs/agent-transport.md @@ -128,6 +128,7 @@ Dispatch receipts should use Bifrost's own public persona in the Bifrost governa The dispatcher now tags those Discord receipts with the Bifrost request id as bridge provenance, and dispatched Codex turns inherit default bridge provenance environment variables so any explicit `tools/bifrost-bridge.mjs` crossing inside the turn can land on the same request trail without hand-carried flags. When `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` are configured, the dispatcher also records `/dispatch/runs/*` receipts in the hosted app so Bifrost keeps a first-class run ledger for launch, completion, cancellation, and failure. Those dispatched turns also inherit Bifrost-owned GitHub gates through environment config. `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked inside dispatched work even with `--no-verify`, and mutating `gh` commands are blocked too unless the bridge explicitly authorizes the GitHub mutation it owns. When that same bridge configuration is present, `tools/agent-transport.mjs` records `/transport/receipts` entries for queue, claim, release, and close operations on the request lane. The packet still lives in CultCache. The hosted receipt row is the Bifrost-side witness that an agent or dispatcher touched it. +`apply-snapshot` is treated as a mutating import path too. It now fails closed without that same bridge configuration and derives request-lane receipts from the imported state delta, so a snapshot can no longer rewrite queue or claim state behind Bifrost's back. Those mutating scripts now fail closed without that bridge configuration. Use `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` only for explicit local recovery work where you are intentionally accepting a blind spot. The `snapshot` command writes a MessagePack-encoded `cultnet.snapshot_response_raw.v0` message. This keeps CultCache payload bytes intact for peers that share the document binding. diff --git a/tests/Bifrost.Web.Tests/BridgeCliTests.cs b/tests/Bifrost.Web.Tests/BridgeCliTests.cs index 30d8714..5d2c37b 100644 --- a/tests/Bifrost.Web.Tests/BridgeCliTests.cs +++ b/tests/Bifrost.Web.Tests/BridgeCliTests.cs @@ -118,6 +118,92 @@ console.log(JSON.stringify({ Assert.Equal("queued", payload.RootElement.GetProperty("status").GetString()); } + [Fact] + public async Task Agent_transport_apply_snapshot_fails_closed_without_bifrost_receipt_gate() + { + var sourceStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-source-{Guid.NewGuid():N}.cc"); + var targetStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-target-{Guid.NewGuid():N}.cc"); + var snapshotPath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.msgpack"); + + await RunNodeAsync([ + "tools/agent-transport.mjs", + "enqueue", + "--repo", "Bifrost", + "--agent", "nibu", + "--title", "Snapshot gate seed", + "--request", "Do the thing.", + "--store", sourceStorePath, + "--allow-unmirrored", "true", + "--allow-unreceipted-activity", "true", + ]); + + await RunNodeAsync([ + "tools/agent-transport.mjs", + "snapshot", + "--store", sourceStorePath, + "--out", snapshotPath, + ]); + + var result = await RunNodeAsync([ + "tools/agent-transport.mjs", + "apply-snapshot", + "--store", targetStorePath, + "--in", snapshotPath, + ]); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Agent_transport_apply_snapshot_allows_explicit_operator_recovery() + { + var sourceStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-source-{Guid.NewGuid():N}.cc"); + var targetStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-target-{Guid.NewGuid():N}.cc"); + var snapshotPath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.msgpack"); + + await RunNodeAsync([ + "tools/agent-transport.mjs", + "enqueue", + "--repo", "Bifrost", + "--agent", "nibu", + "--title", "Snapshot recovery seed", + "--request", "Do the thing.", + "--store", sourceStorePath, + "--allow-unmirrored", "true", + "--allow-unreceipted-activity", "true", + ]); + + await RunNodeAsync([ + "tools/agent-transport.mjs", + "snapshot", + "--store", sourceStorePath, + "--out", snapshotPath, + ]); + + var result = await RunNodeAsync([ + "tools/agent-transport.mjs", + "apply-snapshot", + "--store", targetStorePath, + "--in", snapshotPath, + "--allow-unreceipted-activity", "true", + ]); + + Assert.Equal(0, result.ExitCode); + + var listed = await RunNodeAsync([ + "tools/agent-transport.mjs", + "list", + "--store", targetStorePath, + ]); + + Assert.Equal(0, listed.ExitCode); + using var payload = JsonDocument.Parse(listed.Stdout); + Assert.Equal(1, payload.RootElement.GetArrayLength()); + Assert.Equal("Snapshot recovery seed", payload.RootElement[0].GetProperty("title").GetString()); + Assert.Equal("queued", payload.RootElement[0].GetProperty("status").GetString()); + } + [Fact] public async Task Governance_mutation_fails_closed_without_bifrost_receipt_gate() { diff --git a/tools/agent-transport.mjs b/tools/agent-transport.mjs index 3c65e50..2da564f 100644 --- a/tools/agent-transport.mjs +++ b/tools/agent-transport.mjs @@ -129,7 +129,11 @@ async function main() { } function isMutatingCommand(command) { - return command === "enqueue" || command === "claim" || command === "release" || command === "close"; + return command === "enqueue" + || command === "claim" + || command === "release" + || command === "close" + || command === "apply-snapshot"; } function ensureTransportReceiptGate(options) { @@ -419,17 +423,122 @@ async function writeSnapshot(cache, options) { async function applySnapshot(cache, options) { const inPath = resolveOptionPath(requireOption(options, "in")); + const beforeRequests = mapRequestsById(cache.getAll(updateRequestDefinition)); const message = decode(await readFile(inPath)); await cultNetRegistry.applyRawSnapshotResponse(cache, message); + const afterRequests = mapRequestsById(cache.getAll(updateRequestDefinition)); + const receipts = deriveSnapshotReceipts(beforeRequests, afterRequests); + + try { + for (const { request, receipt } of receipts) { + await recordTransportReceiptOrThrow(request, receipt); + } + } catch (error) { + await restoreRequests(cache, beforeRequests, afterRequests); + throw error; + } + printJson({ applied: true, schemaVersion: message.schemaVersion, messageId: message.messageId, documentCount: message.documents?.length ?? 0, + receiptCount: receipts.length, in: inPath, }); } +function mapRequestsById(requests) { + return new Map(requests.map((request) => [request.id, request])); +} + +function deriveSnapshotReceipts(beforeRequests, afterRequests) { + const receipts = []; + + for (const [id, current] of afterRequests.entries()) { + const previous = beforeRequests.get(id); + if (previous && stableJson(previous) === stableJson(current)) { + continue; + } + + receipts.push({ + request: current, + receipt: buildSnapshotReceipt(previous, current), + }); + } + + return receipts; +} + +function buildSnapshotReceipt(previous, current) { + if (!previous) { + return { + activityKind: activityKindForSnapshotState(current.status), + status: current.status, + actorName: receiptActorName(current), + note: `Snapshot import created request state as ${current.status}.`, + }; + } + + if (previous.status === "claimed" && current.status === "queued") { + return { + activityKind: "Released", + status: current.status, + actorName: previous.claimedByAgent ?? receiptActorName(current), + note: "Snapshot import released a claimed request back to the queue.", + }; + } + + if (previous.status !== "claimed" && current.status === "claimed") { + return { + activityKind: "Claimed", + status: current.status, + actorName: current.claimedByAgent ?? receiptActorName(current), + note: `Snapshot import claimed request for ${current.targetRepoName}.`, + }; + } + + if (current.status === "completed" || current.status === "cancelled") { + return { + activityKind: "Closed", + status: current.status, + actorName: current.claimedByAgent ?? previous.claimedByAgent ?? receiptActorName(current), + note: current.closeNote || `Snapshot import closed request as ${current.status}.`, + }; + } + + return { + activityKind: activityKindForSnapshotState(current.status), + status: current.status, + actorName: receiptActorName(current), + note: "Snapshot import refreshed request state.", + }; +} + +function activityKindForSnapshotState(status) { + return status === "claimed" ? "Claimed" : "Queued"; +} + +function receiptActorName(request) { + return request.claimedByAgent ?? request.createdByAgent ?? request.targetAgentIdentity ?? "system"; +} + +async function restoreRequests(cache, beforeRequests, afterRequests) { + for (const [id, request] of beforeRequests.entries()) { + await cache.put(updateRequestDefinition, id, request); + } + + for (const id of afterRequests.keys()) { + if (!beforeRequests.has(id)) { + await cache.delete(updateRequestDefinition, id); + } + } +} + +function stableJson(value) { + return JSON.stringify(value); +} + async function recordTransportReceiptOrThrow(request, receipt) { const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL); const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN);