diff --git a/README.md b/README.md index 4472b09..d4eefea 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 - `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/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 - set `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optionally `BIFROST_REDDIT_CLIENT_SECRET` before posting Reddit organizing threads - set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production diff --git a/docs/agent-transport.md b/docs/agent-transport.md index 2b44296..d53f157 100644 --- a/docs/agent-transport.md +++ b/docs/agent-transport.md @@ -127,6 +127,7 @@ node .\tools\agent-transport.mjs apply-snapshot --in E:\tmp\bifrost-agent-transp Dispatch receipts should use Bifrost's own public persona in the Bifrost governance channel. `tools/dispatch-agent-requests.mjs` reads `BIFROST_DISCORD_CHANNEL_ID` or `DISCORD_BIFROST_CHANNEL_ID` for the receipt target, uses persona name `Bifrost`, and defaults the persona avatar to the public `src/Bifrost.Web/wwwroot/img/bifrost-profile.png` raw GitHub URL unless `BIFROST_DISCORD_PERSONA_AVATAR_URL` or `DISCORD_PERSONA_AVATAR_URL_BIFROST` overrides it. Receipt text must lead with the concrete repo and work title, not a generic "recent consensus" summary, and must not expose request ids, workspace paths, log paths, or other debugging debris in Discord. 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. 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. +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/docs/bridge.md b/docs/bridge.md index 452dfad..d7fcf3b 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -95,6 +95,7 @@ Current CLI wiring: - the dispatcher also uses that same bridge configuration to post dispatch-run start/complete/fail receipts, including the worker pid, thread/turn ids when available, and result/log paths for the local operator trail - the agent transport CLI uses that same bridge configuration to post request-lane receipts for queue, claim, release, and close events, so Bifrost does not go blind between intake and dispatch - the governance threads CLI uses that same bridge configuration to post governance receipts for topic opens, comments, approvals, and promotions, so Bifrost does not lose the pre-dispatch reasoning trail +- those agent-activity CLIs now fail closed without that bridge configuration; `--allow-unreceipted-activity true` or `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true` is the deliberate operator-recovery hatch when you knowingly want local-only activity If the app bridge configuration is absent, `tools/bifrost-bridge.mjs` keeps working as a local actuator. When configured, it asks Bifrost for authorization before acting and reports success or failure back to the same bridge action row. diff --git a/tests/Bifrost.Web.Tests/BridgeCliTests.cs b/tests/Bifrost.Web.Tests/BridgeCliTests.cs index 0a9b2a6..b716c4f 100644 --- a/tests/Bifrost.Web.Tests/BridgeCliTests.cs +++ b/tests/Bifrost.Web.Tests/BridgeCliTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text; using System.Text.Json; namespace Bifrost.Web.Tests; @@ -43,6 +44,135 @@ public sealed class BridgeCliTests Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean()); } + [Fact] + public async Task Agent_transport_mutation_fails_closed_without_bifrost_receipt_gate() + { + var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc"); + var result = await RunNodeAsync([ + "tools/agent-transport.mjs", + "enqueue", + "--repo", "Bifrost", + "--agent", "nibu", + "--title", "Receipt gate test", + "--request", "Do the thing.", + "--store", storePath, + "--allow-unmirrored", "true", + ]); + + 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_mutation_allows_explicit_operator_recovery() + { + var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc"); + var result = await RunNodeAsync([ + "tools/agent-transport.mjs", + "enqueue", + "--repo", "Bifrost", + "--agent", "nibu", + "--title", "Receipt gate recovery", + "--request", "Do the thing.", + "--store", storePath, + "--allow-unmirrored", "true", + "--allow-unreceipted-activity", "true", + ]); + + Assert.Equal(0, result.ExitCode); + using var payload = JsonDocument.Parse(result.Stdout); + Assert.Equal("queued", payload.RootElement.GetProperty("status").GetString()); + } + + [Fact] + public async Task Governance_mutation_fails_closed_without_bifrost_receipt_gate() + { + var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc"); + var result = await RunNodeAsync([ + "tools/governance-threads.mjs", + "open", + "--repo", "Bifrost", + "--agent", "nibu", + "--title", "Governance receipt gate test", + "--summary", "A topic body.", + "--store", storePath, + "--allow-unmirrored", "true", + ]); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Governance_mutation_allows_explicit_operator_recovery() + { + var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc"); + var result = await RunNodeAsync([ + "tools/governance-threads.mjs", + "open", + "--repo", "Bifrost", + "--agent", "nibu", + "--title", "Governance receipt recovery", + "--summary", "A topic body.", + "--store", storePath, + "--allow-unmirrored", "true", + "--allow-unreceipted-activity", "true", + ]); + + Assert.Equal(0, result.ExitCode); + using var payload = JsonDocument.Parse(result.Stdout); + Assert.Equal("open", payload.RootElement.GetProperty("status").GetString()); + } + + [Fact] + public async Task Dispatch_worker_fails_closed_without_bifrost_receipt_gate() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"bifrost-dispatch-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var requestPath = Path.Combine(tempDir, "request.json"); + var promptPath = Path.Combine(tempDir, "prompt.md"); + var logPath = Path.Combine(tempDir, "codex.log"); + + await File.WriteAllTextAsync(requestPath, """ +{ + "id": "req_dispatch_gate_123", + "targetRepoName": "Bifrost", + "targetRepositoryFullName": "GameCult/Bifrost", + "targetAgentIdentity": "nibu", + "title": "Dispatch gate test", + "requestMarkdown": "## Request\n\nDo the thing.", + "priority": 50, + "status": "claimed", + "sourceKind": "manual", + "sourceChannelId": "", + "sourceMessageIds": [], + "sourcePacketPath": "", + "sourcePromptPath": "", + "createdByAgent": "tester", + "claimedByAgent": "tester", + "closeNote": "", + "createdAt": "2026-06-17T00:00:00Z", + "updatedAt": "2026-06-17T00:00:00Z", + "claimedAt": "2026-06-17T00:00:00Z", + "closedAt": "" +} +""", Encoding.UTF8); + await File.WriteAllTextAsync(promptPath, "Hello", Encoding.UTF8); + + var result = await RunNodeAsync([ + "tools/dispatch-agent-requests.mjs", + "run-claimed", + "--request-file", requestPath, + "--repo-root", RepoRoot, + "--prompt-file", promptPath, + "--log", logPath, + "--launch-mode", "codex-exec", + ]); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + } + private static string RepoRoot => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); @@ -68,6 +198,7 @@ public sealed class BridgeCliTests process.StartInfo.Environment["BIFROST_BRIDGE_BASE_URL"] = string.Empty; process.StartInfo.Environment["BIFROST_BRIDGE_TOKEN"] = string.Empty; process.StartInfo.Environment["BIFROST_ALLOW_UNGATED_GITHUB"] = string.Empty; + process.StartInfo.Environment["BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"] = string.Empty; process.Start(); var stdout = await process.StandardOutput.ReadToEndAsync(); diff --git a/tools/agent-transport.mjs b/tools/agent-transport.mjs index d6e3c24..3c65e50 100644 --- a/tools/agent-transport.mjs +++ b/tools/agent-transport.mjs @@ -16,14 +16,14 @@ const defaultPersonaName = "Bifrost"; const defaultPersonaAvatarUrl = "https://raw.githubusercontent.com/GameCult/Bifrost/main/src/Bifrost.Web/wwwroot/img/bifrost-profile.png"; -const cultCacheRequire = createRequire(resolve(projectsRoot, "CultCacheTS", "package.json")); +const cultCacheRequire = createRequire(resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json")); const cultNetRequire = createRequire(resolve(projectsRoot, "CultNetTS", "package.json")); const { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, -} = cultCacheRequire(resolve(projectsRoot, "CultCacheTS", "dist", "index.js")); +} = cultCacheRequire("cultcache-ts"); const { CultNetDocumentRegistry, defineCultNetDocumentBinding, @@ -91,6 +91,10 @@ async function main() { return; } + if (isMutatingCommand(command)) { + ensureTransportReceiptGate(options); + } + const storePath = resolveOptionPath(options.store ?? defaultStorePath); const cache = await openCache(storePath); @@ -124,6 +128,29 @@ async function main() { } } +function isMutatingCommand(command) { + return command === "enqueue" || command === "claim" || command === "release" || command === "close"; +} + +function ensureTransportReceiptGate(options) { + if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) { + return; + } + + throw new Error( + "Bifrost agent transport mutations require BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can keep request-lane receipts. " + + "Set both values, or use --allow-unreceipted-activity true only for explicit operator recovery.", + ); +} + +function hasBifrostBridgeConfig() { + return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) && optionalString(process.env.BIFROST_BRIDGE_TOKEN)); +} + +function allowsUnreceiptedActivity(options) { + return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true"; +} + async function openCache(storePath) { const cache = CultCache.builder() .withDocumentType(updateRequestDefinition) @@ -682,6 +709,7 @@ Mirror options for enqueue: --mirror-content-file Read custom mirror text from a file --mirror-dry-run true Exercise mirror plumbing without posting to Discord --allow-unmirrored true Fixture/debug escape hatch; production writes should not use this + --allow-unreceipted-activity true Operator-recovery hatch; lets a mutation proceed without hosted Bifrost receipts Examples: node tools/agent-transport.mjs enqueue --repo AetheriaLore --agent nibu --title "Wavecrafters" --request-file packet.md --priority 80 diff --git a/tools/dispatch-agent-requests.mjs b/tools/dispatch-agent-requests.mjs index 3c5cd01..56ccc9a 100644 --- a/tools/dispatch-agent-requests.mjs +++ b/tools/dispatch-agent-requests.mjs @@ -27,6 +27,10 @@ async function main() { return; } + if (command === "dispatch" || command === "run-claimed") { + ensureDispatchReceiptGate(options); + } + switch (command) { case "dispatch": await dispatchQueuedRequests(options); @@ -78,6 +82,7 @@ async function dispatchQueuedRequests(options) { ...optionalArg("--channel-id", options["channel-id"]), ...optionalArg("--persona-name", options["persona-name"]), ...optionalArg("--persona-avatar-url", options["persona-avatar-url"]), + ...(options["allow-unreceipted-activity"] === "true" ? ["--allow-unreceipted-activity", "true"] : []), ...(options["no-discord"] === "true" ? ["--no-discord", "true"] : []), ], { @@ -110,6 +115,25 @@ async function dispatchQueuedRequests(options) { }); } +function ensureDispatchReceiptGate(options) { + if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) { + return; + } + + throw new Error( + "Bifrost agent dispatch requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can keep request, run, and bridge receipts aligned. " + + "Set both values, or use --allow-unreceipted-activity true only for explicit operator recovery.", + ); +} + +function hasBifrostBridgeConfig() { + return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) && optionalString(process.env.BIFROST_BRIDGE_TOKEN)); +} + +function allowsUnreceiptedActivity(options) { + return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true"; +} + async function runClaimedRequest(options) { const requestPath = resolveOptionPath(requireOption(options, "request-file")); const repoRoot = resolveOptionPath(requireOption(options, "repo-root")); @@ -988,6 +1012,10 @@ Commands: Example: node tools/dispatch-agent-requests.mjs dispatch --repo AquaSynth --agent aqua --max 1 + node tools/dispatch-agent-requests.mjs dispatch --repo AquaSynth --agent aqua --allow-unreceipted-activity true + +Recovery hatch: + BIFROST_ALLOW_UNRECEIPTED_ACTIVITY=true or --allow-unreceipted-activity true `); } diff --git a/tools/governance-threads.mjs b/tools/governance-threads.mjs index ea40923..1f4a621 100644 --- a/tools/governance-threads.mjs +++ b/tools/governance-threads.mjs @@ -18,12 +18,12 @@ const defaultPersonaName = "Bifrost"; const defaultPersonaAvatarUrl = "https://raw.githubusercontent.com/GameCult/Bifrost/main/src/Bifrost.Web/wwwroot/img/bifrost-profile.png"; -const cultCacheRequire = createRequire(resolve(projectsRoot, "CultCacheTS", "package.json")); +const cultCacheRequire = createRequire(resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json")); const { CultCache, SingleFileMessagePackBackingStore, defineDocumentType, -} = cultCacheRequire(resolve(projectsRoot, "CultCacheTS", "dist", "index.js")); +} = cultCacheRequire("cultcache-ts"); const topicType = "bifrost.governance.topic"; const topicSchemaId = "bifrost.governance.topic.v0"; @@ -101,6 +101,10 @@ async function main() { return; } + if (isMutatingCommand(command)) { + ensureGovernanceReceiptGate(options); + } + const storePath = resolveOptionPath(options.store ?? defaultStorePath); const cache = await openCache(storePath); @@ -524,6 +528,29 @@ async function recordGovernanceReceiptOrThrow(topic, receipt) { } } +function isMutatingCommand(command) { + return command === "open" || command === "comment" || command === "approve" || command === "promote"; +} + +function ensureGovernanceReceiptGate(options) { + if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) { + return; + } + + throw new Error( + "Bifrost governance mutations require BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can keep governance receipts. " + + "Set both values, or use --allow-unreceipted-activity true only for explicit operator recovery.", + ); +} + +function hasBifrostBridgeConfig() { + return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) && optionalString(process.env.BIFROST_BRIDGE_TOKEN)); +} + +function allowsUnreceiptedActivity(options) { + return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true"; +} + function listTopics(cache, options) { const topics = cache.getAll(topicDefinition) .filter((topic) => !options.status || topic.status === options.status) @@ -859,6 +886,7 @@ Mirror options: --mirror-content-file Read mirror text from a file --mirror-dry-run true Exercise mirror plumbing without posting to Discord --allow-unmirrored true Fixture/debug escape hatch; production writes should not use this + --allow-unreceipted-activity true Operator-recovery hatch; lets a mutation proceed without hosted Bifrost receipts `); }