Gate GitHub bridge actions and receipt agent runs

This commit is contained in:
Meta Vens 2026-06-17 04:03:14 +01:00
commit 2281b0e63d
26 changed files with 6264 additions and 98 deletions

View file

@ -71,6 +71,10 @@ For the full staged roadmap, including contribution points, revenue share, patro
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
- 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
- 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
- GitHub bridge mutations now fail closed without those values; `--allow-ungated-github true` is reserved for explicit operator recovery
- 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
- default connection string points at local PostgreSQL

View file

@ -125,6 +125,7 @@ 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.
The `snapshot` command writes a MessagePack-encoded `cultnet.snapshot_response_raw.v0` message. This keeps CultCache payload bytes intact for peers that share the document binding.

View file

@ -45,6 +45,45 @@ It intentionally uses boring local credentials while Heimdall's managed GitHub c
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.
## Bridge Action Ledger
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 hosted app also exposes a dispatch-run ledger for launched agent work:
- `POST /dispatch/runs/start`
- `POST /dispatch/runs/{id}/complete`
- `POST /dispatch/runs/{id}/fail`
This is the companion trail to bridge actions. A bridge action says "this crossing into GitHub/Discord/Reddit was requested and receipted." A dispatch run says "this specific Codex worker actually launched, ran, and ended this way." One guards governed mutation. The other proves agent activity.
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`
- 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
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
- pass `--source-kind`, `--source-id`, `--authority-ref`, `--work-item-id`, or `--motion-id` so agent actions satisfy policy
- 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
- 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
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.
### GitHub Draft PR
```powershell

View file

@ -0,0 +1,10 @@
namespace Bifrost.Web.Configuration;
public sealed class BridgeOptions
{
public const string SectionName = "Bridge";
public string LocalBridgeToken { get; init; } = string.Empty;
public bool HasLocalBridgeToken => !string.IsNullOrWhiteSpace(LocalBridgeToken);
}

View file

