Advertise current bridge capabilities

This commit is contained in:
Meta Vens 2026-06-22 16:04:44 +01:00
commit 718ae94caa
3 changed files with 218 additions and 11 deletions

View file

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

View file

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

View file

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