forked from admin/VolcanicAgenticCore
- JobScheduler: a stored job whose zone lookup throws no longer fails StartAsync; timer re-armed under the lock; a delivery cut off by shutdown isn't recorded (one-offs fire again after restart); CancelJobAsync in one step - Schedules.ParseAt: ISO 8601 with a date only (no machine-clock date for a bare 09:00) - core: a FollowUp prompt that loses the race to a starting run joins it as a follow-up, not steering - Telegram: TimeZoneFor tolerates non-numeric targets; RunPromptAsync waits only when queued; one agent dir - ScriptRunner: bad workspace paths are Failed, not exceptions; zone id helpers share TimeZoneIds.ToIana's rule - tests: no wall-clock sleeps in scheduler tests; transcript-entry, disabled-scheduler and failing-prompt coverage - docs: spec notes for the above; README row for the scheduler
268 lines
13 KiB
C#
268 lines
13 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__<ServerName>__<alias></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";
|
|
|
|
/// <summary>The session's zone for the child (never inherited from the parent: <c>TZ</c> isn't on the allow-list).</summary>
|
|
public const string TimeZoneVariable = "TZ";
|
|
|
|
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, the pinned ones, and
|
|
/// <c>TZ</c> = <paramref name="timeZoneId"/> (the session's zone; not on Windows, which ignores it).
|
|
/// 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, string? timeZoneId = null)
|
|
{
|
|
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");
|
|
var env = new Dictionary<string, string>(AllowListed(parentEnv, maxOutputTokens), StringComparer.Ordinal);
|
|
if (!string.IsNullOrWhiteSpace(timeZoneId) && !OperatingSystem.IsWindows()) env[TimeZoneVariable] = timeZoneId;
|
|
return env;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The IANA id for <c>TZ</c>: the zone's own id or a converted Windows id, or null when there is none that resolves
|
|
/// back to a system zone (an unnamed <c>Local</c> zone). Mirrors the core's <c>TimeZoneIds.ToIana</c> (the extension
|
|
/// references only the Abstractions); keep the two in step.
|
|
/// </summary>
|
|
public static string? IanaZoneId(TimeZoneInfo zone)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(zone);
|
|
if (string.Equals(zone.Id, "Local", StringComparison.OrdinalIgnoreCase)) return null;
|
|
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
|
|
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|