@ -53,6 +53,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<RevenueShareLine> RevenueShareLines => Set<RevenueShareLine>();
public DbSet<BridgeAction> BridgeActions => Set<BridgeAction>();
public DbSet<AgentDispatchRun> AgentDispatchRuns => Set<AgentDispatchRun>();
public DbSet<AuditEvent> AuditEvents => Set<AuditEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
@ -395,6 +399,61 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<BridgeAction>(entity =>
{
entity.Property(x => x.ActorKind).HasConversion<string>();
entity.Property(x => x.TargetSurface).HasConversion<string>();
entity.Property(x => x.ActionKind).HasConversion<string>();
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.ActorName).HasMaxLength(160);
entity.Property(x => x.TargetRepositoryFullName).HasMaxLength(240);
entity.Property(x => x.TargetLocator).HasMaxLength(500);
entity.Property(x => x.SourceKind).HasMaxLength(120);
entity.Property(x => x.SourceId).HasMaxLength(240);
entity.Property(x => x.AuthorityReference).HasMaxLength(240);
entity.Property(x => x.PolicyDecision).HasMaxLength(500);
entity.Property(x => x.Title).HasMaxLength(240);
entity.Property(x => x.ReceiptUrl).HasMaxLength(500);
entity.Property(x => x.ExternalReceiptId).HasMaxLength(240);
entity.HasIndex(x => x.Status);
entity.HasIndex(x => new { x.TargetSurface, x.ActionKind, x.Status });
entity.HasIndex(x => new { x.ActorKind, x.ActorName });
entity.HasIndex(x => new { x.SourceKind, x.SourceId });
entity.HasOne(x => x.ActorUserAccount)
.WithMany(x => x.BridgeActions)
.HasForeignKey(x => x.ActorUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.WorkItem)
.WithMany()
.HasForeignKey(x => x.WorkItemId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.Motion)
.WithMany()
.HasForeignKey(x => x.MotionId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<AgentDispatchRun>(entity =>
{
entity.Property(x => x.RequestId).HasMaxLength(240);
entity.Property(x => x.TargetRepoName).HasMaxLength(120);
entity.Property(x => x.TargetRepositoryFullName).HasMaxLength(240);
entity.Property(x => x.TargetAgentIdentity).HasMaxLength(160);
entity.Property(x => x.LaunchMode).HasMaxLength(40);
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.ThreadId).HasMaxLength(120);
entity.Property(x => x.TurnId).HasMaxLength(120);
entity.Property(x => x.LogPath).HasMaxLength(500);
entity.Property(x => x.ResultPath).HasMaxLength(500);
entity.HasIndex(x => x.RequestId);
entity.HasIndex(x => x.Status);
entity.HasIndex(x => new { x.TargetRepoName, x.TargetAgentIdentity, x.Status });
entity.HasOne(x => x.StartedByUserAccount)
.WithMany(x => x.AgentDispatchRuns)
.HasForeignKey(x => x.StartedByUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<AuditEvent>(entity =>
{
entity.Property(x => x.EntityType).HasMaxLength(120);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,110 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
public partial class AddBridgeActionLedger : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BridgeActions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ActorKind = table.Column<string>(type: "text", nullable: false),
ActorUserAccountId = table.Column<Guid>(type: "uuid", nullable: true),
ActorName = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
TargetSurface = table.Column<string>(type: "text", nullable: false),
ActionKind = table.Column<string>(type: "text", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
WorkItemId = table.Column<Guid>(type: "uuid", nullable: true),
MotionId = table.Column<Guid>(type: "uuid", nullable: true),
TargetRepositoryFullName = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
TargetLocator = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
SourceKind = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
SourceId = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
AuthorityReference = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
PolicyDecision = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
Summary = table.Column<string>(type: "text", nullable: false),
ReceiptUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
ExternalReceiptId = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
ReceiptPayload = table.Column<string>(type: "text", nullable: false),
FailureReason = table.Column<string>(type: "text", nullable: false),
RequestedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
StartedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CompletedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BridgeActions", x => x.Id);
table.ForeignKey(
name: "FK_BridgeActions_Motions_MotionId",
column: x => x.MotionId,
principalTable: "Motions",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_BridgeActions_UserAccounts_ActorUserAccountId",
column: x => x.ActorUserAccountId,
principalTable: "UserAccounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_BridgeActions_WorkItems_WorkItemId",
column: x => x.WorkItemId,
principalTable: "WorkItems",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_BridgeActions_ActorKind_ActorName",
table: "BridgeActions",
columns: new[] { "ActorKind", "ActorName" });
migrationBuilder.CreateIndex(
name: "IX_BridgeActions_ActorUserAccountId",
table: "BridgeActions",
column: "ActorUserAccountId");
migrationBuilder.CreateIndex(
name: "IX_BridgeActions_MotionId",
table: "BridgeActions",
column: "MotionId");
migrationBuilder.CreateIndex(
name: "IX_BridgeActions_SourceKind_SourceId",
table: "BridgeActions",
columns: new[] { "SourceKind", "SourceId" });
migrationBuilder.CreateIndex(
name: "IX_BridgeActions_Status",
table: "BridgeActions",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_BridgeActions_TargetSurface_ActionKind_Status",
table: "BridgeActions",
columns: new[] { "TargetSurface", "ActionKind", "Status" });
migrationBuilder.CreateIndex(
name: "IX_BridgeActions_WorkItemId",
table: "BridgeActions",
column: "WorkItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BridgeActions");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
public partial class AddAgentDispatchRuns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AgentDispatchRuns",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
RequestId = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
TargetRepoName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
TargetRepositoryFullName = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
TargetAgentIdentity = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
LaunchMode = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
StartedByUserAccountId = table.Column<Guid>(type: "uuid", nullable: true),
WorkerProcessId = table.Column<int>(type: "integer", nullable: true),
ThreadId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
TurnId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
LogPath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
ResultPath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Note = table.Column<string>(type: "text", nullable: false),
Error = table.Column<string>(type: "text", nullable: false),
StartedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CompletedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AgentDispatchRuns", x => x.Id);
table.ForeignKey(
name: "FK_AgentDispatchRuns_UserAccounts_StartedByUserAccountId",
column: x => x.StartedByUserAccountId,
principalTable: "UserAccounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_AgentDispatchRuns_RequestId",
table: "AgentDispatchRuns",
column: "RequestId");
migrationBuilder.CreateIndex(
name: "IX_AgentDispatchRuns_StartedByUserAccountId",
table: "AgentDispatchRuns",
column: "StartedByUserAccountId");
migrationBuilder.CreateIndex(
name: "IX_AgentDispatchRuns_Status",
table: "AgentDispatchRuns",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_AgentDispatchRuns_TargetRepoName_TargetAgentIdentity_Status",
table: "AgentDispatchRuns",
columns: new[] { "TargetRepoName", "TargetAgentIdentity", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AgentDispatchRuns");
}
}
}

View file

@ -22,6 +22,97 @@ namespace Bifrost.Web.Data.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Error")
.IsRequired()
.HasColumnType("text");
b.Property<string>("LaunchMode")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("LogPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text");
b.Property<string>("RequestId")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("ResultPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset>("StartedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("StartedByUserAccountId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TargetAgentIdentity")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("TargetRepoName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("TargetRepositoryFullName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("ThreadId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("TurnId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("WorkerProcessId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RequestId");
b.HasIndex("StartedByUserAccountId");
b.HasIndex("Status");
b.HasIndex("TargetRepoName", "TargetAgentIdentity", "Status");
b.ToTable("AgentDispatchRuns");
});
modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b =>
{
b.Property<Guid>("Id")
@ -93,6 +184,130 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("AuditEvents");
});
modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ActionKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ActorKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ActorName")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<Guid?>("ActorUserAccountId")
.HasColumnType("uuid");
b.Property<string>("AuthorityReference")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset?>("CompletedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalReceiptId")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("FailureReason")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("MotionId")
.HasColumnType("uuid");
b.Property<string>("PolicyDecision")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ReceiptPayload")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ReceiptUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset>("RequestedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("SourceId")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("SourceKind")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("StartedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Summary")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TargetLocator")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("TargetRepositoryFullName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("TargetSurface")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("WorkItemId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ActorUserAccountId");
b.HasIndex("MotionId");
b.HasIndex("Status");
b.HasIndex("WorkItemId");
b.HasIndex("ActorKind", "ActorName");
b.HasIndex("SourceKind", "SourceId");
b.HasIndex("TargetSurface", "ActionKind", "Status");
b.ToTable("BridgeActions");
});
modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b =>
{
b.Property<Guid>("Id")
@ -497,9 +712,6 @@ namespace Bifrost.Web.Data.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("RecordedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("text");
@ -519,6 +731,9 @@ namespace Bifrost.Web.Data.Migrations
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("RecordedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("SupportedAtUtc")
.HasColumnType("timestamp with time zone");
@ -527,14 +742,12 @@ namespace Bifrost.Web.Data.Migrations
b.HasKey("Id");
b.HasIndex("UserAccountId");
b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport");
b.HasIndex("Provider", "ProviderEventId")
.IsUnique()
.HasFilter("\"ProviderEventId\" <> ''");
b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport");
b.ToTable("PatronSupportEvents");
});
@ -1125,6 +1338,16 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("WorkReviews");
});
modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b =>
{
b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount")
.WithMany("AgentDispatchRuns")
.HasForeignKey("StartedByUserAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("StartedByUserAccount");
});
modelBuilder.Entity("Bifrost.Web.Domain.Assignment", b =>
{
b.HasOne("Bifrost.Web.Domain.UserAccount", "AssignedByUserAccount")
@ -1162,6 +1385,30 @@ namespace Bifrost.Web.Data.Migrations
b.Navigation("ActorUserAccount");
});
modelBuilder.Entity("Bifrost.Web.Domain.BridgeAction", b =>
{
b.HasOne("Bifrost.Web.Domain.UserAccount", "ActorUserAccount")
.WithMany("BridgeActions")
.HasForeignKey("ActorUserAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Bifrost.Web.Domain.Motion", "Motion")
.WithMany()
.HasForeignKey("MotionId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem")
.WithMany()
.HasForeignKey("WorkItemId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ActorUserAccount");
b.Navigation("Motion");
b.Navigation("WorkItem");
});
modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b =>
{
b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem")
@ -1561,12 +1808,16 @@ namespace Bifrost.Web.Data.Migrations
modelBuilder.Entity("Bifrost.Web.Domain.UserAccount", b =>
{
b.Navigation("AgentDispatchRuns");
b.Navigation("AssignedWorkItems");
b.Navigation("Assignments");
b.Navigation("AuditEvents");
b.Navigation("BridgeActions");
b.Navigation("CreatedMotions");
b.Navigation("LedgerEntries");

View file

@ -174,6 +174,55 @@ public enum ExternalPatronProvider
PayPal
}
public enum BridgeActorKind
{
Member,
Agent,
Persona,
Service
}
public enum BridgeTargetSurface
{
GitHub,
Discord,
Reddit,
CultNet,
CultCache,
Other
}
public enum BridgeActionKind
{
GitHubDraftPullRequest,
GitHubPullRequestComment,
GitHubIssueComment,
DiscordPost,
DiscordDirectMessage,
RedditPost,
AgentUpdateRequest,
Other
}
public enum BridgeActionStatus
{
Requested,
Authorized,
Denied,
InProgress,
Completed,
Failed,
Cancelled
}
public enum AgentDispatchRunStatus
{
Started,
Completed,
Cancelled,
Failed
}
public sealed class UserAccount
{
public Guid Id { get; set; } = Guid.NewGuid();
@ -222,6 +271,10 @@ public sealed class UserAccount
public ICollection<PatronSupportEvent> PatronSupportEvents { get; set; } = [];
public ICollection<BridgeAction> BridgeActions { get; set; } = [];
public ICollection<AgentDispatchRun> AgentDispatchRuns { get; set; } = [];
public ICollection<AuditEvent> AuditEvents { get; set; } = [];
}
@ -822,6 +875,106 @@ public sealed class RevenueShareLine
public Project? Project { get; set; }
}
public sealed class BridgeAction
{
public Guid Id { get; set; } = Guid.NewGuid();
public BridgeActorKind ActorKind { get; set; } = BridgeActorKind.Agent;
public Guid? ActorUserAccountId { get; set; }
public string ActorName { get; set; } = string.Empty;
public BridgeTargetSurface TargetSurface { get; set; } = BridgeTargetSurface.GitHub;
public BridgeActionKind ActionKind { get; set; } = BridgeActionKind.GitHubDraftPullRequest;
public BridgeActionStatus Status { get; set; } = BridgeActionStatus.Requested;
public Guid? WorkItemId { get; set; }
public Guid? MotionId { get; set; }
public string TargetRepositoryFullName { get; set; } = string.Empty;
public string TargetLocator { get; set; } = string.Empty;
public string SourceKind { get; set; } = string.Empty;
public string SourceId { get; set; } = string.Empty;
public string AuthorityReference { get; set; } = string.Empty;
public string PolicyDecision { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string ReceiptUrl { get; set; } = string.Empty;
public string ExternalReceiptId { get; set; } = string.Empty;
public string ReceiptPayload { get; set; } = string.Empty;
public string FailureReason { get; set; } = string.Empty;
public DateTimeOffset RequestedAtUtc { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public DateTimeOffset? StartedAtUtc { get; set; }
public DateTimeOffset? CompletedAtUtc { get; set; }
public UserAccount? ActorUserAccount { get; set; }
public WorkItem? WorkItem { get; set; }
public Motion? Motion { get; set; }
}
public sealed class AgentDispatchRun
{
public Guid Id { get; set; } = Guid.NewGuid();
public string RequestId { get; set; } = string.Empty;
public string TargetRepoName { get; set; } = string.Empty;
public string TargetRepositoryFullName { get; set; } = string.Empty;
public string TargetAgentIdentity { get; set; } = string.Empty;
public string LaunchMode { get; set; } = string.Empty;
public AgentDispatchRunStatus Status { get; set; } = AgentDispatchRunStatus.Started;
public Guid? StartedByUserAccountId { get; set; }
public int? WorkerProcessId { get; set; }
public string ThreadId { get; set; } = string.Empty;
public string TurnId { get; set; } = string.Empty;
public string LogPath { get; set; } = string.Empty;
public string ResultPath { get; set; } = string.Empty;
public string Note { get; set; } = string.Empty;
public string Error { get; set; } = string.Empty;
public DateTimeOffset StartedAtUtc { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public DateTimeOffset? CompletedAtUtc { get; set; }
public UserAccount? StartedByUserAccount { get; set; }
}
public sealed class AuditEvent
{
public Guid Id { get; set; } = Guid.NewGuid();

View file

@ -0,0 +1,196 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.Bridge;
public sealed class AgentDispatchRunService(
BifrostDbContext dbContext,
AuditTrailService auditTrailService,
TimeProvider timeProvider)
{
public async Task<AgentDispatchRunResult> StartAsync(
AgentDispatchRunStartRequest request,
Guid? startedByUserAccountId,
CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var run = new AgentDispatchRun
{
RequestId = NormalizeText(request.RequestId),
TargetRepoName = NormalizeText(request.TargetRepoName),
TargetRepositoryFullName = NormalizeText(request.TargetRepositoryFullName),
TargetAgentIdentity = NormalizeText(request.TargetAgentIdentity),
LaunchMode = NormalizeText(request.LaunchMode),
Status = AgentDispatchRunStatus.Started,
StartedByUserAccountId = startedByUserAccountId,
WorkerProcessId = request.WorkerProcessId,
ThreadId = NormalizeText(request.ThreadId),
TurnId = NormalizeText(request.TurnId),
LogPath = NormalizeText(request.LogPath),
ResultPath = NormalizeText(request.ResultPath),
Note = NormalizeText(request.Note),
StartedAtUtc = now,
UpdatedAtUtc = now
};
dbContext.AgentDispatchRuns.Add(run);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
startedByUserAccountId,
nameof(AgentDispatchRun),
run.Id,
"agent-dispatch.started",
$"Dispatch run started for request {run.RequestId}.",
cancellationToken);
return AgentDispatchRunResult.From(run);
}
public Task<AgentDispatchRunResult?> CompleteAsync(
Guid runId,
AgentDispatchRunCompletionRequest request,
Guid? startedByUserAccountId,
CancellationToken cancellationToken) =>
UpdateAsync(
runId,
startedByUserAccountId,
"agent-dispatch.completed",
run =>
{
var now = timeProvider.GetUtcNow();
run.Status = request.Status;
run.Note = NormalizeText(request.Note);
run.Error = string.Empty;
run.ThreadId = string.IsNullOrWhiteSpace(request.ThreadId) ? run.ThreadId : NormalizeText(request.ThreadId);
run.TurnId = string.IsNullOrWhiteSpace(request.TurnId) ? run.TurnId : NormalizeText(request.TurnId);
run.ResultPath = string.IsNullOrWhiteSpace(request.ResultPath) ? run.ResultPath : NormalizeText(request.ResultPath);
run.UpdatedAtUtc = now;
run.CompletedAtUtc = now;
return $"Dispatch run {run.RequestId} completed as {run.Status}.";
},
cancellationToken);
public Task<AgentDispatchRunResult?> FailAsync(
Guid runId,
AgentDispatchRunFailureRequest request,
Guid? startedByUserAccountId,
CancellationToken cancellationToken) =>
UpdateAsync(
runId,
startedByUserAccountId,
"agent-dispatch.failed",
run =>
{
var now = timeProvider.GetUtcNow();
run.Status = AgentDispatchRunStatus.Failed;
run.Note = NormalizeText(request.Note);
run.Error = NormalizeText(request.Error);
run.ThreadId = string.IsNullOrWhiteSpace(request.ThreadId) ? run.ThreadId : NormalizeText(request.ThreadId);
run.TurnId = string.IsNullOrWhiteSpace(request.TurnId) ? run.TurnId : NormalizeText(request.TurnId);
run.ResultPath = string.IsNullOrWhiteSpace(request.ResultPath) ? run.ResultPath : NormalizeText(request.ResultPath);
run.UpdatedAtUtc = now;
run.CompletedAtUtc = now;
return string.IsNullOrWhiteSpace(run.Error)
? $"Dispatch run {run.RequestId} failed."
: run.Error;
},
cancellationToken);
private async Task<AgentDispatchRunResult?> UpdateAsync(
Guid runId,
Guid? startedByUserAccountId,
string auditAction,
Func<AgentDispatchRun, string> update,
CancellationToken cancellationToken)
{
var run = await dbContext.AgentDispatchRuns
.SingleOrDefaultAsync(x => x.Id == runId, cancellationToken);
if (run is null)
{
return null;
}
var detail = update(run);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
startedByUserAccountId,
nameof(AgentDispatchRun),
run.Id,
auditAction,
detail,
cancellationToken);
return AgentDispatchRunResult.From(run);
}
private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty;
}
public sealed record AgentDispatchRunStartRequest(
string RequestId,
string TargetRepoName,
string TargetRepositoryFullName,
string TargetAgentIdentity,
string LaunchMode,
int? WorkerProcessId,
string ThreadId,
string TurnId,
string LogPath,
string ResultPath,
string Note);
public sealed record AgentDispatchRunCompletionRequest(
AgentDispatchRunStatus Status,
string ThreadId,
string TurnId,
string ResultPath,
string Note);
public sealed record AgentDispatchRunFailureRequest(
string ThreadId,
string TurnId,
string ResultPath,
string Note,
string Error);
public sealed record AgentDispatchRunResult(
Guid Id,
string RequestId,
string TargetRepoName,
string TargetRepositoryFullName,
string TargetAgentIdentity,
string LaunchMode,
AgentDispatchRunStatus Status,
int? WorkerProcessId,
string ThreadId,
string TurnId,
string LogPath,
string ResultPath,
string Note,
string Error,
DateTimeOffset StartedAtUtc,
DateTimeOffset UpdatedAtUtc,
DateTimeOffset? CompletedAtUtc)
{
public static AgentDispatchRunResult From(AgentDispatchRun run) => new(
run.Id,
run.RequestId,
run.TargetRepoName,
run.TargetRepositoryFullName,
run.TargetAgentIdentity,
run.LaunchMode,
run.Status,
run.WorkerProcessId,
run.ThreadId,
run.TurnId,
run.LogPath,
run.ResultPath,
run.Note,
run.Error,
run.StartedAtUtc,
run.UpdatedAtUtc,
run.CompletedAtUtc);
}

View file

@ -0,0 +1,337 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.Bridge;
public sealed class BridgeActionService(
BifrostDbContext dbContext,
AuditTrailService auditTrailService,
TimeProvider timeProvider)
{
public async Task<BridgeActionResult> RequestAsync(
BridgeActionRequest request,
BridgeCaller caller,
CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var normalizedRequest = request.Normalize(caller);
var policy = await EvaluatePolicyAsync(normalizedRequest, caller, cancellationToken);
var action = new BridgeAction
{
ActorKind = normalizedRequest.ActorKind,
ActorUserAccountId = caller.UserAccountId,
ActorName = normalizedRequest.ActorName,
TargetSurface = normalizedRequest.TargetSurface,
ActionKind = normalizedRequest.ActionKind,
Status = policy.Allowed ? BridgeActionStatus.Authorized : BridgeActionStatus.Denied,
WorkItemId = normalizedRequest.WorkItemId,
MotionId = normalizedRequest.MotionId,
TargetRepositoryFullName = NormalizeRepository(normalizedRequest.TargetRepositoryFullName),
TargetLocator = NormalizeText(normalizedRequest.TargetLocator),
SourceKind = NormalizeText(normalizedRequest.SourceKind),
SourceId = NormalizeText(normalizedRequest.SourceId),
AuthorityReference = NormalizeText(normalizedRequest.AuthorityReference),
PolicyDecision = policy.Decision,
Title = NormalizeText(normalizedRequest.Title),
Summary = NormalizeText(normalizedRequest.Summary),
RequestedAtUtc = now,
UpdatedAtUtc = now
};
dbContext.BridgeActions.Add(action);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
caller.UserAccountId,
nameof(BridgeAction),
action.Id,
policy.Allowed ? "bridge.authorized" : "bridge.denied",
policy.Decision,
cancellationToken);
return BridgeActionResult.From(action);
}
public Task<BridgeActionResult?> StartAsync(
Guid actionId,
BridgeCaller caller,
CancellationToken cancellationToken) =>
UpdateAsync(
actionId,
caller,
"bridge.started",
action =>
{
if (action.Status is not BridgeActionStatus.Authorized)
{
throw new BridgeActionException("Only authorized bridge actions can be started.");
}
var now = timeProvider.GetUtcNow();
action.Status = BridgeActionStatus.InProgress;
action.StartedAtUtc = now;
action.UpdatedAtUtc = now;
return "Bridge action started.";
},
cancellationToken);
public Task<BridgeActionResult?> CompleteAsync(
Guid actionId,
BridgeActionReceiptRequest request,
BridgeCaller caller,
CancellationToken cancellationToken) =>
UpdateAsync(
actionId,
caller,
"bridge.completed",
action =>
{
if (action.Status is not (BridgeActionStatus.Authorized or BridgeActionStatus.InProgress))
{
throw new BridgeActionException("Only authorized or in-progress bridge actions can be completed.");
}
if (string.IsNullOrWhiteSpace(request.ReceiptUrl) &&
string.IsNullOrWhiteSpace(request.ExternalReceiptId) &&
string.IsNullOrWhiteSpace(request.ReceiptPayload))
{
throw new BridgeActionException("A completed bridge action must include a receipt URL, external receipt id, or receipt payload.");
}
var now = timeProvider.GetUtcNow();
action.Status = BridgeActionStatus.Completed;
action.ReceiptUrl = NormalizeText(request.ReceiptUrl);
action.ExternalReceiptId = NormalizeText(request.ExternalReceiptId);
action.ReceiptPayload = NormalizeText(request.ReceiptPayload);
action.CompletedAtUtc = now;
action.UpdatedAtUtc = now;
return "Bridge action completed with receipt.";
},
cancellationToken);
public Task<BridgeActionResult?> FailAsync(
Guid actionId,
BridgeActionFailureRequest request,
BridgeCaller caller,
CancellationToken cancellationToken) =>
UpdateAsync(
actionId,
caller,
"bridge.failed",
action =>
{
var now = timeProvider.GetUtcNow();
action.Status = BridgeActionStatus.Failed;
action.FailureReason = NormalizeText(request.FailureReason);
action.UpdatedAtUtc = now;
action.CompletedAtUtc = now;
return string.IsNullOrWhiteSpace(action.FailureReason)
? "Bridge action failed."
: action.FailureReason;
},
cancellationToken);
private async Task<BridgeActionResult?> UpdateAsync(
Guid actionId,
BridgeCaller caller,
string auditAction,
Func<BridgeAction, string> update,
CancellationToken cancellationToken)
{
var action = await dbContext.BridgeActions
.SingleOrDefaultAsync(x => x.Id == actionId, cancellationToken);
if (action is null)
{
return null;
}
if (!caller.CanOperate(action))
{
throw new UnauthorizedAccessException("Caller cannot operate this bridge action.");
}
var detail = update(action);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
caller.UserAccountId,
nameof(BridgeAction),
action.Id,
auditAction,
detail,
cancellationToken);
return BridgeActionResult.From(action);
}
private async Task<BridgePolicyDecision> EvaluatePolicyAsync(
BridgeActionRequest request,
BridgeCaller caller,
CancellationToken cancellationToken)
{
if (!caller.IsAllowedTransport)
{
return BridgePolicyDecision.Reject("Bridge actions require an active member session or a configured local bridge token.");
}
if (request.TargetSurface == BridgeTargetSurface.GitHub &&
string.IsNullOrWhiteSpace(request.TargetRepositoryFullName))
{
return BridgePolicyDecision.Reject("GitHub bridge actions must name a target repository.");
}
if (request.ActorKind is BridgeActorKind.Agent or BridgeActorKind.Persona or BridgeActorKind.Service)
{
var hasProvenance =
!string.IsNullOrWhiteSpace(request.AuthorityReference) ||
(!string.IsNullOrWhiteSpace(request.SourceKind) && !string.IsNullOrWhiteSpace(request.SourceId)) ||
request.WorkItemId is not null ||
request.MotionId is not null;
if (!hasProvenance)
{
return BridgePolicyDecision.Reject("Agent and Persona bridge actions must cite an authority reference, source object, work item, or motion.");
}
}
if (request.WorkItemId is not null)
{
var workItem = await dbContext.WorkItems
.Include(x => x.Project)
.SingleOrDefaultAsync(x => x.Id == request.WorkItemId, cancellationToken);
if (workItem is null)
{
return BridgePolicyDecision.Reject("Referenced work item does not exist.");
}
var targetRepository = NormalizeRepository(request.TargetRepositoryFullName);
var projectRepository = NormalizeRepository(workItem.Project.GitHubRepository);
if (!string.IsNullOrWhiteSpace(targetRepository) &&
!string.IsNullOrWhiteSpace(projectRepository) &&
targetRepository != projectRepository)
{
return BridgePolicyDecision.Reject("GitHub target repository does not match the referenced work item's project.");
}
}
if (request.MotionId is not null &&
!await dbContext.Motions.AnyAsync(x => x.Id == request.MotionId, cancellationToken))
{
return BridgePolicyDecision.Reject("Referenced motion does not exist.");
}
return BridgePolicyDecision.Permit(
caller.IsLocalBridge
? "Authorized through the configured local Bifrost bridge token and Bifrost policy."
: "Authorized for an active Bifrost member through Bifrost policy.");
}
private static string NormalizeRepository(string value) => NormalizeText(value).ToLowerInvariant();
private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty;
}
public sealed record BridgeCaller(
bool IsActiveMember,
bool IsLocalBridge,
Guid? UserAccountId,
string DisplayName)
{
public bool IsAllowedTransport => IsActiveMember || IsLocalBridge;
public bool CanOperate(BridgeAction action) =>
IsLocalBridge ||
(IsActiveMember && action.ActorUserAccountId == UserAccountId);
}
public sealed record BridgeActionRequest(
BridgeActorKind ActorKind,
string ActorName,
BridgeTargetSurface TargetSurface,
BridgeActionKind ActionKind,
string TargetRepositoryFullName,
string TargetLocator,
string SourceKind,
string SourceId,
string AuthorityReference,
string Title,
string Summary,
Guid? WorkItemId = null,
Guid? MotionId = null)
{
public BridgeActionRequest Normalize(BridgeCaller caller) =>
this with
{
ActorKind = ActorKind == default && caller.IsActiveMember ? BridgeActorKind.Member : ActorKind,
ActorName = string.IsNullOrWhiteSpace(ActorName) && caller.IsActiveMember
? caller.DisplayName
: NormalizeText(ActorName),
TargetRepositoryFullName = NormalizeText(TargetRepositoryFullName),
TargetLocator = NormalizeText(TargetLocator),
SourceKind = NormalizeText(SourceKind),
SourceId = NormalizeText(SourceId),
AuthorityReference = NormalizeText(AuthorityReference),
Title = NormalizeText(Title),
Summary = NormalizeText(Summary)
};
private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty;
}
public sealed record BridgeActionReceiptRequest(
string ReceiptUrl,
string ExternalReceiptId,
string ReceiptPayload);
public sealed record BridgeActionFailureRequest(string FailureReason);
public sealed record BridgeActionResult(
Guid Id,
BridgeActorKind ActorKind,
string ActorName,
BridgeTargetSurface TargetSurface,
BridgeActionKind ActionKind,
BridgeActionStatus Status,
string TargetRepositoryFullName,
string TargetLocator,
string SourceKind,
string SourceId,
string AuthorityReference,
string PolicyDecision,
string Title,
string ReceiptUrl,
string ExternalReceiptId,
DateTimeOffset RequestedAtUtc,
DateTimeOffset UpdatedAtUtc)
{
public static BridgeActionResult From(BridgeAction action) => new(
action.Id,
action.ActorKind,
action.ActorName,
action.TargetSurface,
action.ActionKind,
action.Status,
action.TargetRepositoryFullName,
action.TargetLocator,
action.SourceKind,
action.SourceId,
action.AuthorityReference,
action.PolicyDecision,
action.Title,
action.ReceiptUrl,
action.ExternalReceiptId,
action.RequestedAtUtc,
action.UpdatedAtUtc);
}
public sealed record BridgePolicyDecision(bool Allowed, string Decision)
{
public static BridgePolicyDecision Permit(string decision) => new(true, decision);
public static BridgePolicyDecision Reject(string decision) => new(false, decision);
}
public sealed class BridgeActionException(string message) : InvalidOperationException(message);

View file

@ -100,6 +100,40 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
x.OccurredAtUtc))
.ToListAsync(cancellationToken);
var recentBridgeActions = await dbContext.BridgeActions
.AsNoTracking()
.OrderByDescending(x => x.UpdatedAtUtc)
.Take(6)
.Select(x => new DashboardBridgeAction(
x.Id,
x.ActorName,
x.ActorKind.ToString(),
x.TargetSurface.ToString(),
x.ActionKind.ToString(),
x.Status.ToString(),
x.Title,
x.TargetRepositoryFullName,
string.IsNullOrWhiteSpace(x.ReceiptUrl) ? x.ExternalReceiptId : x.ReceiptUrl,
x.UpdatedAtUtc))
.ToListAsync(cancellationToken);
var recentDispatchRuns = await dbContext.AgentDispatchRuns
.AsNoTracking()
.OrderByDescending(x => x.UpdatedAtUtc)
.Take(6)
.Select(x => new DashboardDispatchRun(
x.Id,
x.RequestId,
x.TargetRepoName,
x.TargetAgentIdentity,
x.LaunchMode,
x.Status.ToString(),
x.ThreadId,
x.TurnId,
string.IsNullOrWhiteSpace(x.Note) ? x.Error : x.Note,
x.UpdatedAtUtc))
.ToListAsync(cancellationToken);
var payoutPreviewNominal = await dbContext.LedgerEntries
.AsNoTracking()
.Where(x => x.Status == LedgerEntryStatus.Approved)
@ -114,13 +148,21 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
OpenMotions: await dbContext.Motions.AsNoTracking().CountAsync(x => x.Status == MotionStatus.Open && x.ClosesAtUtc >= now, cancellationToken),
PendingMembershipApprovals: outstandingApprovals,
AssignedToCurrentMember: assignedToMe.Count,
ActiveBridgeActions: await dbContext.BridgeActions.AsNoTracking().CountAsync(
x => x.Status == BridgeActionStatus.Authorized || x.Status == BridgeActionStatus.InProgress,
cancellationToken),
ActiveDispatchRuns: await dbContext.AgentDispatchRuns.AsNoTracking().CountAsync(
x => x.Status == AgentDispatchRunStatus.Started,
cancellationToken),
ApprovedNominalPayoutValue: payoutPreviewNominal,
RecentWorkItems: recentWorkItems,
OpenMotionHighlights: openMotions,
MyAssignedWork: assignedToMe,
MyVolunteeredWork: volunteeredByMe,
MySubmittedWork: submittedByMe,
RecentActivity: recentActivity);
RecentActivity: recentActivity,
RecentBridgeActions: recentBridgeActions,
RecentDispatchRuns: recentDispatchRuns);
}
}
@ -131,13 +173,17 @@ public sealed record DashboardSnapshot(
int OpenMotions,
int PendingMembershipApprovals,
int AssignedToCurrentMember,
int ActiveBridgeActions,
int ActiveDispatchRuns,
decimal ApprovedNominalPayoutValue,
IReadOnlyList<DashboardWorkItem> RecentWorkItems,
IReadOnlyList<DashboardMotion> OpenMotionHighlights,
IReadOnlyList<DashboardLaneItem> MyAssignedWork,
IReadOnlyList<DashboardLaneItem> MyVolunteeredWork,
IReadOnlyList<DashboardLaneItem> MySubmittedWork,
IReadOnlyList<DashboardActivity> RecentActivity);
IReadOnlyList<DashboardActivity> RecentActivity,
IReadOnlyList<DashboardBridgeAction> RecentBridgeActions,
IReadOnlyList<DashboardDispatchRun> RecentDispatchRuns);
public sealed record DashboardWorkItem(
Guid WorkItemId,
@ -170,3 +216,27 @@ public sealed record DashboardActivity(
string Detail,
string ActorName,
DateTimeOffset OccurredAtUtc);
public sealed record DashboardBridgeAction(
Guid BridgeActionId,
string ActorName,
string ActorKind,
string TargetSurface,
string ActionKind,
string Status,
string Title,
string TargetRepositoryFullName,
string ReceiptLabel,
DateTimeOffset UpdatedAtUtc);
public sealed record DashboardDispatchRun(
Guid AgentDispatchRunId,
string RequestId,
string TargetRepoName,
string TargetAgentIdentity,
string LaunchMode,
string Status,
string ThreadId,
string TurnId,
string Note,
DateTimeOffset UpdatedAtUtc);

View file

@ -191,6 +191,88 @@
}
</article>
<article class="panel">
<div class="panel-header">
<div>
<h2>Agent runs</h2>
<p class="muted">Recent dispatched Codex runs recorded by Bifrost.</p>
</div>
</div>
@if (Model.Snapshot.RecentDispatchRuns.Count == 0)
{
<div class="empty-state">No dispatched agent runs have been recorded yet.</div>
}
else
{
<div class="list-grid">
@foreach (var run in Model.Snapshot.RecentDispatchRuns)
{
<div class="status-banner">
<strong>@run.TargetRepoName@if (!string.IsNullOrWhiteSpace(run.TargetAgentIdentity)) {<text> / @run.TargetAgentIdentity</text>}</strong>
<span class="muted">@run.LaunchMode dispatch</span>
<div class="inline-actions">
<span class="pill">@run.Status</span>
<span class="pill">@run.RequestId</span>
</div>
@if (!string.IsNullOrWhiteSpace(run.ThreadId) || !string.IsNullOrWhiteSpace(run.TurnId))
{
<div class="muted">Thread @run.ThreadId Turn @run.TurnId</div>
}
@if (!string.IsNullOrWhiteSpace(run.Note))
{
<div class="muted">@run.Note</div>
}
<div class="muted">@run.UpdatedAtUtc.ToString("dd MMM yyyy HH:mm") UTC</div>
</div>
}
</div>
}
</article>
</section>
<section class="panel-grid" style="margin-top: 1.5rem;">
<article class="panel">
<div class="panel-header">
<div>
<h2>Bridge receipts</h2>
<p class="muted">Recent agent and service crossings recorded by Bifrost.</p>
</div>
</div>
@if (Model.Snapshot.RecentBridgeActions.Count == 0)
{
<div class="empty-state">No bridge actions have been recorded yet.</div>
}
else
{
<div class="list-grid">
@foreach (var action in Model.Snapshot.RecentBridgeActions)
{
<div class="status-banner">
<strong>@action.ActorName</strong>
<span class="muted">@action.ActionKind on @action.TargetSurface</span>
<div class="inline-actions">
<span class="pill">@action.Status</span>
@if (!string.IsNullOrWhiteSpace(action.TargetRepositoryFullName))
{
<span class="pill">@action.TargetRepositoryFullName</span>
}
</div>
<div class="muted">@action.Title</div>
@if (!string.IsNullOrWhiteSpace(action.ReceiptLabel))
{
<div class="muted">@action.ReceiptLabel</div>
}
<div class="muted">@action.UpdatedAtUtc.ToString("dd MMM yyyy HH:mm") UTC</div>
</div>
}
</div>
}
</article>
</section>
<section class="panel-grid" style="margin-top: 1.5rem;">
<article class="panel">
<div class="panel-header">
<div>

