Eine fremde Laufzeit oder der eigene Claude Code des Menschen spricht per MCP mit der Instanz. Der Server bietet genau die Werkzeuge des Agenten hinter dem Token an; jeder Aufruf laeuft durch die Engine wie ein Aufruf des eigenen Modells (Rechtetor, Vorpruefung, Werkzeugrechte, Staging, Audit mit Quelle mcp, Rahmung), und der Aufrufer gilt als Leser fremder Inhalte (#715). Eigener Socket mcp.sock statt manager.sock: ueber den Instanz-Socket antwortet die Verwaltung ohne Token, ueber mcp.sock gibt es nur /mcp und nur mit MCP-Token. Lauf-Tokens nur im Speicher, enden mit dem Lauf; persoenliche Tokens (hoechstens 90 Tage, widerrufbar, nur als SHA-256) in der Agentenansicht. Transporte: HTTP auf mcp.sock, Weboberflaeche und Manager (/mcp/{instanz}), stdio ueber ClawdDotNet --mcp-bridge (Token nur aus Umgebung oder 0600-Datei). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
7.8 KiB
C#
195 lines
7.8 KiB
C#
using ClawdDotNet.Core.Instances;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClawdDotNet.Manager.Web;
|
|
|
|
/// <summary>
|
|
/// Reicht eine Anfrage an den Socket der gemeinten Instanz durch.
|
|
///
|
|
/// <para><b>Er leitet weiter, er implementiert nicht</b> (E-M5). Diese Klasse kennt
|
|
/// Kennungen und Pfade, sonst nichts — keinen Agenten, keine Aufgabe, kein Taskboard. Wer
|
|
/// hier anfängt, eine Antwort umzuformen oder eine Regel anzuwenden, hat Fachlogik in den
|
|
/// Manager gelegt, und die gehört in die Instanz.</para>
|
|
///
|
|
/// <para><b>Deshalb bleibt das Protokoll HTTP</b> (E-M1). Weil auf beiden Seiten HTTP
|
|
/// steht, ist Weiterleiten eine Weiterleitung und keine Übersetzung: Die Endpunkte, die
|
|
/// Übertragungstypen und der Ereignisstrom aus #494 gelten unverändert weiter, es ändert
|
|
/// sich nur die Bindung.</para>
|
|
/// </summary>
|
|
internal sealed class InstanceForwarding(ILogger logger) : IDisposable
|
|
{
|
|
/// <summary>
|
|
/// Je Instanz ein Client, und der bleibt.
|
|
///
|
|
/// <para>Ein <c>HttpClient</c> je Anfrage wäre hier besonders teuer: Jeder brächte
|
|
/// einen eigenen Verbindungsaufbau zum Socket mit, und die Weboberfläche fragt viel
|
|
/// und oft.</para>
|
|
/// </summary>
|
|
private readonly Dictionary<string, HttpClient> _clients =
|
|
new(ClawdDotNet.Core.Storage.PathBoundary.Comparer);
|
|
|
|
private readonly Lock _lock = new();
|
|
|
|
/// <summary>
|
|
/// Kopfzeilen, die nicht weitergereicht werden.
|
|
///
|
|
/// <para><c>Authorization</c>, weil der Socket kein Token kennt und keines bekommen
|
|
/// soll — die Grenze steht dort am Dateisystem. <c>Host</c>, weil die Gegenstelle
|
|
/// keinen Hostnamen hat. Die Übertragungskopfzeilen, weil sie die Verbindung
|
|
/// beschreiben und nicht den Inhalt: Sie einfach mitzunehmen ergibt eine Antwort, die
|
|
/// doppelt gestückelt ist.</para>
|
|
/// </summary>
|
|
private static readonly HashSet<string> NotForwarded = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"Authorization", "Host", "Connection", "Transfer-Encoding",
|
|
"Keep-Alive", "Upgrade", "Proxy-Authorization", "TE", "Trailer",
|
|
InstanceRouting.HeaderName
|
|
};
|
|
|
|
public async Task ForwardAsync(
|
|
HttpContext context, ManagedInstance ziel, string pfad, CancellationToken ct)
|
|
{
|
|
var client = ClientFor(ziel.Directory);
|
|
|
|
using var anfrage = new HttpRequestMessage(
|
|
new HttpMethod(context.Request.Method),
|
|
pfad + context.Request.QueryString);
|
|
|
|
if (context.Request.ContentLength is > 0 || context.Request.Headers.ContentLength is null)
|
|
{
|
|
// Den Rumpf stroemen lassen statt ihn zu puffern: Eine hochgeladene Datei
|
|
// muss nicht erst vollstaendig im Manager liegen, bevor die Instanz sie sieht.
|
|
anfrage.Content = new StreamContent(context.Request.Body);
|
|
|
|
foreach (var (name, werte) in context.Request.Headers)
|
|
{
|
|
if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase))
|
|
anfrage.Content.Headers.TryAddWithoutValidation(name, werte.ToArray());
|
|
}
|
|
}
|
|
|
|
foreach (var (name, werte) in context.Request.Headers)
|
|
{
|
|
if (NotForwarded.Contains(name)) continue;
|
|
if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase)) continue;
|
|
|
|
anfrage.Headers.TryAddWithoutValidation(name, werte.ToArray());
|
|
}
|
|
|
|
HttpResponseMessage antwort;
|
|
|
|
try
|
|
{
|
|
antwort = await client.SendAsync(
|
|
anfrage, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
// Die Instanz antwortet nicht. 502 und nicht 500: Der Fehler liegt nicht hier,
|
|
// und das gehoert in die Antwort - sonst sucht jemand im Manager nach einem
|
|
// Problem, das in der Instanz steckt oder darin, dass sie gar nicht laeuft.
|
|
logger.LogWarning(ex,
|
|
"Instanz {Name} war für {Methode} {Pfad} nicht erreichbar.",
|
|
ziel.Name, context.Request.Method, pfad);
|
|
|
|
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
|
await context.Response.WriteAsJsonAsync(
|
|
new { fehler = $"Die Instanz '{ziel.Name}' antwortet nicht." }, ct);
|
|
|
|
return;
|
|
}
|
|
|
|
using (antwort)
|
|
{
|
|
context.Response.StatusCode = (int)antwort.StatusCode;
|
|
|
|
foreach (var (name, werte) in antwort.Headers)
|
|
{
|
|
if (!NotForwarded.Contains(name))
|
|
context.Response.Headers[name] = werte.ToArray();
|
|
}
|
|
|
|
foreach (var (name, werte) in antwort.Content.Headers)
|
|
{
|
|
if (!NotForwarded.Contains(name))
|
|
context.Response.Headers[name] = werte.ToArray();
|
|
}
|
|
|
|
// Kestrel bestimmt selbst, wie er stueckelt.
|
|
context.Response.Headers.Remove("transfer-encoding");
|
|
|
|
await antwort.Content.CopyToAsync(context.Response.Body, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #713: An MCP message for an instance — to its MCP socket, not its instance socket.
|
|
///
|
|
/// <para>This is the one forwarding that keeps <c>Authorization</c>: the MCP token is
|
|
/// what the instance checks, and the manager adds nothing of its own. Through the
|
|
/// 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)
|
|
{
|
|
HttpClient client;
|
|
lock (_lock)
|
|
{
|
|
var key = "mcp|" + ziel.Directory;
|
|
if (!_clients.TryGetValue(key, out client!))
|
|
_clients[key] = client = InstanceEndpoint.CreateMcpClient(ziel.Directory);
|
|
}
|
|
|
|
using var anfrage = new HttpRequestMessage(HttpMethod.Post, "/mcp")
|
|
{
|
|
Content = new StreamContent(context.Request.Body)
|
|
};
|
|
anfrage.Content.Headers.TryAddWithoutValidation("Content-Type", "application/json");
|
|
if (context.Request.Headers.Authorization.Count > 0)
|
|
anfrage.Headers.TryAddWithoutValidation("Authorization", context.Request.Headers.Authorization.ToString());
|
|
|
|
try
|
|
{
|
|
using var antwort = await client.SendAsync(anfrage, ct).ConfigureAwait(false);
|
|
context.Response.StatusCode = (int)antwort.StatusCode;
|
|
if (antwort.Content.Headers.ContentType is { } typ)
|
|
context.Response.ContentType = typ.ToString();
|
|
await antwort.Content.CopyToAsync(context.Response.Body, ct).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
logger.LogWarning(ex, "Instanz {Name} war für MCP nicht erreichbar.", ziel.Name);
|
|
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
|
}
|
|
}
|
|
|
|
private HttpClient ClientFor(string instanceDirectory)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_clients.TryGetValue(instanceDirectory, out var vorhanden))
|
|
return vorhanden;
|
|
|
|
var client = InstanceEndpoint.CreateClient(instanceDirectory);
|
|
|
|
// Grosszuegig: Hinter einer weitergeleiteten Anfrage kann ein Agentenlauf
|
|
// stecken, und der dauert, so lange er dauert. Der Ereignisstrom laeuft
|
|
// ohnehin nicht hierueber.
|
|
client.Timeout = TimeSpan.FromMinutes(10);
|
|
|
|
_clients[instanceDirectory] = client;
|
|
return client;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
foreach (var client in _clients.Values) client.Dispose();
|
|
_clients.Clear();
|
|
}
|
|
}
|
|
}
|