From 718ae94caa0d5a45580d99149351bc3059aec0ee Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 22 Jun 2026 16:04:44 +0100 Subject: [PATCH] Advertise current bridge capabilities --- README.md | 8 +- .../ProviderAdvertisementTests.cs | 147 ++++++++++++++++++ tools/provider-advertisement.mjs | 74 ++++++++- 3 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs diff --git a/README.md b/README.md index 301409e..31dc0b4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This repository now includes a viable alpha foundation slice of the ASP.NET Core - GitHub App webhook ingestion for issue, pull request, and review sync - Heimdall-signed external patron support intake for Patreon and PayPal support facts - Eve Motion Verse surface and command endpoint for governance participation, with Razor motion forms demoted to a transitional browser lowering -- local bridge tooling for agent-owned GitHub draft PRs and Discord posts +- local bridge tooling for agent-owned GitHub draft PRs and PR comments, Discord posts and DMs, Reddit self-posts, and receipt-only future-surface requests - local bridge tooling for Reddit self-posts in `r/GameCultOrg`, including Persona flair labels through the Bifrost Reddit app - CultCache-backed governance topic threads for feature requests, discussion comments, Persona approvals, and dispatch promotion - CultCache/CultNet-backed agent intake tooling for repo Persona update requests @@ -75,14 +75,14 @@ For the full staged roadmap, including contribution points, revenue share, patro - POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access - set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path - set `Bridge:LocalBridgeToken` to allow the transitional local bridge actuator to register and receipt governed actions through `/bridge/actions/*` -- set `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` when `tools/bifrost-bridge.mjs` should round-trip every action through the app ledger before mutating GitHub, Discord, or Reddit +- set `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` when `tools/bifrost-bridge.mjs` should round-trip every action through the app ledger before mutating GitHub, Discord, or Reddit, or before recording a receipt-only future-surface request - 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 - those hosted runtime receipt lanes are local-bridge-owned writes, not ordinary member-session writes: `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` now require the configured bridge token and do not inherit browser-user identity into agent activity rows - those hosted runtime receipts also validate linkage instead of accepting free-floating text: dispatch runs require an existing request-lane receipt for the same request id, governance promotion receipts require a known promoted dispatch request, and request-lane rows cannot silently drift to a different repo or agent identity midstream - GitHub bridge actions that claim Bifrost-owned provenance now validate that provenance too: a `bifrost_agent_transport_request` source must point at a known request-lane request for the same repo, and governance-backed bridge actions must point at a known Bifrost topic instead of a decorative source string - governance-backed GitHub publication is also no longer allowed to jump the queue: a Bifrost governance topic must have been approved or promoted before it can justify a GitHub bridge action -- non-GitHub bridge activity is no longer allowed to drift off-book either: `discord-post`, `discord-dm`, and `reddit-post` now require `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` by default, with `--allow-unreceipted-activity true` reserved for explicit local recovery and refused inside dispatched work -- Persona and agent `discord-post` / `reddit-post` bridge actions also require a Bifrost identity plus Heimdall-backed capability/account reference in the hosted ledger; pass `--identity`, `--heimdall-capability-ref`, and the Epiphany provenance flags or set the matching environment variables. The bridge token is an actuator credential, not identity or provider authority. +- non-GitHub bridge activity is no longer allowed to drift off-book either: `discord-post`, `discord-dm`, `reddit-post`, and `other-request` now require `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` by default, with `--allow-unreceipted-activity true` reserved for explicit local recovery and refused inside dispatched work +- Persona and agent `discord-post`, `reddit-post`, and `other-request` bridge actions also require a Bifrost identity plus Heimdall-backed capability/account reference in the hosted ledger; pass `--identity`, `--heimdall-capability-ref`, and the Epiphany provenance flags or set the matching environment variables. The bridge token is an actuator credential, not identity or provider authority. - 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` stays blocked, and direct `gh` usage is limited to explicit read-only commands unless `tools/bifrost-bridge.mjs` authorizes the bridge-owned GitHub write path - dispatched Codex turns now default to `workspace-write` instead of full machine access, and the app-server sandbox policy disables network access unless the operator explicitly chooses a broader sandbox - `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 diff --git a/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs new file mode 100644 index 0000000..9e5168b --- /dev/null +++ b/tests/Bifrost.Web.Tests/ProviderAdvertisementTests.cs @@ -0,0 +1,147 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace Bifrost.Web.Tests; + +public sealed class ProviderAdvertisementTests +{ + [Fact] + public async Task Provider_advertisement_names_current_bridge_and_patron_contracts() + { + var result = await RunNodeAsync([ + "tools/provider-advertisement.mjs", + "print", + "--generated-at", + "2026-06-22T00:00:00.000Z", + ]); + + Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); + using var payload = JsonDocument.Parse(result.Stdout); + var root = payload.RootElement; + + AssertEndpoint(root, "bridge-action-ledger", "bifrost.bridge_action.v0", "bridge"); + AssertEndpoint(root, "patron-support-intake", "bifrost.patron_support_event.v0", "patronage"); + AssertEndpoint(root, "github-webhooks", "bifrost.work_item.v0", "github"); + AssertSchemaPurpose(root, "bifrost.bridge_action.v0", "current hosted governed crossing command witness"); + AssertSchemaPurpose(root, "bifrost.bridge_receipt.v0", "current hosted governed crossing result witness"); + AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost"); + AssertBoundaryCommand(root, "bridge", "open GitHub draft PRs and PR comments through Bifrost gate"); + AssertBoundaryCommand(root, "bridge", "record receipt-only future-surface requests before named actuators exist"); + AssertBoundaryCommand(root, "patron", "consume Heimdall-signed Patreon and PayPal support facts"); + AssertBoundaryForbiddenAuthority(root, "patron", "does not store Patreon or PayPal provider tokens"); + } + + [Fact] + public async Task Interface_binding_exposes_current_agent_bridge_capabilities() + { + var result = await RunNodeAsync([ + "tools/provider-advertisement.mjs", + "print-binding", + "--generated-at", + "2026-06-22T00:00:00.000Z", + ]); + + Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}"); + using var payload = JsonDocument.Parse(result.Stdout); + var root = payload.RootElement; + var provider = root.GetProperty("provider"); + + AssertArrayContains(provider.GetProperty("capabilities"), "github-bridge"); + AssertArrayContains(provider.GetProperty("capabilities"), "github-work-sync"); + AssertArrayContains(provider.GetProperty("capabilities"), "future-surface-bridge"); + AssertArrayContains(provider.GetProperty("capabilities"), "heimdall-patron-support-intake"); + + var stats = root.GetProperty("surface").GetProperty("root").GetProperty("props"); + Assert.Equal("2026-06-22T00:00:00.000Z", stats.GetProperty("generatedAt").GetString()); + + var bridgeMetric = root + .GetProperty("surface") + .GetProperty("root") + .GetProperty("children") + .EnumerateArray() + .SelectMany(panel => panel.GetProperty("children").EnumerateArray()) + .Single(node => node.GetProperty("id").GetString() == "metric-bridge"); + + var bridgeLine = bridgeMetric.GetProperty("props").GetProperty("value").GetString(); + Assert.Contains("GitHub yes", bridgeLine, StringComparison.Ordinal); + Assert.Contains("Other yes", bridgeLine, StringComparison.Ordinal); + Assert.Contains("Patron yes", bridgeLine, StringComparison.Ordinal); + } + + private static void AssertEndpoint(JsonElement root, string id, string schemaId, string lowering) + { + var endpoint = root.GetProperty("endpoints") + .EnumerateArray() + .Single(item => item.GetProperty("id").GetString() == id); + Assert.Equal(schemaId, endpoint.GetProperty("schemaId").GetString()); + AssertArrayContains(endpoint.GetProperty("lowerings"), lowering); + } + + private static void AssertSchemaPurpose(JsonElement root, string id, string purpose) + { + var schema = root.GetProperty("schemas") + .EnumerateArray() + .Single(item => item.GetProperty("id").GetString() == id); + Assert.Equal(purpose, schema.GetProperty("purpose").GetString()); + } + + private static void AssertBoundaryCommand(JsonElement root, string area, string command) + { + var boundary = Boundary(root, area); + AssertArrayContains(boundary.GetProperty("commands"), command); + } + + private static void AssertBoundaryForbiddenAuthority(JsonElement root, string area, string forbiddenAuthority) + { + var boundary = Boundary(root, area); + AssertArrayContains(boundary.GetProperty("forbiddenAuthority"), forbiddenAuthority); + } + + private static JsonElement Boundary(JsonElement root, string area) + => root.GetProperty("commandBoundaries") + .EnumerateArray() + .Single(item => item.GetProperty("area").GetString() == area); + + private static void AssertArrayContains(JsonElement array, string expected) + { + Assert.Contains(array.EnumerateArray(), item => item.GetString() == expected); + } + + private static string RepoRoot => + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + + private static async Task RunNodeAsync(string[] args) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "node", + WorkingDirectory = RepoRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + } + }; + + foreach (var arg in args) + { + process.StartInfo.ArgumentList.Add(arg); + } + + process.StartInfo.Environment["BIFROST_BRIDGE_BASE_URL"] = string.Empty; + process.StartInfo.Environment["BIFROST_BRIDGE_TOKEN"] = string.Empty; + process.StartInfo.Environment["BIFROST_SKIP_LOCAL_ENV"] = "true"; + process.StartInfo.Environment["BIFROST_REDDIT_CLIENT_ID"] = string.Empty; + process.StartInfo.Environment["BIFROST_REDDIT_REFRESH_TOKEN"] = string.Empty; + + process.Start(); + var stdout = await process.StandardOutput.ReadToEndAsync(); + var stderr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return new ProcessResult(process.ExitCode, stdout, stderr); + } + + private sealed record ProcessResult(int ExitCode, string Stdout, string Stderr); +} diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 1599ede..d5d522a 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -144,6 +144,15 @@ async function main() { case "print": printJson(buildAdvertisement(options)); return; + case "print-surface": + printJson(buildOperatorSurface(await collectStats(options))); + return; + case "print-binding": + { + const stats = await collectStats(options); + printJson(buildInterfaceBinding(buildOperatorSurface(stats), stats)); + } + return; case "schema": printJson({ documentType, @@ -204,6 +213,9 @@ function buildAdvertisement(options) { endpoint("operator-tui", `${locatedService}/eve/tui`, "gamecult.eve.surface.v1", ["tui", "nightwing-tui"]), endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]), endpoint("operator-commands", `${locatedService}/commands`, "bifrost.bridge_action.v0", ["command"]), + endpoint("bridge-action-ledger", `${locatedService}/bridge/actions`, "bifrost.bridge_action.v0", ["command", "bridge", "receipt"]), + endpoint("patron-support-intake", `${locatedService}/heimdall/patron-support/events`, "bifrost.patron_support_event.v0", ["heimdall", "patronage", "intake"]), + endpoint("github-webhooks", `${locatedService}/github/webhooks`, "bifrost.work_item.v0", ["github", "work-sync", "review-sync"]), endpoint("motion-surface", "https://bifrost.gamecult.org/eve/governance/surface", "gamecult.eve.surface.v1", ["product", "governance", "motion"]), endpoint("motion-commands", "https://bifrost.gamecult.org/eve/governance/commands", "bifrost.motion_command.v0", ["command", "governance", "motion"]), ], @@ -239,8 +251,9 @@ function buildAdvertisement(options) { schema("bifrost.vote.v0", "bifrost.vote", "motion vote witness"), schema("bifrost.motion_command.v0", "bifrost.motion-command", "Eve Motion Verse command envelope for create, vote, and close"), schema("bifrost.ledger_entry.v0", "bifrost.ledger-entry", "planned contributor/patron ledger witness"), - schema("bifrost.bridge_action.v0", "bifrost.bridge-action", "planned governed crossing command witness"), - schema("bifrost.bridge_receipt.v0", "bifrost.bridge-receipt", "planned governed crossing result witness"), + schema("bifrost.bridge_action.v0", "bifrost.bridge-action", "current hosted governed crossing command witness"), + schema("bifrost.bridge_receipt.v0", "bifrost.bridge-receipt", "current hosted governed crossing result witness"), + schema("bifrost.patron_support_event.v0", "bifrost.patron-support-event", "current hosted Heimdall-signed patron support fact consumed by Bifrost"), schema("bifrost.member_capability_snapshot.v0", "bifrost.member-capability-snapshot", "planned Heimdall-consumed membership capability witness"), ], witnesses: [ @@ -324,10 +337,12 @@ function buildAdvertisement(options) { ]), boundary("patron", "Bifrost", [ "record patron pressure", + "consume Heimdall-signed Patreon and PayPal support facts", "surface reward influence", "emit standing receipts", ], [ "does not charge cards", + "does not store Patreon or PayPal provider tokens", "does not execute external payout rails", ]), boundary("project", "Bifrost plus project maintainers", [ @@ -349,6 +364,8 @@ function buildAdvertisement(options) { "prepare governed public crossings", "execute approved handoffs", "record receipts", + "open GitHub draft PRs and PR comments through Bifrost gate", + "record receipt-only future-surface requests before named actuators exist", "post Persona-flaired Reddit organizing threads", ], [ "does not treat local protocol JSON as work authority", @@ -426,8 +443,14 @@ async function collectStats(options) { redditPost: Boolean(process.env.BIFROST_REDDIT_CLIENT_ID && process.env.BIFROST_REDDIT_REFRESH_TOKEN), githubDraftPr: true, githubPrComment: true, + githubWebhookSync: true, + otherRequest: true, + patronSupportIntake: true, + bridgeLedgerConfigured: Boolean(process.env.BIFROST_BRIDGE_BASE_URL && process.env.BIFROST_BRIDGE_TOKEN), credentialSource: process.env.BIFROST_DISCORD_BOT_TOKEN ? "BIFROST_DISCORD_BOT_TOKEN" : process.env.DISCORD_BOT_TOKEN ? "DISCORD_BOT_TOKEN-fallback" : "missing", redditCredentialSource: process.env.BIFROST_REDDIT_CLIENT_ID && process.env.BIFROST_REDDIT_REFRESH_TOKEN ? "BIFROST_REDDIT_CLIENT_ID+BIFROST_REDDIT_REFRESH_TOKEN" : "missing", + bridgeLedgerCredentialSource: process.env.BIFROST_BRIDGE_BASE_URL && process.env.BIFROST_BRIDGE_TOKEN ? "BIFROST_BRIDGE_BASE_URL+BIFROST_BRIDGE_TOKEN" : "missing", + patronSupportAuthority: "Heimdall HMAC via /heimdall/patron-support/events", }, summary: { status: health.ok && ready.ok ? "ready" : "degraded", @@ -553,7 +576,12 @@ function buildOperatorSurface(stats) { metricNode("daemon", "Daemon", `health ${stats.summary.health} / ready ${stats.summary.ready}`, stats.health.ok && stats.ready.ok ? "ok" : "warn"), metricNode("containers", "Containers", `${stats.summary.dockerRunning} up / ${stats.summary.dockerHealthy} healthy`, stats.summary.dockerHealthy > 0 ? "ok" : "warn"), metricNode("stores", "Stores", witnessHealthLine(stats.witnesses), witnessHealthTone(stats.witnesses)), - metricNode("bridge", "Bridge", `Discord post ${yesNo(stats.bridge.discordPost)} / DM ${yesNo(stats.bridge.discordDm)} / Reddit ${yesNo(stats.bridge.redditPost)}`, stats.bridge.redditPost ? "ok" : "warn"), + metricNode( + "bridge", + "Bridge", + `GitHub ${yesNo(stats.bridge.githubDraftPr && stats.bridge.githubPrComment)} / Discord ${yesNo(stats.bridge.discordPost)} / Reddit ${yesNo(stats.bridge.redditPost)} / Other ${yesNo(stats.bridge.otherRequest)} / Patron ${yesNo(stats.bridge.patronSupportIntake)}`, + stats.bridge.githubDraftPr && stats.bridge.githubPrComment && stats.bridge.otherRequest && stats.bridge.patronSupportIntake ? "ok" : "warn", + ), ]), panelNode("activity", "Activity", [ metricNode("topics", "Topics", `${stats.summary.governanceTopics} total / ${stats.summary.openTopics} open / ${stats.summary.recentGovernanceTopics} in ${stats.summary.recentWindowHours}h`, stats.governance.ok ? "ok" : "warn"), @@ -616,7 +644,20 @@ function buildInterfaceBinding(surface, stats) { route("cultcache-witness", ".bifrost/provider-advertisement.cc", "local-cultcache", true), route("websocket-bridge", "ws://192.168.1.66:8797/eve/deck", "compatibility-eve-deck", true), ], - capabilities: ["operator-stats", "bridge-health", "governance-counts", "agent-transport-counts", "motion-surface", "motion-commands"], + capabilities: [ + "operator-stats", + "bridge-health", + "governance-counts", + "agent-transport-counts", + "motion-surface", + "motion-commands", + "github-bridge", + "github-work-sync", + "discord-bridge", + "reddit-bridge", + "future-surface-bridge", + "heimdall-patron-support-intake", + ], usesCultMesh: true, status: stats.summary.status, transport: "CultMesh Eve interface binding.", @@ -822,6 +863,22 @@ function parseAdvertisement(input) { } } + const requiredEndpoints = new Set(["bridge-action-ledger", "patron-support-intake", "github-webhooks"]); + const endpointIds = new Set(advertisement.endpoints.map((endpoint) => endpoint.id)); + for (const id of requiredEndpoints) { + if (!endpointIds.has(id)) { + throw new Error(`Provider advertisement must name endpoint ${id}.`); + } + } + + const requiredSchemas = new Set(["bifrost.bridge_action.v0", "bifrost.bridge_receipt.v0", "bifrost.patron_support_event.v0"]); + const schemaIds = new Set(advertisement.schemas.map((schema) => schema.id)); + for (const id of requiredSchemas) { + if (!schemaIds.has(id)) { + throw new Error(`Provider advertisement must name schema ${id}.`); + } + } + return advertisement; } @@ -907,9 +964,11 @@ function printHelp() { process.stdout.write(`Bifrost Eve provider advertisement Commands: - export Write the Bifrost gamecult.eve.provider_advertisement.v1 document to a CultCache .cc witness - print Print the advertisement as protocol-debug JSON without writing state - schema Print document type metadata + export Write advertisement, operator surface, and interface binding to a CultCache .cc witness + print Print the advertisement as protocol-debug JSON without writing state + print-surface Print the current operator Eve surface JSON without writing state + print-binding Print the current Eve interface binding JSON without writing state + schema Print document type metadata Options: --out Override export path; defaults to .bifrost/provider-advertisement.cc @@ -917,6 +976,7 @@ Options: Examples: node tools/provider-advertisement.mjs print + node tools/provider-advertisement.mjs print-binding node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc `); }