View file

@ -11,7 +11,7 @@ public sealed class IndexModel(
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public DashboardSnapshot Snapshot { get; private set; } =
new(0, 0, 0, 0, 0, 0, 0m, [], [], [], [], [], []);
new(0, 0, 0, 0, 0, 0, 0, 0, 0m, [], [], [], [], [], [], [], []);
public async Task OnGetAsync(CancellationToken cancellationToken)
{

View file

@ -25,6 +25,14 @@
<span>Assigned to me</span>
<strong>@Model.AssignedToCurrentMember</strong>
</article>
<article class="metric-card">
<span>Bridge actions live</span>
<strong>@Model.ActiveBridgeActions</strong>
</article>
<article class="metric-card">
<span>Dispatch runs live</span>
<strong>@Model.ActiveDispatchRuns</strong>
</article>
<article class="metric-card">
<span>Approved nominal payout</span>
<strong>@Model.ApprovedNominalPayoutValue.ToString("0.##")</strong>

View file

@ -4,6 +4,8 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Bridge;
using Bifrost.Web.Features.GitHub;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
@ -41,6 +43,10 @@ builder.Services
.AddOptions<HeimdallOptions>()
.BindConfiguration(HeimdallOptions.SectionName);
builder.Services
.AddOptions<BridgeOptions>()
.BindConfiguration(BridgeOptions.SectionName);
builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizePage("/Membership/Status");
@ -73,6 +79,8 @@ builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<ICurrentBifrostActorAccessor, CurrentBifrostActorAccessor>();
builder.Services.AddScoped<AuditTrailService>();
builder.Services.AddScoped<DashboardSnapshotService>();
builder.Services.AddScoped<BridgeActionService>();
builder.Services.AddScoped<AgentDispatchRunService>();
builder.Services.AddScoped<MembershipSynchronizationService>();
builder.Services.AddScoped<GitHubWebhookService>();
builder.Services.AddScoped<MotionGovernanceService>();
@ -164,7 +172,7 @@ builder.Services.AddAuthorization(options =>
});
var app = builder.Build();
var eveJsonSerializerOptions = CreateEveJsonSerializerOptions();
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
app.Services.GetRequiredService<StartupConfigurationValidator>().Validate();
await using (var scope = app.Services.CreateAsyncScope())
@ -317,6 +325,217 @@ app.MapPost("/heimdall/patron-support/events", 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,
ICurrentBifrostActorAccessor actorAccessor,
AgentDispatchRunService agentDispatchRunService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
var actor = await actorAccessor.GetAsync(cancellationToken);
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value) && !actor.IsActiveMember)
{
return Results.Unauthorized();
}
var result = await agentDispatchRunService.StartAsync(command, actor.UserAccount?.Id, cancellationToken);
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
});
dispatchRuns.MapPost("/{id:guid}/complete", async (
Guid id,
HttpRequest request,
AgentDispatchRunCompletionRequest command,
ICurrentBifrostActorAccessor actorAccessor,
AgentDispatchRunService agentDispatchRunService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
var actor = await actorAccessor.GetAsync(cancellationToken);
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value) && !actor.IsActiveMember)
{
return Results.Unauthorized();
}
var result = await agentDispatchRunService.CompleteAsync(id, command, actor.UserAccount?.Id, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions);
});
dispatchRuns.MapPost("/{id:guid}/fail", async (
Guid id,
HttpRequest request,
AgentDispatchRunFailureRequest command,
ICurrentBifrostActorAccessor actorAccessor,
AgentDispatchRunService agentDispatchRunService,
IOptions<BridgeOptions> bridgeOptions,
CancellationToken cancellationToken) =>
{
var actor = await actorAccessor.GetAsync(cancellationToken);
if (!HasValidLocalBridgeToken(request, bridgeOptions.Value) && !actor.IsActiveMember)
{
return Results.Unauthorized();
}
var result = await agentDispatchRunService.FailAsync(id, command, actor.UserAccount?.Id, cancellationToken);
return result is null
? Results.NotFound()
: JsonResult(result, appJsonSerializerOptions);
});
var eveGovernance = app.MapGroup("/eve/governance")
.RequireAuthorization(ActiveMemberRequirement.PolicyName);
@ -330,7 +549,7 @@ eveGovernance.MapGet("/surface", async (
var state = await motionGovernanceService.GetStateAsync(actor, cancellationToken);
var surface = motionEveSurfaceService.BuildSurface(state);
return Results.Text(
JsonSerializer.Serialize(surface, eveJsonSerializerOptions),
JsonSerializer.Serialize(surface, appJsonSerializerOptions),
"application/json");
});
@ -401,7 +620,7 @@ eveGovernance.MapPost("/commands", async (
var state = await motionGovernanceService.GetStateAsync(actor, cancellationToken);
var surface = motionEveSurfaceService.BuildSurface(state);
return Results.Text(
JsonSerializer.Serialize(surface, eveJsonSerializerOptions),
JsonSerializer.Serialize(surface, appJsonSerializerOptions),
"application/json");
});
@ -443,11 +662,39 @@ app.MapRazorPages();
app.Run();
static JsonSerializerOptions CreateEveJsonSerializerOptions()
static IResult JsonResult<T>(T value, JsonSerializerOptions options, int statusCode = StatusCodes.Status200OK) =>
Results.Text(
JsonSerializer.Serialize(value, options),
"application/json",
statusCode: statusCode);
static JsonSerializerOptions CreateAppJsonSerializerOptions()
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.Converters.Add(new JsonStringEnumConverter());
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;

