Files
Playa/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs

249 lines
12 KiB
C#

using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>Everything the argv builder needs for one request.</summary>
/// <param name="Model">The alias passed to <c>--model</c>.</param>
/// <param name="Effort">The <c>--effort</c> level, or null to omit the flag.</param>
/// <param name="SystemPromptPath">File handed to <c>--system-prompt-file</c>.</param>
/// <param name="CatalogPath">Tool catalogue for the bridge; null when the request carries no tools (empty MCP config).</param>
/// <param name="ViolationPath">Marker the bridge creates on a <c>tools/call</c>.</param>
/// <param name="ReadyPath">Marker the bridge creates after <c>tools/list</c>.</param>
/// <param name="BridgeDllPath">The bridge assembly <c>dotnet</c> runs.</param>
/// <param name="DotnetPath">The dotnet muxer.</param>
/// <param name="ServerName">The MCP server name; tools are exposed as <c>mcp__&lt;ServerName&gt;__&lt;alias&gt;</c>.</param>
public sealed record ClaudeArgsInput(
string Model,
string? Effort,
string SystemPromptPath,
string? CatalogPath = null,
string? ViolationPath = null,
string? ReadyPath = null,
string? BridgeDllPath = null,
string? DotnetPath = null,
string ServerName = ToolCatalog.DefaultServerName);
/// <summary>Builds the exact <c>claude</c> argv and the allow-listed child environment.</summary>
public static class ClaudeArgs
{
// Empty setting sources plus explicit settings preserve subscription authentication while suppressing user and
// project customisations. --bare would disable OAuth, and --safe-mode would disable the proposal MCP server.
// totalTokensReminder would otherwise append a changing reminder that breaks append-only cache reuse, and the
// built-in agents-md plugin loads regardless of setting sources unless disabled here.
public const string Settings = "{\"disableAllHooks\":true,\"autoMemoryEnabled\":false,\"totalTokensReminder\":\"off\",\"enabledPlugins\":{\"agents-md@builtin\":false}}";
public const string EmptyMcpConfig = "{\"mcpServers\":{}}";
public const string CatalogVariable = "VOLCANIC_CLAUDE_TOOL_CATALOG";
public const string ViolationVariable = "VOLCANIC_CLAUDE_TOOL_VIOLATION";
public const string ReadyVariable = "VOLCANIC_CLAUDE_TOOL_READY";
public const string MaxOutputTokensVariable = "CLAUDE_CODE_MAX_OUTPUT_TOKENS";
private static readonly JsonSerializerOptions ConfigOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
/// <summary>Variables forwarded from the parent environment (compared case-insensitively). <c>LC_*</c> is forwarded as a prefix.</summary>
public static readonly IReadOnlyList<string> AllowedVariables =
[
"HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "TMPDIR", "TMP", "TEMP",
// A relocated Claude Code configuration directory holds the login to use.
"CLAUDE_CONFIG_DIR",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
// The extra CA bundle a TLS-inspecting proxy requires.
"NODE_EXTRA_CA_CERTS",
"SystemRoot", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "ComSpec", "PATHEXT",
];
/// <summary>Variables that must never reach a Claude child: this provider is subscription-only.</summary>
public static readonly IReadOnlyList<string> DeniedVariables =
[
"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL",
"CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX",
];
/// <summary>The complete argv (without the executable), in the fixed order Claude Code is validated against.</summary>
public static IReadOnlyList<string> Build(ClaudeArgsInput input)
{
ArgumentNullException.ThrowIfNull(input);
if (string.IsNullOrWhiteSpace(input.Model)) throw new ArgumentException("A model alias is required.", nameof(input));
if (string.IsNullOrWhiteSpace(input.SystemPromptPath)) throw new ArgumentException("A system prompt file is required.", nameof(input));
var args = new List<string>
{
"-p",
"--setting-sources", "",
"--settings", Settings,
"--disable-slash-commands",
"--strict-mcp-config",
"--permission-mode", "dontAsk",
"--no-chrome",
"--no-session-persistence",
"--prompt-suggestions", "false",
"--mcp-config", BuildMcpConfig(input),
"--tools", "",
"--model", input.Model,
};
if (input.Effort is not null)
{
args.Add("--effort");
args.Add(input.Effort);
}
// Claude 5 and Opus 4.7+ return thinking blocks whose text is empty unless summarized display is requested.
args.Add("--thinking-display");
args.Add("summarized");
args.Add("--input-format");
args.Add("stream-json");
args.Add("--output-format");
args.Add("stream-json");
args.Add("--include-partial-messages");
args.Add("--verbose");
// The system prompt stays out of the argv, away from command-line size limits.
args.Add("--system-prompt-file");
args.Add(input.SystemPromptPath);
return args;
}
/// <summary>The <c>--mcp-config</c> JSON: the bridge as the only server, or no servers at all without tools.</summary>
public static string BuildMcpConfig(ClaudeArgsInput input)
{
ArgumentNullException.ThrowIfNull(input);
if (input.CatalogPath is null) return EmptyMcpConfig;
if (string.IsNullOrWhiteSpace(input.BridgeDllPath))
throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, "The proposal MCP bridge assembly was not located; tools cannot be exposed to Claude Code");
if (string.IsNullOrWhiteSpace(input.DotnetPath))
throw new ClaudeCodeException(ClaudeCodeException.Codes.McpStartup, "The dotnet muxer was not located; the proposal MCP bridge cannot be launched");
var env = new JsonObject { [CatalogVariable] = input.CatalogPath };
if (input.ViolationPath is not null) env[ViolationVariable] = input.ViolationPath;
if (input.ReadyPath is not null) env[ReadyVariable] = input.ReadyPath;
var config = new JsonObject
{
["mcpServers"] = new JsonObject
{
[input.ServerName] = new JsonObject
{
["command"] = input.DotnetPath,
["args"] = new JsonArray(input.BridgeDllPath),
["env"] = env,
},
},
};
return config.ToJsonString(ConfigOptions);
}
/// <summary>The name of a denied variable present in <paramref name="parentEnv"/>, or null.</summary>
public static string? FindDeniedVariable(IReadOnlyDictionary<string, string?> parentEnv)
{
ArgumentNullException.ThrowIfNull(parentEnv);
foreach (var (name, value) in parentEnv)
{
if (value is null) continue;
if (DeniedVariables.Any(denied => string.Equals(denied, name, StringComparison.OrdinalIgnoreCase))) return name;
}
return null;
}
/// <summary>
/// The complete environment a Claude child receives: the allow-listed parent variables plus the pinned ones.
/// Throws <c>auth</c> when the parent process carries API-key or third-party-provider authentication.
/// </summary>
public static IReadOnlyDictionary<string, string> BuildEnvironment(IReadOnlyDictionary<string, string?> parentEnv, int maxOutputTokens)
{
ArgumentNullException.ThrowIfNull(parentEnv);
if (FindDeniedVariable(parentEnv) is { } denied)
throw new ClaudeCodeException(ClaudeCodeException.Codes.Auth,
$"{denied} is set; the Claude Code provider only supports subscription authentication and never forwards API-key or third-party credentials. Unset it to use this provider");
return AllowListed(parentEnv, maxOutputTokens);
}
/// <summary>The allow-listed environment without the denial check (used by diagnostics that only probe the CLI).</summary>
public static IReadOnlyDictionary<string, string> AllowListed(IReadOnlyDictionary<string, string?> parentEnv, int? maxOutputTokens)
{
ArgumentNullException.ThrowIfNull(parentEnv);
var env = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (name, value) in parentEnv)
{
if (value is null) continue;
var allowed = name.StartsWith("LC_", StringComparison.Ordinal)
|| AllowedVariables.Any(a => string.Equals(a, name, StringComparison.OrdinalIgnoreCase));
if (allowed) env[name] = value;
}
env["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1";
// Claude Code collects git status, log and user.name at startup for a default system prompt this provider
// replaces. Run in the user's project, that status can execute a configured git clean filter.
env["CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS"] = "1";
env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1";
// The core owns compaction. Given a transcript near its limit, Claude Code would otherwise compact it itself.
env["DISABLE_COMPACT"] = "1";
if (maxOutputTokens is { } tokens) env[MaxOutputTokensVariable] = tokens.ToString(System.Globalization.CultureInfo.InvariantCulture);
return env;
}
/// <summary>The current process environment as a dictionary.</summary>
public static IReadOnlyDictionary<string, string?> CurrentEnvironment()
{
var env = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables())
env[(string)entry.Key] = entry.Value as string;
return env;
}
}
/// <summary>Finds the bridge assembly and the dotnet muxer that runs it.</summary>
public static class BridgeLocator
{
public const string BridgeFileName = "Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll";
/// <summary>The bridge dll next to the host, else next to this assembly; null when neither exists.</summary>
public static string? FindBridgeDll()
{
var candidates = new List<string> { Path.Combine(AppContext.BaseDirectory, BridgeFileName) };
var assemblyDirectory = SafeDirectory(typeof(BridgeLocator).Assembly.Location);
if (assemblyDirectory is not null) candidates.Add(Path.Combine(assemblyDirectory, BridgeFileName));
return candidates.FirstOrDefault(File.Exists);
}
/// <summary>The dotnet muxer: the current process when it is dotnet, else <c>dotnet</c> on PATH, else <c>DOTNET_ROOT/dotnet</c>.</summary>
public static string? FindDotnet(Func<string, string?> getEnv)
{
ArgumentNullException.ThrowIfNull(getEnv);
var processPath = Environment.ProcessPath;
if (processPath is not null)
{
var name = Path.GetFileNameWithoutExtension(processPath);
if (string.Equals(name, "dotnet", StringComparison.OrdinalIgnoreCase) && File.Exists(processPath)) return processPath;
}
var names = OperatingSystem.IsWindows() ? new[] { "dotnet.exe" } : new[] { "dotnet" };
foreach (var directory in (getEnv("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
foreach (var name in names)
{
var candidate = Path.Combine(directory, name);
if (File.Exists(candidate)) return candidate;
}
}
var root = getEnv("DOTNET_ROOT");
if (!string.IsNullOrWhiteSpace(root))
{
foreach (var name in names)
{
var candidate = Path.Combine(root, name);
if (File.Exists(candidate)) return candidate;
}
}
return null;
}
private static string? SafeDirectory(string location)
{
if (string.IsNullOrEmpty(location)) return null;
try
{
return Path.GetDirectoryName(location);
}
catch (ArgumentException)
{
return null;
}
}
}