Eingehende Ausloeser: n8n und andere starten Agenten (#725, Stufe 1)
Something outside hands an agent a task - an n8n workflow on a mail, a
schedule, a form, or any system that sends an HTTP request.
- Core/Triggers/InboundTriggers: one token per trigger and agent, kept as a
hash and shown once; a rate per hour; deliveries up to 64 KB. Each
delivery becomes a task whose content is framed as foreign content with its
origin; every delivery, accepted or refused, is in the audit (source
"trigger").
- POST /hooks/trigger on the instance (MCP socket, own token, no browsers);
the manager forwards /hooks/{instance}/trigger. /api/triggers to issue,
list and revoke.
- Agent view: a triggers card with the address and an n8n/curl example.
- Nothing writes back: no right to create or change workflows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
47a20913b0
commit
73565eff18
@@ -454,6 +454,26 @@ internal static class ManagerEndpoints
|
||||
await forwarding.ForwardMcpAsync(context, ziel, ct);
|
||||
});
|
||||
|
||||
// #725: An inbound trigger of an instance — n8n or any system that sends a request. Like
|
||||
// MCP: no manager token, the trigger's own token is the key, and it goes to the
|
||||
// instance's MCP socket, which leads to nothing else.
|
||||
app.MapPost("/hooks/{instanz}/trigger", async (
|
||||
HttpContext context, string instanz, InstanceRegistry registry, InstanceForwarding forwarding,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (context.Request.Headers.Origin.Count > 0)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return;
|
||||
}
|
||||
if (Find(registry, instanz) is not { } ziel)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
await forwarding.ForwardMcpAsync(context, ziel, ct, "/hooks/trigger");
|
||||
});
|
||||
|
||||
app.Map("/api/{**pfad}", async (
|
||||
HttpContext context, InstanceRegistry registry, InstanceForwarding forwarding,
|
||||
CancellationToken ct) =>
|
||||
|
||||
@@ -131,7 +131,7 @@ internal sealed class InstanceForwarding(ILogger logger) : IDisposable
|
||||
/// instance socket the same request would have arrived token-free and with all rights —
|
||||
/// that is why it never goes there.</para>
|
||||
/// </summary>
|
||||
public async Task ForwardMcpAsync(HttpContext context, ManagedInstance ziel, CancellationToken ct)
|
||||
public async Task ForwardMcpAsync(HttpContext context, ManagedInstance ziel, CancellationToken ct, string path = "/mcp")
|
||||
{
|
||||
HttpClient client;
|
||||
lock (_lock)
|
||||
@@ -141,7 +141,7 @@ internal sealed class InstanceForwarding(ILogger logger) : IDisposable
|
||||
_clients[key] = client = InstanceEndpoint.CreateMcpClient(ziel.Directory);
|
||||
}
|
||||
|
||||
using var anfrage = new HttpRequestMessage(HttpMethod.Post, "/mcp")
|
||||
using var anfrage = new HttpRequestMessage(HttpMethod.Post, path)
|
||||
{
|
||||
Content = new StreamContent(context.Request.Body)
|
||||
};
|
||||
|
||||
@@ -1100,6 +1100,17 @@
|
||||
"mcp.copy": "Kopieren",
|
||||
"mcp.copied": "Kopiert.",
|
||||
"mcp.copyFailed": "Kopieren ist hier nicht erlaubt — Text markieren und von Hand kopieren.",
|
||||
"trigger.title": "Eingehende Auslöser (n8n und andere)",
|
||||
"trigger.hint": "Etwas von außen startet Arbeit für diesen Agenten: ein n8n-Workflow auf eine Mail, einen Zeitplan oder ein Formular — oder jedes System, das eine HTTP-Anfrage schickt. Jede Lieferung wird eine Aufgabe für diesen Agenten; ihr Inhalt ist Fremdinhalt, nie Anweisung. Ein Token je Auslöser, mit Obergrenze je Stunde.",
|
||||
"trigger.labelPlaceholder": "z. B. n8n neue Bestellungen",
|
||||
"trigger.perHour": "Höchstens je Stunde",
|
||||
"trigger.issue": "Auslöser anlegen",
|
||||
"trigger.none": "Keine Auslöser für diesen Agenten.",
|
||||
"trigger.status": "höchstens {perHour} je Stunde · {received} empfangen · zuletzt {last}",
|
||||
"trigger.issuedTitle": "Neuer Auslöser für {agent}",
|
||||
"trigger.address": "Adresse",
|
||||
"trigger.example": "Beispiel",
|
||||
"trigger.exampleHint": "In n8n: ein HTTP-Request-Knoten, Methode POST, diese Adresse, Kopfzeile Authorization: Bearer und das Token. Ein JSON-Rumpf mit \"title\" oder \"subject\" benennt die Aufgabe.",
|
||||
|
||||
"runtime.title": "Laufzeit",
|
||||
"runtime.hint": "Native Läufe nutzen unsere Engine mit OpenRouter. Eine fremde Laufzeit arbeitet in der Sandbox auf einer Kopie des Arbeitsbereichs: Was sie ändert, kommt als Vorschlag zurück und wird nie von selbst übernommen. Taskboard und Kollegen erreicht sie nur über den MCP-Server dieser Instanz.",
|
||||
|
||||
@@ -1100,6 +1100,17 @@
|
||||
"mcp.copy": "Copy",
|
||||
"mcp.copied": "Copied.",
|
||||
"mcp.copyFailed": "Copying is not allowed here — select the text and copy it by hand.",
|
||||
"trigger.title": "Inbound triggers (n8n and others)",
|
||||
"trigger.hint": "Something outside starts work for this agent: an n8n workflow on a mail, a schedule or a form — or any system that sends an HTTP request. Each delivery becomes a task for this agent; its content is data from outside, never instructions. One token per trigger, capped per hour.",
|
||||
"trigger.labelPlaceholder": "e.g. n8n new orders",
|
||||
"trigger.perHour": "Per hour at most",
|
||||
"trigger.issue": "Create trigger",
|
||||
"trigger.none": "No triggers for this agent.",
|
||||
"trigger.status": "at most {perHour} an hour · {received} received · last {last}",
|
||||
"trigger.issuedTitle": "New trigger for {agent}",
|
||||
"trigger.address": "Address",
|
||||
"trigger.example": "Example",
|
||||
"trigger.exampleHint": "In n8n: an HTTP Request node, method POST, this address, header Authorization: Bearer and the token. A JSON body with \"title\" or \"subject\" names the task.",
|
||||
|
||||
"runtime.title": "Runtime",
|
||||
"runtime.hint": "Native runs use our engine with OpenRouter. A foreign runtime works in the sandbox on a copy of the workspace: what it changes comes back as a proposal and is never applied by itself. It reaches the taskboard and colleagues only through this instance's MCP server.",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { api } from "../modules/api.js";
|
||||
import { abonnieren } from "../modules/events.js";
|
||||
import { t } from "../modules/i18n.js";
|
||||
import { erstelleMcpKarte } from "./mcp-access.js";
|
||||
import { erstelleMcpKarte, erstelleTriggerKarte } from "./mcp-access.js";
|
||||
import { erstelleLaufzeitKarte } from "./runtime.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
@@ -333,7 +333,7 @@ async function rendereDetails(agentId) {
|
||||
|
||||
// #713: A human's own client working as this agent, through the MCP server.
|
||||
// #712: native, or a foreign runtime in the sandbox.
|
||||
detailContainer.append(basisKarte, werkzeugeKarte, berichtswegKarte, erstelleLaufzeitKarte(agent), erstelleMcpKarte(agent));
|
||||
detailContainer.append(basisKarte, werkzeugeKarte, berichtswegKarte, erstelleLaufzeitKarte(agent), erstelleMcpKarte(agent), erstelleTriggerKarte(agent));
|
||||
}
|
||||
|
||||
function oeffneNeuerAgentDialog() {
|
||||
|
||||
@@ -153,3 +153,123 @@ function zeigeNeuesToken(neu) {
|
||||
const modal = erstelleModal(t("mcp.issuedTitle", { agent: neu.info.agentId }), inhalt, [fertig]);
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
/**
|
||||
* #725: Inbound triggers — n8n or any system that sends a request hands this agent a task.
|
||||
* One token per trigger, shown once; each delivery becomes a task whose content is framed as
|
||||
* foreign content. Nothing goes back: ClawdDotNet gets no right to change workflows.
|
||||
*/
|
||||
export function erstelleTriggerKarte(agent) {
|
||||
const liste = erstelleElement("div", { klasse: "mcp-token-liste" });
|
||||
const bezeichnung = erstelleElement("input", { type: "text", placeholder: t("trigger.labelPlaceholder"), maxlength: "60" });
|
||||
const proStunde = erstelleElement("input", { type: "number", min: "1", max: "600", step: "1", value: "30", "aria-label": t("trigger.perHour") });
|
||||
|
||||
const ausgeben = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche primaer",
|
||||
text: t("trigger.issue"),
|
||||
onclick: async () => {
|
||||
ausgeben.disabled = true;
|
||||
try {
|
||||
const neu = await api("/triggers", {
|
||||
method: "POST",
|
||||
body: { agentId: agent.agentId, label: bezeichnung.value.trim(), perHour: Number(proStunde.value) }
|
||||
});
|
||||
bezeichnung.value = "";
|
||||
zeigeNeuenAusloeser(neu);
|
||||
await lade();
|
||||
} catch (err) {
|
||||
zeigeMeldung(t("mcp.failed", { error: err.message }), "gefahr");
|
||||
} finally {
|
||||
ausgeben.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function lade() {
|
||||
try {
|
||||
const alle = (await api("/triggers")) || [];
|
||||
const eigene = alle.filter((x) => x.agentId.toLowerCase() === agent.agentId.toLowerCase());
|
||||
liste.replaceChildren(...(eigene.length === 0
|
||||
? [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("trigger.none") })]
|
||||
: eigene.map(zeile)));
|
||||
} catch (err) {
|
||||
liste.replaceChildren(erstelleElement("p", { klasse: "gedaempft text-klein", text: err.message }));
|
||||
}
|
||||
}
|
||||
|
||||
function zeile(trigger) {
|
||||
const status = trigger.active
|
||||
? t("trigger.status", {
|
||||
perHour: trigger.perHour,
|
||||
received: trigger.received,
|
||||
last: trigger.lastUsedAt ? formatiereDatumZeit(trigger.lastUsedAt) : "—"
|
||||
})
|
||||
: t("mcp.revoked", { time: formatiereDatumZeit(trigger.revokedAt) });
|
||||
|
||||
const aktionen = erstelleElement("div", { klasse: "werkzeug-aktionen" });
|
||||
if (trigger.active) {
|
||||
aktionen.append(erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach klein",
|
||||
text: t("mcp.revoke"),
|
||||
onclick: async () => {
|
||||
try {
|
||||
await api(`/triggers/${encodeURIComponent(trigger.id)}`, { method: "DELETE" });
|
||||
zeigeMeldung(t("mcp.revokedMessage"), "info");
|
||||
await lade();
|
||||
} catch (err) {
|
||||
zeigeMeldung(t("mcp.failed", { error: err.message }), "gefahr");
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return erstelleElement("div", { klasse: `werkzeug-zeile${trigger.active ? "" : " mcp-token-inaktiv"}` },
|
||||
erstelleElement("div", { klasse: "werkzeug-info" },
|
||||
erstelleElement("strong", { text: trigger.label || trigger.id }),
|
||||
erstelleElement("div", { klasse: "werkzeug-beschreibung gedaempft", text: status })),
|
||||
aktionen);
|
||||
}
|
||||
|
||||
lade();
|
||||
|
||||
return erstelleKarte(t("trigger.title"), [
|
||||
erstelleElement("p", { klasse: "gedaempft", text: t("trigger.hint") }),
|
||||
liste,
|
||||
erstelleElement("div", { klasse: "mcp-token-formular" }, bezeichnung, proStunde, ausgeben)
|
||||
]);
|
||||
}
|
||||
|
||||
/** The trigger's token and address once — with how n8n (or anything else) calls it. */
|
||||
function zeigeNeuenAusloeser(neu) {
|
||||
const instanz = holeAktiveInstanz();
|
||||
const adresse = `${location.origin}/hooks/${instanz ? `${encodeURIComponent(instanz)}/` : ""}trigger`;
|
||||
const beispiel = `curl -X POST "${adresse}" \\\n -H "Authorization: Bearer ${neu.token}" \\\n -H "Content-Type: application/json" \\\n -d '{"title":"New order","order":"4711"}'`;
|
||||
|
||||
const feld = (wert, zeilen) => {
|
||||
const f = erstelleElement("textarea", { readonly: true, rows: String(zeilen), klasse: "mcp-token-feld" });
|
||||
f.value = wert;
|
||||
f.addEventListener("focus", () => f.select());
|
||||
return f;
|
||||
};
|
||||
|
||||
const inhalt = erstelleElement("div", { klasse: "mcp-token-neu" },
|
||||
erstelleElement("p", { klasse: "text-klein", text: `⚠ ${t("mcp.shownOnce")}` }),
|
||||
erstelleElement("label", { text: t("trigger.address") }),
|
||||
feld(adresse, 1),
|
||||
erstelleElement("label", { text: t("mcp.token") }),
|
||||
feld(neu.token, 2),
|
||||
erstelleElement("label", { text: t("trigger.example") }),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("trigger.exampleHint") }),
|
||||
feld(beispiel, 5));
|
||||
|
||||
const fertig = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche primaer",
|
||||
text: t("common.close"),
|
||||
onclick: () => modal.schliessen()
|
||||
});
|
||||
const modal = erstelleModal(t("trigger.issuedTitle", { agent: neu.info.agentId }), inhalt, [fertig]);
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user