View file

@ -0,0 +1,81 @@
using System.Diagnostics;
using System.Text.Json;
namespace Bifrost.Web.Tests;
public sealed class BridgeCliTests
{
[Fact]
public async Task GitHub_bridge_command_fails_closed_without_bifrost_gate()
{
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",
]);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("require Bifrost authorization", result.Stderr, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task GitHub_bridge_command_allows_explicit_operator_recovery_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",
"--allow-ungated-github", "true",
"--dry-run", "true",
]);
Assert.Equal(0, result.ExitCode);
using var payload = JsonDocument.Parse(result.Stdout);
Assert.Equal("github-pr-comment", payload.RootElement.GetProperty("action").GetString());
Assert.True(payload.RootElement.GetProperty("dryRun").GetBoolean());
}
private static string RepoRoot =>
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
private static async Task<ProcessResult> RunNodeAsync(string[] args)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "node",
WorkingDirectory = RepoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
}
};
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
process.StartInfo.Environment["BIFROST_BRIDGE_BASE_URL"] = string.Empty;
process.StartInfo.Environment["BIFROST_BRIDGE_TOKEN"] = string.Empty;
process.StartInfo.Environment["BIFROST_ALLOW_UNGATED_GITHUB"] = string.Empty;
process.Start();
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return new ProcessResult(process.ExitCode, stdout, stderr);
}
private sealed record ProcessResult(int ExitCode, string Stdout, string Stderr);
}

