319 lines
14 KiB
C#
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);
|
|
}
|
|
}
|