diff --git a/docs/claude-code-provider.md b/docs/claude-code-provider.md index c7f4030..4bb084b 100644 --- a/docs/claude-code-provider.md +++ b/docs/claude-code-provider.md @@ -58,7 +58,11 @@ conversation is replayed each time. call replays the transcript including the result. 6. The `system/init` record is validated strictly: the exposed tools, `permissionMode`, empty slash commands, `apiKeySource == "none"` (proof of subscription auth) and a connected `volcanic` MCP server. Anything else - fails closed. + fails closed, with one exception: built-in plugins (`@builtin`, such as `cc-plugin-*`) load whatever the + setting sources say, and which ones load depends on the CLI version and the account's feature flags. The + settings disable every known built-in (`BuiltinPlugins.Known`); when the init lists only built-ins that weren't + disabled, the provider adds them to the process-wide `BuiltinPlugins.Shared` set and replays the request once. + Nothing is published before the replay. A built-in that still loads, or any other plugin, fails the request. ## Session expiry and re-login diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/BuiltinPlugins.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/BuiltinPlugins.cs new file mode 100644 index 0000000..84b282b --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/BuiltinPlugins.cs @@ -0,0 +1,51 @@ +namespace Volcanic.AgenticCore.Extensions.ClaudeCode; + +/// +/// The built-in Claude Code plugins every request disables through enabledPlugins. Built-ins load whatever the +/// setting sources say, and which ones load depends on the CLI version and the account's feature flags, so the set +/// starts with the known ones and learns new ones from system/init (the request is then replayed once). +/// +public sealed class BuiltinPlugins +{ + public const string Suffix = "@builtin"; + + /// The built-ins known to the CLI versions this provider was tested with. + public static readonly IReadOnlyList Known = + [ + "agents-md@builtin", "telemetry@builtin", + "cc-plugin-agents-md@builtin", "cc-plugin-claude-test@builtin", "cc-plugin-diff@builtin", "cc-plugin-mermaid@builtin", + "cc-plugin-mods-guide@builtin", "cc-plugin-plugin-authoring@builtin", "cc-plugin-responsive-mode@builtin", + "cc-plugin-sec-default@builtin", "cc-plugin-telemetry@builtin", "cc-plugin-tips@builtin", "cc-plugin-you-should-know@builtin", + ]; + + /// Process-wide: a built-in learned in one session is disabled for every later request. + public static BuiltinPlugins Shared { get; } = new(); + + private readonly Lock _gate = new(); + private readonly SortedSet _disabled; + + public BuiltinPlugins(IEnumerable? initial = null) => + _disabled = new SortedSet(initial ?? Known, StringComparer.Ordinal); + + /// A sorted snapshot of the plugins to disable. + public IReadOnlyList Disabled + { + get { lock (_gate) return [.. _disabled]; } + } + + /// Adds built-in plugin ids; true when at least one was new. + public bool Disable(IEnumerable plugins) + { + ArgumentNullException.ThrowIfNull(plugins); + var added = false; + lock (_gate) + { + foreach (var plugin in plugins) + if (IsBuiltin(plugin)) added |= _disabled.Add(plugin); + } + return added; + } + + public static bool IsBuiltin(string? source) => + source is { Length: > 9 } s && s.EndsWith(Suffix, StringComparison.Ordinal) && !s.Contains('/') && !s.Contains('\\'); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs index 2a928ad..0accdea 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeArgs.cs @@ -14,6 +14,7 @@ namespace Volcanic.AgenticCore.Extensions.ClaudeCode; /// The bridge assembly dotnet runs. /// The dotnet muxer. /// The MCP server name; tools are exposed as mcp__<ServerName>__<alias>. +/// Built-in plugins turned off in --settings; when null. public sealed record ClaudeArgsInput( string Model, string? Effort, @@ -23,7 +24,8 @@ public sealed record ClaudeArgsInput( string? ReadyPath = null, string? BridgeDllPath = null, string? DotnetPath = null, - string ServerName = ToolCatalog.DefaultServerName); + string ServerName = ToolCatalog.DefaultServerName, + IReadOnlyList? DisabledPlugins = null); /// Builds the exact claude argv and the allow-listed child environment. public static class ClaudeArgs @@ -31,8 +33,8 @@ 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}}"; + // built-in plugins (agents-md, cc-plugin-*) load regardless of setting sources unless disabled here. + public static string Settings => BuildSettings(BuiltinPlugins.Known); public const string EmptyMcpConfig = "{\"mcpServers\":{}}"; public const string CatalogVariable = "VOLCANIC_CLAUDE_TOOL_CATALOG"; @@ -75,7 +77,7 @@ public static class ClaudeArgs { "-p", "--setting-sources", "", - "--settings", Settings, + "--settings", BuildSettings(input.DisabledPlugins ?? BuiltinPlugins.Known), "--disable-slash-commands", "--strict-mcp-config", "--permission-mode", "dontAsk", @@ -106,6 +108,22 @@ public static class ClaudeArgs return args; } + /// The --settings JSON, with every plugin in set to false. + public static string BuildSettings(IEnumerable disabledPlugins) + { + ArgumentNullException.ThrowIfNull(disabledPlugins); + var plugins = new JsonObject(); + foreach (var plugin in disabledPlugins) plugins[plugin] = false; + var settings = new JsonObject + { + ["disableAllHooks"] = true, + ["autoMemoryEnabled"] = false, + ["totalTokensReminder"] = "off", + ["enabledPlugins"] = plugins, + }; + return settings.ToJsonString(ConfigOptions); + } + /// The --mcp-config JSON: the bridge as the only server, or no servers at all without tools. public static string BuildMcpConfig(ClaudeArgsInput input) { diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs index 2fea0f4..13bf946 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeApi.cs @@ -46,6 +46,9 @@ public sealed record ClaudeCodeApiOptions /// Timeouts and retry budget for the automatic login; the defaults when null. public ClaudeLoginOptions? LoginOptions { get; init; } + + /// The built-in plugins every request disables; when null. + public BuiltinPlugins? BuiltinPlugins { get; init; } } /// @@ -167,12 +170,27 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions } /// - /// One Claude process run. Publishes its events and its terminal event, except that with - /// a login-required failure arriving before any content is returned unpublished - /// (the held-back Start is dropped) so the caller can log in and replay. + /// One request attempt. When Claude Code loads built-in plugins the provider doesn't disable yet (a new CLI release or + /// feature flag), nothing has been published: disable them for this host process and replay exactly once. /// private async Task RunAttemptAsync(Model model, LlmContext context, StreamOptions streamOptions, ChannelWriter writer, bool deferLoginFailure, CancellationToken ct) + { + var plugins = Options.BuiltinPlugins ?? BuiltinPlugins.Shared; + var failure = await RunProcessAsync(model, context, streamOptions, writer, deferLoginFailure, plugins, deferBuiltinPlugins: true, ct).ConfigureAwait(false); + if (failure is not { UnexpectedBuiltinPlugins.Count: > 0 } builtin) return failure; + plugins.Disable(builtin.UnexpectedBuiltinPlugins); + return await RunProcessAsync(model, context, streamOptions, writer, deferLoginFailure, plugins, deferBuiltinPlugins: false, ct).ConfigureAwait(false); + } + + /// + /// One Claude process run. Publishes its events and its terminal event, except that with + /// a login-required failure arriving before any content is returned unpublished + /// (the held-back Start is dropped) so the caller can log in and replay. With + /// an init that loaded unexpected built-in plugins is returned unpublished the same way. + /// + private async Task RunProcessAsync(Model model, LlmContext context, StreamOptions streamOptions, + ChannelWriter writer, bool deferLoginFailure, BuiltinPlugins plugins, bool deferBuiltinPlugins, CancellationToken ct) { var builder = new AssistantMessageBuilder(model); StreamEventMapper? mapper = null; @@ -256,7 +274,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions } var stdin = ContextSerializer.BuildStdinMessage(context, catalog, images, cacheBreakpoint: streamOptions.CacheRetention != CacheRetention.None); - var argv = ClaudeArgs.Build(new ClaudeArgsInput(model.Id, effort, systemPromptPath, catalogPath, violationPath, readyPath, Options.BridgeDllPath, Options.DotnetPath, Options.ServerName)); + var argv = ClaudeArgs.Build(new ClaudeArgsInput(model.Id, effort, systemPromptPath, catalogPath, violationPath, readyPath, Options.BridgeDllPath, Options.DotnetPath, Options.ServerName, plugins.Disabled)); var env = ClaudeArgs.BuildEnvironment(Options.ParentEnvironment ?? ClaudeArgs.CurrentEnvironment(), maxOutputTokens, Options.TimeZoneId?.Invoke()); if (streamOptions.OnPayload is not null) @@ -361,6 +379,7 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions var aborted = ct.IsCancellationRequested || ex is OperationCanceledException; if (process is not null) await process.TerminateAsync().ConfigureAwait(false); if (deferLoginFailure && !wroteAny && !aborted && ex is ClaudeCodeException { LoginRequired: true } login) return login; + if (deferBuiltinPlugins && !wroteAny && !aborted && ex is ClaudeCodeException { UnexpectedBuiltinPlugins.Count: > 0 } builtin) return builtin; if (responsePending) { // The request is already failing; an observer error must not keep the failure from being published. diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs index a64bc1c..02c9164 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ClaudeCodeException.cs @@ -11,6 +11,12 @@ public sealed class ClaudeCodeException(string code, string message, bool retrya /// True when the CLI's subscription session expired and claude auth login would clear the failure. public bool LoginRequired { get; } = loginRequired; + /// + /// Built-in plugins Claude Code loaded although the request didn't expect them (and nothing else unexpected); the + /// provider disables them and replays the request once. + /// + public IReadOnlyList UnexpectedBuiltinPlugins { get; init; } = []; + /// /// The message the provider publishes: [code] message, except that a context overflow keeps its /// context_length_exceeded: prefix intact because the core's retry policy keys on it. diff --git a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs index b70f862..aa411d2 100644 --- a/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs +++ b/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/StreamEventMapper.cs @@ -195,7 +195,14 @@ public sealed partial class StreamEventMapper if (record.TryGetProperty(field, out var inventory) && inventory.ValueKind == JsonValueKind.Array && inventory.GetArrayLength() > 0) loaded.Add($"{field}: {InventoryNames(inventory)}"); } - if (loaded.Count > 0) throw Init($"Claude Code loaded unexpected customizations ({string.Join("; ", loaded)})"); + if (loaded.Count > 0) + { + var message = $"Claude Code loaded unexpected customizations ({string.Join("; ", loaded)})"; + // Only built-in plugins: the provider can disable them and replay. Anything else fails closed for good. + if (loaded.Count == 1 && BuiltinSources(record) is { Count: > 0 } builtins) + throw new ClaudeCodeException(ClaudeCodeException.Codes.ProtocolInit, message) { UnexpectedBuiltinPlugins = builtins }; + throw Init(message); + } var apiKeySource = Str(record, "apiKeySource"); if (apiKeySource != "none") @@ -735,6 +742,20 @@ public sealed partial class StreamEventMapper return s.Length == 0 ? t : $"{t}/{s}"; } + /// The source ids of the loaded plugins when every one is a built-in, else null. + private static List? BuiltinSources(JsonElement record) + { + if (!record.TryGetProperty("plugins", out var plugins) || plugins.ValueKind != JsonValueKind.Array) return null; + var sources = new List(); + foreach (var plugin in plugins.EnumerateArray()) + { + var source = plugin.ValueKind == JsonValueKind.Object ? Str(plugin, "source") : null; + if (!BuiltinPlugins.IsBuiltin(source) || Str(plugin, "path") != "builtin") return null; + sources.Add(source!); + } + return sources; + } + private static string InventoryNames(JsonElement entries) { var names = entries.EnumerateArray().Take(5).Select(entry => diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs index 0cf144f..3e0124b 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeArgsTests.cs @@ -20,7 +20,7 @@ public class ClaudeArgsTests [ "-p", "--setting-sources", "", - "--settings", "{\"disableAllHooks\":true,\"autoMemoryEnabled\":false,\"totalTokensReminder\":\"off\",\"enabledPlugins\":{\"agents-md@builtin\":false}}", + "--settings", ClaudeArgs.Settings, "--disable-slash-commands", "--strict-mcp-config", "--permission-mode", "dontAsk", @@ -66,6 +66,29 @@ public class ClaudeArgsTests Assert.DoesNotContain("--effort", args); } + [Fact] + public void Settings_disable_hooks_memory_and_every_known_builtin_plugin() + { + var settings = JsonSerializer.Deserialize(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(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() { diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs index 8e2f913..6d7b9db 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/ClaudeCode/ClaudeCodeApiTests.cs @@ -47,8 +47,11 @@ public class ClaudeCodeApiTests : IDisposable return script; } + private readonly BuiltinPlugins _builtins = new(); + private ClaudeCodeApi Api(string claudePath, Func>? loginUi = null, ClaudeLoginCoordinator? coordinator = null) => new(_registry, new ClaudeCodeApiOptions { + BuiltinPlugins = _builtins, LoginUi = loginUi, Coordinator = coordinator ?? new ClaudeLoginCoordinator(), ClaudePath = claudePath, @@ -571,4 +574,69 @@ public class ClaudeCodeApiTests : IDisposable Assert.Equal([0], responses); Assert.Equal(0, _loginRequests); } + + /// + /// A fake claude that loads unless --settings disables it (or always, with + /// ); without the plugin it proposes the probe tool. + /// + private string PluginClaude(string plugin, bool ignoreSettings = false) + { + var state = Path.Combine(_root, "state"); + Directory.CreateDirectory(state); + var init = Fixtures.ProbeInit().GetRawText().Replace("\"plugins\":[]", $"\"plugins\":[{plugin}]", StringComparison.Ordinal); + return FakeClaude( + $"echo run >> \"{state}/runs\"\n" + + "settings=\"\"\nprev=\"\"\n" + + "for a in \"$@\"; do if [ \"$prev\" = \"--settings\" ]; then settings=\"$a\"; fi; prev=\"$a\"; done\n" + + (ignoreSettings ? "" : "case \"$settings\" in *'\"cc-plugin-new@builtin\":false'*) " + + $"cat \"{Fixtures.FixturePath("claude-2.1.281-stream-tool-ok.jsonl")}\"; exit 0;; esac\n") + + $"echo '{init}'", + tail: "exit 0"); + } + + private const string NewBuiltin = "{\"name\":\"cc-plugin-new\",\"path\":\"builtin\",\"source\":\"cc-plugin-new@builtin\"}"; + + [Fact] + public async Task An_unexpected_builtin_plugin_is_disabled_and_the_request_replayed_once() + { + if (!Fixtures.IsPosix) return; + var api = Api(PluginClaude(NewBuiltin)); + + var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None)); + + Assert.Equal(["Start", "TextStart", "TextDelta", "TextEnd", "ToolCallStart", "ToolCallDelta", "ToolCallEnd", "Done"], events.Select(e => e.GetType().Name).ToArray()); + Assert.Equal(2, Runs()); + Assert.Contains("cc-plugin-new@builtin", _builtins.Disabled); + + // Learned for the process: the next request disables it from the start. + await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None)); + Assert.Equal(3, Runs()); + } + + [Fact] + public async Task A_builtin_plugin_that_stays_loaded_fails_after_one_replay() + { + if (!Fixtures.IsPosix) return; + var api = Api(PluginClaude(NewBuiltin, ignoreSettings: true)); + + var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None)); + + var error = Assert.IsType(Assert.Single(events)); + Assert.Contains("[protocol_init] Claude Code loaded unexpected customizations (plugins: cc-plugin-new)", error.Message.ErrorMessage); + Assert.Equal(2, Runs()); + } + + [Fact] + public async Task A_non_builtin_plugin_fails_closed_without_a_replay() + { + if (!Fixtures.IsPosix) return; + var api = Api(PluginClaude("{\"name\":\"cc-plugin-new\",\"path\":\"/home/u/.claude/plugins/x\",\"source\":\"cc-plugin-new@market\"}")); + + var events = await Collect(api.StreamAsync(Fixtures.Sonnet, ToolContext(), new StreamOptions { SessionId = "session-1" }, CancellationToken.None)); + + var error = Assert.IsType(Assert.Single(events)); + Assert.Contains("unexpected customizations", error.Message.ErrorMessage); + Assert.Equal(1, Runs()); + Assert.DoesNotContain("cc-plugin-new@market", _builtins.Disabled); + } }