View file

@ -1,6 +1,9 @@
using System.Net;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
@ -38,4 +41,73 @@ public sealed class MemberConsoleTests : IClassFixture<TestWebApplicationFactory
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Contains("/auth/sign-in", response.Headers.Location?.OriginalString);
}
[Fact]
public async Task Member_console_shows_recent_bridge_receipts()
{
using (var warmupClient = _factory.CreateClient())
{
_ = await warmupClient.GetAsync("/App");
}
using (var scope = _factory.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var actor = dbContext.UserAccounts.Single(x => x.NormalizedGitHubLogin == "TEST-ADMIN");
dbContext.BridgeActions.Add(new BridgeAction
{
ActorKind = BridgeActorKind.Agent,
ActorName = "nibu",
ActorUserAccountId = actor.Id,
TargetSurface = BridgeTargetSurface.GitHub,
ActionKind = BridgeActionKind.GitHubDraftPullRequest,
Status = BridgeActionStatus.Completed,
TargetRepositoryFullName = "gamecult/bifrost",
TargetLocator = "pulls",
SourceKind = "bifrost_governance_topic",
SourceId = "topic_123",
AuthorityReference = "dispatch-approved",
PolicyDecision = "Authorized through Bifrost policy.",
Title = "Draft motion implementation PR",
Summary = "Open a draft PR for the approved topic.",
ReceiptUrl = "https://github.com/GameCult/Bifrost/pull/99",
ExternalReceiptId = "99",
ReceiptPayload = "{\"pr\":99}",
RequestedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-5),
UpdatedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1),
CompletedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1)
});
dbContext.AgentDispatchRuns.Add(new AgentDispatchRun
{
RequestId = "req_dispatch_123",
TargetRepoName = "Bifrost",
TargetRepositoryFullName = "GameCult/Bifrost",
TargetAgentIdentity = "nibu",
LaunchMode = "app-server",
Status = AgentDispatchRunStatus.Completed,
StartedByUserAccountId = actor.Id,
WorkerProcessId = 4242,
ThreadId = "thread_1",
TurnId = "turn_1",
LogPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/codex.log",
ResultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/result.json",
Note = "Codex app turn completed for thread_1/turn_1.",
StartedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-8),
UpdatedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-2),
CompletedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-2)
});
dbContext.SaveChanges();
}
using var client = _factory.CreateClient();
using var response = await client.GetAsync("/App");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
Assert.Contains("Agent runs", html);
Assert.Contains("req_dispatch_123", html);
Assert.Contains("Bridge receipts", html);
Assert.Contains("Draft motion implementation PR", html);
Assert.Contains("nibu", html);
}
}

