forked from admin/VolcanicAgenticCore
fix(claude-code): disable built-in plugins and learn new ones from init
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.
This commit is contained in:
@@ -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 (`<name>@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
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
|
||||
/// <summary>
|
||||
/// The built-in Claude Code plugins every request disables through <c>enabledPlugins</c>. 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 <c>system/init</c> (the request is then replayed once).
|
||||
/// </summary>
|
||||
public sealed class BuiltinPlugins
|
||||
{
|
||||
public const string Suffix = "@builtin";
|
||||
|
||||
/// <summary>The built-ins known to the CLI versions this provider was tested with.</summary>
|
||||
public static readonly IReadOnlyList<string> 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",
|
||||
];
|
||||
|
||||
/// <summary>Process-wide: a built-in learned in one session is disabled for every later request.</summary>
|
||||
public static BuiltinPlugins Shared { get; } = new();
|
||||
|
||||
private readonly Lock _gate = new();
|
||||
private readonly SortedSet<string> _disabled;
|
||||
|
||||
public BuiltinPlugins(IEnumerable<string>? initial = null) =>
|
||||
_disabled = new SortedSet<string>(initial ?? Known, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>A sorted snapshot of the plugins to disable.</summary>
|
||||
public IReadOnlyList<string> Disabled
|
||||
{
|
||||
get { lock (_gate) return [.. _disabled]; }
|
||||
}
|
||||
|
||||
/// <summary>Adds built-in plugin ids; true when at least one was new.</summary>
|
||||
public bool Disable(IEnumerable<string> 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('\\');
|
||||
}
|
||||
@@ -14,6 +14,7 @@ namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
|
||||
/// <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>
|
||||
/// <param name="DisabledPlugins">Built-in plugins turned off in <c>--settings</c>; <see cref="BuiltinPlugins.Known"/> when null.</param>
|
||||
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<string>? DisabledPlugins = null);
|
||||
|
||||
/// <summary>Builds the exact <c>claude</c> argv and the allow-listed child environment.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The <c>--settings</c> JSON, with every plugin in <paramref name="disabledPlugins"/> set to false.</summary>
|
||||
public static string BuildSettings(IEnumerable<string> 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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
|
||||
@@ -46,6 +46,9 @@ public sealed record ClaudeCodeApiOptions
|
||||
|
||||
/// <summary>Timeouts and retry budget for the automatic login; the defaults when null.</summary>
|
||||
public ClaudeLoginOptions? LoginOptions { get; init; }
|
||||
|
||||
/// <summary>The built-in plugins every request disables; <see cref="BuiltinPlugins.Shared"/> when null.</summary>
|
||||
public BuiltinPlugins? BuiltinPlugins { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -167,12 +170,27 @@ public sealed class ClaudeCodeApi(SessionRegistry registry, ClaudeCodeApiOptions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Claude process run. Publishes its events and its terminal event, except that with
|
||||
/// <paramref name="deferLoginFailure"/> a login-required failure arriving before any content is returned unpublished
|
||||
/// (the held-back <c>Start</c> 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.
|
||||
/// </summary>
|
||||
private async Task<ClaudeCodeException?> RunAttemptAsync(Model model, LlmContext context, StreamOptions streamOptions,
|
||||
ChannelWriter<AssistantMessageEvent> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Claude process run. Publishes its events and its terminal event, except that with
|
||||
/// <paramref name="deferLoginFailure"/> a login-required failure arriving before any content is returned unpublished
|
||||
/// (the held-back <c>Start</c> is dropped) so the caller can log in and replay. With <paramref name="deferBuiltinPlugins"/>
|
||||
/// an init that loaded unexpected built-in plugins is returned unpublished the same way.
|
||||
/// </summary>
|
||||
private async Task<ClaudeCodeException?> RunProcessAsync(Model model, LlmContext context, StreamOptions streamOptions,
|
||||
ChannelWriter<AssistantMessageEvent> 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.
|
||||
|
||||
@@ -11,6 +11,12 @@ public sealed class ClaudeCodeException(string code, string message, bool retrya
|
||||
/// <summary>True when the CLI's subscription session expired and <c>claude auth login</c> would clear the failure.</summary>
|
||||
public bool LoginRequired { get; } = loginRequired;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> UnexpectedBuiltinPlugins { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The message the provider publishes: <c>[code] message</c>, except that a context overflow keeps its
|
||||
/// <c>context_length_exceeded:</c> prefix intact because the core's retry policy keys on it.
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
|
||||
/// <summary>The <c>source</c> ids of the loaded plugins when every one is a built-in, else null.</summary>
|
||||
private static List<string>? BuiltinSources(JsonElement record)
|
||||
{
|
||||
if (!record.TryGetProperty("plugins", out var plugins) || plugins.ValueKind != JsonValueKind.Array) return null;
|
||||
var sources = new List<string>();
|
||||
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 =>
|
||||
|
||||
@@ -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<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()
|
||||
{
|
||||
|
||||
@@ -47,8 +47,11 @@ public class ClaudeCodeApiTests : IDisposable
|
||||
return script;
|
||||
}
|
||||
|
||||
private readonly BuiltinPlugins _builtins = new();
|
||||
|
||||
private ClaudeCodeApi Api(string claudePath, Func<CancellationToken, Task<IUserInterface?>>? 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fake claude that loads <paramref name="plugin"/> unless <c>--settings</c> disables it (or always, with
|
||||
/// <paramref name="ignoreSettings"/>); without the plugin it proposes the probe tool.
|
||||
/// </summary>
|
||||
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<AssistantMessageEvent.Error>(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<AssistantMessageEvent.Error>(Assert.Single(events));
|
||||
Assert.Contains("unexpected customizations", error.Message.ErrorMessage);
|
||||
Assert.Equal(1, Runs());
|
||||
Assert.DoesNotContain("cc-plugin-new@market", _builtins.Disabled);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user