Trigger: Auftrag, Adressname, Prioritaet, Bearbeiten und neues Token (#725)
Rueckmeldung aus der Testinstanz: Nach dem Anlegen war nicht zu erkennen,
wofuer ein Trigger da ist, der Agent erfuhr nur 'ein Ereignis kam', und
bearbeiten ging nicht.
- Slug aus dem Namen in der Adresse: /hooks/<instanz>/trigger/<slug>;
ein Token an fremder Adresse oeffnet nichts. Die alte Adresse ohne
Slug bleibt gueltig.
- Auftrag vom Menschen steht vor den gelieferten Daten (die weiter als
Fremdinhalt gerahmt sind); ohne Auftrag wird der Agent darauf hingewiesen.
- Prioritaet der erzeugten Aufgaben.
- PUT /api/triggers/{id} (Token bleibt), POST /api/triggers/{id}/token.
- WebUI: Formular fuer Anlegen und Bearbeiten, Adresse und Auftrag je Zeile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
44613c2b77
commit
e2344f4d33
@@ -3,9 +3,10 @@ namespace ClawdDotNet.Web.Contracts;
|
||||
/// <summary>#725: An inbound trigger — never with its token.</summary>
|
||||
public sealed record TriggerDto(
|
||||
string Id, string Label, string AgentId, int PerHour, DateTime CreatedAt, DateTime? RevokedAt,
|
||||
DateTime? LastUsedAt, int Received, bool Active);
|
||||
DateTime? LastUsedAt, int Received, bool Active, string Slug, string Instruction, int Priority);
|
||||
|
||||
public sealed record TriggerInputDto(string AgentId, string? Label, int? PerHour);
|
||||
/// <summary>What a human sets on a trigger; <c>Slug</c> empty means "from the label" (new) or "unchanged" (edit).</summary>
|
||||
public sealed record TriggerInputDto(string AgentId, string? Label, int? PerHour, string? Instruction = null, int? Priority = null, string? Slug = null);
|
||||
|
||||
/// <summary>The token, once — the instance keeps only its hash.</summary>
|
||||
public sealed record TriggerIssuedDto(TriggerDto Info, string Token);
|
||||
|
||||
@@ -63,51 +63,47 @@ internal static class McpEndpoints
|
||||
|
||||
// #725: An inbound trigger — n8n or any system that sends a request. Outside /api: its
|
||||
// own token is the key, and it can do one thing — hand its agent a task.
|
||||
app.MapPost(Security.McpTransport.TriggerPath, async (HttpContext context, AppHost host, CancellationToken ct) =>
|
||||
{
|
||||
if (context.Request.Headers.Origin.Count > 0)
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
if (host.Triggers is not { } triggers)
|
||||
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
|
||||
if (context.Request.ContentLength is > Core.Triggers.InboundTriggers.MaxPayloadBytes)
|
||||
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
|
||||
string payload;
|
||||
using (var reader = new StreamReader(context.Request.Body))
|
||||
{
|
||||
var buffer = new char[Core.Triggers.InboundTriggers.MaxPayloadBytes + 1];
|
||||
var read = await reader.ReadBlockAsync(buffer.AsMemory(), ct);
|
||||
if (read > Core.Triggers.InboundTriggers.MaxPayloadBytes)
|
||||
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
payload = new string(buffer, 0, read);
|
||||
}
|
||||
|
||||
var result = await triggers.ReceiveAsync(BearerToken(context.Request), payload, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
Core.Triggers.TriggerOutcome.Accepted => Results.Json(new { ok = true, taskId = result.TaskId }, statusCode: StatusCodes.Status202Accepted),
|
||||
Core.Triggers.TriggerOutcome.Unauthorized => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status401Unauthorized),
|
||||
Core.Triggers.TriggerOutcome.Limited => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status429TooManyRequests),
|
||||
Core.Triggers.TriggerOutcome.TooLarge => Results.StatusCode(StatusCodes.Status413PayloadTooLarge),
|
||||
_ => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status503ServiceUnavailable)
|
||||
};
|
||||
});
|
||||
app.MapPost(Security.McpTransport.TriggerPath, (HttpContext context, AppHost host, CancellationToken ct) => ReceiveTriggerAsync(context, host, null, ct));
|
||||
app.MapPost(Security.McpTransport.TriggerPath + "/{name}", (HttpContext context, AppHost host, string name, CancellationToken ct) => ReceiveTriggerAsync(context, host, name, ct));
|
||||
|
||||
var triggerAdmin = app.MapGroup("/api/triggers");
|
||||
|
||||
triggerAdmin.MapGet("/", async (AppHost host, CancellationToken ct) =>
|
||||
host.Triggers is { } triggers
|
||||
? Results.Ok((await triggers.ListAsync(ct)).Select(t => new TriggerDto(t.Id, t.Label, t.AgentId, t.PerHour, t.CreatedAtUtc, t.RevokedAtUtc, t.LastUsedAtUtc, t.Received, t.Active)).ToList())
|
||||
? Results.Ok((await triggers.ListAsync(ct)).Select(TriggerDtoOf).ToList())
|
||||
: Results.Ok(Array.Empty<TriggerDto>()));
|
||||
|
||||
triggerAdmin.MapPost("/", async (AppHost host, TriggerInputDto input, CancellationToken ct) =>
|
||||
{
|
||||
if (host.Triggers is not { } triggers)
|
||||
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
|
||||
if (!host.Instance.Agents.Any(a => string.Equals(a.AgentId, input.AgentId, StringComparison.OrdinalIgnoreCase)))
|
||||
return Results.BadRequest(new OutcomeDto(false, $"There is no agent '{input.AgentId}'."));
|
||||
var (t, token) = await triggers.IssueAsync(input.Label ?? "", input.AgentId, input.PerHour ?? 30, ct);
|
||||
return Results.Ok(new TriggerIssuedDto(new TriggerDto(t.Id, t.Label, t.AgentId, t.PerHour, t.CreatedAtUtc, null, null, 0, true), token));
|
||||
if (TriggerProblem(host, input) is { } problem)
|
||||
return Results.BadRequest(new OutcomeDto(false, problem));
|
||||
var (t, token) = await triggers.IssueAsync(SpecOf(input), ct);
|
||||
return Results.Ok(new TriggerIssuedDto(TriggerDtoOf(t), token));
|
||||
});
|
||||
|
||||
// Change what a human set — the token stays, so the sender needs nothing new unless the slug changed.
|
||||
triggerAdmin.MapPut("/{id}", async (AppHost host, string id, TriggerInputDto input, CancellationToken ct) =>
|
||||
{
|
||||
if (host.Triggers is not { } triggers)
|
||||
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
|
||||
if (TriggerProblem(host, input) is { } problem)
|
||||
return Results.BadRequest(new OutcomeDto(false, problem));
|
||||
return await triggers.UpdateAsync(id, SpecOf(input), ct) is { } t
|
||||
? Results.Ok(TriggerDtoOf(t))
|
||||
: Results.NotFound(new OutcomeDto(false, $"No active trigger '{id}'."));
|
||||
});
|
||||
|
||||
// A new token, shown once; the old one stops working at once.
|
||||
triggerAdmin.MapPost("/{id}/token", async (AppHost host, string id, CancellationToken ct) =>
|
||||
{
|
||||
if (host.Triggers is not { } triggers)
|
||||
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
|
||||
if (await triggers.RotateTokenAsync(id, ct) is not { } token
|
||||
|| (await triggers.ListAsync(ct)).FirstOrDefault(t => t.Id == id) is not { } trigger)
|
||||
return Results.NotFound(new OutcomeDto(false, $"No active trigger '{id}'."));
|
||||
return Results.Ok(new TriggerIssuedDto(TriggerDtoOf(trigger), token));
|
||||
});
|
||||
|
||||
triggerAdmin.MapDelete("/{id}", async (AppHost host, string id, CancellationToken ct) =>
|
||||
@@ -246,6 +242,55 @@ internal static class McpEndpoints
|
||||
return Results.Ok(new OutcomeDto(true, message));
|
||||
}
|
||||
|
||||
/// <summary>#725: A delivery — the token is the key; a name in the address must be the token's own trigger.</summary>
|
||||
private static async Task<IResult> ReceiveTriggerAsync(HttpContext context, AppHost host, string? name, CancellationToken ct)
|
||||
{
|
||||
if (context.Request.Headers.Origin.Count > 0)
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
if (host.Triggers is not { } triggers)
|
||||
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
|
||||
if (context.Request.ContentLength is > Core.Triggers.InboundTriggers.MaxPayloadBytes)
|
||||
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
|
||||
string payload;
|
||||
using (var reader = new StreamReader(context.Request.Body))
|
||||
{
|
||||
var buffer = new char[Core.Triggers.InboundTriggers.MaxPayloadBytes + 1];
|
||||
var read = await reader.ReadBlockAsync(buffer.AsMemory(), ct);
|
||||
if (read > Core.Triggers.InboundTriggers.MaxPayloadBytes)
|
||||
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
payload = new string(buffer, 0, read);
|
||||
}
|
||||
|
||||
var result = await triggers.ReceiveAsync(BearerToken(context.Request), payload, ct, name);
|
||||
return result.Outcome switch
|
||||
{
|
||||
Core.Triggers.TriggerOutcome.Accepted => Results.Json(new { ok = true, taskId = result.TaskId }, statusCode: StatusCodes.Status202Accepted),
|
||||
Core.Triggers.TriggerOutcome.Unauthorized => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status401Unauthorized),
|
||||
Core.Triggers.TriggerOutcome.Limited => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status429TooManyRequests),
|
||||
Core.Triggers.TriggerOutcome.TooLarge => Results.StatusCode(StatusCodes.Status413PayloadTooLarge),
|
||||
_ => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status503ServiceUnavailable)
|
||||
};
|
||||
}
|
||||
|
||||
private static TriggerDto TriggerDtoOf(Core.Triggers.InboundTrigger t)
|
||||
=> new(t.Id, t.Label, t.AgentId, t.PerHour, t.CreatedAtUtc, t.RevokedAtUtc, t.LastUsedAtUtc, t.Received, t.Active,
|
||||
t.AddressName, t.Instruction, t.Priority);
|
||||
|
||||
private static Core.Triggers.TriggerSpec SpecOf(TriggerInputDto input)
|
||||
=> new(input.Label ?? "", input.AgentId, input.PerHour ?? 30, input.Instruction, input.Priority ?? 3, input.Slug);
|
||||
|
||||
private static string? TriggerProblem(AppHost host, TriggerInputDto input)
|
||||
{
|
||||
if (!host.Instance.Agents.Any(a => string.Equals(a.AgentId, input.AgentId, StringComparison.OrdinalIgnoreCase)))
|
||||
return $"There is no agent '{input.AgentId}'.";
|
||||
if ((input.Instruction ?? "").Length > Core.Triggers.InboundTriggers.MaxInstructionChars)
|
||||
return $"The instruction is longer than {Core.Triggers.InboundTriggers.MaxInstructionChars} characters.";
|
||||
if (!string.IsNullOrWhiteSpace(input.Slug) && Core.Triggers.InboundTriggers.Slugify(input.Slug).Length == 0)
|
||||
return "An address name needs letters or digits.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Only the header — never the query string: an address ends up in logs.</summary>
|
||||
private static string? BearerToken(HttpRequest request)
|
||||
{
|
||||
|
||||
@@ -32,11 +32,17 @@ internal sealed class McpTransport
|
||||
/// <summary>#725: Where an inbound trigger delivers.</summary>
|
||||
public const string TriggerPath = "/hooks/trigger";
|
||||
|
||||
/// <summary><c>/hooks/trigger</c>, or <c>/hooks/trigger/{name}</c> with the trigger's slug — one segment, nothing deeper.</summary>
|
||||
public static bool IsTriggerPath(PathString path)
|
||||
=> path.Equals(TriggerPath, StringComparison.Ordinal)
|
||||
|| (path.StartsWithSegments(TriggerPath, StringComparison.Ordinal, out var rest)
|
||||
&& rest.Value is { Length: > 1 } r && r.IndexOf('/', 1) < 0);
|
||||
|
||||
public static async Task OnlyMcp(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
// #725: inbound triggers come the same way — from outside, with a token of their own.
|
||||
if (Applies(context) && !context.Request.Path.Equals("/mcp", StringComparison.Ordinal)
|
||||
&& !context.Request.Path.Equals(TriggerPath, StringComparison.Ordinal))
|
||||
&& !IsTriggerPath(context.Request.Path))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user