Validate runtime receipt linkage
This commit is contained in:
parent
f702a708d9
commit
f58a0cbc1b
8 changed files with 467 additions and 14 deletions
|
|
@ -75,6 +75,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
|
|||
- 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
|
||||
- those hosted runtime receipt lanes are local-bridge-owned writes, not ordinary member-session writes: `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` now require the configured bridge token and do not inherit browser-user identity into agent activity rows
|
||||
- those hosted runtime receipts also validate linkage instead of accepting free-floating text: dispatch runs require an existing request-lane receipt for the same request id, governance promotion receipts require a known promoted dispatch request, and request-lane rows cannot silently drift to a different repo or agent identity midstream
|
||||
- Bifrost-dispatched Codex turns also inject Bifrost-owned GitHub gates through environment config: `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked, and direct `gh` usage is limited to explicit read-only commands unless `tools/bifrost-bridge.mjs` authorizes the bridge-owned GitHub write path
|
||||
- dispatched Codex turns now default to `workspace-write` instead of full machine access, and the app-server sandbox policy disables network access unless the operator explicitly chooses a broader sandbox
|
||||
- `tools/agent-transport.mjs` also uses that bridge configuration to record `/transport/receipts` entries for queue, claim, release, and close events on the request lane
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ node .\tools\agent-transport.mjs apply-snapshot --in E:\tmp\bifrost-agent-transp
|
|||
Dispatch receipts should use Bifrost's own public persona in the Bifrost governance channel. `tools/dispatch-agent-requests.mjs` reads `BIFROST_DISCORD_CHANNEL_ID` or `DISCORD_BIFROST_CHANNEL_ID` for the receipt target, uses persona name `Bifrost`, and defaults the persona avatar to the public `src/Bifrost.Web/wwwroot/img/bifrost-profile.png` raw GitHub URL unless `BIFROST_DISCORD_PERSONA_AVATAR_URL` or `DISCORD_PERSONA_AVATAR_URL_BIFROST` overrides it. Receipt text must lead with the concrete repo and work title, not a generic "recent consensus" summary, and must not expose request ids, workspace paths, log paths, or other debugging debris in Discord.
|
||||
The dispatcher now tags those Discord receipts with the Bifrost request id as bridge provenance, and dispatched Codex turns inherit default bridge provenance environment variables so any explicit `tools/bifrost-bridge.mjs` crossing inside the turn can land on the same request trail without hand-carried flags. When `BIFROST_BRIDGE_BASE_URL` and `BIFROST_BRIDGE_TOKEN` are configured, the dispatcher also records `/dispatch/runs/*` receipts in the hosted app so Bifrost keeps a first-class run ledger for launch, completion, cancellation, and failure.
|
||||
Those hosted runtime receipt endpoints are owned by the local bridge token, not by ordinary member sessions. A signed-in member can govern work and request crossings, but they cannot POST `/dispatch/runs/*`, `/transport/receipts`, or `/governance/receipts` without the configured bridge token and pretend to be the worker runtime.
|
||||
Those hosted receipt endpoints now also cross-check each other. Dispatch starts require an existing request-lane receipt for the same request id, governance promotions that cite `dispatchRequestId` must point at a known request-lane request, and additional request-lane receipts for one request id cannot drift onto a different repo or agent identity. The ledger is still transitional, but it has stopped politely accepting shape-shifting runtime history.
|
||||
Those dispatched turns also inherit Bifrost-owned GitHub gates through environment config. `git` and `gh` wrappers are prepended in `PATH`, a `pre-push` hook backs up raw Git pushes, raw `git push` stays blocked inside dispatched work even with `--no-verify`, and direct `gh` usage is limited to explicit read-only commands unless the bridge explicitly authorizes the GitHub mutation it owns.
|
||||
Those dispatched turns also lock the local-only recovery hatches. `--allow-ungated-github`, `BIFROST_ALLOW_UNGATED_GITHUB`, `--allow-unreceipted-activity`, and `BIFROST_ALLOW_UNRECEIPTED_ACTIVITY` are refused inside dispatched work so an agent cannot quietly downgrade itself out of Bifrost's gate or receipt trail.
|
||||
Those dispatched turns also scrub ambient GitHub auth state from the child environment before the worker starts. `GH_TOKEN` and `GITHUB_TOKEN` are cleared, `gh` gets an isolated config directory, `git` gets an isolated global config plus non-interactive credential settings, and terminal prompts are disabled. That does not prove every imaginable direct-HTTP escape is impossible, but it removes the normal ambient credential path from dispatched work.
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ The hosted app also exposes a governance activity receipt ledger:
|
|||
This covers the other side of the swarm's work: topic opens, comments, approvals, and promotions to dispatch. The topic and comment documents are still the governance substrate. The hosted receipt row is the Bifrost-side witness that an agent or operator performed the governance action.
|
||||
|
||||
These runtime receipt lanes are not generic member write surfaces. In the current machine, `/dispatch/runs/*`, `/transport/receipts`, and `/governance/receipts` are owned by the configured local bridge token. Active members may govern and request bridge actions through the app, but they may not mint worker-history rows directly through a browser session and impersonate runtime activity.
|
||||
They also now validate linkage across the hosted ledger instead of accepting isolated claims. A dispatch run must point at an already-seen request-lane receipt for the same request id. A governance promotion receipt that cites `dispatchRequestId` must point at a known request-lane request. Repeated request-lane rows for one request id may add activity, but they may not quietly change the repo or agent identity the request belongs to.
|
||||
|
||||
Current gate behavior:
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ public sealed class AgentDispatchRunService(
|
|||
Guid? startedByUserAccountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateStartRequest(request);
|
||||
await EnsureLinkedTransportRequestAsync(request, cancellationToken);
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var run = new AgentDispatchRun
|
||||
{
|
||||
|
|
@ -126,6 +129,76 @@ public sealed class AgentDispatchRunService(
|
|||
return AgentDispatchRunResult.From(run);
|
||||
}
|
||||
|
||||
private async Task EnsureLinkedTransportRequestAsync(
|
||||
AgentDispatchRunStartRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var linkedReceipt = await dbContext.AgentTransportReceipts
|
||||
.AsNoTracking()
|
||||
.Where(x => x.RequestId == request.RequestId)
|
||||
.OrderByDescending(x => x.OccurredAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (linkedReceipt is null)
|
||||
{
|
||||
throw new AgentDispatchRunException(
|
||||
$"Dispatch runs require an existing request-lane receipt for request {request.RequestId}.");
|
||||
}
|
||||
|
||||
EnsureSameIdentity(
|
||||
"dispatch run target repo",
|
||||
request.TargetRepoName,
|
||||
"request-lane target repo",
|
||||
linkedReceipt.TargetRepoName);
|
||||
EnsureSameIdentity(
|
||||
"dispatch run target repository",
|
||||
request.TargetRepositoryFullName,
|
||||
"request-lane target repository",
|
||||
linkedReceipt.TargetRepositoryFullName);
|
||||
EnsureSameIdentity(
|
||||
"dispatch run target agent",
|
||||
request.TargetAgentIdentity,
|
||||
"request-lane target agent",
|
||||
linkedReceipt.TargetAgentIdentity);
|
||||
}
|
||||
|
||||
private static void ValidateStartRequest(AgentDispatchRunStartRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RequestId))
|
||||
{
|
||||
throw new AgentDispatchRunException("Dispatch runs require a request id.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TargetRepoName))
|
||||
{
|
||||
throw new AgentDispatchRunException("Dispatch runs require a target repo name.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.LaunchMode))
|
||||
{
|
||||
throw new AgentDispatchRunException("Dispatch runs require a launch mode.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSameIdentity(
|
||||
string currentLabel,
|
||||
string currentValue,
|
||||
string linkedLabel,
|
||||
string linkedValue)
|
||||
{
|
||||
var normalizedCurrent = NormalizeText(currentValue);
|
||||
var normalizedLinked = NormalizeText(linkedValue);
|
||||
if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new AgentDispatchRunException($"{currentLabel} does not match the linked {linkedLabel}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeText(string? value) => value?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
|
|
@ -194,3 +267,5 @@ public sealed record AgentDispatchRunResult(
|
|||
run.UpdatedAtUtc,
|
||||
run.CompletedAtUtc);
|
||||
}
|
||||
|
||||
public sealed class AgentDispatchRunException(string message) : InvalidOperationException(message);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Bifrost.Web.Features.Bridge;
|
||||
|
||||
|
|
@ -14,6 +15,9 @@ public sealed class AgentTransportReceiptService(
|
|||
Guid? actorUserAccountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateRequest(request);
|
||||
await EnsureConsistentIdentityAsync(request, cancellationToken);
|
||||
|
||||
var receipt = new AgentTransportReceipt
|
||||
{
|
||||
RequestId = NormalizeText(request.RequestId),
|
||||
|
|
@ -42,6 +46,103 @@ public sealed class AgentTransportReceiptService(
|
|||
return AgentTransportReceiptResult.From(receipt);
|
||||
}
|
||||
|
||||
private async Task EnsureConsistentIdentityAsync(
|
||||
AgentTransportReceiptRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var priorReceipt = await dbContext.AgentTransportReceipts
|
||||
.AsNoTracking()
|
||||
.Where(x => x.RequestId == request.RequestId)
|
||||
.OrderByDescending(x => x.OccurredAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (priorReceipt is not null)
|
||||
{
|
||||
EnsureSameIdentity(
|
||||
"request-lane target repo",
|
||||
request.TargetRepoName,
|
||||
"prior request-lane target repo",
|
||||
priorReceipt.TargetRepoName);
|
||||
EnsureSameIdentity(
|
||||
"request-lane target repository",
|
||||
request.TargetRepositoryFullName,
|
||||
"prior request-lane target repository",
|
||||
priorReceipt.TargetRepositoryFullName);
|
||||
EnsureSameIdentity(
|
||||
"request-lane target agent",
|
||||
request.TargetAgentIdentity,
|
||||
"prior request-lane target agent",
|
||||
priorReceipt.TargetAgentIdentity);
|
||||
}
|
||||
|
||||
var linkedRun = await dbContext.AgentDispatchRuns
|
||||
.AsNoTracking()
|
||||
.Where(x => x.RequestId == request.RequestId)
|
||||
.OrderByDescending(x => x.StartedAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (linkedRun is not null)
|
||||
{
|
||||
EnsureSameIdentity(
|
||||
"request-lane target repo",
|
||||
request.TargetRepoName,
|
||||
"dispatch-run target repo",
|
||||
linkedRun.TargetRepoName);
|
||||
EnsureSameIdentity(
|
||||
"request-lane target repository",
|
||||
request.TargetRepositoryFullName,
|
||||
"dispatch-run target repository",
|
||||
linkedRun.TargetRepositoryFullName);
|
||||
EnsureSameIdentity(
|
||||
"request-lane target agent",
|
||||
request.TargetAgentIdentity,
|
||||
"dispatch-run target agent",
|
||||
linkedRun.TargetAgentIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateRequest(AgentTransportReceiptRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RequestId))
|
||||
{
|
||||
throw new AgentTransportReceiptException("Request-lane receipts require a request id.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
throw new AgentTransportReceiptException("Request-lane receipts require a title.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.TargetRepoName))
|
||||
{
|
||||
throw new AgentTransportReceiptException("Request-lane receipts require a target repo name.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ActorName))
|
||||
{
|
||||
throw new AgentTransportReceiptException("Request-lane receipts require an actor name.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSameIdentity(
|
||||
string currentLabel,
|
||||
string currentValue,
|
||||
string linkedLabel,
|
||||
string linkedValue)
|
||||
{
|
||||
var normalizedCurrent = NormalizeText(currentValue);
|
||||
var normalizedLinked = NormalizeText(linkedValue);
|
||||
if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new AgentTransportReceiptException($"{currentLabel} does not match the linked {linkedLabel}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAuditDetail(AgentTransportReceipt receipt)
|
||||
{
|
||||
var actorName = string.IsNullOrWhiteSpace(receipt.ActorName) ? "unknown actor" : receipt.ActorName;
|
||||
|
|
@ -89,3 +190,5 @@ public sealed record AgentTransportReceiptResult(
|
|||
receipt.Note,
|
||||
receipt.OccurredAtUtc);
|
||||
}
|
||||
|
||||
public sealed class AgentTransportReceiptException(string message) : InvalidOperationException(message);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Bifrost.Web.Features.Bridge;
|
||||
|
||||
|
|
@ -14,6 +15,9 @@ public sealed class GovernanceActivityReceiptService(
|
|||
Guid? actorUserAccountId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateRequest(request);
|
||||
await EnsureLinkedDispatchRequestAsync(request, cancellationToken);
|
||||
|
||||
var receipt = new GovernanceActivityReceipt
|
||||
{
|
||||
TopicId = NormalizeText(request.TopicId),
|
||||
|
|
@ -43,6 +47,86 @@ public sealed class GovernanceActivityReceiptService(
|
|||
return GovernanceActivityReceiptResult.From(receipt);
|
||||
}
|
||||
|
||||
private async Task EnsureLinkedDispatchRequestAsync(
|
||||
GovernanceActivityReceiptRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.DispatchRequestId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var linkedReceipt = await dbContext.AgentTransportReceipts
|
||||
.AsNoTracking()
|
||||
.Where(x => x.RequestId == request.DispatchRequestId)
|
||||
.OrderByDescending(x => x.OccurredAtUtc)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (linkedReceipt is null)
|
||||
{
|
||||
throw new GovernanceActivityReceiptException(
|
||||
$"Governance receipt references unknown dispatch request {request.DispatchRequestId}.");
|
||||
}
|
||||
|
||||
EnsureSameIdentity(
|
||||
"governance jurisdiction repo",
|
||||
request.JurisdictionRepoName,
|
||||
"dispatch request repo",
|
||||
linkedReceipt.TargetRepoName);
|
||||
EnsureSameIdentity(
|
||||
"governance jurisdiction agent",
|
||||
request.JurisdictionAgentIdentity,
|
||||
"dispatch request agent",
|
||||
linkedReceipt.TargetAgentIdentity);
|
||||
}
|
||||
|
||||
private static void ValidateRequest(GovernanceActivityReceiptRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.TopicId))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException("Governance receipts require a topic id.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException("Governance receipts require a title.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.JurisdictionRepoName))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException("Governance receipts require a jurisdiction repo name.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ActorKind))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException("Governance receipts require an actor kind.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ActorName))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException("Governance receipts require an actor name.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSameIdentity(
|
||||
string currentLabel,
|
||||
string currentValue,
|
||||
string linkedLabel,
|
||||
string linkedValue)
|
||||
{
|
||||
var normalizedCurrent = NormalizeText(currentValue);
|
||||
var normalizedLinked = NormalizeText(linkedValue);
|
||||
if (string.IsNullOrWhiteSpace(normalizedCurrent) || string.IsNullOrWhiteSpace(normalizedLinked))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(normalizedCurrent, normalizedLinked, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new GovernanceActivityReceiptException($"{currentLabel} does not match the linked {linkedLabel}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToAuditSuffix(GovernanceActivityReceiptKind activityKind) => activityKind switch
|
||||
{
|
||||
GovernanceActivityReceiptKind.TopicOpened => "topic-opened",
|
||||
|
|
@ -102,3 +186,5 @@ public sealed record GovernanceActivityReceiptResult(
|
|||
receipt.Note,
|
||||
receipt.OccurredAtUtc);
|
||||
}
|
||||
|
||||
public sealed class GovernanceActivityReceiptException(string message) : InvalidOperationException(message);
|
||||
|
|
|
|||
|
|
@ -490,8 +490,15 @@ dispatchRuns.MapPost("/start", async (
|
|||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
try
|
||||
{
|
||||
var result = await agentDispatchRunService.StartAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
}
|
||||
catch (AgentDispatchRunException error)
|
||||
{
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
dispatchRuns.MapPost("/{id:guid}/complete", async (
|
||||
|
|
@ -507,10 +514,17 @@ dispatchRuns.MapPost("/{id:guid}/complete", async (
|
|||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken);
|
||||
return result is null
|
||||
? Results.NotFound()
|
||||
: JsonResult(result, appJsonSerializerOptions);
|
||||
try
|
||||
{
|
||||
var result = await agentDispatchRunService.CompleteAsync(id, command, null, cancellationToken);
|
||||
return result is null
|
||||
? Results.NotFound()
|
||||
: JsonResult(result, appJsonSerializerOptions);
|
||||
}
|
||||
catch (AgentDispatchRunException error)
|
||||
{
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
dispatchRuns.MapPost("/{id:guid}/fail", async (
|
||||
|
|
@ -526,10 +540,17 @@ dispatchRuns.MapPost("/{id:guid}/fail", async (
|
|||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken);
|
||||
return result is null
|
||||
? Results.NotFound()
|
||||
: JsonResult(result, appJsonSerializerOptions);
|
||||
try
|
||||
{
|
||||
var result = await agentDispatchRunService.FailAsync(id, command, null, cancellationToken);
|
||||
return result is null
|
||||
? Results.NotFound()
|
||||
: JsonResult(result, appJsonSerializerOptions);
|
||||
}
|
||||
catch (AgentDispatchRunException error)
|
||||
{
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
var transportReceipts = app.MapGroup("/transport/receipts");
|
||||
|
|
@ -546,8 +567,15 @@ transportReceipts.MapPost("", async (
|
|||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
try
|
||||
{
|
||||
var result = await agentTransportReceiptService.RecordAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
}
|
||||
catch (AgentTransportReceiptException error)
|
||||
{
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
var governanceReceipts = app.MapGroup("/governance/receipts");
|
||||
|
|
@ -564,8 +592,15 @@ governanceReceipts.MapPost("", async (
|
|||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
try
|
||||
{
|
||||
var result = await governanceActivityReceiptService.RecordAsync(command, null, cancellationToken);
|
||||
return JsonResult(result, appJsonSerializerOptions, StatusCodes.Status202Accepted);
|
||||
}
|
||||
catch (GovernanceActivityReceiptException error)
|
||||
{
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
});
|
||||
|
||||
var eveGovernance = app.MapGroup("/eve/governance")
|
||||
|
|
|
|||
|
|
@ -397,6 +397,18 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
{
|
||||
using var client = _factory.CreateClient();
|
||||
_ = await client.GetAsync("/App");
|
||||
await PostTransportReceiptAsync(client, new
|
||||
{
|
||||
requestId = "req_dispatch_123",
|
||||
title = "Queue the GitHub bridge hardening pass",
|
||||
targetRepoName = "Bifrost",
|
||||
targetRepositoryFullName = "GameCult/Bifrost",
|
||||
targetAgentIdentity = "nibu",
|
||||
activityKind = "Claimed",
|
||||
status = "claimed",
|
||||
actorName = "bifrost-dispatcher",
|
||||
note = "Request claimed for Bifrost."
|
||||
});
|
||||
|
||||
var startPayload = JsonSerializer.Serialize(new
|
||||
{
|
||||
|
|
@ -457,6 +469,18 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
{
|
||||
using var client = _factory.CreateClient();
|
||||
_ = await client.GetAsync("/App");
|
||||
await PostTransportReceiptAsync(client, new
|
||||
{
|
||||
requestId = "req_dispatch_fail_123",
|
||||
title = "Queue the GitHub bridge hardening pass",
|
||||
targetRepoName = "Bifrost",
|
||||
targetRepositoryFullName = "GameCult/Bifrost",
|
||||
targetAgentIdentity = "nibu",
|
||||
activityKind = "Claimed",
|
||||
status = "claimed",
|
||||
actorName = "bifrost-dispatcher",
|
||||
note = "Request claimed for Bifrost."
|
||||
});
|
||||
|
||||
var startPayload = JsonSerializer.Serialize(new
|
||||
{
|
||||
|
|
@ -556,6 +580,18 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
{
|
||||
using var client = _factory.CreateClient();
|
||||
_ = await client.GetAsync("/App");
|
||||
await PostTransportReceiptAsync(client, new
|
||||
{
|
||||
requestId = "req_transport_123",
|
||||
title = "Queue the GitHub bridge hardening pass",
|
||||
targetRepoName = "Bifrost",
|
||||
targetRepositoryFullName = "GameCult/Bifrost",
|
||||
targetAgentIdentity = "nibu",
|
||||
activityKind = "Queued",
|
||||
status = "queued",
|
||||
actorName = "bifrost-dispatcher",
|
||||
note = "Request queued for Bifrost."
|
||||
});
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
|
|
@ -622,6 +658,108 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_run_without_request_lane_receipt_is_rejected()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
requestId = "req_dispatch_missing_123",
|
||||
targetRepoName = "Bifrost",
|
||||
targetRepositoryFullName = "GameCult/Bifrost",
|
||||
targetAgentIdentity = "nibu",
|
||||
launchMode = "app-server",
|
||||
workerProcessId = 4242,
|
||||
threadId = "thread_missing",
|
||||
turnId = "turn_missing",
|
||||
logPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_missing_123/codex.log",
|
||||
resultPath = "E:/Projects/Bifrost/.bifrost/agent-dispatch/req_dispatch_missing_123/result.json",
|
||||
note = "Should fail because the request lane has no receipt."
|
||||
});
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/dispatch/runs/start");
|
||||
request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("existing request-lane receipt", body, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Governance_promotion_receipt_requires_known_dispatch_request()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
topicId = "topic_unknown_dispatch_123",
|
||||
commentId = "comment_unknown_dispatch_123",
|
||||
dispatchRequestId = "req_unknown_transport_123",
|
||||
title = "Promote an unknown dispatch request",
|
||||
jurisdictionRepoName = "Bifrost",
|
||||
jurisdictionAgentIdentity = "nibu",
|
||||
activityKind = "TopicPromoted",
|
||||
actorKind = "face",
|
||||
actorName = "nibu",
|
||||
note = "Should fail because the dispatch request is unknown."
|
||||
});
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/governance/receipts");
|
||||
request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("unknown dispatch request", body, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Request_lane_receipt_cannot_change_repo_identity_for_existing_request()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
await PostTransportReceiptAsync(client, new
|
||||
{
|
||||
requestId = "req_transport_conflict_123",
|
||||
title = "Queue the GitHub bridge hardening pass",
|
||||
targetRepoName = "Bifrost",
|
||||
targetRepositoryFullName = "GameCult/Bifrost",
|
||||
targetAgentIdentity = "nibu",
|
||||
activityKind = "Queued",
|
||||
status = "queued",
|
||||
actorName = "bifrost-dispatcher",
|
||||
note = "Request queued for Bifrost."
|
||||
});
|
||||
|
||||
var conflictPayload = JsonSerializer.Serialize(new
|
||||
{
|
||||
requestId = "req_transport_conflict_123",
|
||||
title = "Queue the GitHub bridge hardening pass",
|
||||
targetRepoName = "VoidBot",
|
||||
targetRepositoryFullName = "GameCult/VoidBot",
|
||||
targetAgentIdentity = "nibu",
|
||||
activityKind = "Claimed",
|
||||
status = "claimed",
|
||||
actorName = "bifrost-dispatcher",
|
||||
note = "This should not be allowed to drift."
|
||||
});
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/transport/receipts");
|
||||
request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
|
||||
request.Content = new StringContent(conflictPayload, Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("target repo", body, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Active_member_session_cannot_record_transport_receipt_without_local_bridge_token()
|
||||
{
|
||||
|
|
@ -692,6 +830,19 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
return request;
|
||||
}
|
||||
|
||||
private static async Task PostTransportReceiptAsync(HttpClient client, object payload)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/transport/receipts");
|
||||
request.Headers.Add("X-Bifrost-Bridge-Token", "test-bridge-token");
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.Accepted,
|
||||
$"Expected transport receipt seed to succeed but got {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
private sealed class BridgeActionHttpResult
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue