forked from admin/VolcanicAgenticCore
Claude Code 2.1.287 loads cc-plugin-* built-ins regardless of --setting-sources, which tripped the fail-closed init check. Disable every known built-in in --settings, and when init lists only built-ins that slipped through, disable them process-wide and replay the request once. Other plugins still fail closed.
188 lines
8.7 KiB
C#
188 lines
8.7 KiB
C#
using System.Text.Json;
|
|
using Volcanic.AgenticCore.Extensions.ClaudeCode;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.Tests.ClaudeCode;
|
|
|
|
public class ClaudeArgsTests
|
|
{
|
|
[Fact]
|
|
public void Build_with_tools_and_effort_yields_the_exact_argv()
|
|
{
|
|
var input = new ClaudeArgsInput(
|
|
"sonnet", "medium", "/tmp/private/system-prompt.txt",
|
|
CatalogPath: "/tmp/private/tools.json", ViolationPath: "/tmp/private/mcp-violation", ReadyPath: "/tmp/private/mcp-ready",
|
|
BridgeDllPath: "/opt/ext/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll", DotnetPath: "/usr/bin/dotnet");
|
|
|
|
var args = ClaudeArgs.Build(input);
|
|
|
|
var mcp = ClaudeArgs.BuildMcpConfig(input);
|
|
string[] expected =
|
|
[
|
|
"-p",
|
|
"--setting-sources", "",
|
|
"--settings", ClaudeArgs.Settings,
|
|
"--disable-slash-commands",
|
|
"--strict-mcp-config",
|
|
"--permission-mode", "dontAsk",
|
|
"--no-chrome",
|
|
"--no-session-persistence",
|
|
"--prompt-suggestions", "false",
|
|
"--mcp-config", mcp,
|
|
"--tools", "",
|
|
"--model", "sonnet",
|
|
"--effort", "medium",
|
|
"--thinking-display", "summarized",
|
|
"--input-format", "stream-json",
|
|
"--output-format", "stream-json",
|
|
"--include-partial-messages",
|
|
"--verbose",
|
|
"--system-prompt-file", "/tmp/private/system-prompt.txt",
|
|
];
|
|
Assert.Equal(expected, args);
|
|
|
|
var server = JsonSerializer.Deserialize<JsonElement>(mcp).GetProperty("mcpServers").GetProperty("volcanic");
|
|
Assert.Equal("/usr/bin/dotnet", server.GetProperty("command").GetString());
|
|
Assert.Equal(["/opt/ext/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll"], server.GetProperty("args").EnumerateArray().Select(a => a.GetString()!).ToArray());
|
|
var env = server.GetProperty("env");
|
|
Assert.Equal("/tmp/private/tools.json", env.GetProperty("VOLCANIC_CLAUDE_TOOL_CATALOG").GetString());
|
|
Assert.Equal("/tmp/private/mcp-violation", env.GetProperty("VOLCANIC_CLAUDE_TOOL_VIOLATION").GetString());
|
|
Assert.Equal("/tmp/private/mcp-ready", env.GetProperty("VOLCANIC_CLAUDE_TOOL_READY").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_without_tools_or_effort_uses_an_empty_mcp_config_and_omits_effort()
|
|
{
|
|
var args = ClaudeArgs.Build(new ClaudeArgsInput("haiku", null, "/tmp/p/system-prompt.txt"));
|
|
|
|
string[] expected =
|
|
[
|
|
"-p", "--setting-sources", "", "--settings", ClaudeArgs.Settings, "--disable-slash-commands", "--strict-mcp-config",
|
|
"--permission-mode", "dontAsk", "--no-chrome", "--no-session-persistence", "--prompt-suggestions", "false",
|
|
"--mcp-config", "{\"mcpServers\":{}}", "--tools", "", "--model", "haiku",
|
|
"--thinking-display", "summarized", "--input-format", "stream-json", "--output-format", "stream-json",
|
|
"--include-partial-messages", "--verbose", "--system-prompt-file", "/tmp/p/system-prompt.txt",
|
|
];
|
|
Assert.Equal(expected, args);
|
|
Assert.DoesNotContain("--effort", args);
|
|
}
|
|
|
|
[Fact]
|
|
public void Settings_disable_hooks_memory_and_every_known_builtin_plugin()
|
|
{
|
|
var settings = JsonSerializer.Deserialize<JsonElement>(ClaudeArgs.Settings);
|
|
|
|
Assert.True(settings.GetProperty("disableAllHooks").GetBoolean());
|
|
Assert.False(settings.GetProperty("autoMemoryEnabled").GetBoolean());
|
|
Assert.Equal("off", settings.GetProperty("totalTokensReminder").GetString());
|
|
var plugins = settings.GetProperty("enabledPlugins").EnumerateObject().ToList();
|
|
Assert.Equal(BuiltinPlugins.Known.Order(StringComparer.Ordinal), plugins.Select(p => p.Name).Order(StringComparer.Ordinal));
|
|
Assert.All(plugins, p => Assert.Equal(JsonValueKind.False, p.Value.ValueKind));
|
|
Assert.Contains("cc-plugin-plugin-authoring@builtin", plugins.Select(p => p.Name));
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_disables_the_given_plugins()
|
|
{
|
|
var args = ClaudeArgs.Build(new ClaudeArgsInput("haiku", null, "/tmp/p/system-prompt.txt", DisabledPlugins: ["x@builtin"]));
|
|
|
|
var settings = JsonSerializer.Deserialize<JsonElement>(args[args.ToList().IndexOf("--settings") + 1]);
|
|
Assert.Equal(["x@builtin"], settings.GetProperty("enabledPlugins").EnumerateObject().Select(p => p.Name));
|
|
}
|
|
|
|
[Fact]
|
|
public void Mcp_config_honours_the_server_name_and_requires_bridge_and_dotnet()
|
|
{
|
|
var mcp = ClaudeArgs.BuildMcpConfig(new ClaudeArgsInput("sonnet", null, "/p/s.txt", CatalogPath: "/p/tools.json", BridgeDllPath: "/b.dll", DotnetPath: "dotnet", ServerName: "pi"));
|
|
Assert.True(JsonSerializer.Deserialize<JsonElement>(mcp).GetProperty("mcpServers").TryGetProperty("pi", out _));
|
|
|
|
var missingBridge = Assert.Throws<ClaudeCodeException>(() => ClaudeArgs.BuildMcpConfig(new ClaudeArgsInput("sonnet", null, "/p/s.txt", CatalogPath: "/p/tools.json", DotnetPath: "dotnet")));
|
|
Assert.Equal("mcp_startup", missingBridge.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildEnvironment_allow_lists_forces_and_rejects()
|
|
{
|
|
var parent = new Dictionary<string, string?>
|
|
{
|
|
["HOME"] = "/home/me",
|
|
["PATH"] = "/usr/bin",
|
|
["LANG"] = "C.UTF-8",
|
|
["LC_ALL"] = "C",
|
|
["LC_CTYPE"] = "C.UTF-8",
|
|
["http_proxy"] = "http://proxy",
|
|
["CLAUDE_CONFIG_DIR"] = "/cfg",
|
|
["SECRET_TOKEN"] = "nope",
|
|
["NPM_TOKEN"] = "nope",
|
|
["DISABLE_COMPACT"] = "0",
|
|
["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] = "1",
|
|
["NULL_VALUE"] = null,
|
|
};
|
|
|
|
var env = ClaudeArgs.BuildEnvironment(parent, 64_000);
|
|
|
|
Assert.Equal("/home/me", env["HOME"]);
|
|
Assert.Equal("/usr/bin", env["PATH"]);
|
|
Assert.Equal("C", env["LC_ALL"]);
|
|
Assert.Equal("C.UTF-8", env["LC_CTYPE"]);
|
|
Assert.Equal("http://proxy", env["http_proxy"]);
|
|
Assert.Equal("/cfg", env["CLAUDE_CONFIG_DIR"]);
|
|
Assert.False(env.ContainsKey("SECRET_TOKEN"));
|
|
Assert.False(env.ContainsKey("NPM_TOKEN"));
|
|
Assert.False(env.ContainsKey("NULL_VALUE"));
|
|
Assert.Equal("1", env["CLAUDE_CODE_DISABLE_AUTO_MEMORY"]);
|
|
Assert.Equal("1", env["CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS"]);
|
|
Assert.Equal("1", env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"]);
|
|
Assert.Equal("1", env["DISABLE_COMPACT"]);
|
|
Assert.Equal("64000", env["CLAUDE_CODE_MAX_OUTPUT_TOKENS"]);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ANTHROPIC_API_KEY")]
|
|
[InlineData("ANTHROPIC_AUTH_TOKEN")]
|
|
[InlineData("ANTHROPIC_BASE_URL")]
|
|
[InlineData("CLAUDE_CODE_OAUTH_TOKEN")]
|
|
[InlineData("CLAUDE_CODE_USE_BEDROCK")]
|
|
[InlineData("claude_code_use_vertex")]
|
|
public void BuildEnvironment_rejects_api_key_and_third_party_auth(string name)
|
|
{
|
|
var parent = new Dictionary<string, string?> { ["HOME"] = "/home/me", [name] = "value" };
|
|
var ex = Assert.Throws<ClaudeCodeException>(() => ClaudeArgs.BuildEnvironment(parent, 1000));
|
|
Assert.Equal("auth", ex.Code);
|
|
Assert.Contains(name, ex.Message);
|
|
Assert.Equal(name, ClaudeArgs.FindDeniedVariable(parent));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildEnvironment_sets_TZ_from_the_zone_id()
|
|
{
|
|
if (OperatingSystem.IsWindows()) return; // Windows ignores TZ; see BuildEnvironment_never_sets_TZ_on_Windows
|
|
var env = ClaudeArgs.BuildEnvironment(new Dictionary<string, string?> { ["PATH"] = "/usr/bin" }, 1000, "Asia/Tokyo");
|
|
Assert.Equal("Asia/Tokyo", env["TZ"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildEnvironment_without_a_zone_leaves_TZ_unset_and_never_copies_the_parents()
|
|
{
|
|
var parent = new Dictionary<string, string?> { ["PATH"] = "/usr/bin", ["TZ"] = "America/Chicago" };
|
|
Assert.False(ClaudeArgs.BuildEnvironment(parent, 1000).ContainsKey("TZ"));
|
|
Assert.False(ClaudeArgs.AllowListed(parent, null).ContainsKey("TZ"));
|
|
var withZone = ClaudeArgs.BuildEnvironment(parent, 1000, "Asia/Tokyo");
|
|
Assert.NotEqual("America/Chicago", withZone.GetValueOrDefault("TZ"));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildEnvironment_never_sets_TZ_on_Windows()
|
|
{
|
|
if (!OperatingSystem.IsWindows()) return;
|
|
Assert.False(ClaudeArgs.BuildEnvironment(new Dictionary<string, string?>(), 1000, "Asia/Tokyo").ContainsKey("TZ"));
|
|
}
|
|
|
|
[Fact]
|
|
public void IanaZoneId_converts_and_rejects_Local()
|
|
{
|
|
Assert.Equal("Asia/Tokyo", ClaudeArgs.IanaZoneId(TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo")));
|
|
Assert.Equal("Asia/Tokyo", ClaudeArgs.IanaZoneId(TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time")));
|
|
Assert.Null(ClaudeArgs.IanaZoneId(TimeZoneInfo.CreateCustomTimeZone("Local", TimeSpan.FromHours(1), "Local", "Local")));
|
|
}
|
|
}
|