Fail closed when agent receipts cannot be recorded
This commit is contained in:
parent
cab9d22f78
commit
793a910886
7 changed files with 222 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue