Move public crossings onto typed Bifrost transport
This commit is contained in:
parent
bd3a5a974b
commit
cde3a78874
18 changed files with 1686 additions and 2073 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
32
README.md
32
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
336
tools/bifrost-crossing-documents.mjs
Normal file
336
tools/bifrost-crossing-documents.mjs
Normal 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);
|
||||
}
|
||||
47
tools/bifrost-discord-command-documents.mjs
Normal file
47
tools/bifrost-discord-command-documents.mjs
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
450
tools/cultmesh-bridge-commands.mjs
Normal file
450
tools/cultmesh-bridge-commands.mjs
Normal 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;
|
||||
});
|
||||
|
|
@ -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
|
||||
`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue