Move public crossings onto typed Bifrost transport

This commit is contained in:
Meta Vens 2026-07-15 22:16:07 +01:00
commit cde3a78874
18 changed files with 1686 additions and 2073 deletions

View file

@ -11,10 +11,6 @@ RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \
/p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
@ -23,7 +19,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \
EXPOSE 5080
COPY --from=build /app/publish .
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:5080/healthz || exit 1
ENTRYPOINT ["dotnet", "Bifrost.Web.dll"]

View file

@ -20,7 +20,8 @@ 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 PR comments, Discord posts and DMs, Reddit self-posts, and receipt-only future-surface requests
- local bridge tooling for agent-owned GitHub draft PRs and PR comments, Discord posts and DMs, Reddit self-posts, and fail-closed future-surface request probes
- canonical `bifrost.crossing_receipt.v1` history in `.bifrost/bridge-receipts.cc` for GitHub, Discord, Reddit, governance, and agent-transport crossings
- 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
@ -74,23 +75,15 @@ For the full staged roadmap, including contribution points, revenue share, patro
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
- 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, 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`, `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. Future-surface `other-request` actions must also carry `--surface-name`, and Bifrost rejects Heimdall references that do not match that named surface. The bridge token is an actuator credential, not identity or provider authority.
- HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`.
- external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed.
- Persona and agent public crossings still require Bifrost identity, Heimdall capability/account reference, and Epiphany provenance, but those facts travel with typed command/receipt documents rather than a local HTTP bridge token.
- 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
- `tools/agent-transport.mjs apply-snapshot` is also treated as a mutating import path now: it fails closed without the bridge receipt config and derives request-lane receipts from the imported state delta
- `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
- dispatched Codex turns now lock those recovery hatches too: local-only `--allow-ungated-github` and `--allow-unreceipted-activity` escapes are refused inside dispatched work so a worker cannot silently downgrade itself out of Bifrost receipts
- `tools/agent-transport.mjs` records queue, claim, release, close, and snapshot activity in the typed request-lane store.
- `tools/governance-threads.mjs` records topic opens, comments, approvals, and promotions in the typed governance store.
- GitHub bridge mutations now fail closed without typed CultMesh command provenance; ungated and HTTP-ledger hatches are removed.
- dispatched Codex turns inherit the same invariant: a worker cannot downgrade itself out of Bifrost gate or receipt policy
- dispatched Codex turns also now scrub ambient GitHub auth state from the child environment: `GH_TOKEN` / `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompting is disabled before the worker starts
- 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
@ -99,10 +92,13 @@ For the full staged roadmap, including contribution points, revenue share, patro
- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed
- dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true`
- print the Eve provider advertisement with `node tools/provider-advertisement.mjs print`
- export the Eve provider advertisement witness with `node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc`
- export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc`
- inspect the interactive Motion Verse surface at `/eve/governance/surface` while signed in as an active member
- container smoke test with `docker compose -f compose.local.yaml up --build`
- container health checks live at `http://127.0.0.1:5080/healthz` and `http://127.0.0.1:5080/readyz`
- container smoke tests may hit `/healthz` and `/readyz` as product probes.
Provider advertisements, Docker health, and deploy gates must come from
Bifrost's typed CultMesh/Idunn operator state; `BIFROST_HEALTH_URL` and
`BIFROST_READY_URL` are not accepted by the provider exporter.
## Read First

View file

@ -125,19 +125,15 @@ node .\tools\agent-transport.mjs apply-snapshot --in E:\tmp\bifrost-agent-transp
`enqueue` also mirrors the queued request into `#bifrost` by default. Use `--mirror-content-file` when a Persona has a better human-facing line than the fallback receipt; use `--mirror-dry-run true` only for smoke tests.
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.
Those hosted runtime receipt endpoints are owned by the local bridge token, not by ordinary member sessions. A signed-in member can govern work and request crossings, but they cannot POST `/dispatch/runs/*`, `/transport/receipts`, or `/governance/receipts` without the configured bridge token and pretend to be the worker runtime.
Those hosted receipt endpoints now also cross-check each other. Dispatch starts require an existing request-lane receipt for the same request id, governance promotions that cite `dispatchRequestId` must point at a known request-lane request, and additional request-lane receipts for one request id cannot drift onto a different repo or agent identity. The ledger is still transitional, but it has stopped politely accepting shape-shifting runtime history.
That same Bifrost request provenance now matters at the GitHub crossing too. When a dispatched worker uses `tools/bifrost-bridge.mjs` with the inherited `bifrost_agent_transport_request` source, the hosted bridge checks that the cited request exists in the request-lane ledger and still belongs to the same repo before it authorizes the GitHub mutation.
Governance-backed GitHub publication is stricter now too. If a bridge action cites a Bifrost governance topic as its source, the hosted bridge requires that the topic has actually been approved or promoted before it will authorize a GitHub mutation. An opened topic or comment thread by itself is no longer enough to claim GitHub authority.
The rest of the bridge-owned external activity now follows the same receipt discipline. If dispatched work posts a Discord receipt, sends a Discord DM, or opens a Reddit organizing thread through `tools/bifrost-bridge.mjs`, the normal path requires the hosted bridge base URL/token so Bifrost records the crossing. `--allow-unreceipted-activity true` is now an explicit local recovery hatch and is refused inside dispatched work.
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. Runtime receipt truth lives in Bifrost's typed request, governance, dispatch, and bridge command stores; the old hosted bridge base URL/token path is no longer a live transport.
That same Bifrost request provenance now matters at the GitHub crossing too. Dispatched work must carry typed Bifrost CultMesh command provenance before a bridge actuator may cross into GitHub, Discord, Reddit, or another public surface.
Those dispatched turns also inherit 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 inside dispatched work even with `--no-verify`, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub mutation it owns.
Those dispatched turns also lock the local-only recovery hatches. `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so an agent cannot quietly downgrade itself out of Bifrost's gate or receipt trail.
Those dispatched turns inherit the same gate and receipt invariant, so an agent cannot quietly downgrade itself out of Bifrost's gate or receipt trail.
Those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts. `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled. That does not prove every imaginable direct-HTTP escape is impossible, but it removes the normal ambient credential path from dispatched work.
Those dispatched turns now also default to the `workspace-write` sandbox instead of full machine access, and the app-server launch path now tells Codex to keep `networkAccess: false` unless the operator explicitly chooses a broader sandbox. The worker can still read the repo and do its job, but GitHub authority no longer leaks in through the default launch posture.
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.
`apply-snapshot` is treated as a mutating import path too. It now fails closed without that same bridge configuration and derives request-lane receipts from the imported state delta, so a snapshot can no longer rewrite queue or claim state behind Bifrost's back.
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.
`tools/agent-transport.mjs` records queue, claim, release, and close operations directly in the typed request-lane store, then writes canonical `bifrost.crossing_receipt.v1` entries to `.bifrost/bridge-receipts.cc`. The packet lives in CultCache/CultMesh; hosted HTTP receipt rows are not a live authority.
`apply-snapshot` is treated as a mutating import path too. It derives canonical crossing receipts from the imported state delta, so a snapshot can no longer rewrite queue or claim state behind Bifrost's back.
Those mutating scripts fail closed if the removed HTTP bridge env is present. Repair the typed CultMesh route instead of 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

@ -45,86 +45,61 @@ 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.
- Future outside-world requests must have a named typed command/receipt contract before they get a surface 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.
It now fails closed for external write surfaces unless the action carries typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`. The old HTTP bridge ledger environment (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport.
`other-request` fails closed by default. It does not execute a transport; it must become a typed Bifrost command/receipt document under a named surface before anyone writes a surface-specific actuator.
## Bridge Action Ledger
## Crossing Receipts
The hosted app now exposes a first-class bridge action ledger for governed transport requests:
- `POST /bridge/actions/request`
- `GET /bridge/actions/{id}`
- `POST /bridge/actions/{id}/start`
- `POST /bridge/actions/{id}/complete`
- `POST /bridge/actions/{id}/fail`
The owner is Bifrost, not the local script. The script or agent may execute the crossing, but Bifrost records the request, policy decision, lifecycle state, and receipt.
The owner is Bifrost, not the local script. The script or agent may execute the crossing, but Bifrost records the request, policy decision, lifecycle state, and receipt in typed CultCache/CultMesh documents.
Persona and agent actions that cross into Discord, Reddit, or a future
outside-world surface (`Other` until the surface has a first-class enum value)
must carry a Bifrost identity plus a Heimdall-backed capability/account reference
at request time. The local bridge token only proves that the actuator may call
the ledger; it is not the actor identity and not outside-provider authority. The
ledger stores the Bifrost identity, Heimdall reference, Epiphany run id, lane id,
at request time. The command document stores the Bifrost identity, Heimdall reference, Epiphany run id, lane id,
and agent identity beside the action so later receipts can answer which agent
did what without opening local transcripts or trusting helper-script folklore.
The hosted app also exposes a dispatch-run ledger for the older Bifrost-launched worker bridge:
The canonical receipt document is `bifrost.crossing_receipt.v1`, stored in
`.bifrost/bridge-receipts.cc`. It records one crossing attempt with command,
source, authority, actor, target, external proof, lifecycle status, and error
details. Surface-specific receipts, including
`bifrost.bridge.discord_post_receipt.v1`, are details that reference the
canonical receipt. They do not own crossing completion.
- `POST /dispatch/runs/start`
- `POST /dispatch/runs/{id}/complete`
- `POST /dispatch/runs/{id}/fail`
That dispatcher was a transitional way for VoidBot-era Persona consensus to queue Codex workers. It is not the blessed Epiphany launch organ. Keep its receipts as historical/quarantine evidence where they exist, but the forward path is Epiphany-owned execution plus Bifrost-owned crossing receipts.
The old hosted dispatch-run and bridge-action ledgers are historical/quarantine evidence where records already exist. The forward path is Epiphany-owned execution plus Bifrost-owned typed crossing receipts.
For Epiphany work, a bridge action says "this Epiphany run/lane requested a governed crossing into GitHub/Discord/Reddit and Bifrost receipted the result." The receipt payload should carry Bifrost identity, Epiphany run id, lane id, agent identity, source object, authority reference, Heimdall capability/account reference, and any external receipt facts such as PR URL, comment id, commit SHA, or changed paths.
The hosted app also exposes a request-lane receipt ledger for transport lifecycle events:
- `POST /transport/receipts`
This covers the activity that happens before or around a visible Codex turn: queueing a request, claiming it, releasing it, and closing it. The `.cc` packet is still the transport document. The hosted receipt row is the operator-facing witness that the activity happened.
The hosted app also exposes a governance activity receipt ledger:
- `POST /governance/receipts`
This covers the other side of the swarm's work: topic opens, comments, approvals, and promotions to dispatch. The topic and comment documents are still the governance substrate. The hosted receipt row is the Bifrost-side witness that an agent or operator performed the governance action.
These runtime receipt lanes are not generic member write surfaces. In the current machine, `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` are owned by the configured local bridge token. Active members may govern and request bridge actions through the app, but they may not mint worker-history rows directly through a browser session and impersonate runtime activity.
They also now validate linkage across the hosted ledger instead of accepting isolated claims. A dispatch run must point at an already-seen request-lane receipt for the same request id. A governance promotion receipt that cites `dispatchRequestId` must point at a known request-lane request. Repeated request-lane rows for one request id may add activity, but they may not quietly change the repo or agent identity the request belongs to.
GitHub bridge actions that cite Bifrost-owned provenance now validate that provenance too. If a bridge action says its source is `bifrost_agent_transport_request`, the hosted bridge checks that the request exists in the request-lane ledger and still belongs to the same repo. If a bridge action says its source is `bifrost_governance_topic` or legacy `governance_topic`, the hosted bridge checks that the topic exists in the governance receipt trail before authorizing the crossing.
For GitHub publication, mere topic existence is no longer enough. A governance-backed GitHub bridge action now requires that the cited topic has reached at least `TopicApproved` or `TopicPromoted` in the governance receipt trail. Opened/commented-only topics are evidence of discussion, not yet permission to mutate GitHub.
Request-lane receipts are canonical crossing receipts for typed request-store mutations: queue, claim, release, close, and snapshot import. Governance receipts are canonical crossing receipts for typed governance-store mutations: topic opens, comments, approvals, and promotions. Runtime HTTP receipt posts are not a live authority.
Current gate behavior:
- active Bifrost members may request bridge actions from an authenticated app session
- the transitional local bridge actuator may call the same endpoints with `X-Bifrost-Bridge-Token`
- daemon and agent crossings must use typed CultMesh command/receipt documents
- agent, Persona, and service actions must cite provenance: `authorityReference`, `sourceKind` plus `sourceId`, `workItemId`, or `motionId`
- completed actions must report a receipt URL, external receipt id, or serialized receipt payload
- GitHub mutation commands fail closed unless Bifrost authorization is configured; `--allow-ungated-github true` is the explicit operator-recovery hatch
- the future blessed credential for public-world crossings is a Bifrost identity plus Heimdall-issued capability token or verifiable capability/account reference. New outside-world bridge surfaces must start behind that same identity/capability gate, using `Other` only as a temporary enum bucket until the target surface gets a named contract. The local bridge token is a transitional service actuator credential, not a substitute for Bifrost identity or Heimdall-backed outside-account authority.
- completed visible actions must write `bifrost.crossing_receipt.v1` with a receipt URL, external receipt id, or serialized receipt payload where the target surface provides one
- GitHub mutation commands fail closed unless typed Bifrost CultMesh command provenance is present
- the future blessed credential for public-world crossings is a Bifrost identity plus Heimdall-issued capability reference or verifiable capability/account reference. New outside-world bridge surfaces must start behind that same identity/capability gate. Bifrost records claim `jti`, `account_id`, `access_revision`, `exp`, grant id/ref, and revoked/expired behavior as references or redacted snapshots, not bearer tokens.
Current CLI wiring:
- set `BIFROST_BRIDGE_BASE_URL` to the Bifrost app base URL
- set `BIFROST_BRIDGE_TOKEN` to the configured local bridge token
- GitHub commands now require both values by default, even for dry-run, so the normal path cannot silently bypass the gate
- provide `BIFROST_CULTMESH_COMMAND_ID` or `--cultmesh-command-id` from the typed command document being processed
- GitHub commands require typed CultMesh command provenance by default, even for dry-run, so the normal path cannot silently bypass the gate
- pass `--identity`, `--source-kind`, `--source-id`, `--authority-ref`, `--work-item-id`, or `--motion-id` so agent actions satisfy policy
- Epiphany callers should also pass `--epiphany-run-id`, `--epiphany-lane-id`, `--epiphany-agent-identity`, and `--heimdall-capability-ref` or provide `EPIPHANY_RUN_ID`, `EPIPHANY_LANE_ID`, `EPIPHANY_AGENT_IDENTITY`, and `HEIMDALL_CAPABILITY_REF` in the environment. The CLI stores the Bifrost identity and capability/account reference or fingerprint-shaped value, never the Heimdall bearer token.
- the dispatcher now preloads `BIFROST_BRIDGE_SOURCE_KIND`, `BIFROST_BRIDGE_SOURCE_ID`, and `BIFROST_BRIDGE_AUTHORITY_REF` into Codex turns launched from Bifrost update requests, so normal bridge use inside a dispatched turn carries request provenance by default
- those dispatched 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 even when `--no-verify` is used, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub write path
- those dispatched turns also lock local-only recovery hatches: `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so a worker cannot opt itself out of Bifrost gate or receipt policy
- those dispatched turns inherit the same gate and receipt invariant, so a worker cannot opt itself out of Bifrost gate or receipt policy
- those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts: `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled
- those dispatched turns now default to the `workspace-write` sandbox in both `codex exec` and app-server launch paths, and the non-danger app-server sandbox policy sets `networkAccess: false` so GitHub mutation authority stays with Bifrost instead of bleeding back into the child runtime by default
- 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
- the dispatcher writes local run artifacts and request-store state for start/complete/fail visibility
- the agent transport CLI writes canonical crossing receipts for queue, claim, release, close, and snapshot import events to `.bifrost/bridge-receipts.cc`
- the governance threads CLI writes canonical crossing receipts for topic opens, comments, approvals, and promotions to `.bifrost/bridge-receipts.cc`
- those agent-activity CLIs now fail closed without that bridge configuration
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.
If hosted app authorization is absent, `tools/bifrost-bridge.mjs` keeps working as a local actuator only when typed CultMesh command provenance is present. It writes requested, running, completed, failed, or cancelled crossing receipts directly to the canonical receipt store.
### GitHub Draft PR
@ -213,7 +188,7 @@ node .\tools\bifrost-bridge.mjs other-request `
--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.
The command is a dry-run shape probe 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. Live writes fail closed until that surface has a typed Bifrost command/receipt contract. 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

View file

