Fail closed when agent receipts cannot be recorded

This commit is contained in:
Meta Vens 2026-06-17 05:56:25 +01:00
commit 793a910886
7 changed files with 222 additions and 4 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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();

View file

@ -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 <path> 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

View file

@ -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
`);
}

View file

@ -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 <path> 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
`);
}