diff --git a/docs/bridge.md b/docs/bridge.md index d5a87ba..5f38da1 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -45,9 +45,11 @@ It intentionally uses boring local credentials while Heimdall's managed GitHub c - GitHub actions use the local `gh` authenticated account. - Discord posts use `BIFROST_DISCORD_BOT_TOKEN` or `DISCORD_BOT_TOKEN`. - Reddit self-posts use `BIFROST_REDDIT_CLIENT_ID`, `BIFROST_REDDIT_REFRESH_TOKEN`, and optional `BIFROST_REDDIT_CLIENT_SECRET`. +- Future outside-world requests use `other-request` as a receipt-only bridge action until the target surface has a named Bifrost contract and actuator. The CLI is not the final permission system. It is the working bridge actuator Bifrost owns now, so VoidBot and repo Personas can stop carrying this machinery themselves. It now fails closed for all external write surfaces by default. GitHub mutations already required the hosted bridge gate; Discord posts, Discord DMs, and Reddit self-posts now require the same `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` receipt path unless an operator explicitly uses `--allow-unreceipted-activity true` for local recovery. Dispatched work is not allowed to use that recovery hatch. +`other-request` also fails closed by default. It does not execute a transport; it asks Bifrost to authorize and receipt the request under `BridgeTargetSurface.Other` so new Persona mouths can start behind identity, Heimdall linkage, and provenance policy before anyone writes a surface-specific actuator. ## Bridge Action Ledger @@ -200,6 +202,19 @@ If the subreddit uses fixed custom flair templates, pass `--persona-flair-id`. I Reddit is not the canonical vote ledger. Reddit comments, upvotes, and flair labels are evidence until a linked actor commits a Bifrost vote, priority signal, topic comment, or receipt. +### Future Surface Request + +```powershell +node .\tools\bifrost-bridge.mjs other-request ` + --identity epiphany.Persona ` + --surface-name bluesky ` + --target-locator at://did:example/app.bsky.feed.post/123 ` + --heimdall-capability-ref heimdall:bluesky:capability:epiphany-persona ` + --content "Persona asks Bifrost to authorize this public crossing." +``` + +The command records and completes a Bifrost bridge action using `targetSurface: Other` and `actionKind: Other`. It is the quarantine reliquary for public surfaces that are real enough to need identity and receipts, but not yet stable enough to deserve a first-class enum, actuator, or UI. Do not put provider bearer tokens in `--heimdall-capability-ref`; that value is the Heimdall-issued account/capability reference or a verifiable fingerprint-shaped reference. + ## Discord Native Interface Target Discord should become a native Bifrost interface for Bifrost-scoped work, not just an output pipe. diff --git a/tests/Bifrost.Web.Tests/BridgeCliTests.cs b/tests/Bifrost.Web.Tests/BridgeCliTests.cs index c57ecc2..af412c6 100644 --- a/tests/Bifrost.Web.Tests/BridgeCliTests.cs +++ b/tests/Bifrost.Web.Tests/BridgeCliTests.cs @@ -183,6 +183,70 @@ console.log(JSON.stringify({ Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Other_bridge_request_fails_closed_without_bifrost_receipt_gate() + { + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", + "other-request", + "--identity", "epiphany.Persona", + "--surface-name", "future-surface", + "--target-locator", "future://surface/thread/1", + "--content", "test message", + "--dry-run", "true", + ]); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Other_bridge_request_allows_explicit_operator_recovery_dry_run() + { + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", + "other-request", + "--identity", "epiphany.Persona", + "--surface-name", "future-surface", + "--target-locator", "future://surface/thread/1", + "--content", "test message", + "--heimdall-capability-ref", "heimdall:future-surface:capability:epiphany-persona", + "--allow-unreceipted-activity", "true", + "--dry-run", "true", + ]); + + Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); + using var payload = JsonDocument.Parse(result.Stdout); + Assert.Equal("other-request", payload.RootElement.GetProperty("action").GetString()); + Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean()); + Assert.Equal("future-surface", payload.RootElement.GetProperty("surfaceName").GetString()); + Assert.Equal("future://surface/thread/1", payload.RootElement.GetProperty("targetLocator").GetString()); + var provenance = payload.RootElement.GetProperty("provenance"); + Assert.Equal("epiphany.Persona", provenance.GetProperty("bifrostIdentity").GetString()); + Assert.Equal("heimdall:future-surface:capability:epiphany-persona", provenance.GetProperty("heimdallCapabilityRef").GetString()); + } + + [Fact] + public async Task Other_bridge_request_recovery_hatch_is_rejected_inside_dispatched_turn() + { + var result = await RunNodeAsync([ + "tools/bifrost-bridge.mjs", + "other-request", + "--identity", "epiphany.Persona", + "--surface-name", "future-surface", + "--target-locator", "future://surface/thread/1", + "--content", "test message", + "--allow-unreceipted-activity", "true", + "--dry-run", "true", + ], new Dictionary + { + ["BIFROST_LOCK_RECOVERY_HATCHES"] = "true", + }); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Discord_dm_bridge_command_fails_closed_without_bifrost_receipt_gate() { diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index f49dc9f..e6a5a80 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -38,6 +38,9 @@ async function main() { case "reddit-post": await postRedditThread(options); return; + case "other-request": + await requestOtherBridgeAction(options); + return; default: throw new Error(`Unknown command "${command}". Run "node tools/bifrost-bridge.mjs help".`); } @@ -503,6 +506,69 @@ async function postRedditThread(options) { }); } +async function requestOtherBridgeAction(options) { + ensureBridgeReceiptGate(options); + const identity = slugify(requireOption(options, "identity")); + const targetLocator = requireOption(options, "target-locator"); + const surfaceName = optionalString(options["surface-name"]) ?? "future-surface"; + const content = await readOptionText(options, "content", "content-file", ""); + const title = optionalString(options.title) ?? `Outside-world bridge request for ${surfaceName}`; + const dryRun = options["dry-run"] === "true"; + + if (dryRun) { + printJson({ + dryRun: true, + action: "other-request", + identity, + surfaceName, + targetLocator, + title, + content, + provenance: buildBridgeProvenance(options), + }); + return; + } + + const bridgeAction = await beginBridgeAction(options, { + actorKind: "Agent", + actorName: identity, + targetSurface: "Other", + actionKind: "Other", + targetRepositoryFullName: options["target-repository-full-name"] ?? "", + targetLocator, + title, + summary: content, + }); + + try { + await bridgeAction?.start(); + await bridgeAction?.complete({ + receiptUrl: `bifrost://bridge/actions/${bridgeAction.id}`, + externalReceiptId: bridgeAction.id, + receiptPayload: JSON.stringify(buildReceiptPayload(options, { + surfaceName, + targetLocator, + title, + })), + }); + } catch (error) { + await bridgeAction?.fail(error); + throw error; + } + + printJson({ + action: "other-request", + ok: true, + bridgeActionId: bridgeAction.id, + receiptUrl: `bifrost://bridge/actions/${bridgeAction.id}`, + identity, + surfaceName, + targetLocator, + title, + provenance: buildBridgeProvenance(options), + }); +} + async function getRedditAccessToken() { const clientId = optionalString(process.env.BIFROST_REDDIT_CLIENT_ID ?? process.env.REDDIT_CLIENT_ID); const clientSecret = process.env.BIFROST_REDDIT_CLIENT_SECRET ?? process.env.REDDIT_CLIENT_SECRET ?? ""; @@ -1302,6 +1368,7 @@ Commands: discord-post Post a message to Discord through the bot token or persona webhook pipe discord-dm Send a Discord DM through Bifrost's bridge-owned bot token reddit-post Create a self-post in r/GameCultOrg through the Bifrost Reddit app + other-request Record a Bifrost-gated request for a future outside-world surface Examples: node tools/bifrost-bridge.mjs github-draft-pr --repo-root E:/Projects/AetheriaLore --identity nibu --title "Nibu: Glitchcraft" --path Aetheria/Articles/Nibu/glitchcraft.md --content-file article.md @@ -1309,6 +1376,7 @@ Examples: node tools/bifrost-bridge.mjs discord-post --channel-id 1501196543150264332 --persona-name Nibu --content "Draft PR opened: https://github.com/..." node tools/bifrost-bridge.mjs discord-dm --recipient-id 123456789 --content "Moderation status update..." node tools/bifrost-bridge.mjs reddit-post --title "Nibu: Reset-loop continuity" --persona-name Nibu --content-file thread.md + node tools/bifrost-bridge.mjs other-request --identity epiphany.Persona --surface-name bluesky --target-locator at://did:example/app.bsky.feed.post/123 --heimdall-capability-ref heimdall:bluesky:capability:epiphany-persona --content "Persona asks to speak." GitHub note: GitHub actions require BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can gate and receipt the crossing.