@ -34,6 +34,9 @@ Current witnesses:
- `.bifrost/agent-transport.cc` stores
`bifrost.agent-transport.update-request` documents and can emit raw CultNet
snapshots.
- `.bifrost/bridge-receipts.cc` stores canonical
`bifrost.crossing_receipt.v1` documents for governed public crossing
attempts.
Required witness path for the hosted service:
@ -103,12 +106,12 @@ cache directly for debugging and development, but that is not the xeno boundary.
Bifrost has a read-only first-cut provider advertisement command:
```powershell
node tools/provider-advertisement.mjs export --out .bifrost/provider-advertisement.cc
node tools/provider-advertisement.mjs publish-odin --odin-cultmesh-rudp 127.0.0.1:17871
node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc
$env:BIFROST_ODIN_CULTMESH_URI="cultmesh://odin/rendezvous/provider-catalog"; node tools/provider-advertisement.mjs publish-odin
```
It writes Bifrost-owned Eve discovery documents into
`.bifrost/provider-advertisement.cc`:
`.bifrost/provider-store.cc`:
- `gamecult.eve.provider_advertisement.v1` names Bifrost's Account, Patron,
Project, Work, Motion, and Operator Verse surfaces; current and planned
@ -125,9 +128,11 @@ This export does not migrate Postgres state, read secrets, execute bridge
actions, or make Razor Pages, HTTP probes, Discord mirrors, Reddit threads, or
local dispatch JSON canonical. The operator surface may display those probes, but the provider
owned `.cc` witness remains the discovery and dashboard source.
When `BIFROST_ODIN_CULTMESH_RUDP` or `--odin-cultmesh-rudp` is configured, the
same provider advertisement can be published once to Odin's CultMesh/RUDP
document catalog through the provider-advertisement tool.
When `BIFROST_ODIN_CULTMESH_URI` or `--odin-cultmesh-uri` is configured, the
same provider advertisement can be published once to Odin's CultMesh rendezvous
URI through the provider-advertisement tool. Concrete RUDP bootstrap endpoints
belong behind CultMesh URI resolution, not in Bifrost provider publication
configuration.
For protocol-debug inspection without writing a witness:
@ -146,6 +151,19 @@ Product surfaces are the canonical interface compositions for:
- work claims, review, completion, receipts, and contributor credit;
- bridge receipts and public handoff targets.
Canonical crossing receipts use `bifrost.crossing_receipt.v1` in
`.bifrost/bridge-receipts.cc`. A receipt records `receiptId`, `commandId`,
`crossingKind`, lifecycle status, Bifrost actor, source provenance, authority
reference, optional Heimdall claim/grant references, optional Epiphany run/lane
identity, target locator, external receipt facts, error details, and related
receipt ids. Surface-specific receipts such as
`bifrost.bridge.discord_post_receipt.v1` may remain as details, but they must
reference the canonical crossing receipt and cannot decide crossing completion.
Heimdall values recorded here are stable references or redacted snapshots:
claim `jti`, `account_id`, `access_revision`, `exp`, grant id/ref, and
revoked/expired behavior. Provider bearer tokens do not belong in Bifrost
receipts.
Operator surfaces are the canonical interface compositions for:
- readiness, build/version, config validation, and deploy target;
@ -160,11 +178,18 @@ Operator surfaces are the canonical interface compositions for:
- Reddit bridge readiness for `r/GameCultOrg` organizing posts and Persona flair.
The Motion Verse has begun the migration from Razor-owned behavior to Eve-owned
presentation. `/eve/governance/surface` emits a `gamecult.eve.surface.v1`
composition for motions and voting. `/eve/governance/commands` accepts
`motion.create`, `motion.vote`, and `motion.close` commands, then routes those
commands through the same Bifrost motion governance service used by the Razor
forms.
presentation. Bifrost publishes the motion surface and command target as
CultMesh addresses discovered through Odin:
```text
cultmesh://asgard.starfire.bifrost/eve/governance/surface
cultmesh://asgard.starfire.bifrost/commands/motion
```
The transitional browser product may lower motion state for members, but it is
not a Verse command transport. `motion.create`, `motion.vote`, and
`motion.close` are command documents for the CultMesh route, then commit through
the same Bifrost motion governance service used by the Razor forms.
The current Razor Pages app is now a transitional browser lowering for motions,
not the behavior owner. Existing health/readiness endpoints lower operator
@ -226,10 +251,10 @@ Bifrost.
## Migration Order
1. Catalog current Bifrost state owners: EF/Postgres tables, `.cc` stores,
bridge receipts, Discord mirrors, Reddit receipts, webhook cache, and app
crossing receipts, Discord mirrors, Reddit receipts, webhook cache, and app
readiness state.
2. Define schema ids for missing Bifrost documents: work item, motion, vote,
ledger entry, bridge action, bridge receipt, member capability snapshot,
ledger entry, bridge action, crossing receipt, member capability snapshot,
service registration, and Eve surface publication.
3. Add Postgres-to-CultCache witness export for alpha entities before replacing
the transactional store.
@ -253,10 +278,15 @@ Demoted surfaces and stores:
- Razor Pages views are no longer the canonical Bifrost presentation owner; they
are browser lowerings of Bifrost product/operator surfaces.
- `/healthz`, `/readyz`, and any future HTTP dashboard/status JSON are probes or
xenos-boundary exports, not service truth.
- `/healthz`, `/readyz`, and any future HTTP dashboard/status JSON are product
smoke probes or xenos-boundary exports, not service truth. Provider
advertisements and Odin-facing operator readiness must reject HTTP probe
configuration and derive status from typed CultMesh/Idunn state.
- `.bifrost/discord-webhook-cache.json` is webhook address cache only; it does
not prove governance, speech authority, or receipt completion.
- `bifrost.bridge.discord_post_receipt.v1` and other surface receipts are
surface-specific evidence only. `bifrost.crossing_receipt.v1` is the
canonical crossing receipt owner.
- `.bifrost/agent-dispatch/**/request.json`, `dispatch.json`, `result.json`, and
app-server protocol JSON are local dispatch/protocol artifacts. They may be
evidence attached to typed receipts, but they cannot decide request status,

View file

@ -4,6 +4,8 @@ namespace Bifrost.Web.Features.Motions;
public sealed class MotionEveSurfaceService
{
private const string MotionCommandTarget = "cultmesh://asgard.starfire.bifrost/commands/motion";
public EveSurfaceDocument BuildSurface(MotionGovernanceState state)
{
var motionNodes = state.Motions.Count > 0
@ -23,7 +25,9 @@ public sealed class MotionEveSurfaceService
["subtitle"] = "Motions, weighted votes, thresholds, and receipts",
["actor"] = state.ActorName,
["effectiveVotingWeight"] = state.EffectiveVotingWeight,
["canonicalCommandTarget"] = "/eve/governance/commands"
["canonicalCommandTarget"] = MotionCommandTarget,
["commandTransport"] = "cultmesh-command-document",
["commandSchema"] = "bifrost.motion_command.v0"
},
[
new EveNode(
@ -33,8 +37,8 @@ public sealed class MotionEveSurfaceService
{
["title"] = "Open Motion",
["command"] = "motion.create",
["target"] = "/eve/governance/commands",
["method"] = "POST"
["target"] = MotionCommandTarget,
["transport"] = "cultmesh-command-document"
},
[
SelectNode("scope", "Scope", EnumNames<MotionScope>()),
@ -121,7 +125,11 @@ public sealed class MotionEveSurfaceService
new EveNode(
$"motion-commands-{motion.Id}",
"commands",
new Dictionary<string, object?> { ["target"] = "/eve/governance/commands" },
new Dictionary<string, object?>
{
["target"] = MotionCommandTarget,
["transport"] = "cultmesh-command-document"
},
commandNodes)
]);
}
@ -139,8 +147,8 @@ public sealed class MotionEveSurfaceService
{
["label"] = label,
["command"] = command,
["target"] = "/eve/governance/commands",
["method"] = "POST",
["target"] = MotionCommandTarget,
["transport"] = "cultmesh-command-document",
["payload"] = payload,
["tone"] = tone
});
@ -193,15 +201,3 @@ public sealed record EveNode(
IReadOnlyList<EveNode>? Children = null);
public sealed record EveOption(string Value, string Label);
public sealed record MotionEveCommandRequest(
string Command,
Guid? MotionId,
MotionScope? Scope,
Guid? ProjectId,
MotionCategory? Category,
string? Title,
string? Summary,
DateTimeOffset? ClosesAtUtc,
VoteChoice? Choice,
string? Comment);

View file