View file

@ -1,4 +1,5 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@ -294,6 +295,217 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
Assert.Contains(verificationContext.AuditEvents, x => x.Action == "motion.voted");
}
[Fact]
public async Task Local_bridge_token_can_authorize_and_receipt_agent_github_action()
{
using var client = _factory.CreateClient();
var requestPayload = JsonSerializer.Serialize(new
{
actorKind = "Agent",
actorName = "nibu",
targetSurface = "GitHub",
actionKind = "GitHubDraftPullRequest",
targetRepositoryFullName = "GameCult/Bifrost",
targetLocator = "pulls",
sourceKind = "governance_topic",
sourceId = "topic_123",
authorityReference = "dispatch-approved",
title = "Draft motion implementation PR",
summary = "Open a draft PR for the approved topic."
});
using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request");
request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var action = await response.Content.ReadFromJsonAsync<BridgeActionHttpResult>();
Assert.NotNull(action);
Assert.Equal("Authorized", action.Status);
using var startRequest = new HttpRequestMessage(HttpMethod.Post, $"/bridge/actions/{action.Id}/start");
startRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
using var startResponse = await client.SendAsync(startRequest);
Assert.Equal(HttpStatusCode.Accepted, startResponse.StatusCode);
var completionPayload = JsonSerializer.Serialize(new
{
receiptUrl = "https://github.com/GameCult/Bifrost/pull/99",
externalReceiptId = "99",
receiptPayload = "{\"pr\":99}"
});
using var completeRequest = new HttpRequestMessage(HttpMethod.Post, $"/bridge/actions/{action.Id}/complete");
completeRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
completeRequest.Content = new StringContent(completionPayload, Encoding.UTF8, "application/json");
using var completeResponse = await client.SendAsync(completeRequest);
Assert.Equal(HttpStatusCode.OK, completeResponse.StatusCode);
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id);
Assert.Equal(BridgeActionStatus.Completed, savedAction.Status);
Assert.Equal("https://github.com/GameCult/Bifrost/pull/99", savedAction.ReceiptUrl);
Assert.Equal("99", savedAction.ExternalReceiptId);
Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(BridgeAction) && x.Action == "bridge.completed");
}
[Fact]
public async Task Agent_github_action_without_provenance_is_denied_and_recorded()
{
using var client = _factory.CreateClient();
var requestPayload = JsonSerializer.Serialize(new
{
actorKind = "Agent",
actorName = "nibu",
targetSurface = "GitHub",
actionKind = "GitHubDraftPullRequest",
targetRepositoryFullName = "GameCult/Bifrost",
targetLocator = "pulls",
title = "Unproven draft",
summary = "This should not pass policy."
});
using var request = new HttpRequestMessage(HttpMethod.Post, "/bridge/actions/request");
request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
request.Content = new StringContent(requestPayload, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var action = await response.Content.ReadFromJsonAsync<BridgeActionHttpResult>();
Assert.NotNull(action);
Assert.Equal("Denied", action.Status);
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var savedAction = dbContext.BridgeActions.Single(x => x.Id == action.Id);
Assert.Equal(BridgeActionStatus.Denied, savedAction.Status);
Assert.Contains("must cite", savedAction.PolicyDecision, StringComparison.OrdinalIgnoreCase);
Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(BridgeAction) && x.Action == "bridge.denied");
}
[Fact]
public async Task Local_bridge_token_can_record_dispatch_run_lifecycle()
{
using var client = _factory.CreateClient();
var startPayload = JsonSerializer.Serialize(new
{
requestId = "req_dispatch_123",
targetRepoName = "Bifrost",
targetRepositoryFullName = "GameCult/Bifrost",
targetAgentIdentity = "nibu",
launchMode = "app-server",
workerProcessId = 4242,
threadId = "thread_1",
turnId = "turn_1",
logPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/codex.log",
resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/result.json",
note = "Codex app-server turn started."
});
using var startRequest = new HttpRequestMessage(HttpMethod.Post, "/dispatch/runs/start");
startRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
startRequest.Content = new StringContent(startPayload, Encoding.UTF8, "application/json");
using var startResponse = await client.SendAsync(startRequest);
Assert.Equal(HttpStatusCode.Accepted, startResponse.StatusCode);
var started = await startResponse.Content.ReadFromJsonAsync<AgentDispatchRunHttpResult>();
Assert.NotNull(started);
Assert.Equal("Started", started.Status);
var completePayload = JsonSerializer.Serialize(new
{
status = "Completed",
threadId = "thread_1",
turnId = "turn_1",
resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_123/result.json",
note = "Codex app turn completed for thread_1/turn_1."
});
using var completeRequest = new HttpRequestMessage(HttpMethod.Post, $"/dispatch/runs/{started.Id}/complete");
completeRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
completeRequest.Content = new StringContent(completePayload, Encoding.UTF8, "application/json");
using var completeResponse = await client.SendAsync(completeRequest);
Assert.Equal(HttpStatusCode.OK, completeResponse.StatusCode);
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var savedRun = dbContext.AgentDispatchRuns.Single(x => x.Id == started.Id);
Assert.Equal(AgentDispatchRunStatus.Completed, savedRun.Status);
Assert.Equal("req_dispatch_123", savedRun.RequestId);
Assert.Equal("turn_1", savedRun.TurnId);
Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(AgentDispatchRun) && x.Action == "agent-dispatch.completed");
}
[Fact]
public async Task Local_bridge_token_can_record_dispatch_run_failure()
{
using var client = _factory.CreateClient();
var startPayload = JsonSerializer.Serialize(new
{
requestId = "req_dispatch_fail_123",
targetRepoName = "Bifrost",
targetRepositoryFullName = "GameCult/Bifrost",
targetAgentIdentity = "nibu",
launchMode = "app-server",
workerProcessId = 4242,
threadId = string.Empty,
turnId = string.Empty,
logPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_fail_123/codex.log",
resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_fail_123/result.json",
note = "Codex app-server launch started."
});
using var startRequest = new HttpRequestMessage(HttpMethod.Post, "/dispatch/runs/start");
startRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
startRequest.Content = new StringContent(startPayload, Encoding.UTF8, "application/json");
using var startResponse = await client.SendAsync(startRequest);
Assert.Equal(HttpStatusCode.Accepted, startResponse.StatusCode);
var started = await startResponse.Content.ReadFromJsonAsync<AgentDispatchRunHttpResult>();
Assert.NotNull(started);
var failPayload = JsonSerializer.Serialize(new
{
threadId = string.Empty,
turnId = string.Empty,
resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_fail_123/result.json",
note = "Codex dispatch failed.",
error = "Codex app-server thread/start returned no thread id."
});
using var failRequest = new HttpRequestMessage(HttpMethod.Post, $"/dispatch/runs/{started.Id}/fail");
failRequest.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
failRequest.Content = new StringContent(failPayload, Encoding.UTF8, "application/json");
using var failResponse = await client.SendAsync(failRequest);
Assert.Equal(HttpStatusCode.OK, failResponse.StatusCode);
using var scope = _factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var savedRun = dbContext.AgentDispatchRuns.Single(x => x.Id == started.Id);
Assert.Equal(AgentDispatchRunStatus.Failed, savedRun.Status);
Assert.Equal("Codex app-server thread/start returned no thread id.", savedRun.Error);
Assert.Contains(dbContext.AuditEvents, x => x.EntityType == nameof(AgentDispatchRun) && x.Action == "agent-dispatch.failed");
}
private static string ComputeSignature(string secret, string payload)
{
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
@ -308,4 +520,18 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
return request;
}
private sealed class BridgeActionHttpResult
{
public Guid Id { get; init; }
public string Status { get; init; } = string.Empty;
}
private sealed class AgentDispatchRunHttpResult
{
public Guid Id { get; init; }
public string Status { get; init; } = string.Empty;
}
}

View file

@ -30,6 +30,7 @@ public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
["GitHubApp:WebhookSecret"] = "test-webhook-secret",
["GitHubApp:PrivateKeyPem"] = "test-private-key",
["GitHubApp:AppId"] = "1",
["Bridge:LocalBridgeToken"] = "test-bridge-token",
["Host:PublicBaseUrl"] = "https://localhost",
["Host:ExpectedHost"] = "localhost",
["Host:RequireStrictHostValidation"] = "false",

View file

