Files
ClawdDotNet/src/ClawdDotNet.Core/Runtimes/ClaudeCodeRuntime.cs
T

319 lines
14 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Sandbox;
namespace ClawdDotNet.Core.Runtimes;
/// <summary>
/// #712: Claude Code as a runtime — <c>claude -p</c> in the sandbox.
///
/// <para><b>Allow lists, never the bypass.</b> Claude Code gets <c>--allowedTools</c> and
/// <c>--max-turns</c>; <c>--dangerously-skip-permissions</c> is never passed. In print mode
/// there is no one to ask, so what is not allowed is denied.</para>
///
/// <para><b>Only our configuration counts.</b> The work copy may carry a
/// <c>.claude/settings.json</c> or an <c>.mcp.json</c> — written by whoever wrote the
/// workspace. <c>--setting-sources user</c> reads only the settings this class writes into
/// the runtime's home before each run; <c>--strict-mcp-config</c> only the one MCP server,
/// ours. Hooks are off, and what a run left in the home that would instruct the next one
/// (agents, commands, skills, a CLAUDE.md) is removed before it starts.</para>
///
/// <para><b>Nothing secret on the command line.</b> The key goes through the launcher's
/// file; the prompt through stdin; the briefing through a file.</para>
/// </summary>
public sealed class ClaudeCodeRuntime(Func<ClaudeCodeSettings> settings) : IForeignRuntime
{
public string Kind => RuntimeKinds.ClaudeCode;
/// <summary>Reading and editing the work copy, and our MCP server. No shell, no web.</summary>
public static readonly IReadOnlyList<string> DefaultAllowedTools = ["Read", "Edit", "Write", "Glob", "Grep", "mcp__clawd"];
/// <summary>Denied even when listed — the web goes through our tools (#193), not around them.</summary>
public static readonly IReadOnlyList<string> AlwaysDenied = ["WebFetch", "WebSearch"];
/// <summary>The hosts Claude Code needs: the API. Updates and telemetry are switched off.</summary>
public static readonly IReadOnlyList<string> Egress = ["api.anthropic.com"];
private static readonly string[] Candidates =
[
"~/.local/bin/claude", "~/.claude/local/claude", "/usr/local/bin/claude", "/usr/bin/claude"
];
/// <summary>The program, as configured or where an installer puts it; links resolved.</summary>
public static string? Locate(ClaudeCodeSettings settings)
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var candidates = string.IsNullOrWhiteSpace(settings.Executable) ? Candidates : [settings.Executable];
foreach (var candidate in candidates)
{
var path = candidate.StartsWith("~/", StringComparison.Ordinal) ? Path.Combine(home, candidate[2..]) : candidate;
if (!File.Exists(path))
continue;
var target = new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true);
return target?.FullName ?? Path.GetFullPath(path);
}
return null;
}
public SandboxRequest Prepare(ForeignRunContext context)
{
var config = settings();
var executable = Locate(config)
?? throw new ForeignRunException("Claude Code is not installed here — set 'claudeCode.executable' in the instance configuration.");
var secret = config.OnSubscription ? config.OAuthToken : config.ApiKey;
if (string.IsNullOrWhiteSpace(secret))
throw new ForeignRunException(config.OnSubscription
? "No subscription token for Claude Code — run 'claude setup-token' and enter it in the instance settings."
: "No API key for Claude Code in the instance settings.");
var runtime = context.Agent.Runtime;
var allowed = (runtime.AllowedTools.Count > 0 ? runtime.AllowedTools : DefaultAllowedTools)
.Where(t => !AlwaysDenied.Contains(t, StringComparer.OrdinalIgnoreCase))
.Where(t => context.HasMcp || !t.StartsWith("mcp__", StringComparison.Ordinal))
.ToList();
PrepareHome(context.HomeDirectory, allowed);
File.WriteAllText(Path.Combine(context.PrivateDirectory, ForeignRunContext.BriefingFileName), Briefing(context));
File.WriteAllText(Path.Combine(context.PrivateDirectory, "mcp.json"), McpConfig(context));
var arguments = new List<string>
{
"-p",
"--output-format", "stream-json",
"--verbose",
"--max-turns", Math.Clamp(runtime.MaxTurns, 1, 500).ToString(System.Globalization.CultureInfo.InvariantCulture),
"--setting-sources", "user",
"--strict-mcp-config",
"--mcp-config", ForeignRunContext.Private + "/mcp.json",
"--append-system-prompt-file", ForeignRunContext.Private + "/" + ForeignRunContext.BriefingFileName,
"--allowedTools", string.Join(",", allowed),
"--disallowedTools", string.Join(",", AlwaysDenied)
};
if (ModelName(context.Agent.Model) is { } model)
arguments.AddRange(["--model", model]);
var environment = new Dictionary<string, string>(context.McpEnvironment(), StringComparer.Ordinal)
{
["DISABLE_AUTOUPDATER"] = "1",
["DISABLE_TELEMETRY"] = "1",
["DISABLE_ERROR_REPORTING"] = "1",
["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
};
var readOnly = new List<string>(config.ReadOnlyPaths.Concat(context.HelperPaths()));
var directory = Path.GetDirectoryName(executable)!;
if (!directory.StartsWith("/usr/", StringComparison.Ordinal))
readOnly.Add(directory);
return new SandboxRequest
{
Executable = executable,
Arguments = arguments,
WorkspacePath = context.WorkCopy,
ProtectedPaths = [],
ReadOnlyPaths = readOnly,
Mounts = context.Mounts(),
Environment = environment,
SecretEnvironment = new Dictionary<string, string>
{
[config.OnSubscription ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY"] = secret.Trim()
},
Egress = [.. Egress, .. runtime.Egress],
StandardInput = context.Message,
Limits = ForeignRunner.Limits(runtime)
};
}
/// <summary>
/// The home as this run finds it: our settings, and nothing a previous run left to
/// instruct this one. Login state and history stay.
/// </summary>
internal static void PrepareHome(string home, IReadOnlyList<string> allowed)
{
// A previous run could have made .claude a link to anywhere on the host — writing
// through it would put our file there. Links go; only a real folder is used.
var claude = Path.Combine(home, ".claude");
if (new DirectoryInfo(claude).LinkTarget is not null || File.Exists(claude))
DeleteEntry(claude);
Directory.CreateDirectory(claude);
foreach (var name in new[] { "agents", "commands", "skills", "hooks", "output-styles", "plugins" })
{
var path = Path.Combine(claude, name);
if (Directory.Exists(path) || File.Exists(path))
DeleteEntry(path);
}
foreach (var name in new[] { "CLAUDE.md", "settings.local.json" })
{
var path = Path.Combine(claude, name);
if (File.Exists(path) || Directory.Exists(path))
DeleteEntry(path);
}
var settings = new JsonObject
{
["permissions"] = new JsonObject
{
["allow"] = new JsonArray(allowed.Select(a => (JsonNode)a).ToArray()),
["deny"] = new JsonArray(AlwaysDenied.Select(a => (JsonNode)a).ToArray())
},
["disableAllHooks"] = true
};
var file = Path.Combine(claude, "settings.json");
if (File.Exists(file) || new FileInfo(file).LinkTarget is not null)
DeleteEntry(file);
File.WriteAllText(file, settings.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
}
/// <summary>Removes a file, a folder or a link — never what a link points to.</summary>
private static void DeleteEntry(string path)
{
var info = new FileInfo(path);
if (info.LinkTarget is not null || !Directory.Exists(path))
info.Delete();
else
Directory.Delete(path, recursive: true);
}
private static string Briefing(ForeignRunContext context)
=> context.Briefing + "\n\n## How this run works\n\n"
+ "You work in /work, a copy of your workspace. Nothing you change there is used directly: "
+ "the changed files become a proposal that is reviewed before anything is applied.\n"
+ (context.HasMcp
? "The taskboard, your colleagues and your other tools are reachable only through the MCP server 'clawd'.\n"
: "")
+ "End with a short summary of what you did and what is left.";
private static string McpConfig(ForeignRunContext context)
{
var servers = new JsonObject();
if (context.HasMcp)
{
var env = new JsonObject
{
[Mcp.McpStdioBridge.TokenFileVariable] = ForeignRunContext.Private + "/" + ForeignRunContext.TokenFileName
};
// An MCP server does not inherit the runtime's environment, only a few variables.
foreach (var (key, value) in context.Helper!.Environment)
env[key] = value;
servers["clawd"] = new JsonObject
{
["type"] = "stdio",
["command"] = context.Helper.Executable,
["args"] = new JsonArray("--mcp-bridge", Mcp.McpStdioBridge.SocketArgument, ForeignRunContext.McpSocketInSandbox),
["env"] = env
};
}
return new JsonObject { ["mcpServers"] = servers }.ToJsonString();
}
/// <summary><c>anthropic/claude-sonnet-5</c> (OpenRouter's name) → <c>claude-sonnet-5</c>; empty stays the runtime's default.</summary>
public static string? ModelName(string? model)
{
if (string.IsNullOrWhiteSpace(model))
return null;
var slash = model.IndexOf('/');
if (slash >= 0 && !model.StartsWith("anthropic/", StringComparison.OrdinalIgnoreCase))
return null;
return slash >= 0 ? model[(slash + 1)..] : model;
}
public ForeignRunOutcome Interpret(SandboxResult result, ForeignRunContext context, Action<string> note)
{
var onSubscription = settings().OnSubscription;
JsonObject? final = null;
string? model = null;
var turns = 0;
foreach (var line in result.StandardOutput.Split('\n'))
{
if (!line.StartsWith('{'))
continue;
JsonObject? message;
try
{
message = JsonNode.Parse(line) as JsonObject;
}
catch (JsonException)
{
continue;
}
switch (Text(message?["type"]))
{
case "system" when Text(message!["subtype"]) == "init":
model = Text(message["model"]);
break;
case "assistant":
turns++;
foreach (var part in message!["message"]?["content"] as JsonArray ?? [])
{
if (Text(part?["type"]) == "tool_use")
note($"Tool: {Text(part!["name"])}");
else if (Text(part?["type"]) == "text" && Text(part!["text"]) is { Length: > 0 } text)
note(text.Length > 500 ? text[..500] + " …" : text);
}
break;
case "result":
final = message;
break;
}
}
if (final is null)
{
var reason = result.TimedOut ? "timed out"
: result.OutputTruncated ? "produced more output than allowed"
: $"ended with exit code {result.ExitCode} and no result";
return new ForeignRunOutcome
{
Status = result.TimedOut ? AgentRunStatus.Cancelled : AgentRunStatus.Failed,
Text = $"[Claude Code {reason}]" + Tail(result.StandardError),
Turns = turns,
Model = model,
OnSubscription = onSubscription
};
}
var usage = final["usage"] as JsonObject;
var input = Number(usage?["input_tokens"]);
var cacheWrite = Number(usage?["cache_creation_input_tokens"]);
var cacheRead = Number(usage?["cache_read_input_tokens"]);
var subtype = Text(final["subtype"]) ?? "";
var isError = final["is_error"]?.GetValueKind() == JsonValueKind.True;
var status = subtype == "error_max_turns" ? AgentRunStatus.LoopLimitExceeded
: isError || subtype != "success" ? AgentRunStatus.Failed
: AgentRunStatus.Completed;
decimal? cost = onSubscription ? 0m : null;
if (!onSubscription && final["total_cost_usd"] is JsonValue value && value.TryGetValue<decimal>(out var usd))
cost = usd;
return new ForeignRunOutcome
{
Status = status,
Text = Text(final["result"]) ?? (status == AgentRunStatus.Completed ? "" : $"[Claude Code: {subtype}]"),
Turns = (int)Math.Max(turns, Number(final["num_turns"])),
PromptTokens = (int)(input + cacheWrite + cacheRead),
CompletionTokens = (int)Number(usage?["output_tokens"]),
CachedTokens = (int)cacheRead,
CostUsd = cost,
OnSubscription = onSubscription,
Model = model
};
}
private static string? Text(JsonNode? node)
=> node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
private static long Number(JsonNode? node)
=> node is JsonValue value && value.TryGetValue<long>(out var number) ? number : 0;
internal static string Tail(string stderr)
{
var text = stderr.Trim();
if (text.Length == 0)
return "";
return "\n" + (text.Length > 1000 ? "…" + text[^1000..] : text);
}
}