@ -470,282 +470,6 @@ app.MapPost("/patronage/stripe/webhook", async (
};
}).AllowAnonymous();
var bridgeActions = app.MapGroup("/bridge/actions");
bridgeActions.MapPost("/request", async (
HttpRequest request,
BridgeActionRequest command,
ICurrentBifrostActorAccessor actorAccessor,
BridgeActionService bridgeActionService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
var actor = await actorAccessor.GetAsync(cancellationToken);
var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value);
if (!caller.IsAllowedTransport)
{
return Results.Unauthorized();
}
var result = await bridgeActionService.RequestAsync(command, caller, cancellationToken);
return result.Status == BridgeActionStatus.Denied
? JsonResult(result, appJsonSerializerOptions, StatusCodes.Status403Forbidden)
: JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
});
bridgeActions.MapGet("/{id:guid}", async (
Guid id,
HttpRequest request,
ICurrentBifrostActorAccessor actorAccessor,
BifrostDbContext dbContext,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
var actor = await actorAccessor.GetAsync(cancellationToken);
var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value);
if (!caller.IsAllowedTransport)
{
return Results.Unauthorized();
}
var action = await dbContext.BridgeActions
.AsNoTracking()
.SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
if (action is null)
{
return Results.NotFound();
}
if (!caller.CanOperate(action))
{
return Results.Forbid();
}
return JsonResult(BridgeActionResult.From(action), appJsonSerializerOptions);
});
bridgeActions.MapPost("/{id:guid}/start", async (
Guid id,
HttpRequest request,
ICurrentBifrostActorAccessor actorAccessor,
BridgeActionService bridgeActionService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
var actor = await actorAccessor.GetAsync(cancellationToken);
var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value);
if (!caller.IsAllowedTransport)
{
return Results.Unauthorized();
}
try
{
var result = await bridgeActionService.StartAsync(id, caller, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
}
catch (UnauthorizedAccessException)
{
return Results.Forbid();
}
catch (BridgeActionException error)
{
return Results.BadRequest(error.Message);
}
});
bridgeActions.MapPost("/{id:guid}/complete", async (
Guid id,
HttpRequest request,
BridgeActionReceiptRequest command,
ICurrentBifrostActorAccessor actorAccessor,
BridgeActionService bridgeActionService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
var actor = await actorAccessor.GetAsync(cancellationToken);
var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value);
if (!caller.IsAllowedTransport)
{
return Results.Unauthorized();
}
try
{
var result = await bridgeActionService.CompleteAsync(id, command, caller, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions);
}
catch (UnauthorizedAccessException)
{
return Results.Forbid();
}
catch (BridgeActionException error)
{
return Results.BadRequest(error.Message);
}
});
bridgeActions.MapPost("/{id:guid}/fail", async (
Guid id,
HttpRequest request,
BridgeActionFailureRequest command,
ICurrentBifrostActorAccessor actorAccessor,
BridgeActionService bridgeActionService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
var actor = await actorAccessor.GetAsync(cancellationToken);
var caller = BuildBridgeCaller(actor, request, bridgeOptions.Value);
if (!caller.IsAllowedTransport)
{
return Results.Unauthorized();
}
try
{
var result = await bridgeActionService.FailAsync(id, command, caller, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions);
}
catch (UnauthorizedAccessException)
{
return Results.Forbid();
}
});
var dispatchRuns = app.MapGroup("/dispatch/runs");
dispatchRuns.MapPost("/start", async (
HttpRequest request,
AgentDispatchRunStartRequest command,
AgentDispatchRunService agentDispatchRunService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value))
{
return Results.Unauthorized();
}
try
{
var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
}
catch (AgentDispatchRunException error)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
dispatchRuns.MapPost("/{id:guid}/complete", async (
Guid id,
HttpRequest request,
AgentDispatchRunCompletionRequest command,
AgentDispatchRunService agentDispatchRunService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value))
{
return Results.Unauthorized();
}
try
{
var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions);
}
catch (AgentDispatchRunException error)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
dispatchRuns.MapPost("/{id:guid}/fail", async (
Guid id,
HttpRequest request,
AgentDispatchRunFailureRequest command,
AgentDispatchRunService agentDispatchRunService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value))
{
return Results.Unauthorized();
}
try
{
var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions);
}
catch (AgentDispatchRunException error)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
var transportReceipts = app.MapGroup("/transport/receipts");
transportReceipts.MapPost("", async (
HttpRequest request,
AgentTransportReceiptRequest command,
AgentTransportReceiptService agentTransportReceiptService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value))
{
return Results.Unauthorized();
}
try
{
var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
}
catch (AgentTransportReceiptException error)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
var governanceReceipts = app.MapGroup("/governance/receipts");
governanceReceipts.MapPost("", async (
HttpRequest request,
GovernanceActivityReceiptRequest command,
GovernanceActivityReceiptService governanceActivityReceiptService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value))
{
return Results.Unauthorized();
}
try
{
var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
}
catch (GovernanceActivityReceiptException error)
{
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
var eveGovernance = app.MapGroup("/eve/governance")
.RequireAuthorization(ActiveMemberRequirement.PolicyName);
@ -763,77 +487,6 @@ eveGovernance.MapGet("/surface", async (
"application/json");
});
eveGovernance.MapPost("/commands", async (
MotionEveCommandRequest command,
ICurrentBifrostActorAccessor actorAccessor,
MotionGovernanceService motionGovernanceService,
MotionEveSurfaceService motionEveSurfaceService,
CancellationToken cancellationToken) =>
{
var actor = await actorAccessor.GetAsync(cancellationToken);
try
{
switch (command.Command)
{
case "motion.create":
if (command.Scope is null ||
command.Category is null ||
command.ClosesAtUtc is null ||
string.IsNullOrWhiteSpace(command.Title) ||
string.IsNullOrWhiteSpace(command.Summary))
{
return Results.BadRequest("motion.create requires scope, category, title, summary, and closesAtUtc.");
}
await motionGovernanceService.CreateMotionAsync(
actor,
new CreateMotionCommand(
command.Scope.Value,
command.ProjectId,
command.Category.Value,
command.Title,
command.Summary,
command.ClosesAtUtc.Value),
cancellationToken);
break;
case "motion.vote":
if (command.MotionId is null || command.Choice is null)
{
return Results.BadRequest("motion.vote requires motionId and choice.");
}
await motionGovernanceService.CastVoteAsync(
actor,
command.MotionId.Value,
command.Choice.Value,
command.Comment,
cancellationToken);
break;
case "motion.close":
if (command.MotionId is null)
{
return Results.BadRequest("motion.close requires motionId.");
}
await motionGovernanceService.CloseMotionAsync(actor, command.MotionId.Value, cancellationToken);
break;
default:
return Results.BadRequest($"Unknown Eve governance command '{command.Command}'.");
}
}
catch (UnauthorizedAccessException)
{
return Results.Forbid();
}
var state = await motionGovernanceService.GetStateAsync(actor, cancellationToken);
var surface = motionEveSurfaceService.BuildSurface(state);
return Results.Text(
JsonSerializer.Serialize(surface, appJsonSerializerOptions),
"application/json");
});
app.MapGet("/auth/heimdall/wait", async (
HttpContext httpContext,
HeimdallAuthService heimdallAuthService,
@ -926,28 +579,6 @@ static JsonSerializerOptions CreateAppJsonSerializerOptions()
return options;
}
static BridgeCaller BuildBridgeCaller(
CurrentBifrostActor actor,
HttpRequest request,
BridgeOptions bridgeOptions)
{
var isLocalBridge = HasValidLocalBridgeToken(request, bridgeOptions);
return new BridgeCaller(
actor.IsActiveMember,
isLocalBridge,
actor.UserAccount?.Id,
actor.DisplayName);
}
static bool HasValidLocalBridgeToken(HttpRequest request, BridgeOptions bridgeOptions)
{
var bridgeToken = request.Headers["X-Bifrost-Bridge-Token"].ToString();
return
bridgeOptions.HasLocalBridgeToken &&
string.Equals(bridgeToken, bridgeOptions.LocalBridgeToken, StringComparison.Ordinal);
}
public partial class Program;
public sealed record NativeBifrostRegistrationRequest(

View file

@ -24,7 +24,27 @@ public sealed class BridgeCliTests
}
[Fact]
public async Task GitHub_bridge_command_allows_explicit_operator_recovery_dry_run()
public async Task GitHub_bridge_command_allows_bifrost_gate_dry_run()
{
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
"github-pr-comment",
"--repo-root", RepoRoot,
"--identity", "nibu",
"--pr", "1",
"--content", "test comment",
"--target-repository-full-name", "GameCult/Bifrost",
"--dry-run", "true",
], TypedCommandEnv());
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("github-pr-comment", payload.RootElement.GetProperty("action").GetString());
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
}
[Fact]
public async Task GitHub_bridge_recovery_hatch_is_rejected()
{
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
@ -38,30 +58,6 @@ public sealed class BridgeCliTests
"--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("github-pr-comment", payload.RootElement.GetProperty("action").GetString());
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
}
[Fact]
public async Task GitHub_bridge_recovery_hatch_is_rejected_inside_dispatched_turn()
{
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
"github-pr-comment",
"--repo-root", RepoRoot,
"--identity", "nibu",
"--pr", "1",
"--content", "test comment",
"--target-repository-full-name", "GameCult/Bifrost",
"--allow-ungated-github", "true",
"--dry-run", "true",
], new Dictionary<string, string?>
{
["BIFROST_LOCK_RECOVERY_HATCHES"] = "true",
});
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("cannot use --allow-ungated-github", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
@ -70,6 +66,7 @@ public sealed class BridgeCliTests
public async Task GitHub_pr_comment_returns_concrete_github_receipt()
{
var fakeToolsDir = Path.Combine(Path.GetTempPath(), $"bifrost-gh-{Guid.NewGuid():N}");
var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc");
Directory.CreateDirectory(fakeToolsDir);
var fakeGhPath = Path.Combine(fakeToolsDir, "fake-gh.js");
await File.WriteAllTextAsync(fakeGhPath, """
@ -97,8 +94,8 @@ console.log(JSON.stringify({
"--epiphany-lane-id", "hands-publication",
"--epiphany-agent-identity", "huginn",
"--heimdall-capability-ref", "heimdall-capability-abc",
"--allow-ungated-github", "true",
]);
"--receipt-store", receiptStorePath,
], TypedCommandEnv());
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
using var payload = JsonDocument.Parse(result.Stdout);
@ -114,6 +111,57 @@ console.log(JSON.stringify({
Assert.Equal("hands-publication", provenance.GetProperty("epiphanyLaneId").GetString());
Assert.Equal("huginn", provenance.GetProperty("epiphanyAgentIdentity").GetString());
Assert.Equal("heimdall-capability-abc", provenance.GetProperty("heimdallCapabilityRef").GetString());
var crossingReceiptId = payload.RootElement.GetProperty("crossingReceiptId").GetString();
Assert.Equal("crossing_test-cultmesh-command", crossingReceiptId);
using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath);
var receipt = Assert.Single(receipts.RootElement.EnumerateArray());
Assert.Equal("bifrost.crossing_receipt.v1", receipt.GetProperty("schemaVersion").GetString());
Assert.Equal("github.pr_comment", receipt.GetProperty("crossingKind").GetString());
Assert.Equal("completed", receipt.GetProperty("status").GetString());
Assert.True(receipt.GetProperty("ok").GetBoolean());
Assert.Equal("repo-work-public-proof-huginn", receipt.GetProperty("source").GetProperty("id").GetString());
Assert.Equal("bifrost_publication_gate", receipt.GetProperty("authority").GetProperty("authorityRef").GetString());
Assert.Equal("heimdall-capability-abc", receipt.GetProperty("authority").GetProperty("heimdallCapabilityRef").GetString());
Assert.Equal("https://github.com/GameCult/Bifrost/pull/1#issuecomment-123456", receipt.GetProperty("externalReceipt").GetProperty("url").GetString());
}
[Fact]
public async Task GitHub_pr_comment_writes_failed_crossing_receipt_on_actuator_failure()
{
var fakeToolsDir = Path.Combine(Path.GetTempPath(), $"bifrost-gh-{Guid.NewGuid():N}");
var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc");
Directory.CreateDirectory(fakeToolsDir);
var fakeGhPath = Path.Combine(fakeToolsDir, "fake-gh.js");
await File.WriteAllTextAsync(fakeGhPath, """
console.error("synthetic gh failure");
process.exit(42);
""", Encoding.UTF8);
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
"github-pr-comment",
"--repo-root", RepoRoot,
"--identity", "nibu",
"--pr", "1",
"--content", "test comment",
"--target-repository-full-name", "GameCult/Bifrost",
"--gh-executable", "node",
"--gh-exec-args", fakeGhPath,
"--source-kind", "epiphany_repo_work",
"--source-id", "repo-work-public-proof-huginn",
"--authority-ref", "bifrost_publication_gate",
"--receipt-store", receiptStorePath,
], TypedCommandEnv());
Assert.NotEqual(0, result.ExitCode);
using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath);
var receipt = Assert.Single(receipts.RootElement.EnumerateArray());
Assert.Equal("github.pr_comment", receipt.GetProperty("crossingKind").GetString());
Assert.Equal("failed", receipt.GetProperty("status").GetString());
Assert.False(receipt.GetProperty("ok").GetBoolean());
Assert.Contains("synthetic gh failure", receipt.GetProperty("error").GetProperty("message").GetString(), StringComparison.Ordinal);
}
[Fact]
@ -128,11 +176,28 @@ console.log(JSON.stringify({
]);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Discord_bridge_command_allows_explicit_operator_recovery_dry_run()
public async Task Discord_bridge_command_allows_bifrost_receipt_gate_dry_run()
{
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
"discord-post",
"--channel-id", "1501196543150264332",
"--content", "test message",
"--dry-run", "true",
], TypedCommandEnv());
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("discord-post", payload.RootElement.GetProperty("action").GetString());
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
}
[Fact]
public async Task Discord_bridge_recovery_hatch_is_rejected()
{
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
@ -143,29 +208,55 @@ console.log(JSON.stringify({
"--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("discord-post", payload.RootElement.GetProperty("action").GetString());
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Discord_bridge_recovery_hatch_is_rejected_inside_dispatched_turn()
public async Task Discord_cultmesh_command_receipt_references_canonical_crossing_receipt()
{
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
"discord-post",
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-provider-store-{Guid.NewGuid():N}.cc");
var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc");
var commandId = $"discord-command-{Guid.NewGuid():N}";
var written = await RunNodeAsync([
"tools/cultmesh-bridge-commands.mjs",
"write-smoke",
"--store", storePath,
"--command-id", commandId,
"--channel-id", "1501196543150264332",
"--content", "test message",
"--allow-unreceipted-activity", "true",
"--dry-run", "true",
], new Dictionary<string, string?>
{
["BIFROST_LOCK_RECOVERY_HATCHES"] = "true",
});
]);
Assert.Equal(0, written.ExitCode);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
var processed = await RunNodeAsync([
"tools/cultmesh-bridge-commands.mjs",
"process",
"--store", storePath,
"--receipt-store", receiptStorePath,
"--command-id", commandId,
]);
Assert.Equal(0, processed.ExitCode);
using var processedPayload = JsonDocument.Parse(processed.Stdout);
Assert.False(processedPayload.RootElement.GetProperty("ok").GetBoolean());
var surfaceReceiptResult = await RunNodeAsync([
"tools/cultmesh-bridge-commands.mjs",
"receipt",
"--store", storePath,
"--command-id", commandId,
]);
Assert.Equal(0, surfaceReceiptResult.ExitCode);
using var surfaceReceiptPayload = JsonDocument.Parse(surfaceReceiptResult.Stdout);
var surfaceReceipt = surfaceReceiptPayload.RootElement.GetProperty("receipt");
Assert.Equal("failed", surfaceReceipt.GetProperty("status").GetString());
Assert.Equal($"crossing_{commandId}", surfaceReceipt.GetProperty("canonicalReceiptId").GetString());
using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath);
var receipt = Assert.Single(receipts.RootElement.EnumerateArray());
Assert.Equal("discord.post", receipt.GetProperty("crossingKind").GetString());
Assert.Equal("failed", receipt.GetProperty("status").GetString());
Assert.Equal($"crossing_{commandId}", receipt.GetProperty("receiptId").GetString());
}
[Fact]
@ -180,7 +271,7 @@ console.log(JSON.stringify({
]);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@ -197,11 +288,11 @@ console.log(JSON.stringify({
]);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Other_bridge_request_allows_explicit_operator_recovery_dry_run()
public async Task Other_bridge_request_allows_bifrost_receipt_gate_dry_run()
{
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
@ -211,9 +302,8 @@ console.log(JSON.stringify({
"--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",
]);
], TypedCommandEnv());
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
using var payload = JsonDocument.Parse(result.Stdout);
@ -227,7 +317,7 @@ console.log(JSON.stringify({
}
[Fact]
public async Task Other_bridge_request_recovery_hatch_is_rejected_inside_dispatched_turn()
public async Task Other_bridge_request_recovery_hatch_is_rejected()
{
var result = await RunNodeAsync([
"tools/bifrost-bridge.mjs",
@ -238,10 +328,7 @@ console.log(JSON.stringify({
"--content", "test message",
"--allow-unreceipted-activity", "true",
"--dry-run", "true",
], new Dictionary<string, string?>
{
["BIFROST_LOCK_RECOVERY_HATCHES"] = "true",
});
]);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("cannot use --allow-unreceipted-activity", result.Stderr, StringComparison.OrdinalIgnoreCase);
@ -259,11 +346,11 @@ console.log(JSON.stringify({
]);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.Contains("CultMesh command id", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Agent_transport_mutation_fails_closed_without_bifrost_receipt_gate()
public async Task Agent_transport_mutation_rejects_removed_http_bridge_config()
{
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc");
var result = await RunNodeAsync([
@ -275,10 +362,10 @@ console.log(JSON.stringify({
"--request", "Do the thing.",
"--store", storePath,
"--allow-unmirrored", "true",
]);
], RemovedHttpBridgeEnv());
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@ -293,15 +380,42 @@ console.log(JSON.stringify({
"--title", "Discord inheritance guard",
"--request", "Do not post fixture noise.",
"--store", storePath,
"--allow-unreceipted-activity", "true",
]);
], TypedCommandEnv());
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("require a Discord mirror", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Agent_transport_mutation_allows_explicit_operator_recovery()
public async Task Agent_transport_mutation_allows_bifrost_receipt_gate()
{
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc");
var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{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,
"--receipt-store", receiptStorePath,
"--allow-unmirrored", "true",
], TypedCommandEnv());
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("queued", payload.RootElement.GetProperty("status").GetString());
using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath);
var receipt = Assert.Single(receipts.RootElement.EnumerateArray());
Assert.Equal("agent_transport.request", receipt.GetProperty("crossingKind").GetString());
Assert.Equal("completed", receipt.GetProperty("status").GetString());
Assert.Equal(payload.RootElement.GetProperty("id").GetString(), receipt.GetProperty("source").GetProperty("requestId").GetString());
}
[Fact]
public async Task Agent_transport_recovery_hatch_is_rejected()
{
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-{Guid.NewGuid():N}.cc");
var result = await RunNodeAsync([
@ -316,36 +430,12 @@ console.log(JSON.stringify({
"--allow-unreceipted-activity", "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("queued", payload.RootElement.GetProperty("status").GetString());
}
[Fact]
public async Task Agent_transport_recovery_hatch_is_rejected_inside_dispatched_turn()
{
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",
], new Dictionary<string, string?>
{
["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 Agent_transport_apply_snapshot_fails_closed_without_bifrost_receipt_gate()
public async Task Agent_transport_apply_snapshot_rejects_removed_http_bridge_config()
{
var sourceStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-source-{Guid.NewGuid():N}.cc");
var targetStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-target-{Guid.NewGuid():N}.cc");
@ -360,8 +450,7 @@ console.log(JSON.stringify({
"--request", "Do the thing.",
"--store", sourceStorePath,
"--allow-unmirrored", "true",
"--allow-unreceipted-activity", "true",
]);
], TypedCommandEnv());
await RunNodeAsync([
"tools/agent-transport.mjs",
@ -375,14 +464,14 @@ console.log(JSON.stringify({
"apply-snapshot",
"--store", targetStorePath,
"--in", snapshotPath,
]);
], RemovedHttpBridgeEnv());
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Agent_transport_apply_snapshot_allows_explicit_operator_recovery()
public async Task Agent_transport_apply_snapshot_allows_bifrost_receipt_gate()
{
var sourceStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-source-{Guid.NewGuid():N}.cc");
var targetStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-agent-transport-target-{Guid.NewGuid():N}.cc");
@ -397,8 +486,7 @@ console.log(JSON.stringify({
"--request", "Do the thing.",
"--store", sourceStorePath,
"--allow-unmirrored", "true",
"--allow-unreceipted-activity", "true",
]);
], TypedCommandEnv());
await RunNodeAsync([
"tools/agent-transport.mjs",
@ -412,8 +500,7 @@ console.log(JSON.stringify({
"apply-snapshot",
"--store", targetStorePath,
"--in", snapshotPath,
"--allow-unreceipted-activity", "true",
]);
], TypedCommandEnv());
Assert.Equal(0, result.ExitCode);
@ -431,7 +518,7 @@ console.log(JSON.stringify({
}
[Fact]
public async Task Governance_mutation_fails_closed_without_bifrost_receipt_gate()
public async Task Governance_mutation_rejects_removed_http_bridge_config()
{
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc");
var result = await RunNodeAsync([
@ -443,16 +530,17 @@ console.log(JSON.stringify({
"--summary", "A topic body.",
"--store", storePath,
"--allow-unmirrored", "true",
]);
], RemovedHttpBridgeEnv());
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Governance_mutation_allows_explicit_operator_recovery()
public async Task Governance_mutation_allows_bifrost_receipt_gate()
{
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc");
var receiptStorePath = Path.Combine(Path.GetTempPath(), $"bifrost-crossing-receipts-{Guid.NewGuid():N}.cc");
var result = await RunNodeAsync([
"tools/governance-threads.mjs",
"open",
@ -461,17 +549,23 @@ console.log(JSON.stringify({
"--title", "Governance receipt recovery",
"--summary", "A topic body.",
"--store", storePath,
"--receipt-store", receiptStorePath,
"--allow-unmirrored", "true",
"--allow-unreceipted-activity", "true",
]);
], TypedCommandEnv());
Assert.Equal(0, result.ExitCode);
using var payload = JsonDocument.Parse(result.Stdout);
Assert.Equal("open", payload.RootElement.GetProperty("status").GetString());
using var receipts = await ReadCrossingReceiptsAsync(receiptStorePath);
var receipt = Assert.Single(receipts.RootElement.EnumerateArray());
Assert.Equal("governance.topic", receipt.GetProperty("crossingKind").GetString());
Assert.Equal("completed", receipt.GetProperty("status").GetString());
Assert.Equal(payload.RootElement.GetProperty("id").GetString(), receipt.GetProperty("source").GetProperty("topicId").GetString());
}
[Fact]
public async Task Governance_recovery_hatch_is_rejected_inside_dispatched_turn()
public async Task Governance_recovery_hatch_is_rejected()
{
var storePath = Path.Combine(Path.GetTempPath(), $"bifrost-governance-{Guid.NewGuid():N}.cc");
var result = await RunNodeAsync([
@ -484,17 +578,14 @@ console.log(JSON.stringify({
"--store", storePath,
"--allow-unmirrored", "true",
"--allow-unreceipted-activity", "true",
], new Dictionary<string, string?>
{
["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 Dispatch_worker_fails_closed_without_bifrost_receipt_gate()
public async Task Dispatch_worker_rejects_removed_http_bridge_config()
{
var tempDir = Path.Combine(Path.GetTempPath(), $"bifrost-dispatch-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
@ -525,8 +616,8 @@ console.log(JSON.stringify({
"claimedAt": "2026-06-17T00:00:00Z",
"closedAt": ""
}
""", Encoding.UTF8);
await File.WriteAllTextAsync(promptPath, "Hello", Encoding.UTF8);
""", new UTF8Encoding(false));
await File.WriteAllTextAsync(promptPath, "Hello", new UTF8Encoding(false));
var result = await RunNodeAsync([
"tools/dispatch-agent-requests.mjs",
@ -536,10 +627,10 @@ console.log(JSON.stringify({
"--prompt-file", promptPath,
"--log", logPath,
"--launch-mode", "codex-exec",
]);
], RemovedHttpBridgeEnv());
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN", result.Stderr, StringComparison.OrdinalIgnoreCase);
Assert.Contains("were removed", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@ -629,8 +720,7 @@ writeFileSync(process.env.DISPATCH_ENV_DUMP, JSON.stringify({
"--title", "Dispatch env seed",
"--request", "Seed the live request lane.",
"--allow-unmirrored", "true",
"--allow-unreceipted-activity", "true",
]);
], TypedCommandEnv());
var result = await RunNodeAsync([
"tools/dispatch-agent-requests.mjs",
@ -642,13 +732,12 @@ writeFileSync(process.env.DISPATCH_ENV_DUMP, JSON.stringify({
"--launch-mode", "codex-exec",
"--codex-executable", "node",
"--codex-exec-args", fakeCodexPath,
], new Dictionary<string, string?>
], TypedCommandEnv(new Dictionary<string, string?>
{
["BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"] = "true",
["GH_TOKEN"] = "parent-gh-token",
["GITHUB_TOKEN"] = "parent-github-token",
["DISPATCH_ENV_DUMP"] = dumpPath,
});
}));
Assert.Equal(0, result.ExitCode);
Assert.True(File.Exists(dumpPath), $"Dispatch env dump was not created. stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
@ -771,8 +860,7 @@ rl.on("line", (line) => {
"--title", "Dispatch app-server seed",
"--request", "Seed the live request lane.",
"--allow-unmirrored", "true",
"--allow-unreceipted-activity", "true",
]);
], TypedCommandEnv());
var result = await RunNodeAsync([
"tools/dispatch-agent-requests.mjs",
@ -785,11 +873,10 @@ rl.on("line", (line) => {
"--codex-executable", "node",
"--codex-exec-args", fakeServerPath,
"--no-discord", "true",
], new Dictionary<string, string?>
], TypedCommandEnv(new Dictionary<string, string?>
{
["BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"] = "true",
["DISPATCH_APP_SERVER_DUMP"] = dumpPath,
});
}));
Assert.Equal(0, result.ExitCode);
Assert.True(File.Exists(dumpPath), "Fake app-server did not record dispatch policy.");
@ -887,7 +974,7 @@ console.log("https://github.com/GameCult/Bifrost/pull/999");
""", Encoding.UTF8);
var environment = BuildDispatchedGitGateEnv();
environment["BIFROST_ALLOW_UNGATED_GITHUB"] = "true";
environment["BIFROST_CULTMESH_COMMAND_ID"] = "test-cultmesh-command";
environment["BIFROST_REAL_GH"] = ResolveExecutable("node");
environment["BIFROST_REAL_GH_ARGS"] = fakeGhPath;
@ -1058,6 +1145,56 @@ exit /b 0
private static string RepoRoot =>
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private static Dictionary<string, string?> TypedCommandEnv(
IReadOnlyDictionary<string, string?>? extras = null)
{
var environment = new Dictionary<string, string?>
{
["BIFROST_CULTMESH_COMMAND_ID"] = "test-cultmesh-command",
};
if (extras is null)
{
return environment;
}
foreach (var pair in extras)
{
environment[pair.Key] = pair.Value;
}
return environment;
}
private static Dictionary<string, string?> RemovedHttpBridgeEnv()
=> new()
{
["BIFROST_BRIDGE_BASE_URL"] = "http://127.0.0.1:1/",
["BIFROST_BRIDGE_TOKEN"] = "removed-test-token",
};
private static async Task<JsonDocument> ReadCrossingReceiptsAsync(string storePath)
{
var result = await RunNodeAsync([
"-e",
"""
import('./tools/bifrost-crossing-documents.mjs')
.then(async module => {
const store = await module.openCrossingReceiptStore(process.argv[1]);
console.log(JSON.stringify(store.getAll()));
})
.catch(error => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
""",
storePath,
]);
Assert.True(result.ExitCode == 0, $"stdout:{Environment.NewLine}{result.Stdout}{Environment.NewLine}stderr:{Environment.NewLine}{result.Stderr}");
return JsonDocument.Parse(result.Stdout);
}
private static async Task<ProcessResult> RunNodeAsync(string[] args, IReadOnlyDictionary<string, string?>? environmentOverrides = null)
{
using var process = new Process

File diff suppressed because it is too large Load diff

View file

@ -19,16 +19,18 @@ public sealed class ProviderAdvertisementTests
using var payload = JsonDocument.Parse(result.Stdout);
var root = payload.RootElement;
AssertEndpoint(root, "bridge-action-ledger", "bifrost.bridge_action.v0", "bridge");
AssertEndpoint(root, "crossing-receipts", "bifrost.crossing_receipt.v1", "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.crossing_receipt.v1", "canonical Bifrost-owned public crossing receipt");
AssertSchemaPurpose(root, "bifrost.patron_support_event.v0", "current hosted Heimdall-signed patron support fact consumed by Bifrost");
AssertWitness(root, ".bifrost/bridge-receipts.cc", "bifrost.crossing_receipt.v1", "current");
AssertBoundaryCommand(root, "bridge", "open GitHub draft PRs and PR comments through Bifrost gate");
AssertBoundaryCommand(root, "bridge", "own canonical bifrost.crossing_receipt.v1 documents for every crossing attempt");
AssertBoundaryCommand(root, "bridge", "post Discord messages and DMs through Bifrost gate with Heimdall-linked actor capability");
AssertBoundaryCommand(root, "bridge", "record receipt-only future-surface requests with named-surface Heimdall capability matching before named actuators exist");
AssertBoundaryCommand(root, "bridge", "post Persona-flaired Reddit organizing threads");
AssertBoundaryForbiddenAuthority(root, "bridge", "surface-specific receipts cannot decide crossing completion");
AssertBoundaryCommand(root, "patron", "consume Heimdall-signed Patreon and PayPal support facts");
AssertBoundaryForbiddenAuthority(root, "patron", "does not store Patreon or PayPal provider tokens");
}
@ -70,7 +72,7 @@ public sealed class ProviderAdvertisementTests
Assert.Contains("GitHub live", bridgeLine, StringComparison.Ordinal);
Assert.Contains("Discord prepared", bridgeLine, StringComparison.Ordinal);
Assert.Contains("Reddit prepared", bridgeLine, StringComparison.Ordinal);
Assert.Contains("Other live", bridgeLine, StringComparison.Ordinal);
Assert.Contains("Other prepared", bridgeLine, StringComparison.Ordinal);
Assert.Contains("Patron live", bridgeLine, StringComparison.Ordinal);
Assert.Equal("warn", bridgeMetric.GetProperty("props").GetProperty("tone").GetString());
@ -89,6 +91,16 @@ public sealed class ProviderAdvertisementTests
Assert.Contains(readinessRows, row => row is not null && row.Contains("reddit: prepared", StringComparison.Ordinal));
Assert.Contains(readinessRows, row => row is not null && row.Contains("patron: live", StringComparison.Ordinal) && row.Contains("Bifrost stores no provider tokens", StringComparison.Ordinal));
var receiptMetric = root
.GetProperty("surface")
.GetProperty("root")
.GetProperty("children")
.EnumerateArray()
.SelectMany(panel => panel.GetProperty("children").EnumerateArray())
.Single(node => node.GetProperty("id").GetString() == "metric-crossing-receipts");
Assert.Contains("total", receiptMetric.GetProperty("props").GetProperty("value").GetString(), StringComparison.Ordinal);
}
private static void AssertEndpoint(JsonElement root, string id, string schemaId, string lowering)
@ -108,6 +120,15 @@ public sealed class ProviderAdvertisementTests
Assert.Equal(purpose, schema.GetProperty("purpose").GetString());
}
private static void AssertWitness(JsonElement root, string path, string schemaId, string status)
{
var witness = root.GetProperty("witnesses")
.EnumerateArray()
.Single(item => item.GetProperty("path").GetString() == path);
Assert.Equal(schemaId, witness.GetProperty("schemaIds").GetString());
Assert.Equal(status, witness.GetProperty("status").GetString());
}
private static void AssertBoundaryCommand(JsonElement root, string area, string command)
{
var boundary = Boundary(root, area);

View file

@ -6,6 +6,11 @@ import { existsSync, readFileSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
commandIdFromOptions,
crossingProvenanceFromOptions,
writeCrossingReceipt,
} from "./bifrost-crossing-documents.mjs";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
@ -164,29 +169,23 @@ function isMutatingCommand(command) {
}
function ensureTransportReceiptGate(options) {
if (wantsUnreceiptedActivity(options) && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
if (wantsUnreceiptedActivity(options)) {
throw new Error(
"Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
"Request-lane mutations from a dispatched turn must keep Bifrost receipts.",
"Bifrost request-lane mutations cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
"Use the typed Bifrost CultCache/CultMesh request store instead of mutating off-book.",
);
}
if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) {
return;
if (hasRemovedHttpBridgeConfig()) {
throw new Error(
"BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " +
"Bifrost request-lane receipts are written to the typed CultCache/CultMesh store.",
);
}
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 wantsUnreceiptedActivity(options);
function hasRemovedHttpBridgeConfig() {
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN));
}
function wantsUnreceiptedActivity(options) {
@ -236,7 +235,7 @@ async function enqueue(cache, options) {
status: request.status,
actorName: request.createdByAgent ?? "system",
note: "Update request queued.",
});
}, options);
printJson(request);
}
@ -293,7 +292,7 @@ function allowsUnmirrored(options) {
}
function bridgeRecoveryArgs(options) {
return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : [];
return [];
}
function renderRequestMirrorFallback(request) {
@ -371,7 +370,7 @@ async function claim(cache, options) {
status: claimed.status,
actorName: claimed.claimedByAgent ?? claimed.targetAgentIdentity ?? "codex",
note: `Request claimed for ${claimed.targetRepoName}.`,
});
}, options);
printJson(claimed);
}
@ -401,7 +400,7 @@ async function closeRequest(cache, options) {
status: closed.status,
actorName: current.claimedByAgent ?? current.targetAgentIdentity ?? "system",
note: closed.closeNote || `Request closed as ${closed.status}.`,
});
}, options);
printJson(closed);
}
@ -431,7 +430,7 @@ async function releaseRequest(cache, options) {
status: released.status,
actorName: current.claimedByAgent ?? current.targetAgentIdentity ?? "system",
note: released.closeNote || "Claim released back to the queue.",
});
}, options);
printJson(released);
}
@ -474,7 +473,7 @@ async function applySnapshot(cache, options) {
try {
for (const { request, receipt } of receipts) {
await recordTransportReceiptOrThrow(request, receipt);
await recordTransportReceiptOrThrow(request, receipt, options);
}
} catch (error) {
await restoreRequests(cache, beforeRequests, afterRequests);
@ -582,36 +581,53 @@ function stableJson(value) {
return JSON.stringify(value);
}
async function recordTransportReceiptOrThrow(request, receipt) {
const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN);
if (!baseUrl || !token) {
return;
}
const response = await fetch(new URL("/transport/receipts", ensureTrailingSlash(baseUrl)), {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Bifrost-Bridge-Token": token,
},
body: JSON.stringify({
requestId: request.id,
title: request.title,
targetRepoName: request.targetRepoName,
targetRepositoryFullName: request.targetRepositoryFullName ?? "",
targetAgentIdentity: request.targetAgentIdentity ?? "",
activityKind: receipt.activityKind,
status: receipt.status,
actorName: receipt.actorName,
note: receipt.note,
}),
async function recordTransportReceiptOrThrow(request, receipt, options = {}) {
const commandId = commandIdFromOptions(options) || `agent-transport:${request.id}:${receipt.activityKind}`;
const provenance = crossingProvenanceFromOptions({
...options,
"cultmesh-command-id": commandId,
"source-kind": request.sourceKind || "bifrost.agent-transport.update-request",
"source-id": request.id,
"request-id": request.id,
"authority-ref": "bifrost_agent_transport",
identity: receipt.actorName,
});
const text = await response.text();
if (response.status !== 202) {
throw new Error(`Bifrost transport receipt failed with ${response.status}: ${text}`);
}
const now = new Date().toISOString();
await writeCrossingReceipt({
receiptId: `crossing_${slugifyReceiptPart(commandId)}_${slugifyReceiptPart(receipt.activityKind)}`,
commandId,
crossingKind: "agent_transport.request",
status: request.status === "cancelled" ? "cancelled" : "completed",
ok: true,
requestedAt: request.createdAt || now,
startedAt: request.updatedAt || now,
completedAt: now,
actor: {
kind: "agent",
name: receipt.actorName || "system",
bifrostIdentity: receipt.actorName || "system",
},
source: provenance.source,
authority: provenance.authority,
epiphany: provenance.epiphany,
target: {
surface: "agent-transport",
locator: request.id,
repositoryFullName: request.targetRepositoryFullName || request.targetRepoName,
requestId: request.id,
status: request.status,
},
externalReceipt: {
id: request.id,
transport: "cultcache",
url: "",
},
surfaceReceipt: {
activityKind: receipt.activityKind,
note: receipt.note || "",
requestStatus: receipt.status,
},
}, options);
}
function filteredRequests(cache, options) {
@ -781,6 +797,15 @@ function equalsIgnoreCase(left, right) {
&& left.toLowerCase() === right.toLowerCase();
}
function slugifyReceiptPart(value) {
return String(value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9._:-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120) || "receipt";
}
function resolveOptionPath(path) {
return resolve(process.cwd(), path);
}
@ -861,8 +886,6 @@ 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
node tools/agent-transport.mjs claim --repo AetheriaLore --agent nibu --claimed-by nibu

View file

@ -4,6 +4,10 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
beginCrossingReceipt,
crossingProvenanceFromOptions,
} from "./bifrost-crossing-documents.mjs";
const PERSONA_WEBHOOK_NAME = "Bifrost Persona Pipe";
const PERSONA_WEBHOOK_CACHE_PATH = resolve(".bifrost/discord-webhook-cache.json");
@ -190,6 +194,7 @@ async function createGitHubDraftPr(options) {
commitSha,
changedPaths,
prUrl,
crossingReceiptId: bridgeAction?.receiptId ?? "",
provenance: buildBridgeProvenance(options),
});
}
@ -283,6 +288,7 @@ async function commentGitHubPr(options) {
pr,
receiptUrl,
externalReceiptId,
crossingReceiptId: bridgeAction?.receiptId ?? "",
provenance: buildBridgeProvenance(options),
});
} catch (error) {
@ -314,10 +320,6 @@ async function postDiscordMessage(options) {
return;
}
if (!token) {
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before posting to Discord.");
}
const bridgeAction = await beginBridgeAction(options, {
actorKind: personaName ? "Persona" : "Service",
actorName: personaName ?? "bifrost",
@ -332,6 +334,9 @@ async function postDiscordMessage(options) {
let result;
try {
await bridgeAction?.start();
if (!token) {
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before posting to Discord.");
}
result = personaName
? await postDiscordPersonaMessage(token, channelId, content, {
personaName,
@ -361,6 +366,7 @@ async function postDiscordMessage(options) {
messageId: result.id,
transport: result.transport,
url: `https://discord.com/channels/${result.guildId ?? "@me"}/${channelId}/${result.id}`,
crossingReceiptId: bridgeAction?.receiptId ?? "",
provenance: buildBridgeProvenance(options),
});
}
@ -382,10 +388,6 @@ async function sendDiscordDm(options) {
return;
}
if (!token) {
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM.");
}
const bridgeAction = await beginBridgeAction(options, {
actorKind: "Service",
actorName: "bifrost",
@ -401,6 +403,9 @@ async function sendDiscordDm(options) {
let result;
try {
await bridgeAction?.start();
if (!token) {
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM.");
}
channelId = await openDiscordDmChannel(token, recipientId);
result = await postDiscordBotMessage(token, channelId, content, undefined);
await bridgeAction?.complete({
@ -425,6 +430,7 @@ async function sendDiscordDm(options) {
messageId: result.id,
transport: result.transport,
url: `https://discord.com/channels/@me/${channelId}/${result.id}`,
crossingReceiptId: bridgeAction?.receiptId ?? "",
provenance: buildBridgeProvenance(options),
});
}
@ -502,6 +508,7 @@ async function postRedditThread(options) {
personaFlairText,
thingId: result.thingId,
url: result.url,
crossingReceiptId: bridgeAction?.receiptId ?? "",
provenance: buildBridgeProvenance(options),
});
}
@ -529,45 +536,10 @@ async function requestOtherBridgeAction(options) {
return;
}
const bridgeAction = await beginBridgeAction(options, {
actorKind: "Agent",
actorName: identity,
targetSurface: "Other",
actionKind: "Other",
targetSurfaceName: surfaceName,
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),
});
throw new Error(
"other-request was a receipt-only HTTP bridge hatch and has been removed. " +
"Define a typed Bifrost CultMesh command/receipt document for this surface before executing it.",
);
}
async function getRedditAccessToken() {
@ -1078,76 +1050,86 @@ function resolveGitCommand() {
}
async function beginBridgeAction(options, action) {
const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
if (!baseUrl) {
return null;
}
const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN);
if (!token) {
throw new Error("Set BIFROST_BRIDGE_TOKEN or pass --bifrost-token when using the Bifrost bridge action ledger.");
}
const payload = {
...action,
sourceKind: optionalString(options["source-kind"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_KIND) ?? "",
sourceId: optionalString(options["source-id"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_ID) ?? "",
authorityReference: optionalString(options["authority-ref"]) ?? optionalString(process.env.BIFROST_BRIDGE_AUTHORITY_REF) ?? "",
bifrostIdentity: optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "",
heimdallCapabilityReference:
optionalString(options["heimdall-capability-ref"]) ??
optionalString(process.env.HEIMDALL_CAPABILITY_REF) ??
optionalString(process.env.BIFROST_HEIMDALL_CAPABILITY_REF) ??
"",
epiphanyRunId: optionalString(options["epiphany-run-id"]) ?? optionalString(process.env.EPIPHANY_RUN_ID) ?? "",
epiphanyLaneId: optionalString(options["epiphany-lane-id"]) ?? optionalString(process.env.EPIPHANY_LANE_ID) ?? "",
epiphanyAgentIdentity: optionalString(options["epiphany-agent-identity"]) ?? optionalString(process.env.EPIPHANY_AGENT_IDENTITY) ?? "",
workItemId: optionalString(options["work-item-id"]) ?? null,
motionId: optionalString(options["motion-id"]) ?? null,
};
const requested = await postBridgeJson(
baseUrl,
token,
"/bridge/actions/request",
payload,
new Set([202, 403]),
);
if (requested.status === 403) {
throw new Error(`Bifrost denied bridge action: ${requested.body?.policyDecision ?? requested.text}`);
}
const id = requested.body?.id;
if (!id) {
throw new Error(`Bifrost bridge request returned no action id: ${requested.text}`);
}
rejectRemovedHttpBridgeConfig(options);
const provenance = crossingProvenanceFromOptions(options);
const actorName = action.actorName ?? provenance.actor.name ?? "bifrost";
const targetSurface = normalizeSurface(action.targetSurface);
const crossingKind = crossingKindForAction(action.actionKind);
const lifecycle = await beginCrossingReceipt(options, {
commandId: provenance.commandId,
crossingKind,
actor: {
kind: action.actorKind ?? provenance.actor.kind ?? "Service",
name: actorName,
bifrostIdentity: provenance.actor.bifrostIdentity || actorName,
},
source: provenance.source,
authority: provenance.authority,
epiphany: provenance.epiphany,
target: {
surface: targetSurface,
repositoryFullName: action.targetRepositoryFullName ?? "",
locator: action.targetLocator ?? "",
title: action.title ?? "",
summary: action.summary ?? "",
},
});
return {
id,
receiptId: lifecycle.receiptId,
commandId: lifecycle.commandId,
async start() {
await postBridgeJson(baseUrl, token, `/bridge/actions/${id}/start`, undefined, new Set([202]));
return lifecycle.start();
},
async complete(receipt) {
await postBridgeJson(baseUrl, token, `/bridge/actions/${id}/complete`, receipt, new Set([200]));
async complete(result) {
return lifecycle.complete({
externalReceipt: {
url: result?.receiptUrl ?? "",
id: result?.externalReceiptId ?? "",
transport: targetSurface.toLowerCase(),
payload: parseOptionalJson(result?.receiptPayload),
},
});
},
async fail(error) {
const failureReason = error instanceof Error ? error.message : String(error);
try {
await postBridgeJson(
baseUrl,
token,
`/bridge/actions/${id}/fail`,
{ failureReason },
new Set([200, 400]),
);
} catch {
// Best effort: the underlying command error is still the real failure.
}
return lifecycle.fail(error);
},
};
}
function crossingKindForAction(actionKind) {
switch (actionKind) {
case "GitHubDraftPullRequest":
return "github.draft_pr";
case "GitHubPullRequestComment":
return "github.pr_comment";
case "DiscordPost":
return "discord.post";
case "DiscordDirectMessage":
return "discord.dm";
case "RedditPost":
return "reddit.post";
default:
return "future_surface.request";
}
}
function normalizeSurface(value) {
return optionalString(value) ?? "future-surface";
}
function parseOptionalJson(value) {
const text = optionalString(value);
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch {
return { raw: text };
}
}
function parseRequiredJson(text, label) {
try {
return JSON.parse(text);
@ -1158,61 +1140,69 @@ function parseRequiredJson(text, label) {
}
function ensureGitHubBridgeGate(options) {
const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN);
rejectRemovedHttpBridgeConfig(options);
const cultMeshCommandId =
optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID);
const allowUngated =
options["allow-ungated-github"] === "true" ||
process.env.BIFROST_ALLOW_UNGATED_GITHUB === "true";
if (allowUngated && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
if (allowUngated) {
throw new Error(
"Dispatched Bifrost work cannot use --allow-ungated-github or BIFROST_ALLOW_UNGATED_GITHUB. " +
"GitHub mutations from a dispatched turn must go through the normal Bifrost gate and receipt path.",
"Bifrost GitHub mutations cannot use --allow-ungated-github or BIFROST_ALLOW_UNGATED_GITHUB. " +
"Use a typed Bifrost CultMesh command request/receipt route instead of mutating off-book.",
);
}
if (baseUrl && token) {
return;
}
if (allowUngated) {
if (cultMeshCommandId) {
return;
}
throw new Error(
"GitHub bridge actions require Bifrost authorization and receipt logging. " +
"Set BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN, or use --allow-ungated-github true only for explicit operator recovery.",
"Provide BIFROST_CULTMESH_COMMAND_ID from a typed CultMesh command request; HTTP bridge hatches have been removed.",
);
}
function ensureBridgeReceiptGate(options) {
const baseUrl = optionalString(options["bifrost-base-url"]) ?? optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
const token = optionalString(options["bifrost-token"]) ?? optionalString(process.env.BIFROST_BRIDGE_TOKEN);
rejectRemovedHttpBridgeConfig(options);
const cultMeshCommandId =
optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID);
const allowUnreceipted =
options["allow-unreceipted-activity"] === "true" ||
process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
if (allowUnreceipted && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
if (allowUnreceipted) {
throw new Error(
"Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
"External bridge activity from a dispatched turn must go through the normal Bifrost receipt path.",
"Bifrost bridge activity cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
"Use a typed Bifrost CultMesh command request/receipt route instead of mutating off-book.",
);
}
if (baseUrl && token) {
return;
}
if (allowUnreceipted) {
if (cultMeshCommandId) {
return;
}
throw new Error(
"Bridge activity requires BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN so Bifrost can receipt the external crossing. " +
"Set BIFROST_BRIDGE_BASE_URL and BIFROST_BRIDGE_TOKEN, or use --allow-unreceipted-activity true only for explicit operator recovery.",
"Bridge activity requires a Bifrost CultMesh command id so Bifrost can receipt the external crossing. " +
"Use a typed CultMesh command request/receipt route; HTTP and unreceipted bridge hatches have been removed.",
);
}
function rejectRemovedHttpBridgeConfig(options) {
if (
optionalString(options["bifrost-base-url"]) ||
optionalString(options["bifrost-token"]) ||
optionalString(process.env.BIFROST_BRIDGE_BASE_URL) ||
optionalString(process.env.BIFROST_BRIDGE_TOKEN)
) {
throw new Error(
"BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN and --bifrost-base-url / --bifrost-token were removed. " +
"Use typed Bifrost CultMesh command request/receipt documents discovered through Odin.",
);
}
}
async function postBridgeJson(baseUrl, token, path, payload, allowedStatuses) {
const url = new URL(path, ensureTrailingSlash(baseUrl));
const response = await fetch(url, {
@ -1380,8 +1370,7 @@ Examples:
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.
Use --allow-ungated-github true only for explicit operator recovery.
GitHub actions require typed Bifrost CultMesh command provenance such as BIFROST_CULTMESH_COMMAND_ID so Bifrost can gate and receipt the crossing.
Provenance note:
--identity names the Bifrost identity acting through the bridge.

View file

@ -0,0 +1,336 @@
import { createRequire } from "node:module";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
const repoRoot = resolve(import.meta.dirname, "..");
const projectsRoot = resolve(repoRoot, "..");
export const crossingReceiptDocumentType = "bifrost.crossing_receipt";
export const crossingReceiptSchemaId = "bifrost.crossing_receipt.v1";
export const defaultCrossingReceiptStorePath = resolve(repoRoot, ".bifrost", "bridge-receipts.cc");
export const crossingReceiptStatuses = Object.freeze(["requested", "running", "completed", "failed", "cancelled"]);
const validStatuses = new Set(crossingReceiptStatuses);
let cachedRuntime;
let cachedDefinition;
export function crossingReceiptDefinition(defineDocumentTypeInput) {
const defineDocumentType = defineDocumentTypeInput ?? loadCultCacheRuntime().defineDocumentType;
cachedDefinition ??= defineDocumentType({
type: crossingReceiptDocumentType,
schemaId: crossingReceiptSchemaId,
schemaName: crossingReceiptDocumentType,
schemaVersion: crossingReceiptSchemaId,
contentHash: crossingReceiptSchemaId,
global: false,
name: "receiptId",
indexes: {
commandId: "commandId",
crossingKind: "crossingKind",
status: "status",
},
schema: { parse: parseCrossingReceipt },
members: [
{ slot: 1, memberName: "receiptId", typeName: "string", isName: true },
{ slot: 2, memberName: "commandId", typeName: "string", indexAlias: "commandId" },
{ slot: 3, memberName: "crossingKind", typeName: "string", indexAlias: "crossingKind" },
{ slot: 4, memberName: "status", typeName: "string", indexAlias: "status" },
{ slot: 5, memberName: "ok", typeName: "boolean" },
{ slot: 6, memberName: "requestedAt", typeName: "string" },
{ slot: 7, memberName: "startedAt", typeName: "string" },
{ slot: 8, memberName: "completedAt", typeName: "string" },
{ slot: 9, memberName: "actor", typeName: "object" },
{ slot: 10, memberName: "source", typeName: "object" },
{ slot: 11, memberName: "authority", typeName: "object" },
{ slot: 12, memberName: "epiphany", typeName: "object" },
{ slot: 13, memberName: "target", typeName: "object" },
{ slot: 14, memberName: "externalReceipt", typeName: "object" },
{ slot: 15, memberName: "error", typeName: "object" },
{ slot: 16, memberName: "supersedes", typeName: "string" },
{ slot: 17, memberName: "relatedReceiptIds", typeName: "string", isMany: true },
{ slot: 18, memberName: "surfaceReceipt", typeName: "object" },
],
});
return cachedDefinition;
}
export async function openCrossingReceiptStore(storePath = defaultCrossingReceiptStorePath) {
const { CultCache, SingleFileMessagePackBackingStore } = loadCultCacheRuntime();
await mkdir(dirname(storePath), { recursive: true });
const cache = CultCache.builder()
.withDocumentType(crossingReceiptDefinition())
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.pullAllBackingStores();
return {
storePath,
definition: crossingReceiptDefinition(),
cache,
async put(receipt) {
const parsed = buildCrossingReceipt(receipt);
await cache.put(crossingReceiptDefinition(), parsed.receiptId, parsed);
return parsed;
},
get(receiptId) {
return cache.get(crossingReceiptDefinition(), receiptId);
},
getAll() {
return cache.getAll(crossingReceiptDefinition());
},
};
}
export function resolveCrossingReceiptStorePath(options = {}) {
return resolveOptionPath(
options["receipt-store"] ??
process.env.BIFROST_CROSSING_RECEIPT_STORE ??
process.env.BIFROST_BRIDGE_RECEIPT_STORE ??
defaultCrossingReceiptStorePath,
);
}
export async function writeCrossingReceipt(receipt, options = {}) {
const store = await openCrossingReceiptStore(resolveCrossingReceiptStorePath(options));
return store.put(receipt);
}
export async function beginCrossingReceipt(options, input) {
const requestedAt = input.requestedAt ?? new Date().toISOString();
const commandId = requiredString(input.commandId ?? commandIdFromOptions(options), "commandId");
const receiptId = input.receiptId ?? `crossing_${commandId}`;
const base = buildCrossingReceipt({
schemaName: crossingReceiptDocumentType,
schemaVersion: crossingReceiptSchemaId,
receiptId,
commandId,
crossingKind: requiredString(input.crossingKind, "crossingKind"),
status: "requested",
ok: false,
requestedAt,
startedAt: "",
completedAt: "",
actor: normalizeObject(input.actor),
source: normalizeObject(input.source),
authority: normalizeObject(input.authority),
epiphany: normalizeObject(input.epiphany),
target: normalizeObject(input.target),
externalReceipt: {},
error: {},
supersedes: optionalString(input.supersedes) ?? "",
relatedReceiptIds: normalizeStringArray(input.relatedReceiptIds),
surfaceReceipt: normalizeObject(input.surfaceReceipt),
});
await writeCrossingReceipt(base, options);
let current = base;
return {
receiptId,
commandId,
requested: base,
async start(extra = {}) {
current = await writeCrossingReceipt({
...current,
...extra,
status: "running",
ok: false,
startedAt: extra.startedAt ?? new Date().toISOString(),
}, options);
return current;
},
async complete(extra = {}) {
current = await writeCrossingReceipt({
...current,
...extra,
status: "completed",
ok: true,
startedAt: extra.startedAt ?? current.startedAt,
completedAt: extra.completedAt ?? new Date().toISOString(),
externalReceipt: normalizeObject(extra.externalReceipt ?? current.externalReceipt),
surfaceReceipt: normalizeObject(extra.surfaceReceipt ?? current.surfaceReceipt),
}, options);
return current;
},
async fail(error, extra = {}) {
current = await writeCrossingReceipt({
...current,
...extra,
status: "failed",
ok: false,
startedAt: extra.startedAt ?? current.startedAt,
completedAt: extra.completedAt ?? new Date().toISOString(),
error: normalizeError(error),
externalReceipt: normalizeObject(extra.externalReceipt ?? current.externalReceipt),
surfaceReceipt: normalizeObject(extra.surfaceReceipt ?? current.surfaceReceipt),
}, options);
return current;
},
};
}
export function buildCrossingReceipt(input) {
return parseCrossingReceipt({
schemaName: crossingReceiptDocumentType,
schemaVersion: crossingReceiptSchemaId,
...input,
});
}
export function crossingProvenanceFromOptions(options = {}) {
const commandId = commandIdFromOptions(options);
return {
commandId,
actor: {
bifrostIdentity: optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "",
kind: optionalString(options["actor-kind"]) ?? "",
name: optionalString(options["actor-name"]) ?? optionalString(options.identity) ?? optionalString(process.env.BIFROST_IDENTITY) ?? "",
},
source: {
kind: optionalString(options["source-kind"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_KIND) ?? "cultmesh-command",
id: optionalString(options["source-id"]) ?? optionalString(process.env.BIFROST_BRIDGE_SOURCE_ID) ?? commandId,
topicId: optionalString(options["topic-id"]) ?? "",
requestId: optionalString(options["request-id"]) ?? "",
workItemId: optionalString(options["work-item-id"]) ?? "",
motionId: optionalString(options["motion-id"]) ?? "",
channelId: optionalString(options["source-channel-id"]) ?? "",
messageId: optionalString(options["source-message-id"]) ?? "",
},
authority: {
authorityRef: optionalString(options["authority-ref"]) ?? optionalString(process.env.BIFROST_BRIDGE_AUTHORITY_REF) ?? `cultmesh-command:${commandId}`,
heimdallCapabilityRef:
optionalString(options["heimdall-capability-ref"]) ??
optionalString(process.env.HEIMDALL_CAPABILITY_REF) ??
optionalString(process.env.BIFROST_HEIMDALL_CAPABILITY_REF) ??
"",
heimdallClaimJti: optionalString(options["heimdall-claim-jti"]) ?? optionalString(process.env.HEIMDALL_CLAIM_JTI) ?? "",
heimdallAccountId: optionalString(options["heimdall-account-id"]) ?? optionalString(process.env.HEIMDALL_ACCOUNT_ID) ?? "",
heimdallAccessRevision: optionalString(options["heimdall-access-revision"]) ?? optionalString(process.env.HEIMDALL_ACCESS_REVISION) ?? "",
heimdallGrantRef: optionalString(options["heimdall-grant-ref"]) ?? optionalString(process.env.HEIMDALL_GRANT_REF) ?? "",
heimdallClaimExpiresAt: optionalString(options["heimdall-claim-exp"]) ?? optionalString(process.env.HEIMDALL_CLAIM_EXP) ?? "",
policyDecisionId: optionalString(options["policy-decision-id"]) ?? optionalString(process.env.BIFROST_POLICY_DECISION_ID) ?? "",
},
epiphany: {
runId: optionalString(options["epiphany-run-id"]) ?? optionalString(process.env.EPIPHANY_RUN_ID) ?? "",
laneId: optionalString(options["epiphany-lane-id"]) ?? optionalString(process.env.EPIPHANY_LANE_ID) ?? "",
agentIdentity: optionalString(options["epiphany-agent-identity"]) ?? optionalString(process.env.EPIPHANY_AGENT_IDENTITY) ?? "",
},
};
}
export function commandIdFromOptions(options = {}) {
return optionalString(options["cultmesh-command-id"]) ?? optionalString(process.env.BIFROST_CULTMESH_COMMAND_ID) ?? "";
}
function parseCrossingReceipt(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new Error("Bifrost crossing receipt must be an object.");
}
const receipt = {
schemaName: optionalString(input.schemaName) ?? crossingReceiptDocumentType,
schemaVersion: optionalString(input.schemaVersion) ?? crossingReceiptSchemaId,
receiptId: requiredString(input.receiptId, "receiptId"),
commandId: requiredString(input.commandId, "commandId"),
crossingKind: requiredString(input.crossingKind, "crossingKind"),
status: requiredString(input.status, "status"),
ok: Boolean(input.ok),
requestedAt: requiredString(input.requestedAt, "requestedAt"),
startedAt: optionalString(input.startedAt) ?? "",
completedAt: optionalString(input.completedAt) ?? "",
actor: normalizeObject(input.actor),
source: normalizeObject(input.source),
authority: normalizeObject(input.authority),
epiphany: normalizeObject(input.epiphany),
target: normalizeObject(input.target),
externalReceipt: normalizeObject(input.externalReceipt),
error: normalizeObject(input.error),
supersedes: optionalString(input.supersedes) ?? "",
relatedReceiptIds: normalizeStringArray(input.relatedReceiptIds),
surfaceReceipt: normalizeObject(input.surfaceReceipt),
};
if (!validStatuses.has(receipt.status)) {
throw new Error(`Bifrost crossing receipt status "${receipt.status}" is not valid.`);
}
if (!receipt.source.kind && !receipt.source.id) {
throw new Error("Bifrost crossing receipt requires source provenance.");
}
if (!receipt.authority.authorityRef && !receipt.authority.heimdallCapabilityRef && !receipt.authority.heimdallClaimJti) {
throw new Error("Bifrost crossing receipt requires authority provenance.");
}
return receipt;
}
function loadCultCacheRuntime() {
if (cachedRuntime) {
return cachedRuntime;
}
const packagePath = resolveFirstExisting("CultCache TypeScript runtime", [
resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"),
resolve(projectsRoot, "CultCacheTS", "package.json"),
resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json"),
]);
const requireCultCache = createRequire(packagePath);
cachedRuntime = requireCultCache("cultcache-ts");
return cachedRuntime;
}
function resolveFirstExisting(label, candidates) {
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
throw new Error(`${label} is unavailable. Tried: ${candidates.join(", ")}`);
}
function normalizeError(error) {
if (!error) {
return {};
}
if (error instanceof Error) {
return {
code: error.name || "Error",
message: error.message,
};
}
return {
code: "Error",
message: String(error),
};
}
function normalizeObject(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function normalizeStringArray(value) {
if (value === undefined || value === null) {
return [];
}
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => optionalString(item)).filter(Boolean);
}
function requiredString(value, field) {
const normalized = optionalString(value);
if (!normalized) {
throw new Error(`Bifrost crossing receipt field "${field}" must be a non-empty string.`);
}
return normalized;
}
function optionalString(value) {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function resolveOptionPath(path) {
return resolve(process.cwd(), path);
}

View file

@ -0,0 +1,47 @@
export const discordPostCommandDocumentType = "bifrost.bridge.discord_post_command";
export const discordPostCommandSchemaId = "bifrost.bridge.discord_post_command.v1";
export const discordPostReceiptDocumentType = "bifrost.bridge.discord_post_receipt";
export const discordPostReceiptSchemaId = "bifrost.bridge.discord_post_receipt.v1";
let cachedDefinitions;
export function discordPostCommandDefinition(defineDocumentType) {
cachedDefinitions ??= {};
cachedDefinitions.command ??= defineDocumentType({
type: discordPostCommandDocumentType,
schemaName: discordPostCommandDocumentType,
schemaId: discordPostCommandSchemaId,
schemaVersion: discordPostCommandSchemaId,
contentHash: discordPostCommandSchemaId,
global: false,
name: "commandId",
schema: objectSchema("Bifrost Discord post command"),
});
return cachedDefinitions.command;
}
export function discordPostReceiptDefinition(defineDocumentType) {
cachedDefinitions ??= {};
cachedDefinitions.receipt ??= defineDocumentType({
type: discordPostReceiptDocumentType,
schemaName: discordPostReceiptDocumentType,
schemaId: discordPostReceiptSchemaId,
schemaVersion: discordPostReceiptSchemaId,
contentHash: discordPostReceiptSchemaId,
global: false,
name: "commandId",
schema: objectSchema("Bifrost Discord post receipt"),
});
return cachedDefinitions.receipt;
}
function objectSchema(label) {
return {
parse(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new Error(`${label} must be an object.`);
}
return input;
},
};
}

View file

@ -0,0 +1,450 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { crossingReceiptDefinition } from "./bifrost-crossing-documents.mjs";
import {
discordPostCommandDefinition,
discordPostCommandDocumentType as commandDocumentType,
discordPostCommandSchemaId as commandSchemaId,
discordPostReceiptDefinition,
discordPostReceiptDocumentType as receiptDocumentType,
discordPostReceiptSchemaId as receiptSchemaId,
} from "./bifrost-discord-command-documents.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
const projectsRoot = resolve(repoRoot, "..");
const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc");
async function main() {
loadBifrostLocalEnv();
const [command, ...rawArgs] = process.argv.slice(2);
const options = parseArgs(rawArgs);
if (!command || command === "help" || command === "--help" || command === "-h") {
printHelp();
return;
}
switch (command) {
case "process":
await processCommands(options);
return;
case "write-smoke":
await writeSmokeCommand(options);
return;
case "receipt":
await printReceipt(options);
return;
case "delete":
await deleteCommand(options);
return;
default:
throw new Error(`Unknown command "${command}". Run "node tools/cultmesh-bridge-commands.mjs help".`);
}
}
async function processCommands(options) {
const storePath = resolve(options.store ?? defaultStorePath);
const commandIdFilter = optionalString(options["command-id"]);
const node = await openCommandNode(storePath);
await node.cache?.pullAllBackingStores?.();
const commands = listCommandRecords(node)
.filter((command) => command.status === "pending" || command.status === "running")
.filter((command) => !commandIdFilter || command.commandId === commandIdFilter)
.sort((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)));
const processed = [];
for (const command of commands) {
processed.push(await processOneCommand(node, command, options));
}
await node.flush?.();
printJson({
ok: processed.every((receipt) => receipt.status === "completed"),
action: "process",
storePath,
commandCount: commands.length,
processed,
});
}
async function writeSmokeCommand(options) {
const storePath = resolve(options.store ?? defaultStorePath);
const commandId = optionalString(options["command-id"]) ?? `bifrost-smoke-${Date.now()}`;
const command = {
schemaName: commandDocumentType,
schemaVersion: commandSchemaId,
commandId,
command: "discord-post",
status: "pending",
requestedBy: "bifrost-smoke",
requestedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
source: {
kind: "smoke",
id: commandId,
},
payload: {
channelId: requireOption(options, "channel-id"),
content: optionalString(options.content) ?? "Bifrost CultMesh Discord command smoke.",
personaName: optionalString(options["persona-name"]),
personaAvatarUrl: optionalString(options["persona-avatar-url"]),
replyToMessageId: optionalString(options["reply-to-message-id"]),
},
};
const node = await openCommandNode(storePath);
await node.put(commandDefinition(), command.commandId, command);
await node.flush?.();
printJson({ ok: true, action: "write-smoke", storePath, commandId });
}
async function printReceipt(options) {
const commandId = requireOption(options, "command-id");
const storePath = resolve(options.store ?? defaultStorePath);
const node = await openCommandNode(storePath);
await node.cache?.pullAllBackingStores?.();
const receipt = unwrapRecord(await node.get(receiptDefinition(), commandId));
if (!receipt) {
printJson({ ok: false, commandId, receipt: null });
return;
}
printJson({ ok: receipt.status === "completed", commandId, receipt });
}
async function deleteCommand(options) {
const commandId = requireOption(options, "command-id");
const storePath = resolve(options.store ?? defaultStorePath);
const node = await openCommandNode(storePath);
await node.cache?.pullAllBackingStores?.();
const deletedCommand = typeof node.cache?.delete === "function"
? await node.cache.delete(commandDefinition(), commandId)
: false;
const deletedReceipt = typeof node.cache?.delete === "function"
? await node.cache.delete(receiptDefinition(), commandId)
: false;
await node.flush?.();
printJson({ ok: Boolean(deletedCommand || deletedReceipt), action: "delete", storePath, commandId, deletedCommand, deletedReceipt });
}
async function processOneCommand(node, command, options) {
const running = {
...command,
status: "running",
updatedAt: new Date().toISOString(),
};
await node.put(commandDefinition(), command.commandId, running);
const payload = command.payload && typeof command.payload === "object" ? command.payload : {};
const tempDir = resolve(repoRoot, ".bifrost", "cultmesh-command-payloads", command.commandId);
const contentPath = resolve(tempDir, "content.md");
await mkdir(tempDir, { recursive: true });
await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8");
try {
const args = [
"tools/bifrost-bridge.mjs",
"discord-post",
"--channel-id",
requireString(payload.channelId, "payload.channelId"),
"--content-file",
contentPath,
"--cultmesh-command-id",
command.commandId,
"--source-kind",
optionalString(command.source?.kind) ?? "cultmesh-command",
"--source-id",
optionalString(command.source?.id) ?? command.commandId,
"--identity",
optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost",
];
pushOption(args, "--persona-name", payload.personaName);
pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl);
pushOption(args, "--reply-to-message-id", payload.replyToMessageId);
pushOption(args, "--receipt-store", options["receipt-store"]);
const result = spawnSync(process.execPath, args, {
cwd: repoRoot,
encoding: "utf8",
windowsHide: true,
env: {
...process.env,
BIFROST_CULTMESH_COMMAND_ID: command.commandId,
},
});
if (result.status !== 0 || result.error) {
throw new Error(renderSpawnFailure(result));
}
const posted = parseJson(result.stdout, "bifrost bridge discord-post receipt");
const receipt = buildReceipt(command, {
status: "completed",
ok: true,
action: "discord-post",
channelId: posted.channelId,
messageId: posted.messageId,
transport: posted.transport,
url: posted.url,
canonicalReceiptId: posted.crossingReceiptId,
payload: posted,
});
await node.put(receiptDefinition(), command.commandId, receipt);
await node.put(commandDefinition(), command.commandId, {
...running,
status: "completed",
updatedAt: receipt.completedAt,
receiptId: receipt.receiptId,
});
return receipt;
} catch (error) {
const receipt = buildReceipt(command, {
status: "failed",
ok: false,
action: "discord-post",
canonicalReceiptId: `crossing_${command.commandId}`,
error: error instanceof Error ? error.message : String(error),
});
await node.put(receiptDefinition(), command.commandId, receipt);
await node.put(commandDefinition(), command.commandId, {
...running,
status: "failed",
updatedAt: receipt.completedAt,
receiptId: receipt.receiptId,
error: receipt.error,
});
return receipt;
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
function buildReceipt(command, result) {
const completedAt = new Date().toISOString();
return {
schemaName: receiptDocumentType,
schemaVersion: receiptSchemaId,
receiptId: command.commandId,
commandId: command.commandId,
command: command.command,
status: result.status,
ok: result.ok,
action: result.action,
channelId: result.channelId ?? command.payload?.channelId ?? "",
messageId: result.messageId ?? "",
transport: result.transport ?? "",
url: result.url ?? "",
canonicalReceiptId: result.canonicalReceiptId ?? "",
error: result.error ?? "",
requestedAt: command.requestedAt ?? command.createdAt ?? "",
completedAt,
source: command.source ?? {},
actor: command.actor ?? {},
payload: result.payload ?? {},
};
}
function listCommandRecords(node) {
if (typeof node.cache?.getAll !== "function") {
return [];
}
return node.cache.getAll(commandDefinition())
.map(unwrapRecord)
.filter((entry) => entry && entry.command === "discord-post" && entry.commandId);
}
async function openCommandNode(storePath) {
const { CultMesh, defineDocumentType } = loadCultMeshRuntime();
return CultMesh.createNode(storePath, {
documents: [
commandDefinition(defineDocumentType),
receiptDefinition(defineDocumentType),
crossingReceiptDefinition(defineDocumentType),
genericDocument(defineDocumentType, "gamecult.eve.provider_advertisement", "gamecult.eve.provider_advertisement.v1", "providerId"),
genericDocument(defineDocumentType, "gamecult.eve.surface_state", "gamecult.eve.surface_state.v1", "providerId"),
genericDocument(defineDocumentType, "gamecult.eve.interface_binding", "gamecult.eve.interface_binding.v1", "bindingId"),
],
});
}
function commandDefinition(defineDocumentTypeInput) {
const defineDocumentType = defineDocumentTypeInput ?? loadCultMeshRuntime().defineDocumentType;
return discordPostCommandDefinition(defineDocumentType);
}
function receiptDefinition(defineDocumentTypeInput) {
const defineDocumentType = defineDocumentTypeInput ?? loadCultMeshRuntime().defineDocumentType;
return discordPostReceiptDefinition(defineDocumentType);
}
function genericDocument(defineDocumentType, type, schemaId, name) {
return defineDocumentType({
type,
schemaName: type,
schemaId,
schemaVersion: schemaId,
contentHash: schemaId,
global: false,
name,
schema: parseObjectDocument(type),
});
}
function loadCultMeshRuntime() {
const candidates = [
resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"),
resolve(projectsRoot, "CultMeshTS", "package.json"),
];
for (const packageJson of candidates) {
if (!existsSync(packageJson)) {
continue;
}
try {
const requireCult = createRequire(packageJson);
const { CultMesh } = requireCult("cultmesh-ts");
const { defineDocumentType } = requireCult("cultcache-ts");
if (CultMesh && defineDocumentType) {
return { CultMesh, defineDocumentType };
}
} catch {
}
}
throw new Error("CultMesh/CultCache TypeScript runtime is unavailable.");
}
function parseObjectDocument(label) {
return {
parse(input) {
if (!input || typeof input !== "object") {
throw new Error(`${label} must be an object.`);
}
return input;
},
};
}
function loadBifrostLocalEnv() {
if (process.env.BIFROST_SKIP_LOCAL_ENV === "true") {
return;
}
loadLocalEnv(resolve(repoRoot, ".env"));
loadLocalEnv(resolve(projectsRoot, "VoidBot", ".env"));
}
function loadLocalEnv(path) {
if (!existsSync(path)) {
return;
}
const content = readFileSync(path, "utf8");
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match || process.env[match[1]] !== undefined) {
continue;
}
process.env[match[1]] = match[2].replace(/^"(.*)"$/, "$1");
}
}
function parseArgs(args) {
const options = {};
for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (!token.startsWith("--")) {
throw new Error(`Unexpected argument "${token}".`);
}
const key = token.slice(2);
const next = args[index + 1];
if (!next || next.startsWith("--")) {
options[key] = "true";
continue;
}
options[key] = next;
index += 1;
}
return options;
}
function requireOption(options, name) {
const value = optionalString(options[name]);
if (!value) {
throw new Error(`Missing required option --${name}.`);
}
return value;
}
function requireString(value, name) {
const text = optionalString(value);
if (!text) {
throw new Error(`Missing required ${name}.`);
}
return text;
}
function optionalString(value) {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function pushOption(args, name, value) {
const text = optionalString(value);
if (text) {
args.push(name, text);
}
}
function unwrapRecord(record) {
const candidate = Array.isArray(record) && record.length === 1 ? record[0] : record?.value ?? record;
return candidate && typeof candidate === "object" ? candidate : null;
}
function parseJson(value, label) {
try {
return JSON.parse(value);
} catch {
throw new Error(`${label} returned non-JSON output: ${value}`);
}
}
function renderSpawnFailure(result) {
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
const error = result.error instanceof Error ? result.error.message : "";
return [
`Bifrost Discord actuator failed with status ${result.status ?? "unknown"}.`,
error,
stderr,
stdout,
].filter(Boolean).join(" ");
}
function printJson(value) {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function printHelp() {
process.stdout.write(`Bifrost CultMesh bridge commands
Usage:
node tools/cultmesh-bridge-commands.mjs process [--store <path>] [--command-id <id>]
node tools/cultmesh-bridge-commands.mjs receipt --command-id <id> [--store <path>]
node tools/cultmesh-bridge-commands.mjs delete --command-id <id> [--store <path>]
node tools/cultmesh-bridge-commands.mjs write-smoke --channel-id <id> [--store <path>]
The command request document is ${commandDocumentType} / ${commandSchemaId}.
The receipt document is ${receiptDocumentType} / ${receiptSchemaId}.
`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

View file

@ -90,7 +90,6 @@ 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"] : []),
],
{
@ -124,21 +123,26 @@ async function dispatchQueuedRequests(options) {
}
function ensureDispatchReceiptGate(options) {
if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) {
return;
if (wantsUnreceiptedActivity(options)) {
throw new Error(
"Bifrost dispatch cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
"Use the typed Bifrost CultCache/CultMesh dispatch store instead of launching off-book work.",
);
}
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.",
);
if (hasRemovedHttpBridgeConfig()) {
throw new Error(
"BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " +
"Bifrost dispatch receipts are written to the typed CultCache/CultMesh request/run artifacts.",
);
}
}
function hasBifrostBridgeConfig() {
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) && optionalString(process.env.BIFROST_BRIDGE_TOKEN));
function hasRemovedHttpBridgeConfig() {
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN));
}
function allowsUnreceiptedActivity(options) {
function wantsUnreceiptedActivity(options) {
return options["allow-unreceipted-activity"] === "true" || process.env.BIFROST_ALLOW_UNRECEIPTED_ACTIVITY === "true";
}
@ -550,7 +554,7 @@ function resolveDispatchReceiptChannelId(options) {
}
function bridgeRecoveryArgs(options) {
return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : [];
return [];
}
function renderDispatchReceiptContent(request) {
@ -840,61 +844,9 @@ async function writeDispatchResult(logPath, result) {
}
async function beginDispatchRun(request, input) {
const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN);
if (!baseUrl || !token) {
return null;
}
const started = await postDispatchRunJson(
baseUrl,
token,
"/dispatch/runs/start",
{
requestId: request.id,
targetRepoName: request.targetRepoName,
targetRepositoryFullName: request.targetRepositoryFullName ?? "",
targetAgentIdentity: request.targetAgentIdentity ?? "",
launchMode: input.launchMode,
workerProcessId: input.workerProcessId,
threadId: input.threadId,
turnId: input.turnId,
logPath: input.logPath,
resultPath: input.resultPath,
note: input.note,
},
new Set([202]),
);
const id = started.body?.id;
if (!id) {
throw new Error(`Bifrost dispatch run start returned no run id: ${started.text}`);
}
return {
async complete(payload) {
await postDispatchRunJson(
baseUrl,
token,
`/dispatch/runs/${id}/complete`,
payload,
new Set([200]),
);
},
async fail(payload) {
try {
await postDispatchRunJson(
baseUrl,
token,
`/dispatch/runs/${id}/fail`,
payload,
new Set([200]),
);
} catch {
// Best effort: the run failure still lands in the local result/log files.
}
},
};
void request;
void input;
return null;
}
async function postDispatchRunJson(baseUrl, token, path, payload, allowedStatuses) {
@ -1098,10 +1050,6 @@ 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

@ -6,6 +6,11 @@ import { existsSync, readFileSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
commandIdFromOptions,
crossingProvenanceFromOptions,
writeCrossingReceipt,
} from "./bifrost-crossing-documents.mjs";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
@ -209,7 +214,7 @@ async function openTopic(cache, options) {
actorKind: "agent",
actorName: topic.createdByActor ?? "system",
note: "Governance topic opened.",
});
}, options);
} catch (error) {
await cache.delete(topicDefinition, topic.id);
throw error;
@ -249,7 +254,7 @@ async function addComment(cache, options) {
actorKind: comment.authorKind,
actorName: comment.authorId,
note: `Governance topic ${comment.stance} comment recorded.`,
});
}, options);
} catch (error) {
await cache.delete(commentDefinition, comment.id);
throw error;
@ -304,7 +309,7 @@ async function approveTopic(cache, options) {
actorKind: "face",
actorName: approvedBy,
note: "Governance topic approved.",
});
}, options);
} catch (error) {
await cache.put(topicDefinition, topic.id, topic);
if (comment) {
@ -389,7 +394,7 @@ async function promoteTopic(cache, options) {
actorKind: "system",
actorName: topic.approvedByAgent ?? topic.createdByActor ?? "bifrost",
note: `Governance topic promoted to update request ${request.id}.`,
});
}, options);
} catch (error) {
await cache.put(topicDefinition, topic.id, topic);
await cache.delete(commentDefinition, receipt.id);
@ -470,7 +475,7 @@ function allowsUnmirrored(options) {
}
function bridgeRecoveryArgs(options) {
return allowsUnreceiptedActivity(options) ? ["--allow-unreceipted-activity", "true"] : [];
return [];
}
function renderGovernanceMirrorContent(topic, eventLabel, content) {
@ -523,37 +528,55 @@ function truncateMarkdown(value, maxLength) {
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
}
async function recordGovernanceReceiptOrThrow(topic, receipt) {
const baseUrl = optionalString(process.env.BIFROST_BRIDGE_BASE_URL);
const token = optionalString(process.env.BIFROST_BRIDGE_TOKEN);
if (!baseUrl || !token) {
return;
}
const response = await fetch(new URL("/governance/receipts", ensureTrailingSlash(baseUrl)), {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Bifrost-Bridge-Token": token,
},
body: JSON.stringify({
topicId: topic.id,
commentId: receipt.commentId,
dispatchRequestId: receipt.dispatchRequestId,
title: topic.title,
jurisdictionRepoName: topic.jurisdictionRepoName,
jurisdictionAgentIdentity: topic.jurisdictionAgentIdentity ?? "",
activityKind: receipt.activityKind,
actorKind: receipt.actorKind,
actorName: receipt.actorName,
note: receipt.note,
}),
async function recordGovernanceReceiptOrThrow(topic, receipt, options = {}) {
const commandId = commandIdFromOptions(options) || `governance:${topic.id}:${receipt.activityKind}`;
const provenance = crossingProvenanceFromOptions({
...options,
"cultmesh-command-id": commandId,
"source-kind": "bifrost.governance.topic",
"source-id": topic.id,
"topic-id": topic.id,
"authority-ref": `bifrost_governance_${receipt.activityKind}`,
identity: receipt.actorName,
});
const text = await response.text();
if (response.status !== 202) {
throw new Error(`Bifrost governance receipt failed with ${response.status}: ${text}`);
}
const now = new Date().toISOString();
await writeCrossingReceipt({
receiptId: `crossing_${slugifyReceiptPart(commandId)}_${slugifyReceiptPart(receipt.activityKind)}`,
commandId,
crossingKind: "governance.topic",
status: topic.status === "cancelled" ? "cancelled" : "completed",
ok: true,
requestedAt: topic.createdAt || now,
startedAt: topic.updatedAt || now,
completedAt: now,
actor: {
kind: receipt.actorKind || "system",
name: receipt.actorName || "bifrost",
bifrostIdentity: receipt.actorName || "bifrost",
},
source: provenance.source,
authority: provenance.authority,
epiphany: provenance.epiphany,
target: {
surface: "governance",
locator: topic.id,
repositoryFullName: topic.jurisdictionRepoName,
topicId: topic.id,
dispatchRequestId: receipt.dispatchRequestId || "",
status: topic.status,
},
externalReceipt: {
id: receipt.commentId || receipt.dispatchRequestId || topic.id,
transport: "cultcache",
url: "",
},
surfaceReceipt: {
activityKind: receipt.activityKind,
note: receipt.note || "",
commentId: receipt.commentId || "",
dispatchRequestId: receipt.dispatchRequestId || "",
},
}, options);
}
function isMutatingCommand(command) {
@ -561,29 +584,23 @@ function isMutatingCommand(command) {
}
function ensureGovernanceReceiptGate(options) {
if (wantsUnreceiptedActivity(options) && process.env.BIFROST_LOCK_RECOVERY_HATCHES === "true") {
if (wantsUnreceiptedActivity(options)) {
throw new Error(
"Dispatched Bifrost work cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
"Governance mutations from a dispatched turn must keep Bifrost receipts.",
"Bifrost governance mutations cannot use --allow-unreceipted-activity or BIFROST_ALLOW_UNRECEIPTED_ACTIVITY. " +
"Use the typed Bifrost CultCache/CultMesh governance store instead of mutating off-book.",
);
}
if (hasBifrostBridgeConfig() || allowsUnreceiptedActivity(options)) {
return;
if (hasRemovedHttpBridgeConfig()) {
throw new Error(
"BIFROST_BRIDGE_BASE_URL / BIFROST_BRIDGE_TOKEN were removed. " +
"Bifrost governance receipts are written to the typed CultCache/CultMesh store.",
);
}
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 wantsUnreceiptedActivity(options);
function hasRemovedHttpBridgeConfig() {
return Boolean(optionalString(process.env.BIFROST_BRIDGE_BASE_URL) || optionalString(process.env.BIFROST_BRIDGE_TOKEN));
}
function wantsUnreceiptedActivity(options) {
@ -843,6 +860,15 @@ function equalsIgnoreCase(left, right) {
&& left.toLowerCase() === right.toLowerCase();
}
function slugifyReceiptPart(value) {
return String(value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9._:-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120) || "receipt";
}
function runNodeJson(args, cwd) {
const result = spawnSync(process.execPath, args, {
cwd,
@ -925,7 +951,6 @@ 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
`);
}

View file

@ -5,11 +5,27 @@ import { mkdir } from "node:fs/promises";
import { existsSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
crossingReceiptDefinition,
crossingReceiptDocumentType,
crossingReceiptSchemaId,
openCrossingReceiptStore,
resolveCrossingReceiptStorePath,
} from "./bifrost-crossing-documents.mjs";
import {
discordPostCommandDefinition,
discordPostCommandDocumentType,
discordPostCommandSchemaId,
discordPostReceiptDefinition,
discordPostReceiptDocumentType,
discordPostReceiptSchemaId,
} from "./bifrost-discord-command-documents.mjs";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const projectsRoot = resolve(repoRoot, "..");
const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-advertisement.cc");
const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc");
const defaultOdinCultMeshUri = "cultmesh://odin/rendezvous/provider-catalog";
const cultCacheRequire = createRequire(resolveCultCachePackagePath());
const cultMeshRequire = createRequire(resolveCultMeshPackagePath());
@ -146,6 +162,8 @@ const currentMachine = "starfire";
const canonicalService = `${rootVerse}.bifrost`;
const locatedService = `${rootVerse}.${currentMachine}.bifrost`;
const plannedLocatedService = `${rootVerse}.yggdrasil.bifrost`;
const motionSurfaceCultMeshUri = `cultmesh://${locatedService}/eve/governance/surface`;
const motionCommandCultMeshUri = `cultmesh://${locatedService}/commands/motion`;
const advertisementDefinition = defineDocumentType({
type: documentType,
@ -153,7 +171,12 @@ const advertisementDefinition = defineDocumentType({
schemaName: documentType,
schemaVersion: schemaId,
schema: {
parse: parseAdvertisement,
// Persisted provider stores can contain an older advertisement revision
// beside live command and receipt documents. Decode that generated
// discovery metadata so export can replace it without discarding the
// command ledger; only newly built advertisements must satisfy the current
// contract below.
parse: (input) => parseAdvertisement(input, { requireCurrentContract: false }),
},
name: "providerId",
indexes: {
@ -215,6 +238,8 @@ async function main() {
return;
}
rejectRemovedHttpProbeConfig(options);
switch (command) {
case "export":
await exportAdvertisement(options);
@ -255,6 +280,9 @@ async function exportAdvertisement(options) {
.withDocumentType(advertisementDefinition)
.withDocumentType(surfaceDefinition)
.withDocumentType(interfaceBindingDefinition)
.withDocumentType(discordPostCommandDefinition(defineDocumentType))
.withDocumentType(discordPostReceiptDefinition(defineDocumentType))
.withDocumentType(crossingReceiptDefinition(defineDocumentType))
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
@ -294,15 +322,16 @@ 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("crossing-receipts", `${locatedService}/receipts/crossings`, crossingReceiptSchemaId, ["receipt", "bridge", "cultmesh"]),
endpoint("discord-post-commands", `${locatedService}/commands/discord-post`, discordPostCommandSchemaId, ["command", "discord", "cultmesh"]),
endpoint("discord-post-receipts", `${locatedService}/receipts/discord-post`, discordPostReceiptSchemaId, ["receipt", "discord", "cultmesh"]),
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"]),
endpoint("motion-surface", motionSurfaceCultMeshUri, "gamecult.eve.surface.v1", ["cultmesh", "governance", "motion"]),
endpoint("motion-commands", motionCommandCultMeshUri, "bifrost.motion_command.v0", ["command", "cultmesh", "governance", "motion"]),
],
routes: [
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),
route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true),
],
authority: {
owner: "Bifrost",
@ -334,22 +363,25 @@ function buildAdvertisement(options) {
schema("bifrost.ledger_entry.v0", "bifrost.ledger-entry", "planned contributor/patron ledger 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(crossingReceiptSchemaId, crossingReceiptDocumentType, "canonical Bifrost-owned public crossing receipt"),
schema(discordPostCommandSchemaId, discordPostCommandDocumentType, "CultMesh command request for Bifrost-owned Discord persona/bot posts"),
schema(discordPostReceiptSchemaId, discordPostReceiptDocumentType, "CultMesh receipt for Bifrost-owned Discord persona/bot posts"),
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: [
witness(".bifrost/provider-advertisement.cc", schemaId, "current", "read-only provider advertisement exported by this tool"),
witness(".bifrost/provider-store.cc", `${schemaId}; ${surfaceSchemaId}; ${interfaceBindingSchemaId}; ${discordPostCommandSchemaId}; ${discordPostReceiptSchemaId}`, "current", "typed provider advertisement, operator surface, Eve interface binding, and Bifrost Discord command request/receipt store"),
witness(".bifrost/governance-threads.cc", "bifrost.governance.topic.v0; bifrost.governance.topic_comment.v0", "current", "governance discussion, approvals, and dispatch promotion topics"),
witness(".bifrost/agent-transport.cc", "bifrost.agent-transport.update-request.v0", "current", "repo Persona update requests and dispatch queue state"),
witness(".bifrost/work-items.cc", "bifrost.work_item.v0", "planned-export", "work items exported from the alpha transactional store"),
witness(".bifrost/motions.cc", "bifrost.motion.v0; bifrost.vote.v0", "planned-export", "app-native motions and votes exported from the alpha transactional store"),
witness(".bifrost/ledger.cc", "bifrost.ledger_entry.v0", "planned-export", "patron and contributor ledger entries exported from the alpha transactional store"),
witness(".bifrost/member-capabilities.cc", "bifrost.member_capability_snapshot.v0", "planned-export", "membership and account capability snapshots consumed by Bifrost"),
witness(".bifrost/bridge-receipts.cc", "bifrost.bridge_action.v0; bifrost.bridge_receipt.v0", "planned-export", "governed public crossing actions and receipts"),
witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "product and operator Eve/CultUI surface publications"),
witness(".bifrost/bridge-receipts.cc", crossingReceiptSchemaId, "current", "canonical governed public crossing receipt history"),
witness(".bifrost/eve-surfaces.cc", "gamecult.eve.surface.v1", "planned-export", "future product Eve/CultUI surfaces not yet in the provider store"),
],
surfaces: [
surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-advertisement.cc", "eve", [
surface("bifrost", "Bifrost Operator Dashboard", "gamecult.bifrost.surface.operator", "gamecult.eve.surface_state.v1", ".bifrost/provider-store.cc", "eve", [
"service health",
"compact service status",
"topic and request status",
@ -445,12 +477,15 @@ function buildAdvertisement(options) {
"prepare governed public crossings",
"execute approved handoffs",
"record receipts",
"own canonical bifrost.crossing_receipt.v1 documents for every crossing attempt",
"open GitHub draft PRs and PR comments through Bifrost gate",
"post Discord messages and DMs through Bifrost gate with Heimdall-linked actor capability",
"record receipt-only future-surface requests with named-surface Heimdall capability matching before named actuators exist",
"post Persona-flaired Reddit organizing threads",
"accept typed CultMesh Discord post command requests and write typed Discord post receipts",
], [
"does not treat local protocol JSON as work authority",
"surface-specific receipts cannot decide crossing completion",
"does not touch secrets in this advertisement",
]),
],
@ -463,40 +498,54 @@ function buildAdvertisement(options) {
],
demotions: [
"Razor Pages are browser lowerings, not the canonical presentation owner.",
"HTTP health/readiness JSON is a probe, not service truth.",
"HTTP health/readiness JSON is product smoke output only; provider readiness comes from typed CultMesh/Idunn state.",
"Discord messages are mirrors and input surfaces until Bifrost commits typed state.",
"Reddit threads are organizing surfaces until Bifrost commits typed votes, priority signals, comments, or receipts.",
"Local dispatch JSON is evidence for receipts, not command authority.",
"This advertisement is read-only discovery metadata and does not migrate runtime state.",
],
commands: [
command("discord.post", "write", "Write a bifrost.bridge.discord_post_command.v1 document into the provider CultMesh store and wait for a bifrost.bridge.discord_post_receipt.v1 receipt.", {
transport: "cultmesh-command-document",
commandDocumentType: discordPostCommandDocumentType,
commandSchemaId: discordPostCommandSchemaId,
receiptDocumentType: discordPostReceiptDocumentType,
receiptSchemaId: discordPostReceiptSchemaId,
storeRoute: ".bifrost/provider-store.cc",
cultMeshUri: `${locatedService}/commands/discord-post`,
}),
],
});
}
async function collectStats(options) {
const generatedAt = options["generated-at"] ?? new Date().toISOString();
const health = await fetchProbe("http://127.0.0.1:5080/healthz");
const ready = await fetchProbe("http://127.0.0.1:5080/readyz");
const transport = runJsonTool(["tools/agent-transport.mjs", "list", "--json"]);
const governance = runJsonTool(["tools/governance-threads.mjs", "list", "--json"]);
const crossingReceipts = await readCrossingReceipts(options);
const docker = dockerContainers();
const witnesses = [
witnessStat(".bifrost/provider-advertisement.cc"),
witnessStat(".bifrost/provider-store.cc"),
witnessStat(".bifrost/governance-threads.cc"),
witnessStat(".bifrost/agent-transport.cc"),
witnessStat(".bifrost/bridge-receipts.cc"),
witnessStat(".bifrost/discord-webhook-cache.json"),
];
const topics = Array.isArray(governance.value) ? governance.value : [];
const requests = Array.isArray(transport.value) ? transport.value : [];
const topicCounts = countBy(topics, "status");
const requestCounts = countBy(requests, "status");
const receiptCounts = countBy(crossingReceipts.value, "status");
const recentWindowHours = 24;
const dockerHealthy = docker.items.filter((item) => /healthy/i.test(item.status)).length;
const dockerRunning = docker.items.filter((item) => /^Up\b/i.test(item.status)).length;
const bridge = buildBridgeStats();
const service = buildTypedServiceStatus({ witnesses, transport, governance, bridge });
return {
generatedAt,
health,
ready,
health: service.health,
ready: service.ready,
docker,
governance: {
ok: governance.ok,
@ -518,16 +567,27 @@ async function collectStats(options) {
recentChannelCounts: countRecentBy(requests, "sourceKind", recentWindowHours),
latestUpdatedAt: latestTimestamp(requests.map((item) => item.updatedAt)),
},
crossingReceipts: {
ok: crossingReceipts.ok,
error: crossingReceipts.error,
count: crossingReceipts.value.length,
statusCounts: receiptCounts,
recentCount: countRecent(crossingReceipts.value, recentWindowHours),
gapRows: buildReceiptGapRows(crossingReceipts.value),
latestUpdatedAt: latestTimestamp(crossingReceipts.value.map((item) => item.completedAt || item.startedAt || item.requestedAt)),
},
witnesses,
bridge: buildBridgeStats(),
bridge,
summary: {
status: health.ok && ready.ok ? "ready" : "degraded",
health: health.value ?? health.error,
ready: ready.value ?? ready.error,
status: service.ready.ok ? "ready" : "degraded",
health: service.health.value,
ready: service.ready.value,
dockerRunning,
dockerHealthy,
governanceTopics: topics.length,
transportRequests: requests.length,
crossingReceipts: crossingReceipts.value.length,
crossingReceiptGaps: buildReceiptGapRows(crossingReceipts.value).length,
recentGovernanceTopics: countRecent(topics, recentWindowHours),
recentTransportRequests: countRecent(requests, recentWindowHours),
openTopics: topicCounts.open ?? 0,
@ -537,26 +597,67 @@ async function collectStats(options) {
};
}
async function fetchProbe(url) {
async function readCrossingReceipts(options) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2500);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
const text = (await response.text()).trim();
return {
ok: response.ok,
statusCode: response.status,
value: text,
};
const storePath = resolveCrossingReceiptStorePath(options);
if (!existsSync(storePath)) {
return { ok: true, error: "", value: [] };
}
const store = await openCrossingReceiptStore(storePath);
return { ok: true, error: "", value: store.getAll() };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
value: [],
};
}
}
function buildReceiptGapRows(receipts) {
return receipts
.filter((receipt) => receipt.status === "failed" || receipt.status === "running" || receipt.status === "requested")
.sort((left, right) => String(right.requestedAt).localeCompare(String(left.requestedAt)))
.slice(0, 8)
.map((receipt) =>
`${receipt.receiptId}: ${receipt.crossingKind} ${receipt.status}; source=${receipt.source?.kind || "unknown"}/${receipt.source?.id || "unknown"}; target=${receipt.target?.surface || "unknown"} ${receipt.target?.locator || ""}`.trim(),
);
}
function buildTypedServiceStatus({ witnesses, transport, governance, bridge }) {
const providerStore = witnesses.find((item) => item.path === ".bifrost/provider-store.cc");
const storesReady = witnesses.every((item) => item.exists);
const healthOk = Boolean(providerStore?.exists) && transport.ok && governance.ok;
const readyOk = healthOk && storesReady && bridge.ready;
return {
health: {
ok: healthOk,
value: healthOk
? `typed-store ${providerStore.path} / command stores readable`
: `typed-store ${providerStore?.exists ? "present" : "missing"} / command stores ${transport.ok && governance.ok ? "readable" : "degraded"}`,
},
ready: {
ok: readyOk,
value: readyOk
? "CultMesh command/surface store ready"
: `CultMesh command/surface store ${storesReady ? "present" : "incomplete"} / bridge ${bridge.ready ? "ready" : "not-ready"}`,
},
};
}
function rejectRemovedHttpProbeConfig(options) {
const removed = [
["--health-url", options["health-url"]],
["--ready-url", options["ready-url"]],
["BIFROST_HEALTH_URL", process.env.BIFROST_HEALTH_URL],
["BIFROST_READY_URL", process.env.BIFROST_READY_URL],
].filter(([, value]) => optionalString(value));
if (removed.length > 0) {
throw new Error(`${removed.map(([name]) => name).join(" / ")} were removed from provider advertisement status. Publish Bifrost readiness through typed CultMesh/Idunn state instead of HTTP probes.`);
}
}
function runJsonTool(args) {
const result = spawnSync(process.execPath, args, {
cwd: repoRoot,
@ -637,16 +738,19 @@ function witnessStat(relativePath) {
}
async function publishOdinAdvertisement(options) {
const endpoint = options["odin-cultmesh-rudp"] ?? options.odin ?? process.env.BIFROST_ODIN_CULTMESH_RUDP;
if (!endpoint) {
throw new Error("Bifrost Odin publication requires --odin-cultmesh-rudp <host:port> or BIFROST_ODIN_CULTMESH_RUDP.");
if (options["odin-cultmesh-rudp"] || process.env.BIFROST_ODIN_CULTMESH_RUDP) {
throw new Error("--odin-cultmesh-rudp and BIFROST_ODIN_CULTMESH_RUDP were removed. Configure --odin-cultmesh-uri cultmesh://odin/rendezvous/provider-catalog and let CultMesh resolve Odin's transport.");
}
const odinCultMeshUri = options["odin-cultmesh-uri"] ?? options.odin ?? process.env.BIFROST_ODIN_CULTMESH_URI ?? defaultOdinCultMeshUri;
if (!odinCultMeshUri) {
throw new Error("Bifrost Odin publication requires an Odin CultMesh URI.");
}
const advertisement = buildAdvertisement(options);
await CultMesh.publishRudpDocumentOnce(
"bifrost",
0x0d1d0002,
normalizeRudpEndpoint(endpoint),
odinCultMeshUri,
defineCultNetDocumentBinding({ definition: advertisementDefinition }),
advertisement.providerId,
advertisement,
@ -661,7 +765,7 @@ async function publishOdinAdvertisement(options) {
documentType,
schemaId,
providerId: advertisement.providerId,
odinCultMeshRudp: endpoint,
odinCultMeshUri,
});
}
@ -674,17 +778,13 @@ function buildBridgeStats() {
const redditCredentialSource = process.env.BIFROST_REDDIT_CLIENT_ID && process.env.BIFROST_REDDIT_REFRESH_TOKEN
? "BIFROST_REDDIT_CLIENT_ID+BIFROST_REDDIT_REFRESH_TOKEN"
: "missing";
const bridgeLedgerCredentialSource = process.env.BIFROST_BRIDGE_BASE_URL && process.env.BIFROST_BRIDGE_TOKEN
? "BIFROST_BRIDGE_BASE_URL+BIFROST_BRIDGE_TOKEN"
: "missing";
const surfaces = [
{
id: "github",
label: "GitHub",
prepared: true,
ready: true,
authority: "Bifrost bridge gate plus GitHub webhook sync",
authority: "Bifrost typed CultMesh command gate plus GitHub webhook sync",
credentialSource: "GitHub app/OAuth/gh runtime",
note: "draft PRs, PR comments, and webhook work sync are hooked",
},
@ -693,7 +793,7 @@ function buildBridgeStats() {
label: "Discord",
prepared: true,
ready: discordCredentialSource !== "missing",
authority: "Bifrost bridge gate plus Heimdall-linked account/capability reference",
authority: "Bifrost typed CultMesh command gate plus Heimdall-linked account/capability reference",
credentialSource: discordCredentialSource,
note: discordCredentialSource === "missing"
? "transport actuator token is not visible to this process"
@ -704,7 +804,7 @@ function buildBridgeStats() {
label: "Reddit",
prepared: true,
ready: redditCredentialSource !== "missing",
authority: "Bifrost bridge gate plus Heimdall-linked reddit capability reference",
authority: "Bifrost typed CultMesh command gate plus Heimdall-linked reddit capability reference",
credentialSource: redditCredentialSource,
note: redditCredentialSource === "missing"
? "reddit transport credentials are not visible to this process"
@ -714,10 +814,10 @@ function buildBridgeStats() {
id: "other",
label: "Other",
prepared: true,
ready: true,
authority: "Bifrost receipt-only future-surface gate with named Heimdall reference",
credentialSource: "not-required-for-receipt-only",
note: "records governed future-surface requests without provider transport",
ready: false,
authority: "typed CultMesh command contract required before use",
credentialSource: "not-configured",
note: "the old receipt-only HTTP bridge hatch is removed",
},
{
id: "patron",
@ -739,10 +839,10 @@ function buildBridgeStats() {
githubWebhookSync: true,
otherRequest: true,
patronSupportIntake: true,
bridgeLedgerConfigured: bridgeLedgerCredentialSource !== "missing",
credentialSource: discordCredentialSource,
redditCredentialSource,
bridgeLedgerCredentialSource,
bridgeLedgerConfigured: false,
bridgeLedgerCredentialSource: "removed-http-bridge",
patronSupportAuthority: "Heimdall HMAC via /heimdall/patron-support/events",
prepared: surfaces.every((surface) => surface.prepared),
ready: surfaces.every((surface) => surface.ready),
@ -778,10 +878,13 @@ function buildOperatorSurface(stats) {
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"),
metricNode("requests", "Requests", `${stats.summary.transportRequests} total / ${stats.summary.queuedRequests} queued / ${stats.summary.recentTransportRequests} in ${stats.summary.recentWindowHours}h`, stats.transport.ok ? "ok" : "warn"),
metricNode("crossing-receipts", "Crossing Receipts", `${stats.summary.crossingReceipts} total / ${stats.summary.crossingReceiptGaps} gaps`, stats.crossingReceipts.ok && stats.summary.crossingReceiptGaps === 0 ? "ok" : "warn"),
listNode("status", "Status", [
`topics: ${compactCounts(stats.governance.statusCounts)}`,
`requests: ${compactCounts(stats.transport.statusCounts)}`,
`crossing receipts: ${compactCounts(stats.crossingReceipts.statusCounts)}`,
]),
listNode("receipt-gaps", "Receipt Gaps", stats.crossingReceipts.gapRows),
listNode("channels", "Dispatch Channels", compactChannelLines(stats.transport.channelCounts, stats.transport.recentChannelCounts, stats.summary.recentWindowHours)),
]),
];
@ -796,6 +899,17 @@ function buildOperatorSurface(stats) {
schema: "gamecult.eve.surface.v1",
id: "bifrost-operator-dashboard",
title: "Bifrost Operator Dashboard",
commands: [
command("discord.post", "write", "Post to Discord through Bifrost's CultMesh command request/receipt documents.", {
transport: "cultmesh-command-document",
commandDocumentType: discordPostCommandDocumentType,
commandSchemaId: discordPostCommandSchemaId,
receiptDocumentType: discordPostReceiptDocumentType,
receiptSchemaId: discordPostReceiptSchemaId,
storeRoute: ".bifrost/provider-store.cc",
cultMeshUri: `${locatedService}/commands/discord-post`,
}),
],
root: {
id: "bifrost-root",
kind: "dashboard",
@ -833,8 +947,7 @@ function buildInterfaceBinding(surface, stats) {
endpoint("operator-gui", `${locatedService}/eve/gui`, "gamecult.eve.surface.v1", ["gui", "browser", "eve-native"]),
],
routes: [
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),
route("cultmesh-store", ".bifrost/provider-store.cc", "local-cultmesh", true),
],
capabilities: [
"operator-stats",
@ -1008,6 +1121,16 @@ function boundary(area, owner, commands, forbiddenAuthority) {
return { area, owner, commands, forbiddenAuthority };
}
function command(id, mode, purpose, metadata = {}) {
return {
id,
command: id,
mode,
purpose,
...metadata,
};
}
function style(area, capabilities) {
return { area, capabilities };
}
@ -1020,7 +1143,7 @@ function route(id, address, transport, demoted) {
return { id, address, transport, demoted };
}
function parseAdvertisement(input) {
function parseAdvertisement(input, { requireCurrentContract = true } = {}) {
if (!input || typeof input !== "object") {
throw new Error("Provider advertisement must be an object.");
}
@ -1047,6 +1170,10 @@ function parseAdvertisement(input) {
demotions: requireStringArray(input.demotions, "demotions"),
};
if (!requireCurrentContract) {
return advertisement;
}
const requiredSurfaces = new Set(["bifrost.work", "bifrost.motion", "bifrost.patron", "bifrost.project", "bifrost.account"]);
const surfaceIds = new Set(advertisement.surfaces.map((surface) => surface.id));
for (const id of requiredSurfaces) {
@ -1055,7 +1182,7 @@ function parseAdvertisement(input) {
}
}
const requiredEndpoints = new Set(["bridge-action-ledger", "patron-support-intake", "github-webhooks"]);
const requiredEndpoints = new Set(["crossing-receipts", "discord-post-commands", "discord-post-receipts", "patron-support-intake", "github-webhooks"]);
const endpointIds = new Set(advertisement.endpoints.map((endpoint) => endpoint.id));
for (const id of requiredEndpoints) {
if (!endpointIds.has(id)) {
@ -1063,7 +1190,7 @@ function parseAdvertisement(input) {
}
}
const requiredSchemas = new Set(["bifrost.bridge_action.v0", "bifrost.bridge_receipt.v0", "bifrost.patron_support_event.v0"]);
const requiredSchemas = new Set([crossingReceiptSchemaId, "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)) {
@ -1148,14 +1275,6 @@ function resolveOptionPath(path) {
return resolve(process.cwd(), path);
}
function normalizeRudpEndpoint(endpoint) {
const text = String(endpoint || "").trim();
if (!text) {
throw new Error("Odin CultMesh/RUDP endpoint must be non-empty.");
}
return text.toLowerCase().startsWith("rudp://") ? text : `rudp://${text}`;
}
function printJson(value) {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
@ -1164,24 +1283,25 @@ function printHelp() {
process.stdout.write(`Bifrost Eve provider advertisement
Commands:
export Write advertisement, operator surface, and interface binding to a CultCache .cc witness
publish-odin Publish the provider advertisement once to Odin over CultMesh/RUDP
export Write advertisement, operator surface, and interface binding to the Bifrost provider store
publish-odin Publish the provider advertisement once to Odin through its CultMesh rendezvous URI
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
--odin-cultmesh-rudp <host:port>
Odin CultMesh/RUDP document catalog endpoint; also reads BIFROST_ODIN_CULTMESH_RUDP
--out <path> Override export path; defaults to .bifrost/provider-store.cc
--odin-cultmesh-uri <cultmesh-uri>
Odin CultMesh rendezvous URI; also reads BIFROST_ODIN_CULTMESH_URI
--generated-at <iso> Pin generatedAt for deterministic fixture checks
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
node tools/provider-advertisement.mjs publish-odin --odin-cultmesh-rudp 127.0.0.1:17871
node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc
node tools/provider-advertisement.mjs print-surface
$env:BIFROST_ODIN_CULTMESH_URI="cultmesh://odin/rendezvous/provider-catalog"; node tools/provider-advertisement.mjs publish-odin
`);
}