@ -194,6 +194,10 @@ async function mirrorEnqueuedRequestOrThrow(request, options) {
"--channel-id", channelId,
"--content", mirrorContent,
"--persona-name", personaName,
"--source-kind", documentType,
"--source-id", request.id,
"--authority-ref", "bifrost_agent_transport_enqueue",
...optionalArg("--target-repository-full-name", request.targetRepositoryFullName ?? request.targetRepoName),
...optionalArg("--persona-avatar-url", personaAvatarUrl),
...optionalArg("--reply-to-message-id", options["mirror-reply-to-message-id"]),
...(options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []),

View file

@ -45,6 +45,7 @@ async function main() {
}
async function createGitHubDraftPr(options) {
ensureGitHubBridgeGate(options);
const repoRoot = resolve(requireOption(options, "repo-root"));
const identity = slugify(requireOption(options, "identity"));
const title = requireOption(options, "title");
@ -56,6 +57,9 @@ async function createGitHubDraftPr(options) {
const commitMessage = options["commit-message"] ?? title;
const allowDirty = options["allow-dirty"] === "true";
const dryRun = options["dry-run"] === "true";
const targetRepositoryFullName =
optionalString(options["target-repository-full-name"]) ??
detectGitHubRepositoryFullName(repoRoot);
const originalBranch = git(["branch", "--show-current"], repoRoot).stdout.trim();
const status = git(["status", "--short"], repoRoot).stdout.trim();
@ -85,31 +89,59 @@ async function createGitHubDraftPr(options) {
return;
}
const bridgeAction = await beginBridgeAction(options, {
actorKind: "Agent",
actorName: identity,
targetSurface: "GitHub",
actionKind: "GitHubDraftPullRequest",
targetRepositoryFullName,
targetLocator: relativePath,
title,
summary: body,
});
let pushed = false;
let prUrl = "";
try {
git(["switch", "-c", branch], repoRoot);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, content, "utf8");
git(["add", "--", relativePath], repoRoot);
git(["commit", "-m", commitMessage], repoRoot);
git(["push", "-u", "origin", branch], repoRoot);
pushed = true;
try {
await bridgeAction?.start();
git(["switch", "-c", branch], repoRoot);
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, content, "utf8");
git(["add", "--", relativePath], repoRoot);
git(["commit", "-m", commitMessage], repoRoot);
git(["push", "-u", "origin", branch], repoRoot);
pushed = true;
const pr = run("gh", [
"pr",
"create",
"--draft",
"--title",
title,
"--body",
body,
"--base",
base,
"--head",
branch,
], repoRoot);
prUrl = pr.stdout.trim();
const pr = run("gh", [
"pr",
"create",
"--draft",
"--title",
title,
"--body",
body,
"--base",
base,
"--head",
branch,
], repoRoot);
prUrl = pr.stdout.trim();
await bridgeAction?.complete({
receiptUrl: prUrl,
externalReceiptId: extractGitHubNumber(prUrl) ?? branch,
receiptPayload: JSON.stringify({
branch,
base,
path: relativePath,
commitMessage,
prUrl,
}),
});
} catch (error) {
await bridgeAction?.fail(error);
throw error;
}
} finally {
if (originalBranch) {
git(["switch", originalBranch], repoRoot, { allowFailure: true });
@ -131,12 +163,16 @@ async function createGitHubDraftPr(options) {
}
async function commentGitHubPr(options) {
ensureGitHubBridgeGate(options);
const repoRoot = resolve(requireOption(options, "repo-root"));
const identity = slugify(requireOption(options, "identity"));
const pr = requireOption(options, "pr");
const content = await readOptionText(options, "content", "content-file");
const dryRun = options["dry-run"] === "true";
const body = `${identity} says:\n\n${content.trim()}`;
const targetRepositoryFullName =
optionalString(options["target-repository-full-name"]) ??
detectGitHubRepositoryFullName(repoRoot);
if (dryRun) {
printJson({
@ -150,7 +186,34 @@ async function commentGitHubPr(options) {
return;
}
run("gh", ["pr", "comment", pr, "--body", body], repoRoot);
const bridgeAction = await beginBridgeAction(options, {
actorKind: "Agent",
actorName: identity,
targetSurface: "GitHub",
actionKind: "GitHubPullRequestComment",
targetRepositoryFullName,
targetLocator: `pull/${pr}`,
title: `PR comment on #${pr}`,
summary: content,
});
try {
await bridgeAction?.start();
run("gh", ["pr", "comment", pr, "--body", body], repoRoot);
await bridgeAction?.complete({
receiptUrl: `https://github.com/${targetRepositoryFullName}/pull/${pr}#issuecomment-bridge`,
externalReceiptId: String(pr),
receiptPayload: JSON.stringify({
pullRequestNumber: Number(pr),
repositoryFullName: targetRepositoryFullName,
author: identity,
body,
}),
});
} catch (error) {
await bridgeAction?.fail(error);
throw error;
}
printJson({
action: "github-pr-comment",
ok: true,
@ -186,13 +249,41 @@ async function postDiscordMessage(options) {
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before posting to Discord.");
}
const result = personaName
? await postDiscordPersonaMessage(token, channelId, content, {
personaName,
personaAvatarUrl,
replyToMessageId,
})
: await postDiscordBotMessage(token, channelId, content, replyToMessageId);
const bridgeAction = await beginBridgeAction(options, {
actorKind: personaName ? "Persona" : "Service",
actorName: personaName ?? "bifrost",
targetSurface: "Discord",
actionKind: "DiscordPost",
targetRepositoryFullName: options["target-repository-full-name"] ?? "",
targetLocator: `channel/${channelId}`,
title: personaName ? `Discord post from ${personaName}` : "Discord post",
summary: content,
});
let result;
try {
await bridgeAction?.start();
result = personaName
? await postDiscordPersonaMessage(token, channelId, content, {
personaName,
personaAvatarUrl,
replyToMessageId,
})
: await postDiscordBotMessage(token, channelId, content, replyToMessageId);
await bridgeAction?.complete({
receiptUrl: `https://discord.com/channels/${result.guildId ?? "@me"}/${channelId}/${result.id}`,
externalReceiptId: result.id,
receiptPayload: JSON.stringify({
channelId,
messageId: result.id,
guildId: result.guildId ?? "",
transport: result.transport,
}),
});
} catch (error) {
await bridgeAction?.fail(error);
throw error;
}
printJson({
action: "discord-post",
@ -224,8 +315,37 @@ async function sendDiscordDm(options) {
throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM.");
}
const channelId = await openDiscordDmChannel(token, recipientId);
const result = await postDiscordBotMessage(token, channelId, content, undefined);
const bridgeAction = await beginBridgeAction(options, {
actorKind: "Service",
actorName: "bifrost",
targetSurface: "Discord",
actionKind: "DiscordDirectMessage",
targetRepositoryFullName: options["target-repository-full-name"] ?? "",
targetLocator: `recipient/${recipientId}`,
title: "Discord direct message",
summary: content,
});
let channelId;
let result;
try {
await bridgeAction?.start();
channelId = await openDiscordDmChannel(token, recipientId);
result = await postDiscordBotMessage(token, channelId, content, undefined);
await bridgeAction?.complete({
receiptUrl: `https://discord.com/channels/@me/${channelId}/${result.id}`,
externalReceiptId: result.id,
receiptPayload: JSON.stringify({
recipientId,
channelId,
messageId: result.id,
transport: result.transport,
}),
});
} catch (error) {
await bridgeAction?.fail(error);
throw error;
}
printJson({
action: "discord-dm",
ok: true,
@ -260,15 +380,45 @@ async function postRedditThread(options) {
return;
}
const accessToken = await getRedditAccessToken();
const result = await submitRedditSelfPost(accessToken, {
subreddit,
const bridgeAction = await beginBridgeAction(options, {
actorKind: personaName ? "Persona" : "Service",
actorName: personaName ?? "bifrost",
targetSurface: "Reddit",
actionKind: "RedditPost",
targetRepositoryFullName: options["target-repository-full-name"] ?? "",
targetLocator: `r/${subreddit}`,
title,
content,
flairId: personaFlairId,
flairText: personaFlairText,
summary: content,
});
let result;
try {
await bridgeAction?.start();
const accessToken = await getRedditAccessToken();
result = await submitRedditSelfPost(accessToken, {
subreddit,
title,
content,
flairId: personaFlairId,
flairText: personaFlairText,
});
await bridgeAction?.complete({
receiptUrl: result.url,
externalReceiptId: result.thingId,
receiptPayload: JSON.stringify({
subreddit,
thingId: result.thingId,
url: result.url,
personaName,
personaFlairId,
personaFlairText,
}),
});
} catch (error) {
await bridgeAction?.fail(error);
throw error;
}
printJson({
action: "reddit-post",
ok: true,
@ -743,6 +893,140 @@ function optionalString(value) {
return trimmed.length > 0 ? trimmed : undefined;
}
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) ?? "",
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}`);
}
return {
id,
async start() {
await postBridgeJson(baseUrl, token, `/bridge/actions/${id}/start`, undefined, new Set([202]));
},
async complete(receipt) {
await postBridgeJson(baseUrl, token, `/bridge/actions/${id}/complete`, receipt, new Set([200]));
},
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.
}
},
};
}
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);
const allowUngated =
options["allow-ungated-github"] === "true" ||
process.env.BIFROST_ALLOW_UNGATED_GITHUB === "true";
if (baseUrl && token) {
return;
}
if (allowUngated) {
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.",
);
}
async function postBridgeJson(baseUrl, token, path, payload, allowedStatuses) {
const url = new URL(path, ensureTrailingSlash(baseUrl));
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Bifrost-Bridge-Token": token,
},
body: payload === undefined ? undefined : JSON.stringify(payload),
});
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = null;
}
}
if (!allowedStatuses.has(response.status)) {
throw new Error(`Bifrost bridge call to ${path} failed with ${response.status}: ${text}`);
}
return {
status: response.status,
text,
body,
};
}
function ensureTrailingSlash(value) {
return value.endsWith("/") ? value : `${value}/`;
}
function detectGitHubRepositoryFullName(repoRoot) {
const result = run(
"gh",
["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
repoRoot,
{ allowFailure: true },
);
return optionalString(result.stdout) ?? "";
}
function extractGitHubNumber(value) {
const match = optionalString(value)?.match(/\/(\d+)(?:[#?].*)?$/);
return match ? match[1] : undefined;
}
function normalizeSubreddit(value) {
const subreddit = optionalString(value)?.replace(/^\/?r\//i, "");
if (!subreddit || !/^[A-Za-z0-9_]{3,21}$/.test(subreddit)) {
@ -829,6 +1113,10 @@ Examples:
node tools/bifrost-bridge.mjs discord-post --channel-id 1501196543150264332 --persona-name Nibu --content "Draft PR opened: https://github.com/..."
node tools/bifrost-bridge.mjs discord-dm --recipient-id 123456789 --content "Moderation status update..."
node tools/bifrost-bridge.mjs reddit-post --title "Nibu: Reset-loop continuity" --persona-name Nibu --content-file thread.md
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.
`);
}

View file

@ -137,61 +137,125 @@ async function runClaimedViaCodexExec(request, repoRoot, promptPath, logPath, op
const reasoningEffort = options["reasoning-effort"] ?? process.env.CODEX_MODEL_REASONING_EFFORT ?? "medium";
const sandbox = options.sandbox ?? "workspace-write";
const startedAt = new Date().toISOString();
const bridgeContextEnv = buildBridgeContextEnv(request);
const resultPath = resolve(dirname(logPath), "result.json");
await mkdir(dirname(logPath), { recursive: true });
appendLog(logPath, `started ${startedAt}`);
appendLog(logPath, `request ${request.id}: ${request.title}`);
appendLog(logPath, `repoRoot ${repoRoot}`);
let dispatchRun = null;
const prompt = await readFile(promptPath, "utf8");
const result = spawnSync(
codexExecutable,
[
...codexExecArgs,
"exec",
"-m", model,
"-c", 'approval_policy="never"',
"-c", `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`,
"--skip-git-repo-check",
"-s", sandbox,
"-",
],
{
cwd: repoRoot,
input: prompt,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
);
try {
dispatchRun = await beginDispatchRun(request, {
launchMode: "codex-exec",
workerProcessId: process.pid,
threadId: "",
turnId: "",
logPath,
resultPath,
note: "Codex dispatch process started.",
});
appendLog(logPath, result.stdout ?? "");
appendLog(logPath, result.stderr ?? "");
const ok = result.status === 0;
const closeStatus = ok ? "completed" : "cancelled";
const note = ok
? `Codex dispatch completed from Bifrost. Log: ${logPath}`
: `Codex dispatch failed with exit ${result.status ?? "unknown"}. Log: ${logPath}`;
const close = runNodeJson([
transportCli,
"close",
"--id", request.id,
"--status", closeStatus,
"--note", note,
], bifrostRoot);
await writeFile(
resolve(dirname(logPath), "result.json"),
`${JSON.stringify({
const prompt = await readFile(promptPath, "utf8");
const result = spawnSync(
codexExecutable,
[
...codexExecArgs,
"exec",
"-m", model,
"-c", 'approval_policy="never"',
"-c", `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`,
"--skip-git-repo-check",
"-s", sandbox,
"-",
],
{
cwd: repoRoot,
input: prompt,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
...bridgeContextEnv,
},
windowsHide: true,
},
);
appendLog(logPath, result.stdout ?? "");
appendLog(logPath, result.stderr ?? "");
const launchError = result.error ? summarizeError(result.error) : "";
const ok = result.status === 0 && !launchError;
const closeStatus = ok ? "completed" : "cancelled";
const note = ok
? `Codex dispatch completed from Bifrost. Log: ${logPath}`
: launchError
? `Codex dispatch failed before completion: ${launchError}. Log: ${logPath}`
: `Codex dispatch failed with exit ${result.status ?? "unknown"}. Log: ${logPath}`;
const close = runNodeJson([
transportCli,
"close",
"--id", request.id,
"--status", closeStatus,
"--note", note,
], bifrostRoot);
if (launchError) {
await dispatchRun?.fail({
threadId: "",
turnId: "",
resultPath,
note,
error: launchError,
});
} else {
await dispatchRun?.complete({
status: ok ? "Completed" : "Cancelled",
threadId: "",
turnId: "",
resultPath,
note,
});
}
await writeDispatchResult(logPath, {
requestId: request.id,
ok,
exitCode: result.status,
error: launchError,
launchMode: "codex-exec",
startedAt,
finishedAt: new Date().toISOString(),
close,
}, null, 2)}\n`,
"utf8",
);
});
} catch (error) {
const message = summarizeError(error);
const note = `Codex dispatch failed before completion: ${message}. Log: ${logPath}`;
const close = runNodeJson([
transportCli,
"close",
"--id", request.id,
"--status", "cancelled",
"--note", note,
], bifrostRoot);
await dispatchRun?.fail({
threadId: "",
turnId: "",
resultPath,
note,
error: message,
});
await writeDispatchResult(logPath, {
requestId: request.id,
ok: false,
error: message,
launchMode: "codex-exec",
startedAt,
finishedAt: new Date().toISOString(),
close,
});
throw error;
}
}
async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, options) {
@ -200,9 +264,11 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op
const sandbox = options.sandbox ?? "danger-full-access";
const startedAt = new Date().toISOString();
const prompt = await readFile(promptPath, "utf8");
const resultPath = resolve(dirname(logPath), "result.json");
const client = new CodexAppServerClient({
logPath,
command: resolveCodexCommand(options),
env: buildBridgeContextEnv(request),
});
await mkdir(dirname(logPath), { recursive: true });
@ -213,6 +279,15 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op
let threadId;
let turnId;
const dispatchRun = await beginDispatchRun(request, {
launchMode: "app-server",
workerProcessId: process.pid,
threadId: "",
turnId: "",
logPath,
resultPath,
note: "Codex app-server launch started.",
});
try {
await client.start();
await client.request("initialize", {
@ -294,6 +369,13 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op
"--status", closeStatus,
"--note", note,
], bifrostRoot);
await dispatchRun?.complete({
status: ok ? "Completed" : "Cancelled",
threadId,
turnId,
resultPath,
note,
});
await writeDispatchResult(logPath, {
requestId: request.id,
ok,
@ -305,9 +387,10 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op
close,
});
} catch (error) {
appendLog(logPath, error instanceof Error ? error.stack ?? error.message : String(error));
const message = summarizeError(error);
appendLog(logPath, message);
if (!threadId || !turnId) {
releaseClaimedRequest(request.id, `Visible Codex app turn did not start: ${error instanceof Error ? error.message : String(error)}`);
releaseClaimedRequest(request.id, `Visible Codex app turn did not start: ${message}`);
} else {
runNodeJson([
transportCli,
@ -317,6 +400,13 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op
"--note", `Codex app dispatch failed after turn start for ${threadId}/${turnId}.`,
], bifrostRoot);
}
await dispatchRun?.fail({
threadId: threadId ?? "",
turnId: turnId ?? "",
resultPath,
note: "Codex dispatch failed.",
error: message,
});
await writeDispatchResult(logPath, {
requestId: request.id,
ok: false,
@ -325,7 +415,7 @@ async function runClaimedViaAppServer(request, repoRoot, promptPath, logPath, op
turnId,
startedAt,
finishedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error),
error: message,
});
process.exitCode = 1;
} finally {
@ -387,6 +477,7 @@ Priority: ${request.priority}
Work the request in this workspace. Prefer a small coherent change, create or update tests/docs when appropriate, and leave the repo in a reviewable state. If this is only an analysis/proposal request, produce the artifact requested. Bifrost will close the transport request after this Codex turn exits.
Transport policy: do not push to GitHub, open a pull request, or publish external changes unless this specific request explicitly asks for that. A Bifrost-dispatched turn may prepare local changes and report the commit/PR command it would run, but the bridge owns publication policy.
If this request explicitly authorizes a governed crossing and you use \`tools/bifrost-bridge.mjs\`, the dispatch runtime already provides bridge provenance for request \`${request.id}\`. Preserve it.
${request.requestMarkdown}
`;
@ -408,6 +499,10 @@ function postDispatchReceipt(request, dispatchRecord, options) {
"--channel-id", channelId,
"--persona-name", personaName,
"--content", content,
"--source-kind", "bifrost_agent_transport_request",
"--source-id", request.id,
"--authority-ref", "bifrost_dispatch_started_receipt",
...optionalArg("--target-repository-full-name", request.targetRepositoryFullName),
...optionalArg("--persona-avatar-url", personaAvatarUrl),
], bifrostRoot);
}
@ -440,9 +535,18 @@ function renderDispatchReceiptContent(request) {
].join("\n");
}
function buildBridgeContextEnv(request) {
return {
BIFROST_BRIDGE_SOURCE_KIND: "bifrost_agent_transport_request",
BIFROST_BRIDGE_SOURCE_ID: request.id,
BIFROST_BRIDGE_AUTHORITY_REF: "bifrost_dispatch_execution",
};
}
class CodexAppServerClient {
constructor({ command, logPath }) {
constructor({ command, env, logPath }) {
this.command = command;
this.env = env;
this.logPath = logPath;
this.child = undefined;
this.nextId = 1;
@ -454,6 +558,10 @@ class CodexAppServerClient {
async start() {
this.child = spawn(this.command.exe, [...this.command.args, "app-server", "--listen", "stdio://"], {
cwd: bifrostRoot,
env: {
...process.env,
...this.env,
},
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
@ -621,6 +729,108 @@ 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.
}
},
};
}
async function postDispatchRunJson(baseUrl, token, path, payload, allowedStatuses) {
const url = new URL(path, ensureTrailingSlash(baseUrl));
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Bifrost-Bridge-Token": token,
},
body: JSON.stringify(payload),
});
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = null;
}
}
if (!allowedStatuses.has(response.status)) {
throw new Error(`Bifrost dispatch run call to ${path} failed with ${response.status}: ${text}`);
}
return {
status: response.status,
text,
body,
};
}
function ensureTrailingSlash(value) {
return value.endsWith("/") ? value : `${value}/`;
}
function summarizeError(error) {
if (error instanceof Error) {
return error.stack ?? error.message;
}
return String(error);
}
function summarizeRequestTopic(request) {
const title = String(request.title ?? "").trim();
if (title && !/^Recent\b/i.test(title)) {

View file

@ -372,6 +372,10 @@ async function mirrorTopicActivityOrThrow(cache, topic, input) {
"--channel-id", channelId,
"--content", mirrorContent,
"--persona-name", personaName,
"--source-kind", "bifrost_governance_topic",
"--source-id", topic.id,
"--authority-ref", `bifrost_governance_${input.eventLabel}`,
"--target-repository-full-name", topic.jurisdictionRepoName,
...optionalArg("--persona-avatar-url", personaAvatarUrl),
...optionalArg("--reply-to-message-id", input.options["mirror-reply-to-message-id"]),
...(input.options["mirror-dry-run"] === "true" ? ["--dry-run", "true"] : []),