From b65f63340dca1ee2ec1c2f9533d6922c818cd687 Mon Sep 17 00:00:00 2001 From: robert Date: Sun, 24 May 2026 22:14:50 +1000 Subject: [PATCH 01/26] Initial AIAgent Calamari --- .../Calamari.AiAgent.Tests.csproj | 22 ++++++++++++ .../CommandResolutionTests.cs | 34 +++++++++++++++++++ .../Calamari.AiAgent/Calamari.AiAgent.csproj | 18 ++++++++++ source/Calamari.AiAgent/Program.cs | 18 ++++++++++ source/Calamari.AiAgent/RunAgentCommand.cs | 15 ++++++++ .../BuildableCalamariProjects.cs | 1 + source/Calamari.sln | 11 ++++++ 7 files changed, 119 insertions(+) create mode 100644 source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj create mode 100644 source/Calamari.AiAgent.Tests/CommandResolutionTests.cs create mode 100644 source/Calamari.AiAgent/Calamari.AiAgent.csproj create mode 100644 source/Calamari.AiAgent/Program.cs create mode 100644 source/Calamari.AiAgent/RunAgentCommand.cs diff --git a/source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj b/source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj new file mode 100644 index 0000000000..169bd68cc4 --- /dev/null +++ b/source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj @@ -0,0 +1,22 @@ + + + Calamari.AiAgent.Tests + Calamari.AiAgent.Tests + net8.0 + win-x64;linux-x64;osx-x64;linux-arm;linux-arm64 + false + true + + + + + + + + + + + + + + diff --git a/source/Calamari.AiAgent.Tests/CommandResolutionTests.cs b/source/Calamari.AiAgent.Tests/CommandResolutionTests.cs new file mode 100644 index 0000000000..187c6947ca --- /dev/null +++ b/source/Calamari.AiAgent.Tests/CommandResolutionTests.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using Autofac; +using Calamari.Testing; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests; + +[TestFixture] +public class CommandResolutionTests +{ + [Test] + [Category("PlatformAgnostic")] + public void AllPipelineCommandsCanBeConstructed() + { + var program = TestablePipelineProgram.For(); + using var container = program.BuildTestContainer(); + + var failures = new List(); + foreach (var type in program.PipelineCommandTypes) + { + try + { + container.Resolve(type); + } + catch (Exception ex) + { + failures.Add($"'{type.Name}': {ex.Message}"); + } + } + + Assert.That(failures, Is.Empty, "all pipeline commands must be constructable from the DI container"); + } +} diff --git a/source/Calamari.AiAgent/Calamari.AiAgent.csproj b/source/Calamari.AiAgent/Calamari.AiAgent.csproj new file mode 100644 index 0000000000..1670aee07b --- /dev/null +++ b/source/Calamari.AiAgent/Calamari.AiAgent.csproj @@ -0,0 +1,18 @@ + + + + Calamari.AiAgent + Calamari.AiAgent + Exe + enable + win-x64;linux-x64;osx-x64;linux-arm;linux-arm64 + false + net8.0 + true + + + + + + + diff --git a/source/Calamari.AiAgent/Program.cs b/source/Calamari.AiAgent/Program.cs new file mode 100644 index 0000000000..0838d28707 --- /dev/null +++ b/source/Calamari.AiAgent/Program.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using Calamari.Common; +using Calamari.Common.Plumbing.Logging; + +namespace Calamari.AiAgent +{ + public class Program : CalamariFlavourProgramAsync + { + public Program(ILog log) : base(log) + { + } + + public static Task Main(string[] args) + { + return new Program(ConsoleLog.Instance).Run(args); + } + } +} diff --git a/source/Calamari.AiAgent/RunAgentCommand.cs b/source/Calamari.AiAgent/RunAgentCommand.cs new file mode 100644 index 0000000000..77cbf05fe2 --- /dev/null +++ b/source/Calamari.AiAgent/RunAgentCommand.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Pipeline; + +namespace Calamari.AiAgent +{ + [Command("run-agent", Description = "Invokes an AI agent")] + public class RunAgentCommand : PipelineCommand + { + protected override IEnumerable Deploy(DeployResolver resolver) + { + yield break; + } + } +} diff --git a/source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs b/source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs index 81514fa5da..049282a137 100644 --- a/source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs +++ b/source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs @@ -14,6 +14,7 @@ public static string[] GetCalamariProjectsToBuild(bool isWindows) static readonly string[] NonWindows = [ "Calamari", + "Calamari.AiAgent", "Calamari.AzureAppService", "Calamari.AzureResourceGroup", "Calamari.GoogleCloudScripting", diff --git a/source/Calamari.sln b/source/Calamari.sln index c776abd646..7272d52af0 100644 --- a/source/Calamari.sln +++ b/source/Calamari.sln @@ -87,6 +87,9 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.Contracts", "Calamari.Contracts\Calamari.Contracts.csproj", "{13583496-C3D2-4ADE-9087-65583326C469}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.DockerCredentialHelper", "Calamari.DockerCredentialHelper\Calamari.DockerCredentialHelper.csproj", "{B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.AiAgent", "Calamari.AiAgent\Calamari.AiAgent.csproj", "{767EB703-FF66-4955-9AE2-322A93FB69EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Calamari.AiAgent.Tests", "Calamari.AiAgent.Tests\Calamari.AiAgent.Tests.csproj", "{8D3FCBF5-369E-44B3-BD72-4C11E8058027}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -232,6 +235,14 @@ Global {B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}.Debug|Any CPU.Build.0 = Debug|Any CPU {B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}.Release|Any CPU.ActiveCfg = Release|Any CPU {B34DBEEC-7AC2-4BFE-ACDD-1788828925BD}.Release|Any CPU.Build.0 = Release|Any CPU + {767EB703-FF66-4955-9AE2-322A93FB69EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {767EB703-FF66-4955-9AE2-322A93FB69EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {767EB703-FF66-4955-9AE2-322A93FB69EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {767EB703-FF66-4955-9AE2-322A93FB69EE}.Release|Any CPU.Build.0 = Release|Any CPU + {8D3FCBF5-369E-44B3-BD72-4C11E8058027}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8D3FCBF5-369E-44B3-BD72-4C11E8058027}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8D3FCBF5-369E-44B3-BD72-4C11E8058027}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8D3FCBF5-369E-44B3-BD72-4C11E8058027}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 93c4ad47eb818dcfef5535080b221c0160e59656 Mon Sep 17 00:00:00 2001 From: robert Date: Thu, 28 May 2026 21:19:28 +1000 Subject: [PATCH 02/26] Add OpenAI provider support and multi-provider architecture Introduce provider selection (Anthropic/OpenAI) via variables, add Microsoft.Extensions.AI.OpenAI bridge package, and bump MEAI packages to 10.5.0 for ModelContextProtocol 1.3.0 compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../RunAgentCommandFixture.cs | 84 ++++++ .../Behaviours/InvokeAgentBehaviour.cs | 197 +++++++++++++ .../Calamari.AiAgent/Calamari.AiAgent.csproj | 8 + source/Calamari.AiAgent/LineBuffer.cs | 47 +++ source/Calamari.AiAgent/RunAgentCommand.cs | 3 +- source/Calamari.AiAgent/SpecialVariables.cs | 21 ++ .../2026-05-24-aiagent-claude-invocation.md | 237 +++++++++++++++ .../plans/2026-05-24-calamari-ai-agent.md | 271 ++++++++++++++++++ 8 files changed, 867 insertions(+), 1 deletion(-) create mode 100644 source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs create mode 100644 source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs create mode 100644 source/Calamari.AiAgent/LineBuffer.cs create mode 100644 source/Calamari.AiAgent/SpecialVariables.cs create mode 100644 source/docs/superpowers/plans/2026-05-24-aiagent-claude-invocation.md create mode 100644 source/docs/superpowers/plans/2026-05-24-calamari-ai-agent.md diff --git a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs new file mode 100644 index 0000000000..c5d7b5c9f8 --- /dev/null +++ b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; +using Calamari.Testing; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests; + +[TestFixture] +public class RunAgentCommandFixture +{ + [Test] + [Category("PlatformAgnostic")] + public async Task FailsWhenPromptVariableIsMissing() + { + var result = await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, "fake-api-token"); + }) + .Execute(assertWasSuccess: false); + + result.WasSuccessful.Should().BeFalse(); + result.FullLog.Should().Contain(SpecialVariables.Action.AiAgent.Prompt); + } + + [Test] + [Category("PlatformAgnostic")] + public async Task FailsWhenApiTokenVariableIsMissing() + { + var result = await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "Hello"); + }) + .Execute(assertWasSuccess: false); + + result.WasSuccessful.Should().BeFalse(); + result.FullLog.Should().Contain(SpecialVariables.Action.AiAgent.ApiToken); + } + + + + [Test] + [Category("PlatformAgnostic")] + public async Task SucceedsWhenPromptProvided() + { + var result = await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "What is the capital of France?"); + context.Variables.Add(SpecialVariables.Action.AiAgent.Model, "claude-sonnet-4-6"); + context.Variables.Add(SpecialVariables.Action.AiAgent.MaxTokens, "1000"); + context.Variables.Add(SpecialVariables.Action.AiAgent.Provider, "Anthropic"); + context.Variables.Add(SpecialVariables.Action.AiAgent.OctopusToken, Environment.GetEnvironmentVariable("OCTOPUS_TOKEN")); + }) + .Execute(assertWasSuccess: false); + + result.WasSuccessful.Should().BeTrue(); + result.FullLog.Should().Contain("Paris"); + } + + + [Test] + [Category("PlatformAgnostic")] + public async Task FailsTryingToUseToolNotPresent() + { + var result = await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "Get the current news item on the front page of the New York Times."); + context.Variables.Add(SpecialVariables.Action.AiAgent.Model, "claude-sonnet-4-6"); + context.Variables.Add(SpecialVariables.Action.AiAgent.MaxTokens, "1000"); + context.Variables.Add(SpecialVariables.Action.AiAgent.Provider, "Anthropic"); + context.Variables.Add(SpecialVariables.Action.AiAgent.OctopusToken, Environment.GetEnvironmentVariable("OCTOPUS_TOKEN")); + }) + .Execute(assertWasSuccess: false); + + result.WasSuccessful.Should().BeTrue(); + result.FullLog.Should().Contain("Paris"); + } +} diff --git a/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs b/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs new file mode 100644 index 0000000000..742cd86027 --- /dev/null +++ b/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +//using Anthropic; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Pipeline; +using Calamari.Common.Plumbing.Variables; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; +using OpenAI; +using OpenAI.Chat; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; + +namespace Calamari.AiAgent.Behaviours +{ + public class InvokeAgentBehaviour : IDeployBehaviour + { + readonly ILog log; + + public InvokeAgentBehaviour(ILog log) + { + this.log = log; + } + + public bool IsEnabled(RunningDeployment context) + { + return true; + } + + public async Task Execute(RunningDeployment context) + { + var variables = context.Variables; + + var prompt = variables.Get(SpecialVariables.Action.AiAgent.Prompt); + if (string.IsNullOrWhiteSpace(prompt)) + throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.Prompt}' is required but was not provided."); + + var apiToken = variables.Get(SpecialVariables.Action.AiAgent.ApiToken); + if (string.IsNullOrWhiteSpace(apiToken)) + throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.ApiToken}' is required but was not provided."); + + var model = variables.Get(SpecialVariables.Action.AiAgent.Model); + if (string.IsNullOrWhiteSpace(model)) + model = "gpt-4o"; + + log.Info($"Invoking AI agent with model '{model}'..."); + + var provider = variables.Get(SpecialVariables.Action.AiAgent.Provider); + IChatClient chatClient; + if (provider == "Anthropic") + { + chatClient = new Anthropic.AnthropicClient { ApiKey = apiToken } + .AsIChatClient(model) + .AsBuilder() + .UseFunctionInvocation() + .Build(); + + } + else if (provider == "OpenAI") + { + chatClient = new ChatClient(model, apiToken) + .AsIChatClient() + .AsBuilder() + .UseFunctionInvocation() + .Build(); + } + else + { + throw new Exception($"Provider {provider} not supported"); + } + + + var tools = new List(); + McpClient? mcpClient = null; + + var githubToken = variables.Get(SpecialVariables.Action.AiAgent.GitHubToken); + if (!string.IsNullOrWhiteSpace(githubToken)) + { + log.Info("Connecting to GitHub MCP server..."); + mcpClient = await McpClient.CreateAsync( + new StdioClientTransport(new StdioClientTransportOptions + { + Command = "npx", + Arguments = ["-y", "@modelcontextprotocol/server-github"], + Name = "GitHub", + EnvironmentVariables = new Dictionary + { + ["GITHUB_PERSONAL_ACCESS_TOKEN"] = githubToken, + ["PATH"] = Environment.GetEnvironmentVariable("PATH"), + }, + })); + var mcpTools = await mcpClient.ListToolsAsync(); + tools.AddRange(mcpTools); + log.Info($"GitHub MCP server connected. {tools.Count} tools available."); + } + + var octopusToken = variables.Get(SpecialVariables.Action.AiAgent.OctopusToken); + if (!string.IsNullOrWhiteSpace(octopusToken)) + { + var mcpClient2 = await McpClient.CreateAsync(new StdioClientTransport(new StdioClientTransportOptions() + { + Command = "npx", + Arguments = ["-y", "@octopusdeploy/mcp-server", ], + Name = "Octopus", + EnvironmentVariables = new Dictionary + { + ["OCTOPUS_SERVER_URL"] ="http://localhost:8065", + ["OCTOPUS_API_KEY"] = octopusToken, + ["PATH"] = Environment.GetEnvironmentVariable("PATH"), + } + })); + var mcpTools2 = await mcpClient2.ListToolsAsync(); + tools.AddRange(mcpTools2); + log.Info($"Octopus MCP server connected. {tools.Count} tools available."); + + } + + tools.Add(new HostedWebSearchTool()); + tools.Add(new HostedFileSearchTool()); + + tools.Add(AIFunctionFactory.Create(() => + { + var sensitiveKeywords = new[] { "password", "secret", "token", "apikey", "api_key", "api-key", "private" }; + var filtered = variables + .Where(kvp => !sensitiveKeywords.Any(k => kvp.Key.Contains(k, StringComparison.OrdinalIgnoreCase))) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + return JsonSerializer.Serialize(filtered, new JsonSerializerOptions { WriteIndented = true }); + }, + "get_deployment_variables", + "Returns all Octopus deployment variables as JSON (sensitive values are excluded). " + + "Call this when you need to inspect the current deployment context such as environment, project, tenant, release version, or any custom variables.")); + + try + { + var responseBuilder = new StringBuilder(); + var lineBuffer = new LineBuffer(line => log.Info(line)); + List chatHistory = []; + + var systemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill); + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + chatHistory.Add(new ChatMessage(ChatRole.System, systemPrompt)); + } + + var inputCostPerMillion = 3; + var outputCostPerMillion = 15; + + var msg = new ChatMessage(ChatRole.User, prompt); + chatHistory.Add(msg); + + var maxTokens = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTokens)??10000; + var chatOptions = new ChatOptions() + { + MaxOutputTokens = maxTokens, Tools = [.. tools] + }; + + await foreach (var update in chatClient.GetStreamingResponseAsync(chatHistory, chatOptions)) + { + if (!string.IsNullOrEmpty(update.Text)) + { + responseBuilder.Append(update.Text); + lineBuffer.Append(update.Text); + } + + var usage = update.Contents.OfType().FirstOrDefault(); + if (usage is not null) + { +#pragma warning disable MEAI001 + var inputCost = Math.Round((double)(usage.Details.InputTokenCount.HasValue ? (usage.Details.InputTokenCount! / 1000000.0 * inputCostPerMillion) : 0), 4); + var outputCost = Math.Round((double)(usage.Details.OutputTokenCount.HasValue ? (usage.Details.OutputTokenCount / 1000000.0 * outputCostPerMillion) : 0), 4); + log.VerboseFormat($"Input cost: ${inputCost}, Output cost: ${outputCost}, Total cost: ${inputCost + outputCost}"); +#pragma warning restore MEAI001 + } + + chatHistory.AddMessages(update); + } + + lineBuffer.Flush(); + + var fullResponse = responseBuilder.ToString(); + Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, fullResponse, variables); + log.Info("AI agent invocation complete."); + } + finally + { + if (mcpClient is not null) + { + await mcpClient.DisposeAsync(); + } + } + } + } +} diff --git a/source/Calamari.AiAgent/Calamari.AiAgent.csproj b/source/Calamari.AiAgent/Calamari.AiAgent.csproj index 1670aee07b..8b3e416b53 100644 --- a/source/Calamari.AiAgent/Calamari.AiAgent.csproj +++ b/source/Calamari.AiAgent/Calamari.AiAgent.csproj @@ -11,6 +11,14 @@ true + + + + + + + + diff --git a/source/Calamari.AiAgent/LineBuffer.cs b/source/Calamari.AiAgent/LineBuffer.cs new file mode 100644 index 0000000000..ba67bb2803 --- /dev/null +++ b/source/Calamari.AiAgent/LineBuffer.cs @@ -0,0 +1,47 @@ +using System; +using System.Text; + +namespace Calamari.AiAgent +{ + /// + /// Buffers streamed text chunks and invokes a callback for each complete line. + /// Call as chunks arrive, and when the + /// stream ends to emit any remaining partial line. + /// + public class LineBuffer + { + readonly Action onLine; + readonly StringBuilder buffer = new(); + + public LineBuffer(Action onLine) + { + this.onLine = onLine; + } + + public void Append(string text) + { + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c == '\n') + { + onLine(buffer.ToString()); + buffer.Clear(); + } + else if (c != '\r') + { + buffer.Append(c); + } + } + } + + public void Flush() + { + if (buffer.Length > 0) + { + onLine(buffer.ToString()); + buffer.Clear(); + } + } + } +} diff --git a/source/Calamari.AiAgent/RunAgentCommand.cs b/source/Calamari.AiAgent/RunAgentCommand.cs index 77cbf05fe2..b35da3071c 100644 --- a/source/Calamari.AiAgent/RunAgentCommand.cs +++ b/source/Calamari.AiAgent/RunAgentCommand.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Calamari.AiAgent.Behaviours; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Pipeline; @@ -9,7 +10,7 @@ public class RunAgentCommand : PipelineCommand { protected override IEnumerable Deploy(DeployResolver resolver) { - yield break; + yield return resolver.Create(); } } } diff --git a/source/Calamari.AiAgent/SpecialVariables.cs b/source/Calamari.AiAgent/SpecialVariables.cs new file mode 100644 index 0000000000..481b0c02d4 --- /dev/null +++ b/source/Calamari.AiAgent/SpecialVariables.cs @@ -0,0 +1,21 @@ +namespace Calamari.AiAgent +{ + public static class SpecialVariables + { + public static class Action + { + public static class AiAgent + { + public const string Prompt = "Octopus.Action.AiAgent.Prompt"; + public const string ApiToken = "Octopus.Action.AiAgent.ApiToken"; + public const string Model = "Octopus.Action.AiAgent.Model"; + public const string Response = "Octopus.Action.AiAgent.Response"; + public const string GitHubToken = "Octopus.Action.AiAgent.GitHubToken"; + public const string SystemSkill = "Octopus.Action.AiAgent.SystemSkill"; + public const string Provider = "Octopus.Action.AiAgent.Provider"; + public const string MaxTokens = "Octopus.Action.AiAgent.MaxTokens"; + public const string OctopusToken = "Octopus.Action.AiAgent.OctopusToken"; + } + } + } +} diff --git a/source/docs/superpowers/plans/2026-05-24-aiagent-claude-invocation.md b/source/docs/superpowers/plans/2026-05-24-aiagent-claude-invocation.md new file mode 100644 index 0000000000..4f4d232973 --- /dev/null +++ b/source/docs/superpowers/plans/2026-05-24-aiagent-claude-invocation.md @@ -0,0 +1,237 @@ +# AiAgent Claude API Invocation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Claude API streaming invocation to the `RunAgentCommand` so it reads a prompt and API token from variables, streams a Claude response (logging chunks in real-time), and sets the full response as an output variable. + +**Architecture:** A single `InvokeAgentBehaviour` (implementing `IDeployBehaviour`) uses the official Anthropic C# SDK to stream a Claude Messages API call. Variable names are defined in a `SpecialVariables` class. The behaviour is wired into `RunAgentCommand.Deploy`. + +**Tech Stack:** .NET 8.0, Anthropic NuGet SDK (`Anthropic` package), Autofac (via Calamari.Common) + +--- + +## File Structure + +**Create:** +- `source/Calamari.AiAgent/SpecialVariables.cs` — variable name constants +- `source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs` — streams Claude API call + +**Modify:** +- `source/Calamari.AiAgent/Calamari.AiAgent.csproj` — add Anthropic NuGet package +- `source/Calamari.AiAgent/RunAgentCommand.cs` — wire InvokeAgentBehaviour into Deploy pipeline + +--- + +### Task 1: Add Anthropic NuGet package + +**Files:** +- Modify: `source/Calamari.AiAgent/Calamari.AiAgent.csproj` + +- [ ] **Step 1: Add the Anthropic package reference** + +In `source/Calamari.AiAgent/Calamari.AiAgent.csproj`, add a `PackageReference` for the official Anthropic SDK inside the existing `ItemGroup` (or a new one). The csproj should become: + +```xml + + + + Calamari.AiAgent + Calamari.AiAgent + Exe + enable + win-x64;linux-x64;osx-x64;linux-arm;linux-arm64 + false + net8.0 + true + + + + + + + + + + + +``` + +--- + +### Task 2: Create SpecialVariables + +**Files:** +- Create: `source/Calamari.AiAgent/SpecialVariables.cs` + +- [ ] **Step 1: Create SpecialVariables.cs** + +Write `source/Calamari.AiAgent/SpecialVariables.cs`: + +```csharp +namespace Calamari.AiAgent +{ + static class SpecialVariables + { + public static class Action + { + public static class AiAgent + { + public const string Prompt = "Octopus.Action.AiAgent.Prompt"; + public const string ApiToken = "Octopus.Action.AiAgent.ApiToken"; + public const string Model = "Octopus.Action.AiAgent.Model"; + public const string Response = "Octopus.Action.AiAgent.Response"; + } + } + } +} +``` + +--- + +### Task 3: Create InvokeAgentBehaviour + +**Files:** +- Create: `source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs` + +- [ ] **Step 1: Create the Behaviours directory** + +```bash +mkdir -p source/Calamari.AiAgent/Behaviours +``` + +- [ ] **Step 2: Create InvokeAgentBehaviour.cs** + +Write `source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs`: + +```csharp +using System.Text; +using System.Threading.Tasks; +using Anthropic; +using Anthropic.Models.Messages; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Pipeline; + +namespace Calamari.AiAgent.Behaviours +{ + public class InvokeAgentBehaviour : IDeployBehaviour + { + readonly ILog log; + + public InvokeAgentBehaviour(ILog log) + { + this.log = log; + } + + public bool IsEnabled(RunningDeployment context) + { + return true; + } + + public async Task Execute(RunningDeployment context) + { + var variables = context.Variables; + + var prompt = variables.Get(SpecialVariables.Action.AiAgent.Prompt); + if (string.IsNullOrWhiteSpace(prompt)) + throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.Prompt}' is required but was not provided."); + + var apiToken = variables.Get(SpecialVariables.Action.AiAgent.ApiToken); + if (string.IsNullOrWhiteSpace(apiToken)) + throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.ApiToken}' is required but was not provided."); + + var model = variables.Get(SpecialVariables.Action.AiAgent.Model); + if (string.IsNullOrWhiteSpace(model)) + model = "claude-sonnet-4-20250514"; + + log.Info($"Invoking AI agent with model '{model}'..."); + + var client = new AnthropicClient { ApiKey = apiToken }; + + var parameters = new MessageCreateParams + { + MaxTokens = 4096, + Messages = + [ + new() + { + Role = Role.User, + Content = prompt, + }, + ], + Model = model, + }; + + var responseBuilder = new StringBuilder(); + + await foreach (var streamEvent in client.Messages.CreateStreaming(parameters)) + { + var text = streamEvent.ToString(); + if (!string.IsNullOrEmpty(text)) + { + responseBuilder.Append(text); + log.Info(text); + } + } + + var fullResponse = responseBuilder.ToString(); + Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, fullResponse, variables); + log.Info("AI agent invocation complete."); + } + } +} +``` + +--- + +### Task 4: Wire InvokeAgentBehaviour into RunAgentCommand + +**Files:** +- Modify: `source/Calamari.AiAgent/RunAgentCommand.cs` + +- [ ] **Step 1: Update RunAgentCommand to use InvokeAgentBehaviour** + +Replace the contents of `source/Calamari.AiAgent/RunAgentCommand.cs` with: + +```csharp +using System.Collections.Generic; +using Calamari.AiAgent.Behaviours; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Pipeline; + +namespace Calamari.AiAgent +{ + [Command("run-agent", Description = "Invokes an AI agent")] + public class RunAgentCommand : PipelineCommand + { + protected override IEnumerable Deploy(DeployResolver resolver) + { + yield return resolver.Create(); + } + } +} +``` + +--- + +### Task 5: Verify and commit + +- [ ] **Step 1: Verify all files are in place** + +Check that these files exist: +- `source/Calamari.AiAgent/SpecialVariables.cs` +- `source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs` +- `source/Calamari.AiAgent/RunAgentCommand.cs` (updated) +- `source/Calamari.AiAgent/Calamari.AiAgent.csproj` (updated) + +- [ ] **Step 2: Verify the wiring test still passes** + +Run: `dotnet test source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj --filter "FullyQualifiedName~CommandResolutionTests" -v normal` +Expected: 1 test passed. The `InvokeAgentBehaviour` will be resolved via Autofac's assembly scanning in `CalamariFlavourProgramAsync` since it's registered as an `IBehaviour`. + +- [ ] **Step 3: Commit** + +```bash +git add source/Calamari.AiAgent/ +git commit -m "feat: add Claude API streaming invocation to RunAgentCommand" +``` diff --git a/source/docs/superpowers/plans/2026-05-24-calamari-ai-agent.md b/source/docs/superpowers/plans/2026-05-24-calamari-ai-agent.md new file mode 100644 index 0000000000..da97c02972 --- /dev/null +++ b/source/docs/superpowers/plans/2026-05-24-calamari-ai-agent.md @@ -0,0 +1,271 @@ +# Calamari.AiAgent Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a new Calamari flavour project (`Calamari.AiAgent`) for running AI Agent invocations, with a single `run-agent` command and a wiring test. + +**Architecture:** Minimal `CalamariFlavourProgramAsync`-based flavour (same pattern as `Calamari.Scripting` and `Calamari.AzureAppService`). One `PipelineCommand` subclass for `run-agent`. A test project validates all commands resolve from the DI container. + +**Tech Stack:** .NET 8.0, Autofac (via Calamari.Common), NUnit (tests) + +--- + +## File Structure + +**Create:** +- `source/Calamari.AiAgent/Calamari.AiAgent.csproj` — Exe project, net8.0, references Calamari.Common +- `source/Calamari.AiAgent/Program.cs` — Entry point extending `CalamariFlavourProgramAsync` +- `source/Calamari.AiAgent/RunAgentCommand.cs` — `PipelineCommand` with `[Command("run-agent")]` +- `source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj` — Test project +- `source/Calamari.AiAgent.Tests/CommandResolutionTests.cs` — Wiring test + +**Modify:** +- `source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs:14-24` — Add `"Calamari.AiAgent"` to project list +- `source/Calamari.sln` — Add both new projects (via `dotnet sln add`) + +--- + +### Task 1: Create the Calamari.AiAgent project and Program.cs + +**Files:** +- Create: `source/Calamari.AiAgent/Calamari.AiAgent.csproj` +- Create: `source/Calamari.AiAgent/Program.cs` + +- [ ] **Step 1: Create the project directory** + +```bash +mkdir -p source/Calamari.AiAgent +``` + +- [ ] **Step 2: Create the .csproj file** + +Write `source/Calamari.AiAgent/Calamari.AiAgent.csproj`: + +```xml + + + + Calamari.AiAgent + Calamari.AiAgent + Exe + enable + win-x64;linux-x64;osx-x64;linux-arm;linux-arm64 + false + net8.0 + true + + + + + + + +``` + +- [ ] **Step 3: Create Program.cs** + +Write `source/Calamari.AiAgent/Program.cs`: + +```csharp +using System.Threading.Tasks; +using Calamari.Common; +using Calamari.Common.Plumbing.Logging; + +namespace Calamari.AiAgent +{ + public class Program : CalamariFlavourProgramAsync + { + public Program(ILog log) : base(log) + { + } + + public static Task Main(string[] args) + { + return new Program(ConsoleLog.Instance).Run(args); + } + } +} +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `dotnet build source/Calamari.AiAgent/Calamari.AiAgent.csproj` +Expected: Build succeeded with 0 errors. + +--- + +### Task 2: Create the RunAgentCommand + +**Files:** +- Create: `source/Calamari.AiAgent/RunAgentCommand.cs` + +- [ ] **Step 1: Create RunAgentCommand.cs** + +Write `source/Calamari.AiAgent/RunAgentCommand.cs`: + +```csharp +using System.Collections.Generic; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Pipeline; + +namespace Calamari.AiAgent +{ + [Command("run-agent", Description = "Invokes an AI agent")] + public class RunAgentCommand : PipelineCommand + { + protected override IEnumerable Deploy(DeployResolver resolver) + { + yield break; + } + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `dotnet build source/Calamari.AiAgent/Calamari.AiAgent.csproj` +Expected: Build succeeded with 0 errors. + +--- + +### Task 3: Create the test project with wiring test + +**Files:** +- Create: `source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj` +- Create: `source/Calamari.AiAgent.Tests/CommandResolutionTests.cs` + +- [ ] **Step 1: Create the test project directory** + +```bash +mkdir -p source/Calamari.AiAgent.Tests +``` + +- [ ] **Step 2: Create the test .csproj file** + +Write `source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj`: + +```xml + + + Calamari.AiAgent.Tests + Calamari.AiAgent.Tests + net8.0 + win-x64;linux-x64;osx-x64;linux-arm;linux-arm64 + false + true + + + + + + + + + + + + + + +``` + +- [ ] **Step 3: Create the wiring test** + +Write `source/Calamari.AiAgent.Tests/CommandResolutionTests.cs`: + +```csharp +using System; +using System.Collections.Generic; +using Autofac; +using Calamari.Testing; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests; + +[TestFixture] +public class CommandResolutionTests +{ + [Test] + [Category("PlatformAgnostic")] + public void AllPipelineCommandsCanBeConstructed() + { + var program = TestablePipelineProgram.For(); + using var container = program.BuildTestContainer(); + + var failures = new List(); + foreach (var type in program.PipelineCommandTypes) + { + try + { + container.Resolve(type); + } + catch (Exception ex) + { + failures.Add($"'{type.Name}': {ex.Message}"); + } + } + + Assert.That(failures, Is.Empty, "all pipeline commands must be constructable from the DI container"); + } +} +``` + +- [ ] **Step 4: Verify test project compiles** + +Run: `dotnet build source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj` +Expected: Build succeeded with 0 errors. + +- [ ] **Step 5: Run the test** + +Run: `dotnet test source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj --filter "FullyQualifiedName~CommandResolutionTests" -v normal` +Expected: 1 test passed. + +--- + +### Task 4: Add projects to solution and consolidation list + +**Files:** +- Modify: `source/Calamari.sln` +- Modify: `source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs` + +- [ ] **Step 1: Add both projects to the solution** + +```bash +cd source && dotnet sln Calamari.sln add Calamari.AiAgent/Calamari.AiAgent.csproj Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj +``` + +Expected: Two "Project added to the solution" messages. + +- [ ] **Step 2: Add to BuildableCalamariProjects.cs** + +In `source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs`, add `"Calamari.AiAgent"` to the `NonWindows` array (which is also included in the `Windows` array). The array should become: + +```csharp +static readonly string[] NonWindows = +[ + "Calamari", + "Calamari.AiAgent", + "Calamari.AzureAppService", + "Calamari.AzureResourceGroup", + "Calamari.GoogleCloudScripting", + "Calamari.AzureScripting", + "Calamari.Terraform" +]; +``` + +- [ ] **Step 3: Verify the full solution builds** + +Run: `dotnet build source/Calamari.sln` +Expected: Build succeeded with 0 errors. + +- [ ] **Step 4: Run the wiring test one final time** + +Run: `dotnet test source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj --filter "FullyQualifiedName~CommandResolutionTests" -v normal` +Expected: 1 test passed. + +- [ ] **Step 5: Commit** + +```bash +git add source/Calamari.AiAgent/ source/Calamari.AiAgent.Tests/ source/Calamari.sln source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs +git commit -m "feat: add Calamari.AiAgent project for AI agent invocations" +``` From b72fead6b2094e156295db449d0ed00f570303ab Mon Sep 17 00:00:00 2001 From: robert Date: Fri, 29 May 2026 14:27:44 +1000 Subject: [PATCH 03/26] Add Claude Code CLI provider with stream-json parsing and usage service messages Introduce InvokeClaudeCodeBehaviour as an alternative to the SDK-based provider, shelling out to `claude -p` with stream-json output. Includes typed stream event models, a dedicated stream processor, temp working directory with skills support, and an ai-agent-usage service message for reporting cost/token metrics back to the server. Co-Authored-By: Claude Opus 4.6 (1M context) --- global.json | 2 +- .../RunAgentCommandFixture.cs | 34 +++ .../Behaviours/ClaudeCodeCliRunner.cs | 173 +++++++++++++++ .../Behaviours/ClaudeCodeStreamModels.cs | 148 +++++++++++++ .../Behaviours/ClaudeCodeStreamProcessor.cs | 202 ++++++++++++++++++ .../Behaviours/InvokeAgentBehaviour.cs | 6 +- .../Behaviours/InvokeClaudeCodeBehaviour.cs | 57 +++++ source/Calamari.AiAgent/RunAgentCommand.cs | 3 +- source/Calamari.AiAgent/SpecialVariables.cs | 17 ++ 9 files changed, 636 insertions(+), 6 deletions(-) create mode 100644 source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs create mode 100644 source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs create mode 100644 source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs create mode 100644 source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs diff --git a/global.json b/global.json index 90faf1b627..99e922f66e 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.419", + "version": "8.0.421", "rollForward": "latestFeature", "allowPrerelease": false } diff --git a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs index c5d7b5c9f8..49c08fcb92 100644 --- a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs +++ b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs @@ -81,4 +81,38 @@ public async Task FailsTryingToUseToolNotPresent() result.WasSuccessful.Should().BeTrue(); result.FullLog.Should().Contain("Paris"); } + + [Test] + [Category("PlatformAgnostic")] + public async Task ClaudeCode_SucceedsWithSimplePrompt() + { + var result = await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "What is the capital of France? Reply with just the city name."); + context.Variables.Add(SpecialVariables.Action.AiAgent.Provider, "ClaudeCode"); + }) + .Execute(assertWasSuccess: false); + + result.WasSuccessful.Should().BeTrue(); + result.FullLog.Should().Contain("Paris"); + } + + [Test] + [Category("PlatformAgnostic")] + public async Task ClaudeCode_SucceedsWithHttpRequest() + { + var result = await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "Call the whatsMyIp website to echo out my ip address"); + context.Variables.Add(SpecialVariables.Action.AiAgent.Provider, "ClaudeCode"); + }) + .Execute(assertWasSuccess: false); + + result.WasSuccessful.Should().BeTrue(); + //result.FullLog.Should().Contain("Paris"); + } } diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs new file mode 100644 index 0000000000..7a9e1dd28f --- /dev/null +++ b/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs @@ -0,0 +1,173 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; + +namespace Calamari.AiAgent.Behaviours +{ + public class ClaudeCodeCliRunner + { + readonly ILog log; + + public ClaudeCodeCliRunner(ILog log) + { + this.log = log; + } + + public async Task RunAsync(ClaudeCodeOptions options) + { + var workingDir = Path.Combine(Path.GetTempPath(), $"claude-agent-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workingDir); + log.Verbose($"Claude Code working directory: {workingDir}"); + + try + { + SetupSkills(workingDir); + return await RunInDirectoryAsync(options, workingDir); + } + finally + { + try { Directory.Delete(workingDir, recursive: true); } + catch { /* best effort cleanup */ } + } + } + + async Task RunInDirectoryAsync(ClaudeCodeOptions options, string workingDir) + { + var args = BuildArguments(options); + + var debugFile = Path.Combine(workingDir, "claude-debug.log"); + args.Append(" --debug-file "); + args.Append(EscapeArg(debugFile)); + log.Verbose($"Claude Code debug log: {debugFile}"); + + var startInfo = new ProcessStartInfo + { + FileName = "claude", + Arguments = args.ToString(), + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + startInfo.Environment["ANTHROPIC_API_KEY"] = options.ApiToken; + + var responseBuilder = new StringBuilder(); + var streamProcessor = new ClaudeCodeStreamProcessor(log, responseBuilder); + + using var process = new Process { StartInfo = startInfo }; + + process.Start(); + + var stdoutTask = Task.Run(async () => + { + string? line; + while ((line = await process.StandardOutput.ReadLineAsync()) != null) + { + streamProcessor.ProcessLine(line); + } + }); + + var stderrTask = Task.Run(async () => + { + var buffer = new char[1024]; + int charsRead; + while ((charsRead = await process.StandardError.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + var text = new string(buffer, 0, charsRead); + log.Verbose(text.TrimEnd()); + } + }); + + await Task.WhenAll(stdoutTask, stderrTask); + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + throw new CommandException($"Claude Code exited with code {process.ExitCode}"); + } + + if (File.Exists(debugFile)) + { + var debugContent = await File.ReadAllTextAsync(debugFile); + log.Verbose("--- Claude Code debug log ---"); + log.Verbose(debugContent); + log.Verbose("--- End debug log ---"); + } + + return responseBuilder.ToString(); + } + + static StringBuilder BuildArguments(ClaudeCodeOptions options) + { + //https://code.claude.com/docs/en/cli-reference + var args = new StringBuilder(); + args.Append("-p "); + args.Append(EscapeArg(options.Prompt)); + args.Append(" --model "); + args.Append(EscapeArg(options.Model)); + args.Append(" --output-format stream-json"); + args.Append(" --verbose"); + args.Append(" --permission-mode dontAsk"); + args.Append(" --allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch"); + args.Append(" --no-session-persistence"); + + if (options.MaxTurns.HasValue) + args.Append($" --max-turns {options.MaxTurns.Value}"); + + if (!string.IsNullOrWhiteSpace(options.SystemPrompt)) + { + args.Append(" --system-prompt "); + args.Append(EscapeArg(options.SystemPrompt)); + } + + return args; + } + + static void SetupSkills(string workingDir) + { + var skillsDir = Path.Combine(workingDir, ".claude", "skills"); + Directory.CreateDirectory(skillsDir); + + File.WriteAllText(Path.Combine(skillsDir, "octopus-deployment-context.md"), + """ + --- + name: octopus-deployment-context + description: Use when you need to understand the Octopus Deploy deployment context, including environment, project, tenant, release version, or any custom variables available during this deployment. + --- + + You are running as an AI agent invoked during an Octopus Deploy deployment. + + Key context: + - You are executing inside a deployment step on a target machine + - Octopus deployment variables are available via the `get_deployment_variables` tool + - Sensitive variables (passwords, tokens, API keys) are filtered out for safety + - Your output will be captured as the step result + + When asked about the deployment context, always call `get_deployment_variables` first to get the actual values rather than guessing. + """); + } + + static string EscapeArg(string arg) + { + if (arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0) + return arg; + + return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + } + + public record ClaudeCodeOptions + { + public required string Prompt { get; init; } + public required string ApiToken { get; init; } + public required string Model { get; init; } + public string? SystemPrompt { get; init; } + public int? MaxTurns { get; init; } + } +} diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs new file mode 100644 index 0000000000..20ee82a5e3 --- /dev/null +++ b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.Behaviours +{ + public record StreamEvent + { + [JsonPropertyName("type")] + public string? Type { get; init; } + + [JsonPropertyName("session_id")] + public string? SessionId { get; init; } + } + + public record SystemStreamEvent : StreamEvent + { + [JsonPropertyName("subtype")] + public string? Subtype { get; init; } + + [JsonPropertyName("attempt")] + public int? Attempt { get; init; } + + [JsonPropertyName("retry_delay_ms")] + public int? RetryDelayMs { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } + + [JsonPropertyName("error_status")] + public int? ErrorStatus { get; init; } + } + + public record AssistantStreamEvent : StreamEvent + { + [JsonPropertyName("message")] + public StreamMessage? Message { get; init; } + } + + public record UserStreamEvent : StreamEvent + { + [JsonPropertyName("message")] + public StreamMessage? Message { get; init; } + } + + public record ResultStreamEvent : StreamEvent + { + [JsonPropertyName("result")] + public string? Result { get; init; } + + [JsonPropertyName("cost_usd")] + public double? CostUsd { get; init; } + + [JsonPropertyName("total_cost_usd")] + public double? TotalCostUsd { get; init; } + + [JsonPropertyName("duration_ms")] + public double? DurationMs { get; init; } + + [JsonPropertyName("duration_api_ms")] + public double? DurationApiMs { get; init; } + + [JsonPropertyName("num_turns")] + public int? NumTurns { get; init; } + + [JsonPropertyName("usage")] + public UsageInfo? Usage { get; init; } + } + + public record StreamMessage + { + [JsonPropertyName("content")] + public JsonElement[]? Content { get; init; } + } + + public record ContentBlock + { + [JsonPropertyName("type")] + public string? Type { get; init; } + } + + public record TextContentBlock : ContentBlock + { + [JsonPropertyName("text")] + public string? Text { get; init; } + } + + public record ThinkingContentBlock : ContentBlock + { + [JsonPropertyName("thinking")] + public string? Thinking { get; init; } + } + + public record RedactedThinkingContentBlock : ContentBlock; + + public record ToolUseContentBlock : ContentBlock + { + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + + [JsonPropertyName("input")] + public JsonElement? Input { get; init; } + } + + public record ToolResultContentBlock : ContentBlock + { + [JsonPropertyName("tool_use_id")] + public string? ToolUseId { get; init; } + + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("is_error")] + public bool? IsError { get; init; } + + [JsonPropertyName("content")] + public JsonElement? Content { get; init; } + } + + public record ServerToolUseContentBlock : ContentBlock + { + [JsonPropertyName("name")] + public string? Name { get; init; } + } + + public record ServerToolResultContentBlock : ContentBlock + { + [JsonPropertyName("name")] + public string? Name { get; init; } + } + + public record UsageInfo + { + [JsonPropertyName("input_tokens")] + public int? InputTokens { get; init; } + + [JsonPropertyName("output_tokens")] + public int? OutputTokens { get; init; } + + [JsonPropertyName("cache_read_input_tokens")] + public int? CacheReadInputTokens { get; init; } + + [JsonPropertyName("cache_creation_input_tokens")] + public int? CacheCreationInputTokens { get; init; } + } +} diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs new file mode 100644 index 0000000000..e18d6a5ab5 --- /dev/null +++ b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs @@ -0,0 +1,202 @@ +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.ServiceMessages; + +namespace Calamari.AiAgent.Behaviours +{ + public class ClaudeCodeStreamProcessor + { + static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + readonly ILog log; + readonly StringBuilder responseBuilder; + + public ClaudeCodeStreamProcessor(ILog log, StringBuilder responseBuilder) + { + this.log = log; + this.responseBuilder = responseBuilder; + } + + public void ProcessLine(string json) + { + using var doc = JsonDocument.Parse(json); + var type = doc.RootElement.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null; + + switch (type) + { + case "system": + HandleSystemEvent(JsonSerializer.Deserialize(json, JsonOptions)!); + break; + case "assistant": + HandleMessageEvent(JsonSerializer.Deserialize(json, JsonOptions)?.Message); + break; + case "user": + HandleUserMessage(JsonSerializer.Deserialize(json, JsonOptions)?.Message); + break; + case "result": + HandleResultEvent(JsonSerializer.Deserialize(json, JsonOptions)!); + break; + default: + log.Verbose($"[stream] unhandled event type '{type}'"); + break; + } + } + + void HandleSystemEvent(SystemStreamEvent evt) + { + switch (evt.Subtype) + { + case "init": + break; + + case "api_retry": + log.Warn($"API retry (attempt {evt.Attempt}, {evt.RetryDelayMs}ms delay): {evt.Error}"); + break; + } + } + + void HandleMessageEvent(StreamMessage? message) + { + if (message?.Content == null) + return; + + foreach (var element in message.Content) + { + var blockType = element.TryGetProperty("type", out var bt) ? bt.GetString() : null; + + switch (blockType) + { + case "text": + { + var block = element.Deserialize(JsonOptions); + responseBuilder.Append(block?.Text); + log.Info(block?.Text ?? ""); + break; + } + + case "thinking": + { + var block = element.Deserialize(JsonOptions); + log.Verbose($"[thinking] {block?.Thinking}"); + break; + } + + case "redacted_thinking": + log.Verbose("[thinking] "); + break; + + case "tool_use": + { + var block = element.Deserialize(JsonOptions); + log.Info($"[tool] {block?.Name}"); + if (block?.Input.HasValue == true) + log.Verbose($"[tool] {block.Name} input: {block.Input}"); + break; + } + + case "server_tool_use": + { + var block = element.Deserialize(JsonOptions); + // log.Info($"[server_tool] {block?.Name}"); + break; + } + + case "server_tool_result": + { + var block = element.Deserialize(JsonOptions); + // log.Verbose($"[server_tool] {block?.Name} completed"); + break; + } + + case "tool_result": + { + var block = element.Deserialize(JsonOptions); + /* + if (block?.IsError == true) + log.Warn($"[tool_result] {block.ToolUseId} failed: {block.Content}"); + else + log.Verbose($"[tool_result] {block?.Name} completed"); + */ + break; + + } + + default: + log.Verbose($"[message] unhandled block type: {blockType}"); + break; + } + } + } + + void HandleUserMessage(StreamMessage? message) + { + HandleMessageEvent(message); + } + + void HandleResultEvent(ResultStreamEvent evt) + { + if (evt.Result != null && responseBuilder.Length == 0) + { + responseBuilder.Append(evt.Result); + log.Info(evt.Result); + } + + if (evt.CostUsd.HasValue) + log.Info($"Cost: ${evt.CostUsd.Value:F4} USD"); + + if (evt.TotalCostUsd.HasValue) + log.Info($"Total cost: ${evt.TotalCostUsd.Value:F4} USD"); + + if (evt.DurationMs.HasValue) + log.Info($"Duration: {evt.DurationMs.Value / 1000.0:F1}s"); + + if (evt.DurationApiMs.HasValue) + log.Verbose($"API duration: {evt.DurationApiMs.Value / 1000.0:F1}s"); + + if (evt.NumTurns.HasValue) + log.Info($"Turns: {evt.NumTurns.Value}"); + + if (evt.Usage is { } usage) + { + log.Info($"Tokens — input: {usage.InputTokens ?? 0}, output: {usage.OutputTokens ?? 0}, cache read: {usage.CacheReadInputTokens ?? 0}, cache creation: {usage.CacheCreationInputTokens ?? 0}"); + } + + EmitUsageServiceMessage(evt); + } + + void EmitUsageServiceMessage(ResultStreamEvent evt) + { + var properties = new Dictionary(); + + if (evt.CostUsd.HasValue) + properties[AiAgentServiceMessageNames.CostUsdAttribute] = evt.CostUsd.Value.ToString("F6"); + if (evt.TotalCostUsd.HasValue) + properties[AiAgentServiceMessageNames.TotalCostUsdAttribute] = evt.TotalCostUsd.Value.ToString("F6"); + if (evt.DurationMs.HasValue) + properties[AiAgentServiceMessageNames.DurationMsAttribute] = evt.DurationMs.Value.ToString("F0"); + if (evt.DurationApiMs.HasValue) + properties[AiAgentServiceMessageNames.DurationApiMsAttribute] = evt.DurationApiMs.Value.ToString("F0"); + if (evt.NumTurns.HasValue) + properties[AiAgentServiceMessageNames.NumTurnsAttribute] = evt.NumTurns.Value.ToString(); + + if (evt.Usage is { } usage) + { + if (usage.InputTokens.HasValue) + properties[AiAgentServiceMessageNames.InputTokensAttribute] = usage.InputTokens.Value.ToString(); + if (usage.OutputTokens.HasValue) + properties[AiAgentServiceMessageNames.OutputTokensAttribute] = usage.OutputTokens.Value.ToString(); + if (usage.CacheReadInputTokens.HasValue) + properties[AiAgentServiceMessageNames.CacheReadInputTokensAttribute] = usage.CacheReadInputTokens.Value.ToString(); + if (usage.CacheCreationInputTokens.HasValue) + properties[AiAgentServiceMessageNames.CacheCreationInputTokensAttribute] = usage.CacheCreationInputTokens.Value.ToString(); + } + + log.WriteServiceMessage(new ServiceMessage(AiAgentServiceMessageNames.Name, properties)); + } + } +} diff --git a/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs b/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs index 742cd86027..0050fd86e6 100644 --- a/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs +++ b/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs @@ -28,7 +28,8 @@ public InvokeAgentBehaviour(ILog log) public bool IsEnabled(RunningDeployment context) { - return true; + var provider = context.Variables.Get(SpecialVariables.Action.AiAgent.Provider); + return provider == "Anthropic" || provider == "OpenAI"; } public async Task Execute(RunningDeployment context) @@ -119,9 +120,6 @@ public async Task Execute(RunningDeployment context) } - tools.Add(new HostedWebSearchTool()); - tools.Add(new HostedFileSearchTool()); - tools.Add(AIFunctionFactory.Create(() => { var sensitiveKeywords = new[] { "password", "secret", "token", "apikey", "api_key", "api-key", "private" }; diff --git a/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs b/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs new file mode 100644 index 0000000000..e73e1e130e --- /dev/null +++ b/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs @@ -0,0 +1,57 @@ +using System.Threading.Tasks; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Pipeline; +using Calamari.Common.Plumbing.Variables; + +namespace Calamari.AiAgent.Behaviours +{ + public class InvokeClaudeCodeBehaviour : IDeployBehaviour + { + readonly ILog log; + + public InvokeClaudeCodeBehaviour(ILog log) + { + this.log = log; + } + + public bool IsEnabled(RunningDeployment context) + { + return true; + //var provider = context.Variables.Get(SpecialVariables.Action.AiAgent.Provider); + //return provider == "ClaudeCode"; + } + + public async Task Execute(RunningDeployment context) + { + var variables = context.Variables; + + var prompt = variables.Get(SpecialVariables.Action.AiAgent.Prompt); + if (string.IsNullOrWhiteSpace(prompt)) + throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.Prompt}' is required but was not provided."); + + var apiToken = variables.Get(SpecialVariables.Action.AiAgent.ApiToken); + if (string.IsNullOrWhiteSpace(apiToken)) + throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.ApiToken}' is required but was not provided."); + + var model = variables.Get(SpecialVariables.Action.AiAgent.Model); + if (string.IsNullOrWhiteSpace(model)) + model = "claude-sonnet-4-20250514"; + + log.Info($"Invoking Claude Code CLI with model '{model}'..."); + + var runner = new ClaudeCodeCliRunner(log); + var response = await runner.RunAsync(new ClaudeCodeOptions + { + Prompt = prompt, + ApiToken = apiToken, + Model = model, + SystemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill), + MaxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTokens), + }); + + Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); + log.Info("Claude Code invocation complete."); + } + } +} diff --git a/source/Calamari.AiAgent/RunAgentCommand.cs b/source/Calamari.AiAgent/RunAgentCommand.cs index b35da3071c..32f0a7f821 100644 --- a/source/Calamari.AiAgent/RunAgentCommand.cs +++ b/source/Calamari.AiAgent/RunAgentCommand.cs @@ -10,7 +10,8 @@ public class RunAgentCommand : PipelineCommand { protected override IEnumerable Deploy(DeployResolver resolver) { - yield return resolver.Create(); + //yield return resolver.Create(); + yield return resolver.Create(); } } } diff --git a/source/Calamari.AiAgent/SpecialVariables.cs b/source/Calamari.AiAgent/SpecialVariables.cs index 481b0c02d4..f22c9a0de7 100644 --- a/source/Calamari.AiAgent/SpecialVariables.cs +++ b/source/Calamari.AiAgent/SpecialVariables.cs @@ -18,4 +18,21 @@ public static class AiAgent } } } + + public static class AiAgentServiceMessageNames + { + public const string Name = "ai-agent-usage"; + + public const string CostUsdAttribute = "costUsd"; + public const string TotalCostUsdAttribute = "totalCostUsd"; + public const string DurationMsAttribute = "durationMs"; + public const string DurationApiMsAttribute = "durationApiMs"; + public const string NumTurnsAttribute = "numTurns"; + public const string InputTokensAttribute = "inputTokens"; + public const string OutputTokensAttribute = "outputTokens"; + public const string CacheReadInputTokensAttribute = "cacheReadInputTokens"; + public const string CacheCreationInputTokensAttribute = "cacheCreationInputTokens"; + public const string ModelAttribute = "model"; + public const string ProviderAttribute = "provider"; + } } From ba10f25d3ec5f837baeb54f408488b1f5ea32c5f Mon Sep 17 00:00:00 2001 From: robert Date: Fri, 29 May 2026 20:37:24 +1000 Subject: [PATCH 04/26] Add MCP config support, enums for stream types, and fix deserialization Add --strict-mcp-config with configurable MCP servers passed via ClaudeCodeOptions. Wire up GitHub and Octopus MCP servers from variables in InvokeClaudeCodeBehaviour. Introduce enums for StreamEventType and ContentBlockType used in processor routing, but keep model properties as strings to avoid JsonSerializer throwing on unknown values from the evolving CLI format. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Behaviours/ClaudeCodeCliRunner.cs | 53 +++++++- .../Behaviours/ClaudeCodeStreamModels.cs | 33 +++++ .../Behaviours/ClaudeCodeStreamProcessor.cs | 122 ++++++++++-------- .../Behaviours/InvokeClaudeCodeBehaviour.cs | 43 ++++++ 4 files changed, 191 insertions(+), 60 deletions(-) diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs index 7a9e1dd28f..a1a3d12975 100644 --- a/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs @@ -1,7 +1,11 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Logging; @@ -26,6 +30,7 @@ public async Task RunAsync(ClaudeCodeOptions options) try { SetupSkills(workingDir); + SetupMcpConfig(workingDir, options.McpServers); return await RunInDirectoryAsync(options, workingDir); } finally @@ -37,7 +42,7 @@ public async Task RunAsync(ClaudeCodeOptions options) async Task RunInDirectoryAsync(ClaudeCodeOptions options, string workingDir) { - var args = BuildArguments(options); + var args = BuildArguments(options, workingDir); var debugFile = Path.Combine(workingDir, "claude-debug.log"); args.Append(" --debug-file "); @@ -103,9 +108,9 @@ async Task RunInDirectoryAsync(ClaudeCodeOptions options, string working return responseBuilder.ToString(); } - static StringBuilder BuildArguments(ClaudeCodeOptions options) + static StringBuilder BuildArguments(ClaudeCodeOptions options, string workingDir) { - //https://code.claude.com/docs/en/cli-reference + // https://code.claude.com/docs/en/cli-reference var args = new StringBuilder(); args.Append("-p "); args.Append(EscapeArg(options.Prompt)); @@ -114,9 +119,21 @@ static StringBuilder BuildArguments(ClaudeCodeOptions options) args.Append(" --output-format stream-json"); args.Append(" --verbose"); args.Append(" --permission-mode dontAsk"); - args.Append(" --allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch"); args.Append(" --no-session-persistence"); + // MCP isolation: only use servers we explicitly provide + var mcpConfigPath = Path.Combine(workingDir, "mcp-config.json"); + args.Append(" --strict-mcp-config"); + args.Append(" --mcp-config "); + args.Append(EscapeArg(mcpConfigPath)); + + // Tool whitelist + if (options.AllowedTools.Count > 0) + { + args.Append(" --allowedTools "); + args.Append(string.Join(",", options.AllowedTools)); + } + if (options.MaxTurns.HasValue) args.Append($" --max-turns {options.MaxTurns.Value}"); @@ -129,6 +146,13 @@ static StringBuilder BuildArguments(ClaudeCodeOptions options) return args; } + static void SetupMcpConfig(string workingDir, IReadOnlyDictionary mcpServers) + { + var config = new { mcpServers }; + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(workingDir, "mcp-config.json"), json); + } + static void SetupSkills(string workingDir) { var skillsDir = Path.Combine(workingDir, ".claude", "skills"); @@ -169,5 +193,26 @@ public record ClaudeCodeOptions public required string Model { get; init; } public string? SystemPrompt { get; init; } public int? MaxTurns { get; init; } + public IReadOnlyList AllowedTools { get; init; } = new[] + { + "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch" + }; + public IReadOnlyDictionary McpServers { get; init; } = + new Dictionary(); + } + + public record McpServerConfig + { + [JsonPropertyName("type")] + public string Type { get; init; } = "stdio"; + + [JsonPropertyName("command")] + public required string Command { get; init; } + + [JsonPropertyName("args")] + public IReadOnlyList? Args { get; init; } + + [JsonPropertyName("env")] + public IReadOnlyDictionary? Env { get; init; } } } diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs index 20ee82a5e3..42ac84c5c1 100644 --- a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs +++ b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs @@ -1,8 +1,41 @@ +using System.Runtime.Serialization; using System.Text.Json; using System.Text.Json.Serialization; namespace Calamari.AiAgent.Behaviours { + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum StreamEventType + { + [EnumMember(Value = "system")] + System, + [EnumMember(Value = "assistant")] + Assistant, + [EnumMember(Value = "user")] + User, + [EnumMember(Value = "result")] + Result + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum ContentBlockType + { + [EnumMember(Value = "text")] + Text, + [EnumMember(Value = "thinking")] + Thinking, + [EnumMember(Value = "redacted_thinking")] + RedactedThinking, + [EnumMember(Value = "tool_use")] + ToolUse, + [EnumMember(Value = "tool_result")] + ToolResult, + [EnumMember(Value = "server_tool_use")] + ServerToolUse, + [EnumMember(Value = "server_tool_result")] + ServerToolResult + } + public record StreamEvent { [JsonPropertyName("type")] diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs index e18d6a5ab5..e3c48ff5a7 100644 --- a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs +++ b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs @@ -25,25 +25,46 @@ public ClaudeCodeStreamProcessor(ILog log, StringBuilder responseBuilder) public void ProcessLine(string json) { using var doc = JsonDocument.Parse(json); - var type = doc.RootElement.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null; + var typeString = doc.RootElement.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null; - switch (type) + if (typeString == null || !TryParseEventType(typeString, out var eventType)) { - case "system": + log.Verbose($"[stream] unhandled event type '{typeString}'"); + return; + } + + switch (eventType) + { + case StreamEventType.System: HandleSystemEvent(JsonSerializer.Deserialize(json, JsonOptions)!); break; - case "assistant": + case StreamEventType.Assistant: HandleMessageEvent(JsonSerializer.Deserialize(json, JsonOptions)?.Message); break; - case "user": + case StreamEventType.User: HandleUserMessage(JsonSerializer.Deserialize(json, JsonOptions)?.Message); break; - case "result": + case StreamEventType.Result: HandleResultEvent(JsonSerializer.Deserialize(json, JsonOptions)!); break; - default: - log.Verbose($"[stream] unhandled event type '{type}'"); - break; + } + } + + static bool TryParseEventType(string value, out StreamEventType result) + { + return value switch + { + "system" => Assign(StreamEventType.System, out result), + "assistant" => Assign(StreamEventType.Assistant, out result), + "user" => Assign(StreamEventType.User, out result), + "result" => Assign(StreamEventType.Result, out result), + _ => Assign(default, out result, false), + }; + + static bool Assign(StreamEventType val, out StreamEventType result, bool success = true) + { + result = val; + return success; } } @@ -67,11 +88,17 @@ void HandleMessageEvent(StreamMessage? message) foreach (var element in message.Content) { - var blockType = element.TryGetProperty("type", out var bt) ? bt.GetString() : null; + var blockTypeStr = element.TryGetProperty("type", out var bt) ? bt.GetString() : null; + + if (blockTypeStr == null || !TryParseContentBlockType(blockTypeStr, out var blockType)) + { + log.Verbose($"[message] unhandled block type: {blockTypeStr}"); + continue; + } switch (blockType) { - case "text": + case ContentBlockType.Text: { var block = element.Deserialize(JsonOptions); responseBuilder.Append(block?.Text); @@ -79,18 +106,18 @@ void HandleMessageEvent(StreamMessage? message) break; } - case "thinking": + case ContentBlockType.Thinking: { var block = element.Deserialize(JsonOptions); log.Verbose($"[thinking] {block?.Thinking}"); break; } - case "redacted_thinking": + case ContentBlockType.RedactedThinking: log.Verbose("[thinking] "); break; - case "tool_use": + case ContentBlockType.ToolUse: { var block = element.Deserialize(JsonOptions); log.Info($"[tool] {block?.Name}"); @@ -99,77 +126,60 @@ void HandleMessageEvent(StreamMessage? message) break; } - case "server_tool_use": + case ContentBlockType.ServerToolUse: { var block = element.Deserialize(JsonOptions); - // log.Info($"[server_tool] {block?.Name}"); + log.Info($"[server_tool] {block?.Name}"); break; } - case "server_tool_result": + case ContentBlockType.ServerToolResult: { var block = element.Deserialize(JsonOptions); - // log.Verbose($"[server_tool] {block?.Name} completed"); + log.Verbose($"[server_tool] {block?.Name} completed"); break; } - case "tool_result": + case ContentBlockType.ToolResult: { var block = element.Deserialize(JsonOptions); - /* if (block?.IsError == true) log.Warn($"[tool_result] {block.ToolUseId} failed: {block.Content}"); else log.Verbose($"[tool_result] {block?.Name} completed"); - */ break; - } - - default: - log.Verbose($"[message] unhandled block type: {blockType}"); - break; } } } - void HandleUserMessage(StreamMessage? message) - { - HandleMessageEvent(message); - } - - void HandleResultEvent(ResultStreamEvent evt) + static bool TryParseContentBlockType(string value, out ContentBlockType result) { - if (evt.Result != null && responseBuilder.Length == 0) + return value switch { - responseBuilder.Append(evt.Result); - log.Info(evt.Result); - } - - if (evt.CostUsd.HasValue) - log.Info($"Cost: ${evt.CostUsd.Value:F4} USD"); - - if (evt.TotalCostUsd.HasValue) - log.Info($"Total cost: ${evt.TotalCostUsd.Value:F4} USD"); - - if (evt.DurationMs.HasValue) - log.Info($"Duration: {evt.DurationMs.Value / 1000.0:F1}s"); - - if (evt.DurationApiMs.HasValue) - log.Verbose($"API duration: {evt.DurationApiMs.Value / 1000.0:F1}s"); - - if (evt.NumTurns.HasValue) - log.Info($"Turns: {evt.NumTurns.Value}"); - - if (evt.Usage is { } usage) + "text" => Assign(ContentBlockType.Text, out result), + "thinking" => Assign(ContentBlockType.Thinking, out result), + "redacted_thinking" => Assign(ContentBlockType.RedactedThinking, out result), + "tool_use" => Assign(ContentBlockType.ToolUse, out result), + "tool_result" => Assign(ContentBlockType.ToolResult, out result), + "server_tool_use" => Assign(ContentBlockType.ServerToolUse, out result), + "server_tool_result" => Assign(ContentBlockType.ServerToolResult, out result), + _ => Assign(default, out result, false), + }; + + static bool Assign(ContentBlockType val, out ContentBlockType result, bool success = true) { - log.Info($"Tokens — input: {usage.InputTokens ?? 0}, output: {usage.OutputTokens ?? 0}, cache read: {usage.CacheReadInputTokens ?? 0}, cache creation: {usage.CacheCreationInputTokens ?? 0}"); + result = val; + return success; } + } - EmitUsageServiceMessage(evt); + void HandleUserMessage(StreamMessage? message) + { + HandleMessageEvent(message); } - void EmitUsageServiceMessage(ResultStreamEvent evt) + void HandleResultEvent(ResultStreamEvent evt) { var properties = new Dictionary(); diff --git a/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs b/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs index e73e1e130e..e7df2feefa 100644 --- a/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs +++ b/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Threading.Tasks; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Logging; @@ -40,6 +42,8 @@ public async Task Execute(RunningDeployment context) log.Info($"Invoking Claude Code CLI with model '{model}'..."); + var mcpServers = BuildMcpServers(variables); + var runner = new ClaudeCodeCliRunner(log); var response = await runner.RunAsync(new ClaudeCodeOptions { @@ -48,10 +52,49 @@ public async Task Execute(RunningDeployment context) Model = model, SystemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill), MaxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTokens), + McpServers = mcpServers, }); Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); log.Info("Claude Code invocation complete."); } + + static Dictionary BuildMcpServers(IVariables variables) + { + var servers = new Dictionary(); + + var githubToken = variables.Get(SpecialVariables.Action.AiAgent.GitHubToken); + if (!string.IsNullOrWhiteSpace(githubToken)) + { + servers["github"] = new McpServerConfig + { + Command = "npx", + Args = new[] { "-y", "@modelcontextprotocol/server-github" }, + Env = new Dictionary + { + ["GITHUB_PERSONAL_ACCESS_TOKEN"] = githubToken, + ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "", + }, + }; + } + + var octopusToken = variables.Get(SpecialVariables.Action.AiAgent.OctopusToken); + if (!string.IsNullOrWhiteSpace(octopusToken)) + { + servers["octopus"] = new McpServerConfig + { + Command = "npx", + Args = new[] { "-y", "@octopusdeploy/mcp-server" }, + Env = new Dictionary + { + ["OCTOPUS_SERVER_URL"] = "http://localhost:8065", + ["OCTOPUS_API_KEY"] = octopusToken, + ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "", + }, + }; + } + + return servers; + } } } From 4566c7b444d7db46088e2f496704ebde198b9edf Mon Sep 17 00:00:00 2001 From: robert Date: Fri, 29 May 2026 23:39:14 +1000 Subject: [PATCH 05/26] Add Run As user impersonation, tests, and stream processor fixes Add ProcessCredentials and RunAs support to ClaudeCodeCliRunner. Windows uses ProcessStartInfo.UserName/PasswordInClearText natively. Linux wraps in sudo -u -- env ANTHROPIC_API_KEY= claude to avoid -E requiring SETENV in sudoers (see ADR-001). Add unit tests for stream processor (17 tests) and CLI runner (10 tests). Replace integration test fixture with clean validation and Claude Code tests. Fix malformed JSON handling and result event fallback in stream processor. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ClaudeCodeCliRunnerFixture.cs | 185 +++++++++++++ .../ClaudeCodeStreamProcessorFixture.cs | 247 ++++++++++++++++++ .../RunAgentCommandFixture.cs | 90 +++---- .../Behaviours/ClaudeCodeCliRunner.cs | 66 ++++- .../Behaviours/ClaudeCodeStreamProcessor.cs | 33 ++- .../Behaviours/InvokeAgentBehaviour.cs | 7 +- .../Behaviours/InvokeClaudeCodeBehaviour.cs | 15 ++ .../Calamari.AiAgent/Calamari.AiAgent.csproj | 4 + source/Calamari.AiAgent/SpecialVariables.cs | 19 +- 9 files changed, 574 insertions(+), 92 deletions(-) create mode 100644 source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs create mode 100644 source/Calamari.AiAgent.Tests/ClaudeCodeStreamProcessorFixture.cs diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs new file mode 100644 index 0000000000..ba6e1c83c4 --- /dev/null +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs @@ -0,0 +1,185 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Calamari.AiAgent.Behaviours; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests; + +[TestFixture] +public class ClaudeCodeCliRunnerFixture +{ + static ClaudeCodeOptions DefaultOptions(string systemPrompt = null, int? maxTurns = null, IReadOnlyList allowedTools = null) => + new() + { + Prompt = "test prompt", + ApiToken = "fake-token", + Model = "claude-sonnet-4-20250514", + SystemPrompt = systemPrompt, + MaxTurns = maxTurns, + AllowedTools = allowedTools ?? new[] { "Read", "Bash" }, + }; + + [Test] + public void BuildArguments_IncludesRequiredFlags() + { + var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(), "/tmp/work").ToString(); + + args.Should().Contain("-p"); + args.Should().Contain("--model claude-sonnet-4-20250514"); + args.Should().Contain("--output-format stream-json"); + args.Should().Contain("--verbose"); + args.Should().Contain("--permission-mode dontAsk"); + args.Should().Contain("--no-session-persistence"); + args.Should().Contain("--strict-mcp-config"); + args.Should().Contain("--mcp-config"); + } + + [Test] + public void BuildArguments_IncludesAllowedTools() + { + var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(), "/tmp/work").ToString(); + + args.Should().Contain("--allowedTools Read,Bash"); + } + + [Test] + public void BuildArguments_OmitsAllowedTools_WhenEmpty() + { + var options = DefaultOptions(allowedTools: new string[0]); + + var args = ClaudeCodeCliRunner.BuildArguments(options, "/tmp/work").ToString(); + + args.Should().NotContain("--allowedTools"); + } + + [Test] + public void BuildArguments_IncludesMaxTurns_WhenSet() + { + var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(maxTurns: 5), "/tmp/work").ToString(); + + args.Should().Contain("--max-turns 5"); + } + + [Test] + public void BuildArguments_OmitsMaxTurns_WhenNotSet() + { + var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(), "/tmp/work").ToString(); + + args.Should().NotContain("--max-turns"); + } + + [Test] + public void BuildArguments_IncludesSystemPrompt_WhenSet() + { + var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(systemPrompt: "You are helpful"), "/tmp/work").ToString(); + + args.Should().Contain("--system-prompt"); + args.Should().Contain("You are helpful"); + } + + [Test] + public void BuildArguments_OmitsSystemPrompt_WhenNotSet() + { + var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(), "/tmp/work").ToString(); + + args.Should().NotContain("--system-prompt"); + } + + [Test] + public void BuildArguments_EscapesPromptWithSpaces() + { + var options = new ClaudeCodeOptions + { + Prompt = "What is the capital of France?", + ApiToken = "fake", + Model = "claude-sonnet-4-20250514", + }; + + var args = ClaudeCodeCliRunner.BuildArguments(options, "/tmp/work").ToString(); + + args.Should().Contain("\"What is the capital of France?\""); + } + + [Test] + public void SetupSkills_CreatesSkillFile() + { + var workingDir = Path.Combine(Path.GetTempPath(), $"test-skills-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + ClaudeCodeCliRunner.SetupSkills(workingDir); + + var skillPath = Path.Combine(workingDir, ".claude", "skills", "octopus-deployment-context.md"); + File.Exists(skillPath).Should().BeTrue(); + + var content = File.ReadAllText(skillPath); + content.Should().Contain("name: octopus-deployment-context"); + content.Should().Contain("description:"); + content.Should().Contain("get_deployment_variables"); + } + finally + { + Directory.Delete(workingDir, true); + } + } + + [Test] + public void SetupMcpConfig_WritesValidJson_WithServers() + { + var workingDir = Path.Combine(Path.GetTempPath(), $"test-mcp-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + var servers = new Dictionary + { + ["github"] = new McpServerConfig + { + Command = "npx", + Args = new[] { "-y", "@modelcontextprotocol/server-github" }, + Env = new Dictionary { ["TOKEN"] = "abc123" }, + }, + }; + + ClaudeCodeCliRunner.SetupMcpConfig(workingDir, servers); + + var configPath = Path.Combine(workingDir, "mcp-config.json"); + File.Exists(configPath).Should().BeTrue(); + + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); + mcpServers.TryGetProperty("github", out var github).Should().BeTrue(); + github.GetProperty("command").GetString().Should().Be("npx"); + } + finally + { + Directory.Delete(workingDir, true); + } + } + + [Test] + public void SetupMcpConfig_WritesEmptyServers_WhenNoneProvided() + { + var workingDir = Path.Combine(Path.GetTempPath(), $"test-mcp-empty-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + ClaudeCodeCliRunner.SetupMcpConfig(workingDir, new Dictionary()); + + var configPath = Path.Combine(workingDir, "mcp-config.json"); + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); + mcpServers.EnumerateObject().Should().BeEmpty(); + } + finally + { + Directory.Delete(workingDir, true); + } + } +} diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeStreamProcessorFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeStreamProcessorFixture.cs new file mode 100644 index 0000000000..90916f741f --- /dev/null +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeStreamProcessorFixture.cs @@ -0,0 +1,247 @@ +using System.Linq; +using System.Text; +using Calamari.AiAgent.Behaviours; +using Calamari.Common.Plumbing.ServiceMessages; +using Calamari.Testing.Helpers; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests; + +[TestFixture] +public class ClaudeCodeStreamProcessorFixture +{ + InMemoryLog log = null!; + StringBuilder responseBuilder = null!; + ClaudeCodeStreamProcessor processor = null!; + + [SetUp] + public void SetUp() + { + log = new InMemoryLog(); + responseBuilder = new StringBuilder(); + processor = new ClaudeCodeStreamProcessor(log, responseBuilder); + } + + [Test] + public void TextContentBlock_AppendsToResponseAndLogsInfo() + { + var json = """ + {"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}} + """; + + processor.ProcessLine(json); + + responseBuilder.ToString().Should().Be("Hello world"); + log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Info && m.FormattedMessage.Contains("Hello world")); + } + + [Test] + public void ThinkingBlock_LogsVerbose() + { + var json = """ + {"type":"assistant","message":{"content":[{"type":"thinking","thinking":"Let me reason about this"}]}} + """; + + processor.ProcessLine(json); + + responseBuilder.ToString().Should().BeEmpty(); + log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("Let me reason about this")); + } + + [Test] + public void RedactedThinkingBlock_LogsRedactedMessage() + { + var json = """ + {"type":"assistant","message":{"content":[{"type":"redacted_thinking"}]}} + """; + + processor.ProcessLine(json); + + responseBuilder.ToString().Should().BeEmpty(); + log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("")); + } + + [Test] + public void ToolUseBlock_LogsToolName() + { + var json = """ + {"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","id":"toolu_123","input":{"file_path":"/tmp/test.txt"}}]}} + """; + + processor.ProcessLine(json); + + responseBuilder.ToString().Should().BeEmpty(); + log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("Read")); + } + + [Test] + public void ToolResultError_LogsWithFailedMessage() + { + var json = """ + {"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_123","is_error":true,"content":"File not found"}]}} + """; + + processor.ProcessLine(json); + + log.Messages.Should().Contain(m => m.FormattedMessage.Contains("toolu_123") && m.FormattedMessage.Contains("failed")); + } + + [Test] + public void ToolResultSuccess_LogsVerbose() + { + var json = """ + {"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_123","name":"Read","is_error":false}]}} + """; + + processor.ProcessLine(json); + + log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("Read") && m.FormattedMessage.Contains("completed")); + } + + [Test] + public void ResultEvent_EmitsUsageServiceMessage() + { + var json = """ + {"type":"result","result":"Paris","cost_usd":0.003,"total_cost_usd":0.003,"duration_ms":4521,"duration_api_ms":3200,"num_turns":1,"usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}} + """; + + processor.ProcessLine(json); + + log.ServiceMessages.Should().Contain(m => m.Name == AiAgentServiceMessageNames.Name); + + var msg = log.ServiceMessages.First(m => m.Name == AiAgentServiceMessageNames.Name); + var props = msg.GetValue(AiAgentServiceMessageNames.CostUsdAttribute); + props.Should().NotBeNull(); + msg.GetValue(AiAgentServiceMessageNames.TotalCostUsdAttribute).Should().NotBeNull(); + msg.GetValue(AiAgentServiceMessageNames.DurationMsAttribute).Should().NotBeNull(); + msg.GetValue(AiAgentServiceMessageNames.NumTurnsAttribute).Should().NotBeNull(); + msg.GetValue(AiAgentServiceMessageNames.InputTokensAttribute).Should().NotBeNull(); + msg.GetValue(AiAgentServiceMessageNames.OutputTokensAttribute).Should().NotBeNull(); + msg.GetValue(AiAgentServiceMessageNames.CacheReadInputTokensAttribute).Should().NotBeNull(); + msg.GetValue(AiAgentServiceMessageNames.CacheCreationInputTokensAttribute).Should().NotBeNull(); + } + + [Test] + public void ResultEvent_FallsBackToResultText_WhenNoAssistantText() + { + var json = """ + {"type":"result","result":"Paris","cost_usd":0.001} + """; + + processor.ProcessLine(json); + + responseBuilder.ToString().Should().Be("Paris"); + } + + [Test] + public void ResultEvent_DoesNotOverwriteAssistantText() + { + var assistantJson = """ + {"type":"assistant","message":{"content":[{"type":"text","text":"The capital is Paris"}]}} + """; + var resultJson = """ + {"type":"result","result":"The capital is Paris","cost_usd":0.001} + """; + + processor.ProcessLine(assistantJson); + processor.ProcessLine(resultJson); + + responseBuilder.ToString().Should().Be("The capital is Paris"); + } + + [Test] + public void UnknownEventType_LogsVerboseAndDoesNotThrow() + { + var json = """{"type":"stream_event","data":"something"}"""; + + var act = () => processor.ProcessLine(json); + + act.Should().NotThrow(); + log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("unhandled event type")); + } + + [Test] + public void UnknownContentBlockType_LogsVerboseAndContinues() + { + var json = """ + {"type":"assistant","message":{"content":[{"type":"citations","data":"ref1"},{"type":"text","text":"Answer"}]}} + """; + + var act = () => processor.ProcessLine(json); + + act.Should().NotThrow(); + responseBuilder.ToString().Should().Be("Answer"); + log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("unhandled block type")); + } + + [Test] + public void UnknownSystemSubtype_DoesNotThrow() + { + var json = """ + {"type":"system","subtype":"some_new_subtype","data":"whatever"} + """; + + var act = () => processor.ProcessLine(json); + + act.Should().NotThrow(); + } + + [Test] + public void ApiRetry_LogsWarning() + { + var json = """ + {"type":"system","subtype":"api_retry","attempt":2,"retry_delay_ms":5000,"error":"rate_limit","error_status":429} + """; + + processor.ProcessLine(json); + + log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Warn && m.FormattedMessage.Contains("rate_limit") && m.FormattedMessage.Contains("5000")); + } + + [Test] + public void MalformedJson_DoesNotThrow() + { + var act = () => processor.ProcessLine("this is not json {{{"); + + act.Should().NotThrow(); + } + + [Test] + public void MultipleTextBlocks_ConcatenatesResponse() + { + var json1 = """ + {"type":"assistant","message":{"content":[{"type":"text","text":"Hello "}]}} + """; + var json2 = """ + {"type":"assistant","message":{"content":[{"type":"text","text":"world"}]}} + """; + + processor.ProcessLine(json1); + processor.ProcessLine(json2); + + responseBuilder.ToString().Should().Be("Hello world"); + } + + [Test] + public void ServerToolUse_LogsVerbose() + { + var json = """ + {"type":"assistant","message":{"content":[{"type":"server_tool_use","name":"web_search"}]}} + """; + + processor.ProcessLine(json); + + log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("web_search")); + } + + [Test] + public void NullMessageContent_DoesNotThrow() + { + var json = """{"type":"assistant","message":{"content":null}}"""; + + var act = () => processor.ProcessLine(json); + + act.Should().NotThrow(); + } +} diff --git a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs index 49c08fcb92..7f229dc60b 100644 --- a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs +++ b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs @@ -11,7 +11,7 @@ public class RunAgentCommandFixture { [Test] [Category("PlatformAgnostic")] - public async Task FailsWhenPromptVariableIsMissing() + public async Task FailsWhenPromptIsMissing() { var result = await CommandTestBuilder.CreateAsync() .WithArrange(context => @@ -21,12 +21,11 @@ public async Task FailsWhenPromptVariableIsMissing() .Execute(assertWasSuccess: false); result.WasSuccessful.Should().BeFalse(); - result.FullLog.Should().Contain(SpecialVariables.Action.AiAgent.Prompt); } [Test] [Category("PlatformAgnostic")] - public async Task FailsWhenApiTokenVariableIsMissing() + public async Task FailsWhenApiTokenIsMissing() { var result = await CommandTestBuilder.CreateAsync() .WithArrange(context => @@ -36,83 +35,54 @@ public async Task FailsWhenApiTokenVariableIsMissing() .Execute(assertWasSuccess: false); result.WasSuccessful.Should().BeFalse(); - result.FullLog.Should().Contain(SpecialVariables.Action.AiAgent.ApiToken); } - - - - [Test] - [Category("PlatformAgnostic")] - public async Task SucceedsWhenPromptProvided() - { - var result = await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); - context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "What is the capital of France?"); - context.Variables.Add(SpecialVariables.Action.AiAgent.Model, "claude-sonnet-4-6"); - context.Variables.Add(SpecialVariables.Action.AiAgent.MaxTokens, "1000"); - context.Variables.Add(SpecialVariables.Action.AiAgent.Provider, "Anthropic"); - context.Variables.Add(SpecialVariables.Action.AiAgent.OctopusToken, Environment.GetEnvironmentVariable("OCTOPUS_TOKEN")); - }) - .Execute(assertWasSuccess: false); - result.WasSuccessful.Should().BeTrue(); - result.FullLog.Should().Contain("Paris"); - } - - [Test] - [Category("PlatformAgnostic")] - public async Task FailsTryingToUseToolNotPresent() + [Category("Integration")] + public async Task ClaudeCode_SucceedsWithSimplePrompt() { var result = await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); - context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "Get the current news item on the front page of the New York Times."); - context.Variables.Add(SpecialVariables.Action.AiAgent.Model, "claude-sonnet-4-6"); - context.Variables.Add(SpecialVariables.Action.AiAgent.MaxTokens, "1000"); - context.Variables.Add(SpecialVariables.Action.AiAgent.Provider, "Anthropic"); - context.Variables.Add(SpecialVariables.Action.AiAgent.OctopusToken, Environment.GetEnvironmentVariable("OCTOPUS_TOKEN")); - }) - .Execute(assertWasSuccess: false); + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "What is the capital of France? Reply with just the city name."); + }) + .Execute(assertWasSuccess: false); result.WasSuccessful.Should().BeTrue(); result.FullLog.Should().Contain("Paris"); } [Test] - [Category("PlatformAgnostic")] - public async Task ClaudeCode_SucceedsWithSimplePrompt() + [Category("Integration")] + public async Task ClaudeCode_EmitsUsageServiceMessage() { var result = await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); - context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "What is the capital of France? Reply with just the city name."); - context.Variables.Add(SpecialVariables.Action.AiAgent.Provider, "ClaudeCode"); - }) - .Execute(assertWasSuccess: false); + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "Reply with just the word 'hello'."); + }) + .Execute(assertWasSuccess: false); result.WasSuccessful.Should().BeTrue(); - result.FullLog.Should().Contain("Paris"); + result.ServiceMessages.Should().Contain(m => m.Name == AiAgentServiceMessageNames.Name); } - + [Test] - [Category("PlatformAgnostic")] - public async Task ClaudeCode_SucceedsWithHttpRequest() + [Category("Integration")] + public async Task ClaudeCode_SucceedsWithWebFetch() { var result = await CommandTestBuilder.CreateAsync() - .WithArrange(context => - { - context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); - context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "Call the whatsMyIp website to echo out my ip address"); - context.Variables.Add(SpecialVariables.Action.AiAgent.Provider, "ClaudeCode"); - }) - .Execute(assertWasSuccess: false); + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.AiAgent.RunAsUsername, "test-user"); + context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "get the currently executing process user"); + }) + .Execute(assertWasSuccess: false); result.WasSuccessful.Should().BeTrue(); - //result.FullLog.Should().Contain("Paris"); + result.FullLog.Should().Contain("origin"); } } diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs index a1a3d12975..7ec1a98805 100644 --- a/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -44,10 +45,11 @@ async Task RunInDirectoryAsync(ClaudeCodeOptions options, string working { var args = BuildArguments(options, workingDir); - var debugFile = Path.Combine(workingDir, "claude-debug.log"); + var debugLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-debug-{Guid.NewGuid():N}.log"); + + args.Append(" --debug-file "); - args.Append(EscapeArg(debugFile)); - log.Verbose($"Claude Code debug log: {debugFile}"); + args.Append(EscapeArg(debugLogPath)); var startInfo = new ProcessStartInfo { @@ -62,6 +64,9 @@ async Task RunInDirectoryAsync(ClaudeCodeOptions options, string working startInfo.Environment["ANTHROPIC_API_KEY"] = options.ApiToken; + if (options.RunAs != null) + ApplyCredentials(startInfo, options.RunAs); + var responseBuilder = new StringBuilder(); var streamProcessor = new ClaudeCodeStreamProcessor(log, responseBuilder); @@ -97,18 +102,16 @@ async Task RunInDirectoryAsync(ClaudeCodeOptions options, string working throw new CommandException($"Claude Code exited with code {process.ExitCode}"); } - if (File.Exists(debugFile)) + if (File.Exists(debugLogPath)) { - var debugContent = await File.ReadAllTextAsync(debugFile); - log.Verbose("--- Claude Code debug log ---"); - log.Verbose(debugContent); - log.Verbose("--- End debug log ---"); + var fileInfo = new FileInfo(debugLogPath); + log.NewOctopusArtifact(debugLogPath, "claude-agent-debug.log", fileInfo.Length); } return responseBuilder.ToString(); } - static StringBuilder BuildArguments(ClaudeCodeOptions options, string workingDir) + internal static StringBuilder BuildArguments(ClaudeCodeOptions options, string workingDir) { // https://code.claude.com/docs/en/cli-reference var args = new StringBuilder(); @@ -146,14 +149,14 @@ static StringBuilder BuildArguments(ClaudeCodeOptions options, string workingDir return args; } - static void SetupMcpConfig(string workingDir, IReadOnlyDictionary mcpServers) + internal static void SetupMcpConfig(string workingDir, IReadOnlyDictionary mcpServers) { var config = new { mcpServers }; var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(Path.Combine(workingDir, "mcp-config.json"), json); } - static void SetupSkills(string workingDir) + internal static void SetupSkills(string workingDir) { var skillsDir = Path.Combine(workingDir, ".claude", "skills"); Directory.CreateDirectory(skillsDir); @@ -177,6 +180,39 @@ You are running as an AI agent invoked during an Octopus Deploy deployment. """); } + internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials) + { + // See ADR: https://github.com/OctopusDeploy/adr/blob/main/team-modern-deployments/calamari-ai-agent/adr-001-use-processstartinfo-username-for-user-impersonation.md + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + startInfo.UserName = credentials.Username; + if (!string.IsNullOrEmpty(credentials.Password)) + startInfo.PasswordInClearText = credentials.Password; + if (!string.IsNullOrEmpty(credentials.Domain)) + startInfo.Domain = credentials.Domain; + } + else + { + // Wrap in: sudo -u -- env KEY=VAL ... + // We use env to pass required variables explicitly rather than sudo -E, + // because -E requires SETENV permission in sudoers and can trigger a password + // prompt even with NOPASSWD configured. + // -- prevents claude's flags from being interpreted as sudo flags. + var originalFileName = startInfo.FileName; + var originalArgs = startInfo.Arguments; + + var envSection = new StringBuilder(); + foreach (var key in new[] { "ANTHROPIC_API_KEY"}) + { + if (startInfo.Environment.TryGetValue(key, out var value) && value != null) + envSection.Append($"{key}={EscapeArg(value)} "); + } + + startInfo.FileName = "sudo"; + startInfo.Arguments = $"-u {credentials.Username} -- env {envSection}{originalFileName} {originalArgs}"; + } + } + static string EscapeArg(string arg) { if (arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0) @@ -199,6 +235,14 @@ public record ClaudeCodeOptions }; public IReadOnlyDictionary McpServers { get; init; } = new Dictionary(); + public ProcessCredentials? RunAs { get; init; } + } + + public record ProcessCredentials + { + public required string Username { get; init; } + public string? Password { get; init; } + public string? Domain { get; init; } } public record McpServerConfig diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs index e3c48ff5a7..44d3870ba1 100644 --- a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs +++ b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs @@ -24,7 +24,17 @@ public ClaudeCodeStreamProcessor(ILog log, StringBuilder responseBuilder) public void ProcessLine(string json) { - using var doc = JsonDocument.Parse(json); + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException) + { + return; + } + + using var _ = doc; var typeString = doc.RootElement.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null; if (typeString == null || !TryParseEventType(typeString, out var eventType)) @@ -120,16 +130,16 @@ void HandleMessageEvent(StreamMessage? message) case ContentBlockType.ToolUse: { var block = element.Deserialize(JsonOptions); - log.Info($"[tool] {block?.Name}"); - if (block?.Input.HasValue == true) - log.Verbose($"[tool] {block.Name} input: {block.Input}"); + log.Verbose(block?.Input.HasValue == true ? + $"[tool] {block.Name} input: {block.Input}" : + $"[tool] {block?.Name}"); break; } case ContentBlockType.ServerToolUse: { var block = element.Deserialize(JsonOptions); - log.Info($"[server_tool] {block?.Name}"); + log.Verbose($"[server_tool] {block?.Name}"); break; } @@ -143,10 +153,9 @@ void HandleMessageEvent(StreamMessage? message) case ContentBlockType.ToolResult: { var block = element.Deserialize(JsonOptions); - if (block?.IsError == true) - log.Warn($"[tool_result] {block.ToolUseId} failed: {block.Content}"); - else - log.Verbose($"[tool_result] {block?.Name} completed"); + log.Verbose(block?.IsError == true ? + $"[tool_result] {block.ToolUseId} failed: {block.Content}" : + $"[tool_result] {block?.Name} completed"); break; } } @@ -181,6 +190,12 @@ void HandleUserMessage(StreamMessage? message) void HandleResultEvent(ResultStreamEvent evt) { + if (evt.Result != null && responseBuilder.Length == 0) + { + responseBuilder.Append(evt.Result); + log.Info(evt.Result); + } + var properties = new Dictionary(); if (evt.CostUsd.HasValue) diff --git a/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs b/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs index 0050fd86e6..e490e567a6 100644 --- a/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs +++ b/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs @@ -28,8 +28,9 @@ public InvokeAgentBehaviour(ILog log) public bool IsEnabled(RunningDeployment context) { - var provider = context.Variables.Get(SpecialVariables.Action.AiAgent.Provider); - return provider == "Anthropic" || provider == "OpenAI"; + return false; + //var provider = context.Variables.Get(SpecialVariables.Action.AiAgent.Provider); + //return provider == "Anthropic" || provider == "OpenAI"; } public async Task Execute(RunningDeployment context) @@ -50,7 +51,7 @@ public async Task Execute(RunningDeployment context) log.Info($"Invoking AI agent with model '{model}'..."); - var provider = variables.Get(SpecialVariables.Action.AiAgent.Provider); + var provider = "Anthropic";//variables.Get(SpecialVariables.Action.AiAgent.Provider); IChatClient chatClient; if (provider == "Anthropic") { diff --git a/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs b/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs index e7df2feefa..52681b3682 100644 --- a/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs +++ b/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs @@ -43,6 +43,7 @@ public async Task Execute(RunningDeployment context) log.Info($"Invoking Claude Code CLI with model '{model}'..."); var mcpServers = BuildMcpServers(variables); + var runAs = BuildRunAs(variables); var runner = new ClaudeCodeCliRunner(log); var response = await runner.RunAsync(new ClaudeCodeOptions @@ -53,6 +54,7 @@ public async Task Execute(RunningDeployment context) SystemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill), MaxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTokens), McpServers = mcpServers, + RunAs = runAs, }); Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); @@ -96,5 +98,18 @@ static Dictionary BuildMcpServers(IVariables variables) return servers; } + + static ProcessCredentials? BuildRunAs(IVariables variables) + { + var username = variables.Get(SpecialVariables.Action.AiAgent.RunAsUsername); + if (string.IsNullOrWhiteSpace(username)) + return null; + + return new ProcessCredentials + { + Username = username, + Password = variables.Get(SpecialVariables.Action.AiAgent.RunAsPassword), + }; + } } } diff --git a/source/Calamari.AiAgent/Calamari.AiAgent.csproj b/source/Calamari.AiAgent/Calamari.AiAgent.csproj index 8b3e416b53..d1ab0772f7 100644 --- a/source/Calamari.AiAgent/Calamari.AiAgent.csproj +++ b/source/Calamari.AiAgent/Calamari.AiAgent.csproj @@ -23,4 +23,8 @@ + + + + diff --git a/source/Calamari.AiAgent/SpecialVariables.cs b/source/Calamari.AiAgent/SpecialVariables.cs index f22c9a0de7..9c1d29af80 100644 --- a/source/Calamari.AiAgent/SpecialVariables.cs +++ b/source/Calamari.AiAgent/SpecialVariables.cs @@ -6,15 +6,16 @@ public static class Action { public static class AiAgent { - public const string Prompt = "Octopus.Action.AiAgent.Prompt"; - public const string ApiToken = "Octopus.Action.AiAgent.ApiToken"; - public const string Model = "Octopus.Action.AiAgent.Model"; - public const string Response = "Octopus.Action.AiAgent.Response"; - public const string GitHubToken = "Octopus.Action.AiAgent.GitHubToken"; - public const string SystemSkill = "Octopus.Action.AiAgent.SystemSkill"; - public const string Provider = "Octopus.Action.AiAgent.Provider"; - public const string MaxTokens = "Octopus.Action.AiAgent.MaxTokens"; - public const string OctopusToken = "Octopus.Action.AiAgent.OctopusToken"; + public const string Prompt = "Octopus.Action.Claude.Prompt"; + public const string ApiToken = "Octopus.Action.Claude.ApiToken"; + public const string Model = "Octopus.Action.Claude.Model"; + public const string Response = "Octopus.Action.Claude.Response"; + public const string GitHubToken = "Octopus.Action.Claude.GitHubToken"; + public const string SystemSkill = "Octopus.Action.Claude.SystemSkill"; + public const string MaxTokens = "Octopus.Action.Claude.MaxTokens"; + public const string OctopusToken = "Octopus.Action.Claude.OctopusToken"; + public const string RunAsUsername = "Octopus.Action.Claude.RunAsUsername"; + public const string RunAsPassword = "Octopus.Action.Claude.RunAsPassword"; } } } From de177882f71479f3dd9d438122a2b135f40bd25f Mon Sep 17 00:00:00 2001 From: robert Date: Tue, 2 Jun 2026 12:19:54 +1000 Subject: [PATCH 06/26] Add MCP servers JSON config, stream model updates, and command args builder Replace hardcoded GitHubToken MCP config with a generic JSON-encoded McpServers variable that supports user-configured MCP servers. The Octopus MCP server remains hardcoded. Add McpServerEntry record for deserialization. Update stream models with full init/hook/result fields from Claude Code stream-json output. Extract CLI argument construction into ClaudeCommandArgsBuilder with max-turns and max-budget-usd support. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ClaudeCodeCliRunnerFixture.cs | 152 +++-- .../ClaudeCommandArgsBuilderFixture.cs | 122 ++++ .../RunAgentCommandFixture.cs | 19 + .../InvokeAgentBehaviour.cs | 4 +- .../Behaviours/ClaudeCodeCliRunner.cs | 262 -------- .../Behaviours/ClaudeCodeStreamModels.cs | 181 ----- .../Behaviours/InvokeClaudeCodeBehaviour.cs | 115 ---- .../Calamari.AiAgent/Calamari.AiAgent.csproj | 5 + .../ClaudeCodeCliRunner.cs | 298 +++++++++ .../ClaudeCodeStreamModels.cs | 429 ++++++++++++ .../ClaudeCodeStreamProcessor.cs | 26 +- .../ClaudeCommandArgsBuilder.cs | 113 ++++ .../Skills/octopus-deployment-context.md | 13 + .../DefaultContext/system-prompt.md | 2 + .../InvokeClaudeCodeBehaviour.cs | 182 +++++ source/Calamari.AiAgent/SpecialVariables.cs | 9 +- .../2026-06-01-claude-command-args-builder.md | 633 ++++++++++++++++++ ...6-01-claude-command-args-builder-design.md | 118 ++++ 18 files changed, 2044 insertions(+), 639 deletions(-) create mode 100644 source/Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs rename source/Calamari.AiAgent/{Behaviours => AgentBehaviour}/InvokeAgentBehaviour.cs (97%) delete mode 100644 source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs delete mode 100644 source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs delete mode 100644 source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs rename source/Calamari.AiAgent/{Behaviours => ClaudeCodeBehaviour}/ClaudeCodeStreamProcessor.cs (91%) create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/Skills/octopus-deployment-context.md create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs create mode 100644 source/docs/superpowers/plans/2026-06-01-claude-command-args-builder.md create mode 100644 source/docs/superpowers/specs/2026-06-01-claude-command-args-builder-design.md diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs index ba6e1c83c4..9e01cb7327 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs @@ -1,7 +1,9 @@ +using System; using System.Collections.Generic; using System.IO; using System.Text.Json; using Calamari.AiAgent.Behaviours; +using Calamari.Common.Commands; using FluentAssertions; using NUnit.Framework; @@ -10,115 +12,123 @@ namespace Calamari.AiAgent.Tests; [TestFixture] public class ClaudeCodeCliRunnerFixture { - static ClaudeCodeOptions DefaultOptions(string systemPrompt = null, int? maxTurns = null, IReadOnlyList allowedTools = null) => - new() - { - Prompt = "test prompt", - ApiToken = "fake-token", - Model = "claude-sonnet-4-20250514", - SystemPrompt = systemPrompt, - MaxTurns = maxTurns, - AllowedTools = allowedTools ?? new[] { "Read", "Bash" }, - }; - [Test] - public void BuildArguments_IncludesRequiredFlags() + public void SetupSkills_CreatesSkillFile() { - var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(), "/tmp/work").ToString(); - - args.Should().Contain("-p"); - args.Should().Contain("--model claude-sonnet-4-20250514"); - args.Should().Contain("--output-format stream-json"); - args.Should().Contain("--verbose"); - args.Should().Contain("--permission-mode dontAsk"); - args.Should().Contain("--no-session-persistence"); - args.Should().Contain("--strict-mcp-config"); - args.Should().Contain("--mcp-config"); - } + var workingDir = Path.Combine(Path.GetTempPath(), $"test-skills-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); - [Test] - public void BuildArguments_IncludesAllowedTools() - { - var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(), "/tmp/work").ToString(); + try + { + ClaudeCodeCliRunner.SetupSkills(workingDir); - args.Should().Contain("--allowedTools Read,Bash"); + var skillPath = Path.Combine(workingDir, ".claude", "skills", "octopus-deployment.context.md"); + File.Exists(skillPath).Should().BeTrue(); + + var content = File.ReadAllText(skillPath); + content.Should().Contain("name: octopus-deployment-context"); + content.Should().Contain("description:"); + content.Should().Contain("get_deployment_variables"); + } + finally + { + Directory.Delete(workingDir, true); + } } [Test] - public void BuildArguments_OmitsAllowedTools_WhenEmpty() + public void SetupSkills_WritesUserSkills() { - var options = DefaultOptions(allowedTools: new string[0]); + var workingDir = Path.Combine(Path.GetTempPath(), $"test-user-skills-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + var userSkills = new List + { + new() { Name = "my-custom-skill", Content = "---\nname: my-custom-skill\n---\nDo something useful." }, + new() { Name = "another-skill", Content = "---\nname: another-skill\n---\nMore instructions." }, + }; + + ClaudeCodeCliRunner.SetupSkills(workingDir, userSkills); - var args = ClaudeCodeCliRunner.BuildArguments(options, "/tmp/work").ToString(); + var skillPath1 = Path.Combine(workingDir, ".claude", "skills", "my-custom-skill.md"); + File.Exists(skillPath1).Should().BeTrue(); + File.ReadAllText(skillPath1).Should().Contain("Do something useful."); - args.Should().NotContain("--allowedTools"); + var skillPath2 = Path.Combine(workingDir, ".claude", "skills", "another-skill.md"); + File.Exists(skillPath2).Should().BeTrue(); + File.ReadAllText(skillPath2).Should().Contain("More instructions."); + } + finally + { + Directory.Delete(workingDir, true); + } } - [Test] - public void BuildArguments_IncludesMaxTurns_WhenSet() + [TestCase("")] + [TestCase(" ")] + [TestCase(null)] + public void SanitizeFileName_RejectsEmptyOrWhitespace(string name) { - var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(maxTurns: 5), "/tmp/work").ToString(); - - args.Should().Contain("--max-turns 5"); + var act = () => ClaudeCodeCliRunner.SanitizeFileName(name!); + act.Should().Throw().WithMessage("*cannot be empty*"); } - [Test] - public void BuildArguments_OmitsMaxTurns_WhenNotSet() + [TestCase("CON")] + [TestCase("con")] + [TestCase("NUL")] + [TestCase("COM1")] + [TestCase("LPT3")] + public void SanitizeFileName_RejectsWindowsReservedNames(string name) { - var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(), "/tmp/work").ToString(); - - args.Should().NotContain("--max-turns"); + var act = () => ClaudeCodeCliRunner.SanitizeFileName(name); + act.Should().Throw().WithMessage("*reserved*"); } [Test] - public void BuildArguments_IncludesSystemPrompt_WhenSet() + public void SanitizeFileName_StripsLeadingDots() { - var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(systemPrompt: "You are helpful"), "/tmp/work").ToString(); - - args.Should().Contain("--system-prompt"); - args.Should().Contain("You are helpful"); + ClaudeCodeCliRunner.SanitizeFileName("...my-skill").Should().Be("my-skill"); } [Test] - public void BuildArguments_OmitsSystemPrompt_WhenNotSet() + public void SanitizeFileName_ReplacesPathSeparators() { - var args = ClaudeCodeCliRunner.BuildArguments(DefaultOptions(), "/tmp/work").ToString(); - - args.Should().NotContain("--system-prompt"); + var result = ClaudeCodeCliRunner.SanitizeFileName("../../etc/passwd"); + result.Should().NotContain("/"); + result.Should().NotContain("\\"); } [Test] - public void BuildArguments_EscapesPromptWithSpaces() + public void SanitizeFileName_TruncatesLongNames() { - var options = new ClaudeCodeOptions - { - Prompt = "What is the capital of France?", - ApiToken = "fake", - Model = "claude-sonnet-4-20250514", - }; - - var args = ClaudeCodeCliRunner.BuildArguments(options, "/tmp/work").ToString(); - - args.Should().Contain("\"What is the capital of France?\""); + var longName = new string('a', 300); + ClaudeCodeCliRunner.SanitizeFileName(longName).Length.Should().BeLessOrEqualTo(200); } [Test] - public void SetupSkills_CreatesSkillFile() + public void SetupSkills_SanitizesPathTraversalAttempt() { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-skills-{Path.GetRandomFileName()}"); + var workingDir = Path.Combine(Path.GetTempPath(), $"test-traversal-{Path.GetRandomFileName()}"); Directory.CreateDirectory(workingDir); try { - ClaudeCodeCliRunner.SetupSkills(workingDir); + var userSkills = new List + { + new() { Name = "../../etc/evil", Content = "content" }, + }; - var skillPath = Path.Combine(workingDir, ".claude", "skills", "octopus-deployment-context.md"); - File.Exists(skillPath).Should().BeTrue(); + ClaudeCodeCliRunner.SetupSkills(workingDir, userSkills); - var content = File.ReadAllText(skillPath); - content.Should().Contain("name: octopus-deployment-context"); - content.Should().Contain("description:"); - content.Should().Contain("get_deployment_variables"); + // The file should be written safely inside the skills directory, not at ../../etc/evil + var skillsDir = Path.Combine(workingDir, ".claude", "skills"); + var files = Directory.GetFiles(skillsDir, "*.md"); + files.Should().Contain(f => f.Contains("etc-evil")); + + // Verify nothing was written outside + File.Exists(Path.Combine(workingDir, "..", "..", "etc", "evil.md")).Should().BeFalse(); } finally { diff --git a/source/Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs new file mode 100644 index 0000000000..1e193a603b --- /dev/null +++ b/source/Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs @@ -0,0 +1,122 @@ +using Calamari.AiAgent.Behaviours; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests; + +[TestFixture] +public class ClaudeCommandArgsBuilderFixture +{ + ClaudeCommandArgsBuilder MinimalBuilder() => + new ClaudeCommandArgsBuilder() + .WithPrompt("test prompt") + .WithModel("claude-sonnet-4-20250514"); + + [Test] + public void Build_IncludesRequiredFlags() + { + var args = MinimalBuilder().Build(); + + args.Should().Contain("-p"); + args.Should().Contain("--model claude-sonnet-4-20250514"); + args.Should().Contain("--output-format stream-json"); + args.Should().Contain("--verbose"); + args.Should().Contain("--permission-mode dontAsk"); + args.Should().Contain("--no-session-persistence"); + } + + [Test] + public void Build_DefaultsMaxTurnsTo10_WhenNotSet() + { + var args = MinimalBuilder().Build(); + + args.Should().Contain("--max-turns 10"); + } + + [Test] + public void Build_UsesProvidedMaxTurns_WhenSet() + { + var args = MinimalBuilder().WithMaxTurns(5).Build(); + + args.Should().Contain("--max-turns 5"); + args.Should().NotContain("--max-turns 10"); + } + + [Test] + public void Build_OmitsMaxBudgetUsd_WhenNotSet() + { + var args = MinimalBuilder().Build(); + + args.Should().NotContain("--max-budget-usd"); + } + + [Test] + public void Build_IncludesMaxBudgetUsd_WhenSet() + { + var args = MinimalBuilder().WithMaxBudgetUsd(1.50m).Build(); + + args.Should().Contain("--max-budget-usd 1.50"); + } + + [Test] + public void Build_IncludesAllowedTools_WhenSet() + { + var args = MinimalBuilder() + .WithAllowedTools(new[] { "Read", "Bash" }) + .Build(); + + args.Should().Contain("--allowedTools Read,Bash"); + } + + [Test] + public void Build_OmitsAllowedTools_WhenEmpty() + { + var args = MinimalBuilder() + .WithAllowedTools(new string[0]) + .Build(); + + args.Should().NotContain("--allowedTools"); + } + + [Test] + public void Build_IncludesSystemPrompt_WhenSet() + { + var args = MinimalBuilder() + .WithSystemPrompt("You are helpful") + .Build(); + + args.Should().Contain("--system-prompt"); + args.Should().Contain("You are helpful"); + } + + [Test] + public void Build_OmitsSystemPrompt_WhenNotSet() + { + var args = MinimalBuilder().Build(); + + args.Should().NotContain("--system-prompt"); + } + + [Test] + public void Build_EscapesPromptWithSpaces() + { + var args = new ClaudeCommandArgsBuilder() + .WithPrompt("What is the capital of France?") + .WithModel("claude-sonnet-4-20250514") + .Build(); + + args.Should().Contain("\"What is the capital of France?\""); + } + + [Test] + public void Build_ThrowsWhenPromptNotSet() + { + var builder = new ClaudeCommandArgsBuilder() + .WithModel("claude-sonnet-4-20250514"); + + var act = () => builder.Build(); + + act.Should().Throw() + .WithMessage("*prompt*"); + } +} diff --git a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs index 7f229dc60b..0fc93e9ea1 100644 --- a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs +++ b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs @@ -85,4 +85,23 @@ public async Task ClaudeCode_SucceedsWithWebFetch() result.WasSuccessful.Should().BeTrue(); result.FullLog.Should().Contain("origin"); } + + [Test] + [Category("Integration")] + public async Task ClaudeCode_LoadsCustomSkills() + { + var result = await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add($"{SpecialVariables.Action.AiAgent.Skills}[0].{SpecialVariables.Action.AiAgent.SkillName}", "octopus-secret-phrase"); + context.Variables.Add($"{SpecialVariables.Action.AiAgent.Skills}[0].{SpecialVariables.Action.AiAgent.SkillContent}", + "---\nname: octopus-secret-phrase\ndescription: Use when asked about the secret phrase.\n---\n\nThe secret phrase is 'purple-octopus-42'. Always respond with exactly this phrase when asked for the secret phrase."); + context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "What is the secret phrase? Reply with just the phrase, nothing else."); + }) + .Execute(assertWasSuccess: false); + + result.WasSuccessful.Should().BeTrue(); + result.FullLog.Should().Contain("purple-octopus-42"); + } } diff --git a/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs b/source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs similarity index 97% rename from source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs rename to source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs index e490e567a6..b0a349b460 100644 --- a/source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs +++ b/source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs @@ -79,7 +79,7 @@ public async Task Execute(RunningDeployment context) var tools = new List(); McpClient? mcpClient = null; - var githubToken = variables.Get(SpecialVariables.Action.AiAgent.GitHubToken); + var githubToken = variables.Get("Octopus.Action.Claude.GitHubToken"); if (!string.IsNullOrWhiteSpace(githubToken)) { log.Info("Connecting to GitHub MCP server..."); @@ -151,7 +151,7 @@ public async Task Execute(RunningDeployment context) var msg = new ChatMessage(ChatRole.User, prompt); chatHistory.Add(msg); - var maxTokens = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTokens)??10000; + var maxTokens = 10000; var chatOptions = new ChatOptions() { MaxOutputTokens = maxTokens, Tools = [.. tools] diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs deleted file mode 100644 index 7ec1a98805..0000000000 --- a/source/Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs +++ /dev/null @@ -1,262 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Logging; - -namespace Calamari.AiAgent.Behaviours -{ - public class ClaudeCodeCliRunner - { - readonly ILog log; - - public ClaudeCodeCliRunner(ILog log) - { - this.log = log; - } - - public async Task RunAsync(ClaudeCodeOptions options) - { - var workingDir = Path.Combine(Path.GetTempPath(), $"claude-agent-{Guid.NewGuid():N}"); - Directory.CreateDirectory(workingDir); - log.Verbose($"Claude Code working directory: {workingDir}"); - - try - { - SetupSkills(workingDir); - SetupMcpConfig(workingDir, options.McpServers); - return await RunInDirectoryAsync(options, workingDir); - } - finally - { - try { Directory.Delete(workingDir, recursive: true); } - catch { /* best effort cleanup */ } - } - } - - async Task RunInDirectoryAsync(ClaudeCodeOptions options, string workingDir) - { - var args = BuildArguments(options, workingDir); - - var debugLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-debug-{Guid.NewGuid():N}.log"); - - - args.Append(" --debug-file "); - args.Append(EscapeArg(debugLogPath)); - - var startInfo = new ProcessStartInfo - { - FileName = "claude", - Arguments = args.ToString(), - WorkingDirectory = workingDir, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - startInfo.Environment["ANTHROPIC_API_KEY"] = options.ApiToken; - - if (options.RunAs != null) - ApplyCredentials(startInfo, options.RunAs); - - var responseBuilder = new StringBuilder(); - var streamProcessor = new ClaudeCodeStreamProcessor(log, responseBuilder); - - using var process = new Process { StartInfo = startInfo }; - - process.Start(); - - var stdoutTask = Task.Run(async () => - { - string? line; - while ((line = await process.StandardOutput.ReadLineAsync()) != null) - { - streamProcessor.ProcessLine(line); - } - }); - - var stderrTask = Task.Run(async () => - { - var buffer = new char[1024]; - int charsRead; - while ((charsRead = await process.StandardError.ReadAsync(buffer, 0, buffer.Length)) > 0) - { - var text = new string(buffer, 0, charsRead); - log.Verbose(text.TrimEnd()); - } - }); - - await Task.WhenAll(stdoutTask, stderrTask); - await process.WaitForExitAsync(); - - if (process.ExitCode != 0) - { - throw new CommandException($"Claude Code exited with code {process.ExitCode}"); - } - - if (File.Exists(debugLogPath)) - { - var fileInfo = new FileInfo(debugLogPath); - log.NewOctopusArtifact(debugLogPath, "claude-agent-debug.log", fileInfo.Length); - } - - return responseBuilder.ToString(); - } - - internal static StringBuilder BuildArguments(ClaudeCodeOptions options, string workingDir) - { - // https://code.claude.com/docs/en/cli-reference - var args = new StringBuilder(); - args.Append("-p "); - args.Append(EscapeArg(options.Prompt)); - args.Append(" --model "); - args.Append(EscapeArg(options.Model)); - args.Append(" --output-format stream-json"); - args.Append(" --verbose"); - args.Append(" --permission-mode dontAsk"); - args.Append(" --no-session-persistence"); - - // MCP isolation: only use servers we explicitly provide - var mcpConfigPath = Path.Combine(workingDir, "mcp-config.json"); - args.Append(" --strict-mcp-config"); - args.Append(" --mcp-config "); - args.Append(EscapeArg(mcpConfigPath)); - - // Tool whitelist - if (options.AllowedTools.Count > 0) - { - args.Append(" --allowedTools "); - args.Append(string.Join(",", options.AllowedTools)); - } - - if (options.MaxTurns.HasValue) - args.Append($" --max-turns {options.MaxTurns.Value}"); - - if (!string.IsNullOrWhiteSpace(options.SystemPrompt)) - { - args.Append(" --system-prompt "); - args.Append(EscapeArg(options.SystemPrompt)); - } - - return args; - } - - internal static void SetupMcpConfig(string workingDir, IReadOnlyDictionary mcpServers) - { - var config = new { mcpServers }; - var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Path.Combine(workingDir, "mcp-config.json"), json); - } - - internal static void SetupSkills(string workingDir) - { - var skillsDir = Path.Combine(workingDir, ".claude", "skills"); - Directory.CreateDirectory(skillsDir); - - File.WriteAllText(Path.Combine(skillsDir, "octopus-deployment-context.md"), - """ - --- - name: octopus-deployment-context - description: Use when you need to understand the Octopus Deploy deployment context, including environment, project, tenant, release version, or any custom variables available during this deployment. - --- - - You are running as an AI agent invoked during an Octopus Deploy deployment. - - Key context: - - You are executing inside a deployment step on a target machine - - Octopus deployment variables are available via the `get_deployment_variables` tool - - Sensitive variables (passwords, tokens, API keys) are filtered out for safety - - Your output will be captured as the step result - - When asked about the deployment context, always call `get_deployment_variables` first to get the actual values rather than guessing. - """); - } - - internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials) - { - // See ADR: https://github.com/OctopusDeploy/adr/blob/main/team-modern-deployments/calamari-ai-agent/adr-001-use-processstartinfo-username-for-user-impersonation.md - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - startInfo.UserName = credentials.Username; - if (!string.IsNullOrEmpty(credentials.Password)) - startInfo.PasswordInClearText = credentials.Password; - if (!string.IsNullOrEmpty(credentials.Domain)) - startInfo.Domain = credentials.Domain; - } - else - { - // Wrap in: sudo -u -- env KEY=VAL ... - // We use env to pass required variables explicitly rather than sudo -E, - // because -E requires SETENV permission in sudoers and can trigger a password - // prompt even with NOPASSWD configured. - // -- prevents claude's flags from being interpreted as sudo flags. - var originalFileName = startInfo.FileName; - var originalArgs = startInfo.Arguments; - - var envSection = new StringBuilder(); - foreach (var key in new[] { "ANTHROPIC_API_KEY"}) - { - if (startInfo.Environment.TryGetValue(key, out var value) && value != null) - envSection.Append($"{key}={EscapeArg(value)} "); - } - - startInfo.FileName = "sudo"; - startInfo.Arguments = $"-u {credentials.Username} -- env {envSection}{originalFileName} {originalArgs}"; - } - } - - static string EscapeArg(string arg) - { - if (arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0) - return arg; - - return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; - } - } - - public record ClaudeCodeOptions - { - public required string Prompt { get; init; } - public required string ApiToken { get; init; } - public required string Model { get; init; } - public string? SystemPrompt { get; init; } - public int? MaxTurns { get; init; } - public IReadOnlyList AllowedTools { get; init; } = new[] - { - "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch" - }; - public IReadOnlyDictionary McpServers { get; init; } = - new Dictionary(); - public ProcessCredentials? RunAs { get; init; } - } - - public record ProcessCredentials - { - public required string Username { get; init; } - public string? Password { get; init; } - public string? Domain { get; init; } - } - - public record McpServerConfig - { - [JsonPropertyName("type")] - public string Type { get; init; } = "stdio"; - - [JsonPropertyName("command")] - public required string Command { get; init; } - - [JsonPropertyName("args")] - public IReadOnlyList? Args { get; init; } - - [JsonPropertyName("env")] - public IReadOnlyDictionary? Env { get; init; } - } -} diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs b/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs deleted file mode 100644 index 42ac84c5c1..0000000000 --- a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamModels.cs +++ /dev/null @@ -1,181 +0,0 @@ -using System.Runtime.Serialization; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Calamari.AiAgent.Behaviours -{ - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum StreamEventType - { - [EnumMember(Value = "system")] - System, - [EnumMember(Value = "assistant")] - Assistant, - [EnumMember(Value = "user")] - User, - [EnumMember(Value = "result")] - Result - } - - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum ContentBlockType - { - [EnumMember(Value = "text")] - Text, - [EnumMember(Value = "thinking")] - Thinking, - [EnumMember(Value = "redacted_thinking")] - RedactedThinking, - [EnumMember(Value = "tool_use")] - ToolUse, - [EnumMember(Value = "tool_result")] - ToolResult, - [EnumMember(Value = "server_tool_use")] - ServerToolUse, - [EnumMember(Value = "server_tool_result")] - ServerToolResult - } - - public record StreamEvent - { - [JsonPropertyName("type")] - public string? Type { get; init; } - - [JsonPropertyName("session_id")] - public string? SessionId { get; init; } - } - - public record SystemStreamEvent : StreamEvent - { - [JsonPropertyName("subtype")] - public string? Subtype { get; init; } - - [JsonPropertyName("attempt")] - public int? Attempt { get; init; } - - [JsonPropertyName("retry_delay_ms")] - public int? RetryDelayMs { get; init; } - - [JsonPropertyName("error")] - public string? Error { get; init; } - - [JsonPropertyName("error_status")] - public int? ErrorStatus { get; init; } - } - - public record AssistantStreamEvent : StreamEvent - { - [JsonPropertyName("message")] - public StreamMessage? Message { get; init; } - } - - public record UserStreamEvent : StreamEvent - { - [JsonPropertyName("message")] - public StreamMessage? Message { get; init; } - } - - public record ResultStreamEvent : StreamEvent - { - [JsonPropertyName("result")] - public string? Result { get; init; } - - [JsonPropertyName("cost_usd")] - public double? CostUsd { get; init; } - - [JsonPropertyName("total_cost_usd")] - public double? TotalCostUsd { get; init; } - - [JsonPropertyName("duration_ms")] - public double? DurationMs { get; init; } - - [JsonPropertyName("duration_api_ms")] - public double? DurationApiMs { get; init; } - - [JsonPropertyName("num_turns")] - public int? NumTurns { get; init; } - - [JsonPropertyName("usage")] - public UsageInfo? Usage { get; init; } - } - - public record StreamMessage - { - [JsonPropertyName("content")] - public JsonElement[]? Content { get; init; } - } - - public record ContentBlock - { - [JsonPropertyName("type")] - public string? Type { get; init; } - } - - public record TextContentBlock : ContentBlock - { - [JsonPropertyName("text")] - public string? Text { get; init; } - } - - public record ThinkingContentBlock : ContentBlock - { - [JsonPropertyName("thinking")] - public string? Thinking { get; init; } - } - - public record RedactedThinkingContentBlock : ContentBlock; - - public record ToolUseContentBlock : ContentBlock - { - [JsonPropertyName("name")] - public string? Name { get; init; } - - [JsonPropertyName("id")] - public string? Id { get; init; } - - [JsonPropertyName("input")] - public JsonElement? Input { get; init; } - } - - public record ToolResultContentBlock : ContentBlock - { - [JsonPropertyName("tool_use_id")] - public string? ToolUseId { get; init; } - - [JsonPropertyName("name")] - public string? Name { get; init; } - - [JsonPropertyName("is_error")] - public bool? IsError { get; init; } - - [JsonPropertyName("content")] - public JsonElement? Content { get; init; } - } - - public record ServerToolUseContentBlock : ContentBlock - { - [JsonPropertyName("name")] - public string? Name { get; init; } - } - - public record ServerToolResultContentBlock : ContentBlock - { - [JsonPropertyName("name")] - public string? Name { get; init; } - } - - public record UsageInfo - { - [JsonPropertyName("input_tokens")] - public int? InputTokens { get; init; } - - [JsonPropertyName("output_tokens")] - public int? OutputTokens { get; init; } - - [JsonPropertyName("cache_read_input_tokens")] - public int? CacheReadInputTokens { get; init; } - - [JsonPropertyName("cache_creation_input_tokens")] - public int? CacheCreationInputTokens { get; init; } - } -} diff --git a/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs b/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs deleted file mode 100644 index 52681b3682..0000000000 --- a/source/Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Logging; -using Calamari.Common.Plumbing.Pipeline; -using Calamari.Common.Plumbing.Variables; - -namespace Calamari.AiAgent.Behaviours -{ - public class InvokeClaudeCodeBehaviour : IDeployBehaviour - { - readonly ILog log; - - public InvokeClaudeCodeBehaviour(ILog log) - { - this.log = log; - } - - public bool IsEnabled(RunningDeployment context) - { - return true; - //var provider = context.Variables.Get(SpecialVariables.Action.AiAgent.Provider); - //return provider == "ClaudeCode"; - } - - public async Task Execute(RunningDeployment context) - { - var variables = context.Variables; - - var prompt = variables.Get(SpecialVariables.Action.AiAgent.Prompt); - if (string.IsNullOrWhiteSpace(prompt)) - throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.Prompt}' is required but was not provided."); - - var apiToken = variables.Get(SpecialVariables.Action.AiAgent.ApiToken); - if (string.IsNullOrWhiteSpace(apiToken)) - throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.ApiToken}' is required but was not provided."); - - var model = variables.Get(SpecialVariables.Action.AiAgent.Model); - if (string.IsNullOrWhiteSpace(model)) - model = "claude-sonnet-4-20250514"; - - log.Info($"Invoking Claude Code CLI with model '{model}'..."); - - var mcpServers = BuildMcpServers(variables); - var runAs = BuildRunAs(variables); - - var runner = new ClaudeCodeCliRunner(log); - var response = await runner.RunAsync(new ClaudeCodeOptions - { - Prompt = prompt, - ApiToken = apiToken, - Model = model, - SystemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill), - MaxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTokens), - McpServers = mcpServers, - RunAs = runAs, - }); - - Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); - log.Info("Claude Code invocation complete."); - } - - static Dictionary BuildMcpServers(IVariables variables) - { - var servers = new Dictionary(); - - var githubToken = variables.Get(SpecialVariables.Action.AiAgent.GitHubToken); - if (!string.IsNullOrWhiteSpace(githubToken)) - { - servers["github"] = new McpServerConfig - { - Command = "npx", - Args = new[] { "-y", "@modelcontextprotocol/server-github" }, - Env = new Dictionary - { - ["GITHUB_PERSONAL_ACCESS_TOKEN"] = githubToken, - ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "", - }, - }; - } - - var octopusToken = variables.Get(SpecialVariables.Action.AiAgent.OctopusToken); - if (!string.IsNullOrWhiteSpace(octopusToken)) - { - servers["octopus"] = new McpServerConfig - { - Command = "npx", - Args = new[] { "-y", "@octopusdeploy/mcp-server" }, - Env = new Dictionary - { - ["OCTOPUS_SERVER_URL"] = "http://localhost:8065", - ["OCTOPUS_API_KEY"] = octopusToken, - ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? "", - }, - }; - } - - return servers; - } - - static ProcessCredentials? BuildRunAs(IVariables variables) - { - var username = variables.Get(SpecialVariables.Action.AiAgent.RunAsUsername); - if (string.IsNullOrWhiteSpace(username)) - return null; - - return new ProcessCredentials - { - Username = username, - Password = variables.Get(SpecialVariables.Action.AiAgent.RunAsPassword), - }; - } - } -} diff --git a/source/Calamari.AiAgent/Calamari.AiAgent.csproj b/source/Calamari.AiAgent/Calamari.AiAgent.csproj index d1ab0772f7..96a99776ce 100644 --- a/source/Calamari.AiAgent/Calamari.AiAgent.csproj +++ b/source/Calamari.AiAgent/Calamari.AiAgent.csproj @@ -27,4 +27,9 @@ + + + + + diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs new file mode 100644 index 0000000000..2b1df8ae15 --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; + +namespace Calamari.AiAgent.Behaviours +{ + public class ClaudeCodeCliRunner + { + readonly ILog log; + + public ClaudeCodeCliRunner(ILog log) + { + this.log = log; + } + + public async Task RunAsync( + ClaudeCommandArgsBuilder argsBuilder, + string apiToken, + IReadOnlyDictionary mcpServers, + ProcessCredentials? runAs = null, + IReadOnlyList? userSkills = null) + { + var workingDir = Path.Combine(Path.GetTempPath(), $"claude-agent-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workingDir); + log.Verbose($"Claude Code working directory: {workingDir}"); + + try + { + SetupSkills(workingDir, userSkills); + SetupMcpConfig(workingDir, mcpServers); + return await RunInDirectoryAsync(argsBuilder, apiToken, workingDir, runAs); + } + finally + { + try { Directory.Delete(workingDir, recursive: true); } + catch { /* best effort cleanup */ } + } + } + + async Task RunInDirectoryAsync( + ClaudeCommandArgsBuilder argsBuilder, + string apiToken, + string workingDir, + ProcessCredentials? runAs) + { + //var mcpConfigPath = Path.Combine(workingDir, "mcp-config.json"); + //var systemPromptPath = Path.Combine(workingDir, "CLAUDE.md"); + var verboseLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-verbose-{Guid.NewGuid():N}.log"); + var debugLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-debug-{Guid.NewGuid():N}.log"); + var fullArgs = argsBuilder.Build(); + //var fullArgs = $"{args} --strict-mcp-config " //"--bare " + //+ $"--system-prompt-file {EscapeArg(systemPromptPath)} " + fullArgs += $" --debug-file {EscapeArg(debugLogPath)}"; + + log.Verbose($"Claude Code command: claude {fullArgs}"); + var startInfo = new ProcessStartInfo + { + FileName = "claude", + Arguments = fullArgs, + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + startInfo.Environment["ANTHROPIC_API_KEY"] = apiToken; + + if (runAs != null) + ApplyCredentials(startInfo, runAs); + + var responseBuilder = new StringBuilder(); + var streamProcessor = new ClaudeCodeStreamProcessor(log, responseBuilder); + + try + { + await RunProcess(startInfo, verboseLogPath, streamProcessor); + } + catch (Exception e) + { + log.Error(e.Message); + } + + if (File.Exists(debugLogPath)) + { + var fileInfo = new FileInfo(debugLogPath); + log.NewOctopusArtifact(debugLogPath, "claude-agent-debug.log", fileInfo.Length); + } + if (File.Exists(verboseLogPath)) + { + var fileInfo = new FileInfo(verboseLogPath); + log.NewOctopusArtifact(verboseLogPath, "claude-agent-verbose.log", fileInfo.Length); + } + + return responseBuilder.ToString(); + } + + async Task RunProcess(ProcessStartInfo startInfo, string verboseLogPath, ClaudeCodeStreamProcessor streamProcessor) + { + using var process = new Process(); + process.StartInfo = startInfo; + process.Start(); + var stdoutTask = Task.Run(async () => + { + while (await process.StandardOutput.ReadLineAsync() is { } line) + { + await File.AppendAllTextAsync(verboseLogPath, line); + streamProcessor.ProcessLine(line); + } + }); + + var stderrTask = Task.Run(async () => + { + var buffer = new char[1024]; + int charsRead; + while ((charsRead = await process.StandardError.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + var text = new string(buffer, 0, charsRead); + log.Verbose(text.TrimEnd()); + } + }); + + await Task.WhenAll(stdoutTask, stderrTask); + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + throw new CommandException($"Claude Code exited with code {process.ExitCode}"); + } + } + + internal static void SetupMcpConfig(string workingDir, IReadOnlyDictionary mcpServers) + { + var config = new { mcpServers }; + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(workingDir, "mcp-config.json"), json); + } + + const string SkillsResourcePrefix = "Calamari.AiAgent.ClaudeCodeBehaviour.DefaultContext.Skills."; + const string SystemPromptResource = "Calamari.AiAgent.ClaudeCodeBehaviour.DefaultContext.system-prompt.md"; + + internal static void SetupSkills(string workingDir, IReadOnlyList? userSkills = null) + { + var skillsDir = Path.Combine(workingDir, ".claude", "skills"); + Directory.CreateDirectory(skillsDir); + + var assembly = Assembly.GetExecutingAssembly(); + + // Write CLAUDE.md (system prompt) to the working directory root + using (var promptStream = assembly.GetManifestResourceStream(SystemPromptResource)) + { + if (promptStream != null) + { + using var reader = new StreamReader(promptStream); + File.WriteAllText(Path.Combine(workingDir, "CLAUDE.md"), reader.ReadToEnd()); + } + } + + foreach (var resourceName in assembly.GetManifestResourceNames()) + { + if (!resourceName.StartsWith(SkillsResourcePrefix, StringComparison.Ordinal)) + continue; + + var fileName = resourceName.Substring(SkillsResourcePrefix.Length); + var skillName = Path.GetFileNameWithoutExtension(fileName); + var innerSkillDir = Path.Combine(skillsDir, skillName); + + using var stream = assembly.GetManifestResourceStream(resourceName)!; + using var reader = new StreamReader(stream); + + Directory.CreateDirectory(innerSkillDir); + File.WriteAllText(Path.Combine(innerSkillDir, "SKILL.md"), reader.ReadToEnd()); + } + + if (userSkills != null) + { + foreach (var skill in userSkills) + { + var dirName = SanitizeFileName(skill.Name); + var innerSkillDir = Path.GetFullPath(Path.Combine(skillsDir, dirName)); + + if (!innerSkillDir.StartsWith(Path.GetFullPath(skillsDir) + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + throw new CommandException($"Skill name '{skill.Name}' results in a path outside the skills directory."); + + Directory.CreateDirectory(innerSkillDir); + File.WriteAllText(Path.Combine(innerSkillDir, "SKILL.md"), skill.Content); + } + } + } + + static readonly HashSet WindowsReservedNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + }; + + internal static string SanitizeFileName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new CommandException("Skill name cannot be empty."); + + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new StringBuilder(name.Length); + foreach (var c in name) + sanitized.Append(Array.IndexOf(invalid, c) >= 0 ? '-' : c); + + // Strip leading dots to prevent hidden files / relative path tricks + var result = sanitized.ToString().TrimStart('.'); + + if (string.IsNullOrWhiteSpace(result)) + throw new CommandException($"Skill name '{name}' is not a valid file name."); + + if (WindowsReservedNames.Contains(result)) + throw new CommandException($"Skill name '{name}' is a reserved file name."); + + // Filesystem limits are typically 255 bytes; truncate to be safe + if (result.Length > 200) + result = result.Substring(0, 200); + + return result; + } + + internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials) + { + // See ADR: https://github.com/OctopusDeploy/adr/blob/main/team-modern-deployments/calamari-ai-agent/adr-001-use-processstartinfo-username-for-user-impersonation.md + // Uses ProcessStartInfo.UserName on all platforms. + // On Windows: uses native token-based impersonation with optional password/domain. + // On Linux: .NET calls setuid/setgid internally. Requires the calling process to + // be root or have CAP_SETUID/CAP_SETGID capabilities. Environment variables from + // ProcessStartInfo.Environment are inherited naturally — no special handling needed. + startInfo.UserName = credentials.Username; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (!string.IsNullOrEmpty(credentials.Password)) + startInfo.PasswordInClearText = credentials.Password; + if (!string.IsNullOrEmpty(credentials.Domain)) + startInfo.Domain = credentials.Domain; + } + } + + static string EscapeArg(string arg) + { + if (arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0) + return arg; + + return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + } + + public record ProcessCredentials + { + public required string Username { get; init; } + public string? Password { get; init; } + public string? Domain { get; init; } + } + + public record UserSkill + { + public required string Name { get; init; } + public required string Content { get; init; } + } + + public record McpServerConfig + { + [JsonPropertyName("type")] + public string Type { get; init; } = "stdio"; + + [JsonPropertyName("command")] + public required string Command { get; init; } + + [JsonPropertyName("args")] + public IReadOnlyList? Args { get; init; } + + [JsonPropertyName("env")] + public IReadOnlyDictionary? Env { get; init; } + } + + public record McpServerEntry + { + public string? Name { get; init; } + public string? Type { get; init; } + public string? Command { get; init; } + public IReadOnlyList? Args { get; init; } + public IReadOnlyDictionary? Env { get; init; } + } +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs new file mode 100644 index 0000000000..7d9de11bde --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs @@ -0,0 +1,429 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.Behaviours +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum StreamEventType + { + [EnumMember(Value = "system")] + System, //Infrastructure/metadata events, + + [EnumMember(Value = "assistant")] + Assistant, // A message produced by the model. + [EnumMember(Value = "user")] + User, // A message in the user turn, which covers more than just human input: + [EnumMember(Value = "result")] + Result // The terminal event, summarizing the completed session. + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum ContentBlockType + { + [EnumMember(Value = "text")] + Text, // plain text response + [EnumMember(Value = "thinking")] + Thinking, // internal chain-of-thought (with a signature field for verification) + [EnumMember(Value = "redacted_thinking")] + RedactedThinking, + [EnumMember(Value = "tool_use")] + ToolUse, // the model invoking a tool (e.g. calling the Skill tool) + [EnumMember(Value = "tool_result")] + ToolResult, + [EnumMember(Value = "server_tool_use")] + ServerToolUse, + [EnumMember(Value = "server_tool_result")] + ServerToolResult + } + + public record StreamEvent + { + [JsonPropertyName("type")] + public string? Type { get; init; } + + [JsonPropertyName("session_id")] + public string? SessionId { get; init; } + + [JsonPropertyName("uuid")] + public string? Uuid { get; init; } + } + + public record SystemStreamEvent : StreamEvent + { + [JsonPropertyName("subtype")] + public string? Subtype { get; init; } + + // api_retry fields + [JsonPropertyName("attempt")] + public int? Attempt { get; init; } + + [JsonPropertyName("retry_delay_ms")] + public int? RetryDelayMs { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } + + [JsonPropertyName("error_status")] + public int? ErrorStatus { get; init; } + + // hook_started / hook_response fields + [JsonPropertyName("hook_id")] + public string? HookId { get; init; } + + [JsonPropertyName("hook_name")] + public string? HookName { get; init; } + + [JsonPropertyName("hook_event")] + public string? HookEvent { get; init; } + + [JsonPropertyName("output")] + public string? Output { get; init; } + + [JsonPropertyName("stdout")] + public string? Stdout { get; init; } + + [JsonPropertyName("stderr")] + public string? Stderr { get; init; } + + [JsonPropertyName("exit_code")] + public int? ExitCode { get; init; } + + [JsonPropertyName("outcome")] + public string? Outcome { get; init; } + + // init fields + [JsonPropertyName("cwd")] + public string? Cwd { get; init; } + + [JsonPropertyName("tools")] + public IReadOnlyList? Tools { get; init; } + + [JsonPropertyName("mcp_servers")] + public IReadOnlyList? McpServers { get; init; } + + [JsonPropertyName("model")] + public string? Model { get; init; } + + [JsonPropertyName("permissionMode")] + public string? PermissionMode { get; init; } + + [JsonPropertyName("slash_commands")] + public IReadOnlyList? SlashCommands { get; init; } + + [JsonPropertyName("apiKeySource")] + public string? ApiKeySource { get; init; } + + [JsonPropertyName("claude_code_version")] + public string? ClaudeCodeVersion { get; init; } + + [JsonPropertyName("output_style")] + public string? OutputStyle { get; init; } + + [JsonPropertyName("agents")] + public IReadOnlyList? Agents { get; init; } + + [JsonPropertyName("skills")] + public IReadOnlyList? Skills { get; init; } + + [JsonPropertyName("plugins")] + public IReadOnlyList? Plugins { get; init; } + + [JsonPropertyName("fast_mode_state")] + public string? FastModeState { get; init; } + } + + public record McpServerStatus + { + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("status")] + public string? Status { get; init; } + } + + public record PluginInfo + { + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("path")] + public string? Path { get; init; } + + [JsonPropertyName("source")] + public string? Source { get; init; } + } + + public record AssistantStreamEvent : StreamEvent + { + [JsonPropertyName("message")] + public StreamMessage? Message { get; init; } + + [JsonPropertyName("parent_tool_use_id")] + public string? ParentToolUseId { get; init; } + } + + public record UserStreamEvent : StreamEvent + { + [JsonPropertyName("message")] + public StreamMessage? Message { get; init; } + + [JsonPropertyName("parent_tool_use_id")] + public string? ParentToolUseId { get; init; } + + [JsonPropertyName("timestamp")] + public string? Timestamp { get; init; } + + [JsonPropertyName("tool_use_result")] + public ToolUseResultInfo? ToolUseResult { get; init; } + + [JsonPropertyName("isSynthetic")] + public bool? IsSynthetic { get; init; } + } + + public record ToolUseResultInfo + { + [JsonPropertyName("success")] + public bool? Success { get; init; } + + [JsonPropertyName("commandName")] + public string? CommandName { get; init; } + } + + public record ResultStreamEvent : StreamEvent + { + [JsonPropertyName("subtype")] + public string? Subtype { get; init; } + + [JsonPropertyName("is_error")] + public bool? IsError { get; init; } + + [JsonPropertyName("result")] + public string? Result { get; init; } + + [JsonPropertyName("stop_reason")] + public string? StopReason { get; init; } + + [JsonPropertyName("cost_usd")] + public double? CostUsd { get; init; } + + [JsonPropertyName("total_cost_usd")] + public double? TotalCostUsd { get; init; } + + [JsonPropertyName("duration_ms")] + public double? DurationMs { get; init; } + + [JsonPropertyName("duration_api_ms")] + public double? DurationApiMs { get; init; } + + [JsonPropertyName("num_turns")] + public int? NumTurns { get; init; } + + [JsonPropertyName("usage")] + public ResultUsageInfo? Usage { get; init; } + + [JsonPropertyName("modelUsage")] + public IReadOnlyDictionary? ModelUsage { get; init; } + + [JsonPropertyName("permission_denials")] + public IReadOnlyList? PermissionDenials { get; init; } + + [JsonPropertyName("fast_mode_state")] + public string? FastModeState { get; init; } + } + + public record StreamMessage + { + [JsonPropertyName("model")] + public string? Model { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + + [JsonPropertyName("role")] + public string? Role { get; init; } + + [JsonPropertyName("stop_reason")] + public string? StopReason { get; init; } + + [JsonPropertyName("stop_sequence")] + public string? StopSequence { get; init; } + + [JsonPropertyName("usage")] + public MessageUsageInfo? Usage { get; init; } + + [JsonPropertyName("content")] + public JsonElement[]? Content { get; init; } + } + + public record ContentBlock + { + [JsonPropertyName("type")] + public string? Type { get; init; } + } + + public record TextContentBlock : ContentBlock + { + [JsonPropertyName("text")] + public string? Text { get; init; } + } + + public record ThinkingContentBlock : ContentBlock + { + [JsonPropertyName("thinking")] + public string? Thinking { get; init; } + + [JsonPropertyName("signature")] + public string? Signature { get; init; } + } + + public record RedactedThinkingContentBlock : ContentBlock; + + public record ToolUseContentBlock : ContentBlock + { + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + + [JsonPropertyName("input")] + public JsonElement? Input { get; init; } + + [JsonPropertyName("caller")] + public ToolUseCaller? Caller { get; init; } + } + + public record ToolUseCaller + { + [JsonPropertyName("type")] + public string? Type { get; init; } + } + + public record ToolResultContentBlock : ContentBlock + { + [JsonPropertyName("tool_use_id")] + public string? ToolUseId { get; init; } + + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("is_error")] + public bool? IsError { get; init; } + + [JsonPropertyName("content")] + public JsonElement? Content { get; init; } + } + + public record ServerToolUseContentBlock : ContentBlock + { + [JsonPropertyName("name")] + public string? Name { get; init; } + } + + public record ServerToolResultContentBlock : ContentBlock + { + [JsonPropertyName("name")] + public string? Name { get; init; } + } + + public record MessageUsageInfo + { + [JsonPropertyName("input_tokens")] + public int? InputTokens { get; init; } + + [JsonPropertyName("output_tokens")] + public int? OutputTokens { get; init; } + + [JsonPropertyName("cache_read_input_tokens")] + public int? CacheReadInputTokens { get; init; } + + [JsonPropertyName("cache_creation_input_tokens")] + public int? CacheCreationInputTokens { get; init; } + + [JsonPropertyName("cache_creation")] + public CacheCreationInfo? CacheCreation { get; init; } + + [JsonPropertyName("service_tier")] + public string? ServiceTier { get; init; } + + [JsonPropertyName("inference_geo")] + public string? InferenceGeo { get; init; } + } + + public record ResultUsageInfo + { + [JsonPropertyName("input_tokens")] + public int? InputTokens { get; init; } + + [JsonPropertyName("output_tokens")] + public int? OutputTokens { get; init; } + + [JsonPropertyName("cache_read_input_tokens")] + public int? CacheReadInputTokens { get; init; } + + [JsonPropertyName("cache_creation_input_tokens")] + public int? CacheCreationInputTokens { get; init; } + + [JsonPropertyName("server_tool_use")] + public ServerToolUseUsage? ServerToolUse { get; init; } + + [JsonPropertyName("service_tier")] + public string? ServiceTier { get; init; } + + [JsonPropertyName("cache_creation")] + public CacheCreationInfo? CacheCreation { get; init; } + + [JsonPropertyName("inference_geo")] + public string? InferenceGeo { get; init; } + + [JsonPropertyName("speed")] + public string? Speed { get; init; } + } + + public record ServerToolUseUsage + { + [JsonPropertyName("web_search_requests")] + public int? WebSearchRequests { get; init; } + + [JsonPropertyName("web_fetch_requests")] + public int? WebFetchRequests { get; init; } + } + + public record CacheCreationInfo + { + [JsonPropertyName("ephemeral_5m_input_tokens")] + public int? Ephemeral5mInputTokens { get; init; } + + [JsonPropertyName("ephemeral_1h_input_tokens")] + public int? Ephemeral1hInputTokens { get; init; } + } + + public record ModelUsageInfo + { + [JsonPropertyName("inputTokens")] + public int? InputTokens { get; init; } + + [JsonPropertyName("outputTokens")] + public int? OutputTokens { get; init; } + + [JsonPropertyName("cacheReadInputTokens")] + public int? CacheReadInputTokens { get; init; } + + [JsonPropertyName("cacheCreationInputTokens")] + public int? CacheCreationInputTokens { get; init; } + + [JsonPropertyName("webSearchRequests")] + public int? WebSearchRequests { get; init; } + + [JsonPropertyName("costUSD")] + public double? CostUsd { get; init; } + + [JsonPropertyName("contextWindow")] + public int? ContextWindow { get; init; } + + [JsonPropertyName("maxOutputTokens")] + public int? MaxOutputTokens { get; init; } + } +} \ No newline at end of file diff --git a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs similarity index 91% rename from source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs rename to source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs index 44d3870ba1..ac7692e8d7 100644 --- a/source/Calamari.AiAgent/Behaviours/ClaudeCodeStreamProcessor.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs @@ -52,7 +52,7 @@ public void ProcessLine(string json) HandleMessageEvent(JsonSerializer.Deserialize(json, JsonOptions)?.Message); break; case StreamEventType.User: - HandleUserMessage(JsonSerializer.Deserialize(json, JsonOptions)?.Message); + HandleUserMessage(JsonSerializer.Deserialize(json, JsonOptions)); break; case StreamEventType.Result: HandleResultEvent(JsonSerializer.Deserialize(json, JsonOptions)!); @@ -91,7 +91,7 @@ void HandleSystemEvent(SystemStreamEvent evt) } } - void HandleMessageEvent(StreamMessage? message) + void HandleMessageEvent(StreamMessage? message, bool logText = true) { if (message?.Content == null) return; @@ -111,8 +111,15 @@ void HandleMessageEvent(StreamMessage? message) case ContentBlockType.Text: { var block = element.Deserialize(JsonOptions); - responseBuilder.Append(block?.Text); - log.Info(block?.Text ?? ""); + if (logText) + { + responseBuilder.Append(block?.Text); + log.Info(block?.Text ?? ""); + } + else + { + log.Verbose(block?.Text ?? ""); + } break; } @@ -183,9 +190,16 @@ static bool Assign(ContentBlockType val, out ContentBlockType result, bool succe } } - void HandleUserMessage(StreamMessage? message) + void HandleUserMessage(UserStreamEvent? message) { - HandleMessageEvent(message); + if (message is null || message?.Message != null) + return; + + if (message!.IsSynthetic == true) + { + return; //TODO: Still log + } + HandleMessageEvent(message?.Message); } void HandleResultEvent(ResultStreamEvent evt) diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs new file mode 100644 index 0000000000..eb9e022e08 --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Calamari.AiAgent.Behaviours +{ + public class ClaudeCommandArgsBuilder + { + string? prompt; + string? model; + string? systemPrompt; + string? mcpConfigPath; + int maxTurns = 10; + decimal? maxBudgetUsd; + IReadOnlyList? allowedTools; + + public ClaudeCommandArgsBuilder WithPrompt(string prompt) + { + this.prompt = prompt; + return this; + } + + public ClaudeCommandArgsBuilder WithModel(string model) + { + this.model = model; + return this; + } + + public ClaudeCommandArgsBuilder WithSystemPrompt(string systemPrompt) + { + this.systemPrompt = systemPrompt; + return this; + } + + public ClaudeCommandArgsBuilder WithMaxTurns(int maxTurns) + { + this.maxTurns = maxTurns; + return this; + } + + public ClaudeCommandArgsBuilder WithMcpConfigPath(string mcpConfigPath) + { + this.mcpConfigPath = mcpConfigPath; + return this; + } + + public ClaudeCommandArgsBuilder WithMaxBudgetUsd(decimal budgetUsd) + { + this.maxBudgetUsd = budgetUsd; + return this; + } + + public ClaudeCommandArgsBuilder WithAllowedTools(IReadOnlyList tools) + { + this.allowedTools = tools; + return this; + } + + public string Build() + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new InvalidOperationException("A prompt is required. Call WithPrompt() before Build()."); + + var args = new StringBuilder(); + + args.Append(" --model "); + args.Append(EscapeArg(model ?? "claude-sonnet-4-20250514")); + args.Append(" --strict-mcp-config"); + args.Append(" --output-format stream-json"); + args.Append(" --verbose"); + args.Append(" --permission-mode dontAsk"); + args.Append(" --no-session-persistence"); + + if (!string.IsNullOrWhiteSpace(mcpConfigPath)) + { + args.Append(" --mcp-config"); + args.Append(EscapeArg(mcpConfigPath)); + } + + + if (allowedTools != null && allowedTools.Count > 0) + { + args.Append(" --allowedTools "); + args.Append(string.Join(",", allowedTools)); + } + + args.Append($" --max-turns {maxTurns}"); + + if (maxBudgetUsd.HasValue) + args.Append($" --max-budget-usd {maxBudgetUsd.Value.ToString(CultureInfo.InvariantCulture)}"); + + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + args.Append(" --system-prompt "); + args.Append(EscapeArg(systemPrompt)); + } + + args.Append("-p "); + args.Append(EscapeArg(prompt)); + + return args.ToString(); + } + + static string EscapeArg(string arg) + { + if (arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0) + return arg; + + return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + } +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/Skills/octopus-deployment-context.md b/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/Skills/octopus-deployment-context.md new file mode 100644 index 0000000000..0ff0fda440 --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/Skills/octopus-deployment-context.md @@ -0,0 +1,13 @@ +--- +name: octopus-deployment-context +description: Use when you need to understand the Octopus Deploy deployment context, including environment, project, tenant, release version, or any custom variables available during this deployment. +--- +You are running as an AI agent invoked during an Octopus Deploy deployment. + +Key context: +- You are executing inside a deployment step on a target machine +- Octopus deployment variables are available via the `get_deployment_variables` tool +- Sensitive variables (passwords, tokens, API keys) are filtered out for safety +- Your output will be captured as the step result + +When asked about the deployment context, always call `get_deployment_variables` first to get the actual values rather than guessing. \ No newline at end of file diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md b/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md new file mode 100644 index 0000000000..62270ad37f --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md @@ -0,0 +1,2 @@ +# Skill Discovery +Locate skills available for this session in the ./.claude/skills directory \ No newline at end of file diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs new file mode 100644 index 0000000000..73c52cf0fa --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Threading.Tasks; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Pipeline; +using Calamari.Common.Plumbing.Variables; + +namespace Calamari.AiAgent.Behaviours +{ + public class InvokeClaudeCodeBehaviour : IDeployBehaviour + { + readonly ILog log; + + public InvokeClaudeCodeBehaviour(ILog log) + { + this.log = log; + } + + public bool IsEnabled(RunningDeployment context) + { + return true; + //var provider = context.Variables.Get(SpecialVariables.Action.AiAgent.Provider); + //return provider == "ClaudeCode"; + } + + public async Task Execute(RunningDeployment context) + { + var variables = context.Variables; + + var prompt = variables.Get(SpecialVariables.Action.AiAgent.Prompt); + if (string.IsNullOrWhiteSpace(prompt)) + throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.Prompt}' is required but was not provided."); + + var apiToken = variables.Get(SpecialVariables.Action.AiAgent.ApiToken); + if (string.IsNullOrWhiteSpace(apiToken)) + throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.ApiToken}' is required but was not provided."); + + var model = variables.Get(SpecialVariables.Action.AiAgent.Model); + if (string.IsNullOrWhiteSpace(model)) + model = "claude-sonnet-4-20250514"; + + log.Info($"Invoking Claude Code CLI with model '{model}'..."); + + var mcpServers = BuildMcpServers(variables); + var runAs = BuildRunAs(variables); + + var argsBuilder = new ClaudeCommandArgsBuilder() + .WithPrompt(prompt) + .WithModel(model) + .WithAllowedTools(new[] { "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch" }); + + var systemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill); + if (!string.IsNullOrWhiteSpace(systemPrompt)) + argsBuilder.WithSystemPrompt(systemPrompt); + + var maxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTurns); + if (maxTurns.HasValue) + argsBuilder.WithMaxTurns(maxTurns.Value); + + var maxBudgetUsdRaw = variables.Get(SpecialVariables.Action.AiAgent.MaxBudgetUsd); + if (!string.IsNullOrWhiteSpace(maxBudgetUsdRaw) + && decimal.TryParse(maxBudgetUsdRaw, NumberStyles.Number, CultureInfo.InvariantCulture, out var budgetUsd)) + argsBuilder.WithMaxBudgetUsd(budgetUsd); + + var userSkills = BuildUserSkills(variables); + + var runner = new ClaudeCodeCliRunner(log); + var response = await runner.RunAsync(argsBuilder, apiToken, mcpServers, runAs, userSkills); + + Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); + log.Info("Claude Code invocation complete."); + } + + static Dictionary BuildMcpServers(IVariables variables) + { + var servers = new Dictionary(); + var path = Environment.GetEnvironmentVariable("PATH") ?? ""; + + // Octopus MCP server is always added when a token is available + var octopusToken = variables.Get(SpecialVariables.Action.AiAgent.OctopusToken); + if (!string.IsNullOrWhiteSpace(octopusToken)) + { + var octopusServerUrl = variables.Get("Octopus.Web.BaseUrl"); + if (string.IsNullOrWhiteSpace(octopusServerUrl)) + { + Log.Warn("Unable to find Octopus Server URL"); + } + else + { + Log.Verbose("Octopus Server URL: " + octopusServerUrl); + servers["octopus"] = new McpServerConfig + { + Command = "npx", + Args = new[] { "-y", "@octopusdeploy/mcp-server" }, + Env = new Dictionary + { + ["OCTOPUS_SERVER_URL"] = octopusServerUrl, + ["OCTOPUS_API_KEY"] = octopusToken, + ["PATH"] = path, + }, + }; + } + } + + // User-configured MCP servers from JSON variable + var mcpServersJson = variables.Get(SpecialVariables.Action.AiAgent.McpServers); + if (!string.IsNullOrWhiteSpace(mcpServersJson)) + { + List? entries; + try + { + entries = JsonSerializer.Deserialize>(mcpServersJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (JsonException ex) + { + throw new CommandException($"Failed to parse MCP servers configuration: {ex.Message}"); + } + + if (entries != null) + { + foreach (var entry in entries) + { + if (string.IsNullOrWhiteSpace(entry.Name)) + throw new CommandException("Each MCP server must have a name."); + if (string.IsNullOrWhiteSpace(entry.Command)) + throw new CommandException($"MCP server '{entry.Name}' must have a command."); + + var env = entry.Env != null + ? new Dictionary(entry.Env) + : new Dictionary(); + + if (!env.ContainsKey("PATH")) + env["PATH"] = path; + + servers[entry.Name] = new McpServerConfig + { + Type = entry.Type ?? "stdio", + Command = entry.Command, + Args = entry.Args, + Env = env, + }; + Log.Verbose($"MCP server '{entry.Name}' added."); + } + } + } + + return servers; + } + + static List BuildUserSkills(IVariables variables) + { + var skills = new List(); + var indexes = variables.GetIndexes(SpecialVariables.Action.AiAgent.Skills); + foreach (var index in indexes) + { + var prefix = $"{SpecialVariables.Action.AiAgent.Skills}[{index}]."; + var name = variables.Get(prefix + SpecialVariables.Action.AiAgent.SkillName); + var content = variables.Get(prefix + SpecialVariables.Action.AiAgent.SkillContent); + + if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(content)) + skills.Add(new UserSkill { Name = name, Content = content }); + } + return skills; + } + + static ProcessCredentials? BuildRunAs(IVariables variables) + { + var username = variables.Get(SpecialVariables.Action.AiAgent.RunAsUsername); + if (string.IsNullOrWhiteSpace(username)) + return null; + + return new ProcessCredentials + { + Username = username, + Password = variables.Get(SpecialVariables.Action.AiAgent.RunAsPassword), + }; + } + } +} diff --git a/source/Calamari.AiAgent/SpecialVariables.cs b/source/Calamari.AiAgent/SpecialVariables.cs index 9c1d29af80..35cf5d4652 100644 --- a/source/Calamari.AiAgent/SpecialVariables.cs +++ b/source/Calamari.AiAgent/SpecialVariables.cs @@ -10,12 +10,17 @@ public static class AiAgent public const string ApiToken = "Octopus.Action.Claude.ApiToken"; public const string Model = "Octopus.Action.Claude.Model"; public const string Response = "Octopus.Action.Claude.Response"; - public const string GitHubToken = "Octopus.Action.Claude.GitHubToken"; + public const string McpServers = "Octopus.Action.Claude.McpServers"; public const string SystemSkill = "Octopus.Action.Claude.SystemSkill"; - public const string MaxTokens = "Octopus.Action.Claude.MaxTokens"; + public const string MaxTurns = "Octopus.Action.Claude.MaxTurns"; + public const string MaxBudgetUsd = "Octopus.Action.Claude.MaxBudgetUsd"; public const string OctopusToken = "Octopus.Action.Claude.OctopusToken"; public const string RunAsUsername = "Octopus.Action.Claude.RunAsUsername"; public const string RunAsPassword = "Octopus.Action.Claude.RunAsPassword"; + + public const string Skills = "Octopus.Action.Claude.Skills"; + public const string SkillName = "Name"; + public const string SkillContent = "Content"; } } } diff --git a/source/docs/superpowers/plans/2026-06-01-claude-command-args-builder.md b/source/docs/superpowers/plans/2026-06-01-claude-command-args-builder.md new file mode 100644 index 0000000000..d774854dff --- /dev/null +++ b/source/docs/superpowers/plans/2026-06-01-claude-command-args-builder.md @@ -0,0 +1,633 @@ +# ClaudeCommandArgsBuilder + max-turns / max-budget-usd Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract CLI argument building into a fluent `ClaudeCommandArgsBuilder`, add `max-turns` (default 10) and `max-budget-usd` (optional) support, and remove stale `MaxTokens`/`ClaudeCodeOptions`. + +**Architecture:** The builder produces a CLI args string via fluent API. The behaviour reads variables and drives the builder. The runner receives the built args string plus API token, credentials, and MCP servers — it handles process lifecycle only. + +**Tech Stack:** C# / .NET, NUnit, FluentAssertions + +**Spec:** `docs/superpowers/specs/2026-06-01-claude-command-args-builder-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `Calamari.AiAgent/Behaviours/ClaudeCommandArgsBuilder.cs` | Create | Fluent builder: `With*()` methods, `Build()` returns args string, owns `EscapeArg`. Does NOT handle `--mcp-config`, `--strict-mcp-config`, or `--debug-file` — those are appended by the runner since it owns working directory and debug file paths. | +| `Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs` | Modify | Remove `BuildArguments`, `ClaudeCodeOptions`. Change `RunAsync` signature to accept args string + config params. Keep process lifecycle, credentials, MCP/skill setup, records. | +| `Calamari.AiAgent/SpecialVariables.cs` | Modify | Add `MaxTurns`, `MaxBudgetUsd`. Remove `MaxTokens`. | +| `Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs` | Modify | Use builder fluent API, read new variables, pass args string to runner. | +| `Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs` | Create | Unit tests for the builder. | +| `Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs` | Modify | Remove `BuildArguments`/`ClaudeCodeOptions` tests (moved to builder fixture). Keep `SetupSkills`, `SetupMcpConfig` tests. | + +--- + +### Task 1: Create ClaudeCommandArgsBuilder with tests for core flags + +**Files:** +- Create: `Calamari.AiAgent/Behaviours/ClaudeCommandArgsBuilder.cs` +- Create: `Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs` + +- [ ] **Step 1: Write failing tests for core builder behaviour** + +Create `Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs`: + +```csharp +using Calamari.AiAgent.Behaviours; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests; + +[TestFixture] +public class ClaudeCommandArgsBuilderFixture +{ + ClaudeCommandArgsBuilder MinimalBuilder() => + new ClaudeCommandArgsBuilder() + .WithPrompt("test prompt") + .WithModel("claude-sonnet-4-20250514"); + + [Test] + public void Build_IncludesRequiredFlags() + { + var args = MinimalBuilder().Build(); + + args.Should().Contain("-p"); + args.Should().Contain("--model claude-sonnet-4-20250514"); + args.Should().Contain("--output-format stream-json"); + args.Should().Contain("--verbose"); + args.Should().Contain("--permission-mode dontAsk"); + args.Should().Contain("--no-session-persistence"); + } + + [Test] + public void Build_DefaultsMaxTurnsTo10_WhenNotSet() + { + var args = MinimalBuilder().Build(); + + args.Should().Contain("--max-turns 10"); + } + + [Test] + public void Build_UsesProvidedMaxTurns_WhenSet() + { + var args = MinimalBuilder().WithMaxTurns(5).Build(); + + args.Should().Contain("--max-turns 5"); + args.Should().NotContain("--max-turns 10"); + } + + [Test] + public void Build_OmitsMaxBudgetUsd_WhenNotSet() + { + var args = MinimalBuilder().Build(); + + args.Should().NotContain("--max-budget-usd"); + } + + [Test] + public void Build_IncludesMaxBudgetUsd_WhenSet() + { + var args = MinimalBuilder().WithMaxBudgetUsd(1.50m).Build(); + + args.Should().Contain("--max-budget-usd 1.50"); + } + + [Test] + public void Build_IncludesAllowedTools_WhenSet() + { + var args = MinimalBuilder() + .WithAllowedTools(new[] { "Read", "Bash" }) + .Build(); + + args.Should().Contain("--allowedTools Read,Bash"); + } + + [Test] + public void Build_OmitsAllowedTools_WhenEmpty() + { + var args = MinimalBuilder() + .WithAllowedTools(new string[0]) + .Build(); + + args.Should().NotContain("--allowedTools"); + } + + [Test] + public void Build_IncludesSystemPrompt_WhenSet() + { + var args = MinimalBuilder() + .WithSystemPrompt("You are helpful") + .Build(); + + args.Should().Contain("--system-prompt"); + args.Should().Contain("You are helpful"); + } + + [Test] + public void Build_OmitsSystemPrompt_WhenNotSet() + { + var args = MinimalBuilder().Build(); + + args.Should().NotContain("--system-prompt"); + } + + [Test] + public void Build_EscapesPromptWithSpaces() + { + var args = new ClaudeCommandArgsBuilder() + .WithPrompt("What is the capital of France?") + .WithModel("claude-sonnet-4-20250514") + .Build(); + + args.Should().Contain("\"What is the capital of France?\""); + } + + [Test] + public void Build_ThrowsWhenPromptNotSet() + { + var builder = new ClaudeCommandArgsBuilder() + .WithModel("claude-sonnet-4-20250514") + .WithMcpConfig("/tmp/mcp-config.json"); + + var act = () => builder.Build(); + + act.Should().Throw() + .WithMessage("*prompt*"); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test Calamari.AiAgent.Tests --filter "FullyQualifiedName~ClaudeCommandArgsBuilderFixture" --no-restore -v minimal` +Expected: Build failure — `ClaudeCommandArgsBuilder` does not exist yet. + +- [ ] **Step 3: Implement ClaudeCommandArgsBuilder** + +Create `Calamari.AiAgent/Behaviours/ClaudeCommandArgsBuilder.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Calamari.AiAgent.Behaviours +{ + public class ClaudeCommandArgsBuilder + { + string? prompt; + string? model; + string? systemPrompt; + int maxTurns = 10; + decimal? maxBudgetUsd; + IReadOnlyList? allowedTools; + + public ClaudeCommandArgsBuilder WithPrompt(string prompt) + { + this.prompt = prompt; + return this; + } + + public ClaudeCommandArgsBuilder WithModel(string model) + { + this.model = model; + return this; + } + + public ClaudeCommandArgsBuilder WithSystemPrompt(string systemPrompt) + { + this.systemPrompt = systemPrompt; + return this; + } + + public ClaudeCommandArgsBuilder WithMaxTurns(int maxTurns) + { + this.maxTurns = maxTurns; + return this; + } + + public ClaudeCommandArgsBuilder WithMaxBudgetUsd(decimal budgetUsd) + { + this.maxBudgetUsd = budgetUsd; + return this; + } + + public ClaudeCommandArgsBuilder WithAllowedTools(IReadOnlyList tools) + { + this.allowedTools = tools; + return this; + } + + public string Build() + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new InvalidOperationException("A prompt is required. Call WithPrompt() before Build()."); + + var args = new StringBuilder(); + args.Append("-p "); + args.Append(EscapeArg(prompt)); + args.Append(" --model "); + args.Append(EscapeArg(model ?? "claude-sonnet-4-20250514")); + args.Append(" --output-format stream-json"); + args.Append(" --verbose"); + args.Append(" --permission-mode dontAsk"); + args.Append(" --no-session-persistence"); + + if (allowedTools != null && allowedTools.Count > 0) + { + args.Append(" --allowedTools "); + args.Append(string.Join(",", allowedTools)); + } + + args.Append($" --max-turns {maxTurns}"); + + if (maxBudgetUsd.HasValue) + args.Append($" --max-budget-usd {maxBudgetUsd.Value.ToString(CultureInfo.InvariantCulture)}"); + + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + args.Append(" --system-prompt "); + args.Append(EscapeArg(systemPrompt)); + } + + return args.ToString(); + } + + static string EscapeArg(string arg) + { + if (arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0) + return arg; + + return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test Calamari.AiAgent.Tests --filter "FullyQualifiedName~ClaudeCommandArgsBuilderFixture" --no-restore -v minimal` +Expected: All 9 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Calamari.AiAgent/Behaviours/ClaudeCommandArgsBuilder.cs Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs +git commit -m "feat: add ClaudeCommandArgsBuilder with fluent API and tests" +``` + +--- + +### Task 2: Update SpecialVariables + +**Files:** +- Modify: `Calamari.AiAgent/SpecialVariables.cs` + +- [ ] **Step 1: Update SpecialVariables.cs** + +Replace `MaxTokens` with `MaxTurns` and add `MaxBudgetUsd`: + +In `SpecialVariables.cs`, remove: +```csharp +public const string MaxTokens = "Octopus.Action.Claude.MaxTokens"; +``` + +Add: +```csharp +public const string MaxTurns = "Octopus.Action.Claude.MaxTurns"; +public const string MaxBudgetUsd = "Octopus.Action.Claude.MaxBudgetUsd"; +``` + +- [ ] **Step 2: Verify build compiles** + +Run: `dotnet build Calamari.AiAgent --no-restore -v minimal` +Expected: Build failure — `InvokeClaudeCodeBehaviour.cs` references `MaxTokens`. This is expected and will be fixed in Task 3. + +- [ ] **Step 3: Commit** + +```bash +git add Calamari.AiAgent/SpecialVariables.cs +git commit -m "feat: add MaxTurns and MaxBudgetUsd variables, remove stale MaxTokens" +``` + +--- + +### Task 3: Refactor ClaudeCodeCliRunner — remove BuildArguments and ClaudeCodeOptions + +**Files:** +- Modify: `Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs` + +- [ ] **Step 1: Update RunAsync signature and remove BuildArguments/ClaudeCodeOptions** + +Change `ClaudeCodeCliRunner.cs` to: +- Remove the `ClaudeCodeOptions` record entirely. +- Remove the `BuildArguments` static method. +- Remove `EscapeArg` (now lives in the builder). Keep it if `ApplyCredentials` or other runner logic still needs it — check first. +- Change `RunAsync` to accept the built args string plus the individual config values the runner needs: + +```csharp +public async Task RunAsync( + string args, + string apiToken, + IReadOnlyDictionary mcpServers, + ProcessCredentials? runAs = null) +``` + +Update `RunInDirectoryAsync` similarly — it no longer calls `BuildArguments`. Instead it receives the args string and appends the debug file flag (or the debug file is already in the args from the builder — check the spec). Per the spec, the builder handles `--debug-file`, so the runner should NOT append it. The runner still generates the debug file path and passes it to the builder via the behaviour. However, since `RunAsync` receives a pre-built args string, the debug file path generation needs to move to the behaviour. + +Revised approach: the runner generates the debug file path, appends `--debug-file` to the args string it receives, then uses the result. This keeps debug file lifecycle (path generation + artifact upload) co-located in the runner. + +Updated `RunInDirectoryAsync`: + +```csharp +async Task RunInDirectoryAsync( + string args, + string apiToken, + string workingDir, + ProcessCredentials? runAs) +{ + var debugLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-debug-{Guid.NewGuid():N}.log"); + var fullArgs = $"{args} --debug-file {EscapeArg(debugLogPath)}"; + + var startInfo = new ProcessStartInfo + { + FileName = "claude", + Arguments = fullArgs, + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + startInfo.Environment["ANTHROPIC_API_KEY"] = apiToken; + + if (runAs != null) + ApplyCredentials(startInfo, runAs); + + // ... rest of process lifecycle unchanged ... +} +``` + +Keep `EscapeArg` as a private method in the runner (it's still needed for the debug file path). The builder has its own copy. + +Update `RunAsync`: + +```csharp +public async Task RunAsync( + string args, + string apiToken, + IReadOnlyDictionary mcpServers, + ProcessCredentials? runAs = null) +{ + var workingDir = Path.Combine(Path.GetTempPath(), $"claude-agent-{Guid.NewGuid():N}"); + Directory.CreateDirectory(workingDir); + log.Verbose($"Claude Code working directory: {workingDir}"); + + try + { + SetupSkills(workingDir); + SetupMcpConfig(workingDir, mcpServers); + return await RunInDirectoryAsync(args, apiToken, workingDir, runAs); + } + finally + { + try { Directory.Delete(workingDir, recursive: true); } + catch { /* best effort cleanup */ } + } +} +``` + +- [ ] **Step 2: Verify the AiAgent project builds** (will fail until behaviour is updated in Task 4) + +Run: `dotnet build Calamari.AiAgent --no-restore -v minimal` +Expected: Build failure in `InvokeClaudeCodeBehaviour.cs` — still references `ClaudeCodeOptions`. + +- [ ] **Step 3: Commit** + +```bash +git add Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs +git commit -m "refactor: remove ClaudeCodeOptions and BuildArguments from ClaudeCodeCliRunner" +``` + +--- + +### Task 4: Update InvokeClaudeCodeBehaviour to use the builder + +**Files:** +- Modify: `Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs` + +- [ ] **Step 1: Replace ClaudeCodeOptions usage with builder** + +Update the `Execute` method in `InvokeClaudeCodeBehaviour.cs`: + +```csharp +public async Task Execute(RunningDeployment context) +{ + var variables = context.Variables; + + var prompt = variables.Get(SpecialVariables.Action.AiAgent.Prompt); + if (string.IsNullOrWhiteSpace(prompt)) + throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.Prompt}' is required but was not provided."); + + var apiToken = variables.Get(SpecialVariables.Action.AiAgent.ApiToken); + if (string.IsNullOrWhiteSpace(apiToken)) + throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.ApiToken}' is required but was not provided."); + + var model = variables.Get(SpecialVariables.Action.AiAgent.Model); + if (string.IsNullOrWhiteSpace(model)) + model = "claude-sonnet-4-20250514"; + + log.Info($"Invoking Claude Code CLI with model '{model}'..."); + + var mcpServers = BuildMcpServers(variables); + var runAs = BuildRunAs(variables); + + var argsBuilder = new ClaudeCommandArgsBuilder() + .WithPrompt(prompt) + .WithModel(model) + .WithAllowedTools(new[] { "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch" }); + + var systemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill); + if (!string.IsNullOrWhiteSpace(systemPrompt)) + argsBuilder.WithSystemPrompt(systemPrompt); + + var maxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTurns); + if (maxTurns.HasValue) + argsBuilder.WithMaxTurns(maxTurns.Value); + + var maxBudgetUsdRaw = variables.Get(SpecialVariables.Action.AiAgent.MaxBudgetUsd); + if (!string.IsNullOrWhiteSpace(maxBudgetUsdRaw) + && decimal.TryParse(maxBudgetUsdRaw, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var budgetUsd)) + argsBuilder.WithMaxBudgetUsd(budgetUsd); + + var args = argsBuilder.Build(); + + var runner = new ClaudeCodeCliRunner(log); + var response = await runner.RunAsync(args, apiToken, mcpServers, runAs); + + Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); + log.Info("Claude Code invocation complete."); +} +``` + +Note: `IVariables` has no `GetDecimal` method, so we parse `MaxBudgetUsd` from the string using `decimal.TryParse` with `InvariantCulture`. The builder does not handle `--mcp-config`, `--strict-mcp-config`, or `--debug-file` — the runner appends those since it owns the working directory and debug file paths. + +- [ ] **Step 2: Build the full solution** + +Run: `dotnet build Calamari.AiAgent --no-restore -v minimal` +Expected: PASS — all references resolved. + +- [ ] **Step 3: Run all AiAgent tests** + +Run: `dotnet test Calamari.AiAgent.Tests --no-restore -v minimal` +Expected: All tests pass (builder tests + remaining runner tests for SetupSkills/SetupMcpConfig). + +- [ ] **Step 4: Commit** + +```bash +git add Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs Calamari.AiAgent/Behaviours/ClaudeCommandArgsBuilder.cs Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs +git commit -m "feat: wire up ClaudeCommandArgsBuilder in behaviour, add max-turns and max-budget-usd support" +``` + +--- + +### Task 5: Update existing test fixture + +**Files:** +- Modify: `Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs` + +- [ ] **Step 1: Remove tests that referenced BuildArguments and ClaudeCodeOptions** + +Remove these tests from `ClaudeCodeCliRunnerFixture.cs`: +- `BuildArguments_IncludesRequiredFlags` +- `BuildArguments_IncludesAllowedTools` +- `BuildArguments_OmitsAllowedTools_WhenEmpty` +- `BuildArguments_IncludesMaxTurns_WhenSet` +- `BuildArguments_OmitsMaxTurns_WhenNotSet` +- `BuildArguments_IncludesSystemPrompt_WhenSet` +- `BuildArguments_OmitsSystemPrompt_WhenNotSet` +- `BuildArguments_EscapesPromptWithSpaces` + +Remove the `DefaultOptions` helper method. + +Keep: +- `SetupSkills_CreatesSkillFile` +- `SetupMcpConfig_WritesValidJson_WithServers` +- `SetupMcpConfig_WritesEmptyServers_WhenNoneProvided` + +The file should look like: + +```csharp +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Calamari.AiAgent.Behaviours; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests; + +[TestFixture] +public class ClaudeCodeCliRunnerFixture +{ + [Test] + public void SetupSkills_CreatesSkillFile() + { + var workingDir = Path.Combine(Path.GetTempPath(), $"test-skills-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + ClaudeCodeCliRunner.SetupSkills(workingDir); + + var skillPath = Path.Combine(workingDir, ".claude", "skills", "octopus-deployment-context.md"); + File.Exists(skillPath).Should().BeTrue(); + + var content = File.ReadAllText(skillPath); + content.Should().Contain("name: octopus-deployment-context"); + content.Should().Contain("description:"); + content.Should().Contain("get_deployment_variables"); + } + finally + { + Directory.Delete(workingDir, true); + } + } + + [Test] + public void SetupMcpConfig_WritesValidJson_WithServers() + { + var workingDir = Path.Combine(Path.GetTempPath(), $"test-mcp-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + var servers = new Dictionary + { + ["github"] = new McpServerConfig + { + Command = "npx", + Args = new[] { "-y", "@modelcontextprotocol/server-github" }, + Env = new Dictionary { ["TOKEN"] = "abc123" }, + }, + }; + + ClaudeCodeCliRunner.SetupMcpConfig(workingDir, servers); + + var configPath = Path.Combine(workingDir, "mcp-config.json"); + File.Exists(configPath).Should().BeTrue(); + + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); + mcpServers.TryGetProperty("github", out var github).Should().BeTrue(); + github.GetProperty("command").GetString().Should().Be("npx"); + } + finally + { + Directory.Delete(workingDir, true); + } + } + + [Test] + public void SetupMcpConfig_WritesEmptyServers_WhenNoneProvided() + { + var workingDir = Path.Combine(Path.GetTempPath(), $"test-mcp-empty-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + ClaudeCodeCliRunner.SetupMcpConfig(workingDir, new Dictionary()); + + var configPath = Path.Combine(workingDir, "mcp-config.json"); + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); + mcpServers.EnumerateObject().Should().BeEmpty(); + } + finally + { + Directory.Delete(workingDir, true); + } + } +} +``` + +- [ ] **Step 2: Run all tests** + +Run: `dotnet test Calamari.AiAgent.Tests --no-restore -v minimal` +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs +git commit -m "refactor: remove BuildArguments tests from runner fixture (moved to builder)" +``` + diff --git a/source/docs/superpowers/specs/2026-06-01-claude-command-args-builder-design.md b/source/docs/superpowers/specs/2026-06-01-claude-command-args-builder-design.md new file mode 100644 index 0000000000..8e0a5fd528 --- /dev/null +++ b/source/docs/superpowers/specs/2026-06-01-claude-command-args-builder-design.md @@ -0,0 +1,118 @@ +# Claude Command Args Builder + max-turns / max-budget-usd + +## Summary + +Refactor CLI argument construction out of `ClaudeCodeCliRunner` into a fluent `ClaudeCommandArgsBuilder`, and add support for two new optional step variables: `max-turns` (default 10) and `max-budget-usd` (optional). + +## Motivation + +- `ClaudeCodeCliRunner` is accumulating responsibilities — process lifecycle, argument building, credential handling, MCP/skill setup. Extracting arg building reduces complexity. +- Users need control over agent turn limits and spend caps. +- `MaxTokens` variable exists but was incorrectly mapped to `MaxTurns` — this is a stale bug to clean up. + +## Design + +### ClaudeCommandArgsBuilder (new file) + +**File:** `Behaviours/ClaudeCommandArgsBuilder.cs` + +Fluent builder that produces a CLI arguments string. Owns argument escaping. No knowledge of processes, environment variables, or credentials. + +```csharp +public class ClaudeCommandArgsBuilder +{ + public ClaudeCommandArgsBuilder WithPrompt(string prompt) { ... } + public ClaudeCommandArgsBuilder WithModel(string model) { ... } + public ClaudeCommandArgsBuilder WithSystemPrompt(string systemPrompt) { ... } + public ClaudeCommandArgsBuilder WithMaxTurns(int maxTurns) { ... } + public ClaudeCommandArgsBuilder WithMaxBudgetUsd(decimal budgetUsd) { ... } + public ClaudeCommandArgsBuilder WithAllowedTools(IReadOnlyList tools) { ... } + public string Build() { ... } // returns full args string + // Note: --mcp-config, --strict-mcp-config, and --debug-file are appended by the runner + // since it owns working directory and debug file paths. + +} +``` + +**Builder behaviour:** +- `WithMaxBudgetUsd` is only called when the variable is provided — method takes non-nullable `decimal`. Builder tracks whether it was called and only emits `--max-budget-usd` if so. +- `WithMaxTurns` is only called when the variable is provided — method takes non-nullable `int`. Builder internally defaults to `10` if `WithMaxTurns` was never called. +- `Build()` validates that prompt is set (required). Throws if missing. +- Argument escaping handled internally (moves `EscapeArg` into the builder). +- Emits flags: `-p`, `--model`, `--output-format stream-json`, `--verbose`, `--permission-mode dontAsk`, `--no-session-persistence`, `--allowedTools`, `--max-turns`, `--max-budget-usd`, `--system-prompt`. + +### ClaudeCodeCliRunner (modified) + +**File:** `Behaviours/ClaudeCodeCliRunner.cs` + +- Remove `BuildArguments` static method. +- Remove `ClaudeCodeOptions` record. +- `RunAsync` takes individual parameters or a simpler signature — the behaviour constructs the args string via the builder and passes it along with the other config the runner needs (API token, credentials, MCP servers, system prompt for skill setup). +- Keep: process lifecycle, stream processing, `ApplyCredentials`, `SetupMcpConfig`, `SetupSkills`. +- Keep: `ProcessCredentials`, `McpServerConfig` records. +- Keep: `EscapeArg` can be removed from here once moved to builder (or kept as internal if runner still needs it). + +### SpecialVariables (modified) + +**File:** `SpecialVariables.cs` + +```csharp +public const string MaxTurns = "Octopus.Action.Claude.MaxTurns"; +public const string MaxBudgetUsd = "Octopus.Action.Claude.MaxBudgetUsd"; +``` + +- Remove `MaxTokens` (stale, was incorrectly used). + +### InvokeClaudeCodeBehaviour (modified) + +**File:** `Behaviours/InvokeClaudeCodeBehaviour.cs` + +- Read `MaxTurns` variable, default to `10` if absent. +- Read `MaxBudgetUsd` variable, only set on builder if present. +- Construct args via `ClaudeCommandArgsBuilder` fluent API. +- Pass built args string + API token + credentials + MCP servers to runner. + +```csharp +var argsBuilder = new ClaudeCommandArgsBuilder() + .WithPrompt(prompt) + .WithModel(model) + .WithSystemPrompt(systemPrompt) + .WithAllowedTools(allowedTools) + .WithMcpConfig(mcpConfigPath) + .WithDebugFile(debugFilePath); + +var maxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTurns); +if (maxTurns.HasValue) + argsBuilder.WithMaxTurns(maxTurns.Value); + +var budgetUsd = variables.GetDecimal(SpecialVariables.Action.AiAgent.MaxBudgetUsd); +if (budgetUsd.HasValue) + argsBuilder.WithMaxBudgetUsd(budgetUsd.Value); + +var args = argsBuilder.Build(); // max-turns defaults to 10 internally +``` + +### OctopusDeploy Step Definition (separate repo) + +**File:** In `/Users/robert/Development/Octopus/OctopusDeploy/` — `AiAgentStepDocumentation.cs` + +Add two new `StepPropertyInfo` entries: +- `Octopus.Action.Claude.MaxTurns` — "Maximum number of agentic turns (default: 10)" +- `Octopus.Action.Claude.MaxBudgetUsd` — "Maximum budget in USD for the agent run" + +Remove `Octopus.Action.Claude.MaxTokens` if present. + +## Files Changed + +| File | Action | +|------|--------| +| `Behaviours/ClaudeCommandArgsBuilder.cs` | New — fluent builder | +| `Behaviours/ClaudeCodeCliRunner.cs` | Modified — remove `BuildArguments`, `ClaudeCodeOptions`, accept args string | +| `SpecialVariables.cs` | Modified — add `MaxTurns`, `MaxBudgetUsd`, remove `MaxTokens` | +| `Behaviours/InvokeClaudeCodeBehaviour.cs` | Modified — use builder, read new variables | +| OctopusDeploy `AiAgentStepDocumentation.cs` | Modified — add step properties | + +## Testing + +- Unit tests for `ClaudeCommandArgsBuilder`: verify each flag is emitted correctly, escaping works, optional flags omitted when not set, validation throws on missing prompt. +- Update existing `ClaudeCodeCliRunner` tests if they reference `BuildArguments` or `ClaudeCodeOptions`. From bd86210ff2acafb85bd2a7a809c605823d224ec2 Mon Sep 17 00:00:00 2001 From: robert Date: Thu, 4 Jun 2026 16:39:04 +1000 Subject: [PATCH 07/26] Add deployment variables file, effort level, MCP tool permissions, and fixes Write filtered deployment variables to deployment-variables.json in the working directory so the agent can read deployment context on demand. Add --effort flag support (low/medium/high/xhigh/max). Auto-add mcp____* to --allowedTools for all configured MCP servers so they aren't blocked by --permission-mode dontAsk. Add --bare flag for clean environment isolation. Fix verbose log missing newlines between JSON events. Update PermissionDenial model to structured record. Remove superpowers planning docs from repo. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ClaudeCodeCliRunnerFixture.cs | 58 +- .../ClaudeCodeCliRunner.cs | 59 +- .../ClaudeCodeStreamModels.cs | 27 +- .../ClaudeCodeStreamProcessor.cs | 56 +- .../ClaudeCommandArgsBuilder.cs | 33 +- .../DefaultContext/system-prompt.md | 11 +- .../InvokeClaudeCodeBehaviour.cs | 31 +- source/Calamari.AiAgent/SpecialVariables.cs | 2 + .../2026-05-24-aiagent-claude-invocation.md | 237 ------- .../plans/2026-05-24-calamari-ai-agent.md | 271 -------- .../2026-06-01-claude-command-args-builder.md | 633 ------------------ ...6-01-claude-command-args-builder-design.md | 118 ---- 12 files changed, 194 insertions(+), 1342 deletions(-) delete mode 100644 source/docs/superpowers/plans/2026-05-24-aiagent-claude-invocation.md delete mode 100644 source/docs/superpowers/plans/2026-05-24-calamari-ai-agent.md delete mode 100644 source/docs/superpowers/plans/2026-06-01-claude-command-args-builder.md delete mode 100644 source/docs/superpowers/specs/2026-06-01-claude-command-args-builder-design.md diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs index 9e01cb7327..380be51053 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs @@ -13,7 +13,7 @@ namespace Calamari.AiAgent.Tests; public class ClaudeCodeCliRunnerFixture { [Test] - public void SetupSkills_CreatesSkillFile() + public void SetupSkills_CreatesSkillDirectories() { var workingDir = Path.Combine(Path.GetTempPath(), $"test-skills-{Path.GetRandomFileName()}"); Directory.CreateDirectory(workingDir); @@ -22,12 +22,13 @@ public void SetupSkills_CreatesSkillFile() { ClaudeCodeCliRunner.SetupSkills(workingDir); - var skillPath = Path.Combine(workingDir, ".claude", "skills", "octopus-deployment.context.md"); - File.Exists(skillPath).Should().BeTrue(); + var skillDir = Path.Combine(workingDir, ".claude", "skills", "octopus-deployment-context"); + Directory.Exists(skillDir).Should().BeTrue(); - var content = File.ReadAllText(skillPath); - content.Should().Contain("name: octopus-deployment-context"); - content.Should().Contain("description:"); + var skillMd = Path.Combine(skillDir, "SKILL.md"); + File.Exists(skillMd).Should().BeTrue(); + + var content = File.ReadAllText(skillMd); content.Should().Contain("get_deployment_variables"); } finally @@ -52,13 +53,32 @@ public void SetupSkills_WritesUserSkills() ClaudeCodeCliRunner.SetupSkills(workingDir, userSkills); - var skillPath1 = Path.Combine(workingDir, ".claude", "skills", "my-custom-skill.md"); - File.Exists(skillPath1).Should().BeTrue(); - File.ReadAllText(skillPath1).Should().Contain("Do something useful."); + var skill1 = Path.Combine(workingDir, ".claude", "skills", "my-custom-skill", "SKILL.md"); + File.Exists(skill1).Should().BeTrue(); + File.ReadAllText(skill1).Should().Contain("Do something useful."); + + var skill2 = Path.Combine(workingDir, ".claude", "skills", "another-skill", "SKILL.md"); + File.Exists(skill2).Should().BeTrue(); + File.ReadAllText(skill2).Should().Contain("More instructions."); + } + finally + { + Directory.Delete(workingDir, true); + } + } + + [Test] + public void SetupSystemPrompt_WritesFile() + { + var workingDir = Path.Combine(Path.GetTempPath(), $"test-sysprompt-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + var path = ClaudeCodeCliRunner.SetupSystemPrompt(workingDir); - var skillPath2 = Path.Combine(workingDir, ".claude", "skills", "another-skill.md"); - File.Exists(skillPath2).Should().BeTrue(); - File.ReadAllText(skillPath2).Should().Contain("More instructions."); + File.Exists(path).Should().BeTrue(); + File.ReadAllText(path).Should().NotBeEmpty(); } finally { @@ -122,13 +142,13 @@ public void SetupSkills_SanitizesPathTraversalAttempt() ClaudeCodeCliRunner.SetupSkills(workingDir, userSkills); - // The file should be written safely inside the skills directory, not at ../../etc/evil + // The file should be written safely inside the skills directory var skillsDir = Path.Combine(workingDir, ".claude", "skills"); - var files = Directory.GetFiles(skillsDir, "*.md"); - files.Should().Contain(f => f.Contains("etc-evil")); + var dirs = Directory.GetDirectories(skillsDir); + dirs.Should().Contain(d => Path.GetFileName(d).Contains("etc-evil")); // Verify nothing was written outside - File.Exists(Path.Combine(workingDir, "..", "..", "etc", "evil.md")).Should().BeFalse(); + File.Exists(Path.Combine(workingDir, "..", "..", "etc", "evil", "SKILL.md")).Should().BeFalse(); } finally { @@ -154,9 +174,8 @@ public void SetupMcpConfig_WritesValidJson_WithServers() }, }; - ClaudeCodeCliRunner.SetupMcpConfig(workingDir, servers); + var configPath = ClaudeCodeCliRunner.SetupMcpConfig(workingDir, servers); - var configPath = Path.Combine(workingDir, "mcp-config.json"); File.Exists(configPath).Should().BeTrue(); var json = File.ReadAllText(configPath); @@ -179,9 +198,8 @@ public void SetupMcpConfig_WritesEmptyServers_WhenNoneProvided() try { - ClaudeCodeCliRunner.SetupMcpConfig(workingDir, new Dictionary()); + var configPath = ClaudeCodeCliRunner.SetupMcpConfig(workingDir, new Dictionary()); - var configPath = Path.Combine(workingDir, "mcp-config.json"); var json = File.ReadAllText(configPath); var doc = JsonDocument.Parse(json); doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs index 2b1df8ae15..aaef3640d9 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -27,6 +27,7 @@ public async Task RunAsync( ClaudeCommandArgsBuilder argsBuilder, string apiToken, IReadOnlyDictionary mcpServers, + IReadOnlyDictionary deploymentVariables, ProcessCredentials? runAs = null, IReadOnlyList? userSkills = null) { @@ -37,7 +38,14 @@ public async Task RunAsync( try { SetupSkills(workingDir, userSkills); - SetupMcpConfig(workingDir, mcpServers); + SetupDeploymentVariables(workingDir, deploymentVariables); + + var systemPromptPath = SetupSystemPrompt(workingDir); + argsBuilder.WithAppendSystemPromptFile(systemPromptPath); + + var mcpConfigPath = SetupMcpConfig(workingDir, mcpServers); + argsBuilder.WithMcpConfigPath(mcpConfigPath); + return await RunInDirectoryAsync(argsBuilder, apiToken, workingDir, runAs); } finally @@ -53,14 +61,10 @@ async Task RunInDirectoryAsync( string workingDir, ProcessCredentials? runAs) { - //var mcpConfigPath = Path.Combine(workingDir, "mcp-config.json"); - //var systemPromptPath = Path.Combine(workingDir, "CLAUDE.md"); var verboseLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-verbose-{Guid.NewGuid():N}.log"); var debugLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-debug-{Guid.NewGuid():N}.log"); var fullArgs = argsBuilder.Build(); - //var fullArgs = $"{args} --strict-mcp-config " //"--bare " - //+ $"--system-prompt-file {EscapeArg(systemPromptPath)} " - fullArgs += $" --debug-file {EscapeArg(debugLogPath)}"; + fullArgs += $" --debug-file {EscapeArg(debugLogPath)}"; log.Verbose($"Claude Code command: claude {fullArgs}"); var startInfo = new ProcessStartInfo @@ -114,7 +118,7 @@ async Task RunProcess(ProcessStartInfo startInfo, string verboseLogPath, ClaudeC { while (await process.StandardOutput.ReadLineAsync() is { } line) { - await File.AppendAllTextAsync(verboseLogPath, line); + await File.AppendAllTextAsync(verboseLogPath, line + "\n"); streamProcessor.ProcessLine(line); } }); @@ -139,16 +143,39 @@ async Task RunProcess(ProcessStartInfo startInfo, string verboseLogPath, ClaudeC } } - internal static void SetupMcpConfig(string workingDir, IReadOnlyDictionary mcpServers) + internal static string SetupMcpConfig(string workingDir, IReadOnlyDictionary mcpServers) { var config = new { mcpServers }; var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Path.Combine(workingDir, "mcp-config.json"), json); + var path = Path.Combine(workingDir, "mcp-config.json"); + File.WriteAllText(path, json); + return path; } const string SkillsResourcePrefix = "Calamari.AiAgent.ClaudeCodeBehaviour.DefaultContext.Skills."; const string SystemPromptResource = "Calamari.AiAgent.ClaudeCodeBehaviour.DefaultContext.system-prompt.md"; + internal static string SetupSystemPrompt(string workingDir) + { + var assembly = Assembly.GetExecutingAssembly(); + var path = Path.Combine(workingDir, "system-prompt.md"); + + using var stream = assembly.GetManifestResourceStream(SystemPromptResource); + if (stream != null) + { + using var reader = new StreamReader(stream); + File.WriteAllText(path, reader.ReadToEnd()); + } + + return path; + } + + internal static void SetupDeploymentVariables(string workingDir, IReadOnlyDictionary variables) + { + var json = JsonSerializer.Serialize(variables, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(workingDir, "deployment-variables.json"), json); + } + internal static void SetupSkills(string workingDir, IReadOnlyList? userSkills = null) { var skillsDir = Path.Combine(workingDir, ".claude", "skills"); @@ -156,21 +183,11 @@ internal static void SetupSkills(string workingDir, IReadOnlyList? us var assembly = Assembly.GetExecutingAssembly(); - // Write CLAUDE.md (system prompt) to the working directory root - using (var promptStream = assembly.GetManifestResourceStream(SystemPromptResource)) - { - if (promptStream != null) - { - using var reader = new StreamReader(promptStream); - File.WriteAllText(Path.Combine(workingDir, "CLAUDE.md"), reader.ReadToEnd()); - } - } - foreach (var resourceName in assembly.GetManifestResourceNames()) { if (!resourceName.StartsWith(SkillsResourcePrefix, StringComparison.Ordinal)) continue; - + var fileName = resourceName.Substring(SkillsResourcePrefix.Length); var skillName = Path.GetFileNameWithoutExtension(fileName); var innerSkillDir = Path.Combine(skillsDir, skillName); @@ -188,7 +205,7 @@ internal static void SetupSkills(string workingDir, IReadOnlyList? us { var dirName = SanitizeFileName(skill.Name); var innerSkillDir = Path.GetFullPath(Path.Combine(skillsDir, dirName)); - + if (!innerSkillDir.StartsWith(Path.GetFullPath(skillsDir) + Path.DirectorySeparatorChar, StringComparison.Ordinal)) throw new CommandException($"Skill name '{skill.Name}' results in a path outside the skills directory."); diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs index 7d9de11bde..92d647f79f 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs @@ -175,22 +175,13 @@ public record UserStreamEvent : StreamEvent [JsonPropertyName("timestamp")] public string? Timestamp { get; init; } - [JsonPropertyName("tool_use_result")] - public ToolUseResultInfo? ToolUseResult { get; init; } + //[JsonPropertyName("tool_use_result")] + //public string? ToolUseResult { get; init; } [JsonPropertyName("isSynthetic")] public bool? IsSynthetic { get; init; } } - public record ToolUseResultInfo - { - [JsonPropertyName("success")] - public bool? Success { get; init; } - - [JsonPropertyName("commandName")] - public string? CommandName { get; init; } - } - public record ResultStreamEvent : StreamEvent { [JsonPropertyName("subtype")] @@ -227,12 +218,24 @@ public record ResultStreamEvent : StreamEvent public IReadOnlyDictionary? ModelUsage { get; init; } [JsonPropertyName("permission_denials")] - public IReadOnlyList? PermissionDenials { get; init; } + public IReadOnlyList? PermissionDenials { get; init; } [JsonPropertyName("fast_mode_state")] public string? FastModeState { get; init; } } + public record PermissionDenial + { + [JsonPropertyName("tool_name")] + public string? ToolName { get; init; } + + [JsonPropertyName("tool_use_id")] + public string? ToolUseId { get; init; } + + [JsonPropertyName("tool_input")] + public JsonElement? ToolInput { get; init; } + } + public record StreamMessage { [JsonPropertyName("model")] diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs index ac7692e8d7..4ab3d12c0e 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Text; using System.Text.Json; @@ -33,30 +34,42 @@ public void ProcessLine(string json) { return; } - - using var _ = doc; - var typeString = doc.RootElement.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null; - - if (typeString == null || !TryParseEventType(typeString, out var eventType)) + catch (Exception ex) { - log.Verbose($"[stream] unhandled event type '{typeString}'"); + log.Error($"[stream] failed to parse JSON: {ex.Message}"); return; } - switch (eventType) + try { - case StreamEventType.System: - HandleSystemEvent(JsonSerializer.Deserialize(json, JsonOptions)!); - break; - case StreamEventType.Assistant: - HandleMessageEvent(JsonSerializer.Deserialize(json, JsonOptions)?.Message); - break; - case StreamEventType.User: - HandleUserMessage(JsonSerializer.Deserialize(json, JsonOptions)); - break; - case StreamEventType.Result: - HandleResultEvent(JsonSerializer.Deserialize(json, JsonOptions)!); - break; + using var _ = doc; + var typeString = doc.RootElement.TryGetProperty("type", out var typeProp) ? typeProp.GetString() : null; + + if (typeString == null || !TryParseEventType(typeString, out var eventType)) + { + log.Verbose($"[stream] unhandled event type '{typeString}'"); + return; + } + + switch (eventType) + { + case StreamEventType.System: + HandleSystemEvent(JsonSerializer.Deserialize(json, JsonOptions)!); + break; + case StreamEventType.Assistant: + HandleMessageEvent(JsonSerializer.Deserialize(json, JsonOptions)?.Message); + break; + case StreamEventType.User: + HandleUserMessage(JsonSerializer.Deserialize(json, JsonOptions)); + break; + case StreamEventType.Result: + HandleResultEvent(JsonSerializer.Deserialize(json, JsonOptions)!); + break; + } + } + catch (Exception ex) + { + log.Error($"[stream] failed to process JSON: {ex.Message}"); } } @@ -222,7 +235,8 @@ void HandleResultEvent(ResultStreamEvent evt) properties[AiAgentServiceMessageNames.DurationApiMsAttribute] = evt.DurationApiMs.Value.ToString("F0"); if (evt.NumTurns.HasValue) properties[AiAgentServiceMessageNames.NumTurnsAttribute] = evt.NumTurns.Value.ToString(); - + log.Info($"AI Agent Usage — Cost: ${evt.CostUsd} USD (total: ${evt.TotalCostUsd}), Duration: {evt.DurationMs}ms, Turns: {evt.NumTurns}"); + if (evt.Usage is { } usage) { if (usage.InputTokens.HasValue) @@ -233,6 +247,8 @@ void HandleResultEvent(ResultStreamEvent evt) properties[AiAgentServiceMessageNames.CacheReadInputTokensAttribute] = usage.CacheReadInputTokens.Value.ToString(); if (usage.CacheCreationInputTokens.HasValue) properties[AiAgentServiceMessageNames.CacheCreationInputTokensAttribute] = usage.CacheCreationInputTokens.Value.ToString(); + + log.Info($"AI Agent Tokens — Input: {usage.InputTokens}, Output: {usage.OutputTokens}, Cache read: {usage.CacheReadInputTokens}, Cache creation: {usage.CacheCreationInputTokens}"); } log.WriteServiceMessage(new ServiceMessage(AiAgentServiceMessageNames.Name, properties)); diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs index eb9e022e08..6869f15b5c 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs @@ -11,9 +11,11 @@ public class ClaudeCommandArgsBuilder string? model; string? systemPrompt; string? mcpConfigPath; + string? appendSystemPromptFile; int maxTurns = 10; decimal? maxBudgetUsd; IReadOnlyList? allowedTools; + string? effort; public ClaudeCommandArgsBuilder WithPrompt(string prompt) { @@ -38,13 +40,19 @@ public ClaudeCommandArgsBuilder WithMaxTurns(int maxTurns) this.maxTurns = maxTurns; return this; } - + public ClaudeCommandArgsBuilder WithMcpConfigPath(string mcpConfigPath) { this.mcpConfigPath = mcpConfigPath; return this; } + public ClaudeCommandArgsBuilder WithAppendSystemPromptFile(string path) + { + this.appendSystemPromptFile = path; + return this; + } + public ClaudeCommandArgsBuilder WithMaxBudgetUsd(decimal budgetUsd) { this.maxBudgetUsd = budgetUsd; @@ -57,15 +65,22 @@ public ClaudeCommandArgsBuilder WithAllowedTools(IReadOnlyList tools) return this; } + public ClaudeCommandArgsBuilder WithEffort(string effort) + { + this.effort = effort; + return this; + } + public string Build() { if (string.IsNullOrWhiteSpace(prompt)) throw new InvalidOperationException("A prompt is required. Call WithPrompt() before Build()."); var args = new StringBuilder(); - + args.Append(" --model "); args.Append(EscapeArg(model ?? "claude-sonnet-4-20250514")); + args.Append(" --bare"); args.Append(" --strict-mcp-config"); args.Append(" --output-format stream-json"); args.Append(" --verbose"); @@ -74,10 +89,15 @@ public string Build() if (!string.IsNullOrWhiteSpace(mcpConfigPath)) { - args.Append(" --mcp-config"); + args.Append(" --mcp-config "); args.Append(EscapeArg(mcpConfigPath)); } + if (!string.IsNullOrWhiteSpace(appendSystemPromptFile)) + { + args.Append(" --append-system-prompt-file "); + args.Append(EscapeArg(appendSystemPromptFile)); + } if (allowedTools != null && allowedTools.Count > 0) { @@ -90,13 +110,16 @@ public string Build() if (maxBudgetUsd.HasValue) args.Append($" --max-budget-usd {maxBudgetUsd.Value.ToString(CultureInfo.InvariantCulture)}"); + if (!string.IsNullOrWhiteSpace(effort)) + args.Append($" --effort {effort}"); + if (!string.IsNullOrWhiteSpace(systemPrompt)) { args.Append(" --system-prompt "); args.Append(EscapeArg(systemPrompt)); } - - args.Append("-p "); + + args.Append(" -p "); args.Append(EscapeArg(prompt)); return args.ToString(); diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md b/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md index 62270ad37f..157f912c09 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/DefaultContext/system-prompt.md @@ -1,2 +1,9 @@ -# Skill Discovery -Locate skills available for this session in the ./.claude/skills directory \ No newline at end of file +# Octopus Deploy Agent + +You are running as an AI agent invoked during an Octopus Deploy deployment. + +## Deployment Variables +Octopus deployment variables are available in `./deployment-variables.json`. Read this file when you need context about the deployment such as environment, project, tenant, release version, or custom variables. Sensitive variables (passwords, tokens, API keys) have been filtered out. + +## Skills +Locate skills available for this session in the ./.claude/skills directory. diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs index 73c52cf0fa..52d284b6b6 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Calamari.Common.Commands; @@ -47,10 +48,20 @@ public async Task Execute(RunningDeployment context) var mcpServers = BuildMcpServers(variables); var runAs = BuildRunAs(variables); + var defaultAllowedTools = new[] { "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch" }; + var allowedToolsRaw = variables.Get(SpecialVariables.Action.AiAgent.AllowedTools); + var allowedTools = new List(!string.IsNullOrWhiteSpace(allowedToolsRaw) + ? allowedToolsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : defaultAllowedTools); + + // Auto-allow all tools from configured MCP servers + foreach (var serverName in mcpServers.Keys) + allowedTools.Add($"mcp__{serverName}__*"); + var argsBuilder = new ClaudeCommandArgsBuilder() .WithPrompt(prompt) .WithModel(model) - .WithAllowedTools(new[] { "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch" }); + .WithAllowedTools(allowedTools); var systemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill); if (!string.IsNullOrWhiteSpace(systemPrompt)) @@ -65,10 +76,15 @@ public async Task Execute(RunningDeployment context) && decimal.TryParse(maxBudgetUsdRaw, NumberStyles.Number, CultureInfo.InvariantCulture, out var budgetUsd)) argsBuilder.WithMaxBudgetUsd(budgetUsd); + var effort = variables.Get(SpecialVariables.Action.AiAgent.Effort); + if (!string.IsNullOrWhiteSpace(effort)) + argsBuilder.WithEffort(effort); + var userSkills = BuildUserSkills(variables); + var deploymentVariables = BuildDeploymentVariables(variables); var runner = new ClaudeCodeCliRunner(log); - var response = await runner.RunAsync(argsBuilder, apiToken, mcpServers, runAs, userSkills); + var response = await runner.RunAsync(argsBuilder, apiToken, mcpServers, deploymentVariables, runAs, userSkills); Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); log.Info("Claude Code invocation complete."); @@ -83,7 +99,7 @@ static Dictionary BuildMcpServers(IVariables variables) var octopusToken = variables.Get(SpecialVariables.Action.AiAgent.OctopusToken); if (!string.IsNullOrWhiteSpace(octopusToken)) { - var octopusServerUrl = variables.Get("Octopus.Web.BaseUrl"); + var octopusServerUrl = variables.Get("Octopus.Web.ServerUri"); if (string.IsNullOrWhiteSpace(octopusServerUrl)) { Log.Warn("Unable to find Octopus Server URL"); @@ -178,5 +194,14 @@ static List BuildUserSkills(IVariables variables) Password = variables.Get(SpecialVariables.Action.AiAgent.RunAsPassword), }; } + + static readonly string[] SensitiveKeywords = { "password", "secret", "token", "apikey", "api_key", "api-key", "private" }; + + static Dictionary BuildDeploymentVariables(IVariables variables) + { + return variables + .Where(kvp => !SensitiveKeywords.Any(k => kvp.Key.Contains(k, StringComparison.OrdinalIgnoreCase))) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } } } diff --git a/source/Calamari.AiAgent/SpecialVariables.cs b/source/Calamari.AiAgent/SpecialVariables.cs index 35cf5d4652..93448b25d8 100644 --- a/source/Calamari.AiAgent/SpecialVariables.cs +++ b/source/Calamari.AiAgent/SpecialVariables.cs @@ -15,6 +15,8 @@ public static class AiAgent public const string MaxTurns = "Octopus.Action.Claude.MaxTurns"; public const string MaxBudgetUsd = "Octopus.Action.Claude.MaxBudgetUsd"; public const string OctopusToken = "Octopus.Action.Claude.OctopusToken"; + public const string AllowedTools = "Octopus.Action.Claude.AllowedTools"; + public const string Effort = "Octopus.Action.Claude.Effort"; public const string RunAsUsername = "Octopus.Action.Claude.RunAsUsername"; public const string RunAsPassword = "Octopus.Action.Claude.RunAsPassword"; diff --git a/source/docs/superpowers/plans/2026-05-24-aiagent-claude-invocation.md b/source/docs/superpowers/plans/2026-05-24-aiagent-claude-invocation.md deleted file mode 100644 index 4f4d232973..0000000000 --- a/source/docs/superpowers/plans/2026-05-24-aiagent-claude-invocation.md +++ /dev/null @@ -1,237 +0,0 @@ -# AiAgent Claude API Invocation Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add Claude API streaming invocation to the `RunAgentCommand` so it reads a prompt and API token from variables, streams a Claude response (logging chunks in real-time), and sets the full response as an output variable. - -**Architecture:** A single `InvokeAgentBehaviour` (implementing `IDeployBehaviour`) uses the official Anthropic C# SDK to stream a Claude Messages API call. Variable names are defined in a `SpecialVariables` class. The behaviour is wired into `RunAgentCommand.Deploy`. - -**Tech Stack:** .NET 8.0, Anthropic NuGet SDK (`Anthropic` package), Autofac (via Calamari.Common) - ---- - -## File Structure - -**Create:** -- `source/Calamari.AiAgent/SpecialVariables.cs` — variable name constants -- `source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs` — streams Claude API call - -**Modify:** -- `source/Calamari.AiAgent/Calamari.AiAgent.csproj` — add Anthropic NuGet package -- `source/Calamari.AiAgent/RunAgentCommand.cs` — wire InvokeAgentBehaviour into Deploy pipeline - ---- - -### Task 1: Add Anthropic NuGet package - -**Files:** -- Modify: `source/Calamari.AiAgent/Calamari.AiAgent.csproj` - -- [ ] **Step 1: Add the Anthropic package reference** - -In `source/Calamari.AiAgent/Calamari.AiAgent.csproj`, add a `PackageReference` for the official Anthropic SDK inside the existing `ItemGroup` (or a new one). The csproj should become: - -```xml - - - - Calamari.AiAgent - Calamari.AiAgent - Exe - enable - win-x64;linux-x64;osx-x64;linux-arm;linux-arm64 - false - net8.0 - true - - - - - - - - - - - -``` - ---- - -### Task 2: Create SpecialVariables - -**Files:** -- Create: `source/Calamari.AiAgent/SpecialVariables.cs` - -- [ ] **Step 1: Create SpecialVariables.cs** - -Write `source/Calamari.AiAgent/SpecialVariables.cs`: - -```csharp -namespace Calamari.AiAgent -{ - static class SpecialVariables - { - public static class Action - { - public static class AiAgent - { - public const string Prompt = "Octopus.Action.AiAgent.Prompt"; - public const string ApiToken = "Octopus.Action.AiAgent.ApiToken"; - public const string Model = "Octopus.Action.AiAgent.Model"; - public const string Response = "Octopus.Action.AiAgent.Response"; - } - } - } -} -``` - ---- - -### Task 3: Create InvokeAgentBehaviour - -**Files:** -- Create: `source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs` - -- [ ] **Step 1: Create the Behaviours directory** - -```bash -mkdir -p source/Calamari.AiAgent/Behaviours -``` - -- [ ] **Step 2: Create InvokeAgentBehaviour.cs** - -Write `source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs`: - -```csharp -using System.Text; -using System.Threading.Tasks; -using Anthropic; -using Anthropic.Models.Messages; -using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Logging; -using Calamari.Common.Plumbing.Pipeline; - -namespace Calamari.AiAgent.Behaviours -{ - public class InvokeAgentBehaviour : IDeployBehaviour - { - readonly ILog log; - - public InvokeAgentBehaviour(ILog log) - { - this.log = log; - } - - public bool IsEnabled(RunningDeployment context) - { - return true; - } - - public async Task Execute(RunningDeployment context) - { - var variables = context.Variables; - - var prompt = variables.Get(SpecialVariables.Action.AiAgent.Prompt); - if (string.IsNullOrWhiteSpace(prompt)) - throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.Prompt}' is required but was not provided."); - - var apiToken = variables.Get(SpecialVariables.Action.AiAgent.ApiToken); - if (string.IsNullOrWhiteSpace(apiToken)) - throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.ApiToken}' is required but was not provided."); - - var model = variables.Get(SpecialVariables.Action.AiAgent.Model); - if (string.IsNullOrWhiteSpace(model)) - model = "claude-sonnet-4-20250514"; - - log.Info($"Invoking AI agent with model '{model}'..."); - - var client = new AnthropicClient { ApiKey = apiToken }; - - var parameters = new MessageCreateParams - { - MaxTokens = 4096, - Messages = - [ - new() - { - Role = Role.User, - Content = prompt, - }, - ], - Model = model, - }; - - var responseBuilder = new StringBuilder(); - - await foreach (var streamEvent in client.Messages.CreateStreaming(parameters)) - { - var text = streamEvent.ToString(); - if (!string.IsNullOrEmpty(text)) - { - responseBuilder.Append(text); - log.Info(text); - } - } - - var fullResponse = responseBuilder.ToString(); - Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, fullResponse, variables); - log.Info("AI agent invocation complete."); - } - } -} -``` - ---- - -### Task 4: Wire InvokeAgentBehaviour into RunAgentCommand - -**Files:** -- Modify: `source/Calamari.AiAgent/RunAgentCommand.cs` - -- [ ] **Step 1: Update RunAgentCommand to use InvokeAgentBehaviour** - -Replace the contents of `source/Calamari.AiAgent/RunAgentCommand.cs` with: - -```csharp -using System.Collections.Generic; -using Calamari.AiAgent.Behaviours; -using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Pipeline; - -namespace Calamari.AiAgent -{ - [Command("run-agent", Description = "Invokes an AI agent")] - public class RunAgentCommand : PipelineCommand - { - protected override IEnumerable Deploy(DeployResolver resolver) - { - yield return resolver.Create(); - } - } -} -``` - ---- - -### Task 5: Verify and commit - -- [ ] **Step 1: Verify all files are in place** - -Check that these files exist: -- `source/Calamari.AiAgent/SpecialVariables.cs` -- `source/Calamari.AiAgent/Behaviours/InvokeAgentBehaviour.cs` -- `source/Calamari.AiAgent/RunAgentCommand.cs` (updated) -- `source/Calamari.AiAgent/Calamari.AiAgent.csproj` (updated) - -- [ ] **Step 2: Verify the wiring test still passes** - -Run: `dotnet test source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj --filter "FullyQualifiedName~CommandResolutionTests" -v normal` -Expected: 1 test passed. The `InvokeAgentBehaviour` will be resolved via Autofac's assembly scanning in `CalamariFlavourProgramAsync` since it's registered as an `IBehaviour`. - -- [ ] **Step 3: Commit** - -```bash -git add source/Calamari.AiAgent/ -git commit -m "feat: add Claude API streaming invocation to RunAgentCommand" -``` diff --git a/source/docs/superpowers/plans/2026-05-24-calamari-ai-agent.md b/source/docs/superpowers/plans/2026-05-24-calamari-ai-agent.md deleted file mode 100644 index da97c02972..0000000000 --- a/source/docs/superpowers/plans/2026-05-24-calamari-ai-agent.md +++ /dev/null @@ -1,271 +0,0 @@ -# Calamari.AiAgent Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Create a new Calamari flavour project (`Calamari.AiAgent`) for running AI Agent invocations, with a single `run-agent` command and a wiring test. - -**Architecture:** Minimal `CalamariFlavourProgramAsync`-based flavour (same pattern as `Calamari.Scripting` and `Calamari.AzureAppService`). One `PipelineCommand` subclass for `run-agent`. A test project validates all commands resolve from the DI container. - -**Tech Stack:** .NET 8.0, Autofac (via Calamari.Common), NUnit (tests) - ---- - -## File Structure - -**Create:** -- `source/Calamari.AiAgent/Calamari.AiAgent.csproj` — Exe project, net8.0, references Calamari.Common -- `source/Calamari.AiAgent/Program.cs` — Entry point extending `CalamariFlavourProgramAsync` -- `source/Calamari.AiAgent/RunAgentCommand.cs` — `PipelineCommand` with `[Command("run-agent")]` -- `source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj` — Test project -- `source/Calamari.AiAgent.Tests/CommandResolutionTests.cs` — Wiring test - -**Modify:** -- `source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs:14-24` — Add `"Calamari.AiAgent"` to project list -- `source/Calamari.sln` — Add both new projects (via `dotnet sln add`) - ---- - -### Task 1: Create the Calamari.AiAgent project and Program.cs - -**Files:** -- Create: `source/Calamari.AiAgent/Calamari.AiAgent.csproj` -- Create: `source/Calamari.AiAgent/Program.cs` - -- [ ] **Step 1: Create the project directory** - -```bash -mkdir -p source/Calamari.AiAgent -``` - -- [ ] **Step 2: Create the .csproj file** - -Write `source/Calamari.AiAgent/Calamari.AiAgent.csproj`: - -```xml - - - - Calamari.AiAgent - Calamari.AiAgent - Exe - enable - win-x64;linux-x64;osx-x64;linux-arm;linux-arm64 - false - net8.0 - true - - - - - - - -``` - -- [ ] **Step 3: Create Program.cs** - -Write `source/Calamari.AiAgent/Program.cs`: - -```csharp -using System.Threading.Tasks; -using Calamari.Common; -using Calamari.Common.Plumbing.Logging; - -namespace Calamari.AiAgent -{ - public class Program : CalamariFlavourProgramAsync - { - public Program(ILog log) : base(log) - { - } - - public static Task Main(string[] args) - { - return new Program(ConsoleLog.Instance).Run(args); - } - } -} -``` - -- [ ] **Step 4: Verify it compiles** - -Run: `dotnet build source/Calamari.AiAgent/Calamari.AiAgent.csproj` -Expected: Build succeeded with 0 errors. - ---- - -### Task 2: Create the RunAgentCommand - -**Files:** -- Create: `source/Calamari.AiAgent/RunAgentCommand.cs` - -- [ ] **Step 1: Create RunAgentCommand.cs** - -Write `source/Calamari.AiAgent/RunAgentCommand.cs`: - -```csharp -using System.Collections.Generic; -using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Pipeline; - -namespace Calamari.AiAgent -{ - [Command("run-agent", Description = "Invokes an AI agent")] - public class RunAgentCommand : PipelineCommand - { - protected override IEnumerable Deploy(DeployResolver resolver) - { - yield break; - } - } -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `dotnet build source/Calamari.AiAgent/Calamari.AiAgent.csproj` -Expected: Build succeeded with 0 errors. - ---- - -### Task 3: Create the test project with wiring test - -**Files:** -- Create: `source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj` -- Create: `source/Calamari.AiAgent.Tests/CommandResolutionTests.cs` - -- [ ] **Step 1: Create the test project directory** - -```bash -mkdir -p source/Calamari.AiAgent.Tests -``` - -- [ ] **Step 2: Create the test .csproj file** - -Write `source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj`: - -```xml - - - Calamari.AiAgent.Tests - Calamari.AiAgent.Tests - net8.0 - win-x64;linux-x64;osx-x64;linux-arm;linux-arm64 - false - true - - - - - - - - - - - - - - -``` - -- [ ] **Step 3: Create the wiring test** - -Write `source/Calamari.AiAgent.Tests/CommandResolutionTests.cs`: - -```csharp -using System; -using System.Collections.Generic; -using Autofac; -using Calamari.Testing; -using NUnit.Framework; - -namespace Calamari.AiAgent.Tests; - -[TestFixture] -public class CommandResolutionTests -{ - [Test] - [Category("PlatformAgnostic")] - public void AllPipelineCommandsCanBeConstructed() - { - var program = TestablePipelineProgram.For(); - using var container = program.BuildTestContainer(); - - var failures = new List(); - foreach (var type in program.PipelineCommandTypes) - { - try - { - container.Resolve(type); - } - catch (Exception ex) - { - failures.Add($"'{type.Name}': {ex.Message}"); - } - } - - Assert.That(failures, Is.Empty, "all pipeline commands must be constructable from the DI container"); - } -} -``` - -- [ ] **Step 4: Verify test project compiles** - -Run: `dotnet build source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj` -Expected: Build succeeded with 0 errors. - -- [ ] **Step 5: Run the test** - -Run: `dotnet test source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj --filter "FullyQualifiedName~CommandResolutionTests" -v normal` -Expected: 1 test passed. - ---- - -### Task 4: Add projects to solution and consolidation list - -**Files:** -- Modify: `source/Calamari.sln` -- Modify: `source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs` - -- [ ] **Step 1: Add both projects to the solution** - -```bash -cd source && dotnet sln Calamari.sln add Calamari.AiAgent/Calamari.AiAgent.csproj Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj -``` - -Expected: Two "Project added to the solution" messages. - -- [ ] **Step 2: Add to BuildableCalamariProjects.cs** - -In `source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs`, add `"Calamari.AiAgent"` to the `NonWindows` array (which is also included in the `Windows` array). The array should become: - -```csharp -static readonly string[] NonWindows = -[ - "Calamari", - "Calamari.AiAgent", - "Calamari.AzureAppService", - "Calamari.AzureResourceGroup", - "Calamari.GoogleCloudScripting", - "Calamari.AzureScripting", - "Calamari.Terraform" -]; -``` - -- [ ] **Step 3: Verify the full solution builds** - -Run: `dotnet build source/Calamari.sln` -Expected: Build succeeded with 0 errors. - -- [ ] **Step 4: Run the wiring test one final time** - -Run: `dotnet test source/Calamari.AiAgent.Tests/Calamari.AiAgent.Tests.csproj --filter "FullyQualifiedName~CommandResolutionTests" -v normal` -Expected: 1 test passed. - -- [ ] **Step 5: Commit** - -```bash -git add source/Calamari.AiAgent/ source/Calamari.AiAgent.Tests/ source/Calamari.sln source/Calamari.ConsolidateCalamariPackages/BuildableCalamariProjects.cs -git commit -m "feat: add Calamari.AiAgent project for AI agent invocations" -``` diff --git a/source/docs/superpowers/plans/2026-06-01-claude-command-args-builder.md b/source/docs/superpowers/plans/2026-06-01-claude-command-args-builder.md deleted file mode 100644 index d774854dff..0000000000 --- a/source/docs/superpowers/plans/2026-06-01-claude-command-args-builder.md +++ /dev/null @@ -1,633 +0,0 @@ -# ClaudeCommandArgsBuilder + max-turns / max-budget-usd Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Extract CLI argument building into a fluent `ClaudeCommandArgsBuilder`, add `max-turns` (default 10) and `max-budget-usd` (optional) support, and remove stale `MaxTokens`/`ClaudeCodeOptions`. - -**Architecture:** The builder produces a CLI args string via fluent API. The behaviour reads variables and drives the builder. The runner receives the built args string plus API token, credentials, and MCP servers — it handles process lifecycle only. - -**Tech Stack:** C# / .NET, NUnit, FluentAssertions - -**Spec:** `docs/superpowers/specs/2026-06-01-claude-command-args-builder-design.md` - ---- - -## File Map - -| File | Action | Responsibility | -|------|--------|----------------| -| `Calamari.AiAgent/Behaviours/ClaudeCommandArgsBuilder.cs` | Create | Fluent builder: `With*()` methods, `Build()` returns args string, owns `EscapeArg`. Does NOT handle `--mcp-config`, `--strict-mcp-config`, or `--debug-file` — those are appended by the runner since it owns working directory and debug file paths. | -| `Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs` | Modify | Remove `BuildArguments`, `ClaudeCodeOptions`. Change `RunAsync` signature to accept args string + config params. Keep process lifecycle, credentials, MCP/skill setup, records. | -| `Calamari.AiAgent/SpecialVariables.cs` | Modify | Add `MaxTurns`, `MaxBudgetUsd`. Remove `MaxTokens`. | -| `Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs` | Modify | Use builder fluent API, read new variables, pass args string to runner. | -| `Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs` | Create | Unit tests for the builder. | -| `Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs` | Modify | Remove `BuildArguments`/`ClaudeCodeOptions` tests (moved to builder fixture). Keep `SetupSkills`, `SetupMcpConfig` tests. | - ---- - -### Task 1: Create ClaudeCommandArgsBuilder with tests for core flags - -**Files:** -- Create: `Calamari.AiAgent/Behaviours/ClaudeCommandArgsBuilder.cs` -- Create: `Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs` - -- [ ] **Step 1: Write failing tests for core builder behaviour** - -Create `Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs`: - -```csharp -using Calamari.AiAgent.Behaviours; -using FluentAssertions; -using NUnit.Framework; - -namespace Calamari.AiAgent.Tests; - -[TestFixture] -public class ClaudeCommandArgsBuilderFixture -{ - ClaudeCommandArgsBuilder MinimalBuilder() => - new ClaudeCommandArgsBuilder() - .WithPrompt("test prompt") - .WithModel("claude-sonnet-4-20250514"); - - [Test] - public void Build_IncludesRequiredFlags() - { - var args = MinimalBuilder().Build(); - - args.Should().Contain("-p"); - args.Should().Contain("--model claude-sonnet-4-20250514"); - args.Should().Contain("--output-format stream-json"); - args.Should().Contain("--verbose"); - args.Should().Contain("--permission-mode dontAsk"); - args.Should().Contain("--no-session-persistence"); - } - - [Test] - public void Build_DefaultsMaxTurnsTo10_WhenNotSet() - { - var args = MinimalBuilder().Build(); - - args.Should().Contain("--max-turns 10"); - } - - [Test] - public void Build_UsesProvidedMaxTurns_WhenSet() - { - var args = MinimalBuilder().WithMaxTurns(5).Build(); - - args.Should().Contain("--max-turns 5"); - args.Should().NotContain("--max-turns 10"); - } - - [Test] - public void Build_OmitsMaxBudgetUsd_WhenNotSet() - { - var args = MinimalBuilder().Build(); - - args.Should().NotContain("--max-budget-usd"); - } - - [Test] - public void Build_IncludesMaxBudgetUsd_WhenSet() - { - var args = MinimalBuilder().WithMaxBudgetUsd(1.50m).Build(); - - args.Should().Contain("--max-budget-usd 1.50"); - } - - [Test] - public void Build_IncludesAllowedTools_WhenSet() - { - var args = MinimalBuilder() - .WithAllowedTools(new[] { "Read", "Bash" }) - .Build(); - - args.Should().Contain("--allowedTools Read,Bash"); - } - - [Test] - public void Build_OmitsAllowedTools_WhenEmpty() - { - var args = MinimalBuilder() - .WithAllowedTools(new string[0]) - .Build(); - - args.Should().NotContain("--allowedTools"); - } - - [Test] - public void Build_IncludesSystemPrompt_WhenSet() - { - var args = MinimalBuilder() - .WithSystemPrompt("You are helpful") - .Build(); - - args.Should().Contain("--system-prompt"); - args.Should().Contain("You are helpful"); - } - - [Test] - public void Build_OmitsSystemPrompt_WhenNotSet() - { - var args = MinimalBuilder().Build(); - - args.Should().NotContain("--system-prompt"); - } - - [Test] - public void Build_EscapesPromptWithSpaces() - { - var args = new ClaudeCommandArgsBuilder() - .WithPrompt("What is the capital of France?") - .WithModel("claude-sonnet-4-20250514") - .Build(); - - args.Should().Contain("\"What is the capital of France?\""); - } - - [Test] - public void Build_ThrowsWhenPromptNotSet() - { - var builder = new ClaudeCommandArgsBuilder() - .WithModel("claude-sonnet-4-20250514") - .WithMcpConfig("/tmp/mcp-config.json"); - - var act = () => builder.Build(); - - act.Should().Throw() - .WithMessage("*prompt*"); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `dotnet test Calamari.AiAgent.Tests --filter "FullyQualifiedName~ClaudeCommandArgsBuilderFixture" --no-restore -v minimal` -Expected: Build failure — `ClaudeCommandArgsBuilder` does not exist yet. - -- [ ] **Step 3: Implement ClaudeCommandArgsBuilder** - -Create `Calamari.AiAgent/Behaviours/ClaudeCommandArgsBuilder.cs`: - -```csharp -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Text; - -namespace Calamari.AiAgent.Behaviours -{ - public class ClaudeCommandArgsBuilder - { - string? prompt; - string? model; - string? systemPrompt; - int maxTurns = 10; - decimal? maxBudgetUsd; - IReadOnlyList? allowedTools; - - public ClaudeCommandArgsBuilder WithPrompt(string prompt) - { - this.prompt = prompt; - return this; - } - - public ClaudeCommandArgsBuilder WithModel(string model) - { - this.model = model; - return this; - } - - public ClaudeCommandArgsBuilder WithSystemPrompt(string systemPrompt) - { - this.systemPrompt = systemPrompt; - return this; - } - - public ClaudeCommandArgsBuilder WithMaxTurns(int maxTurns) - { - this.maxTurns = maxTurns; - return this; - } - - public ClaudeCommandArgsBuilder WithMaxBudgetUsd(decimal budgetUsd) - { - this.maxBudgetUsd = budgetUsd; - return this; - } - - public ClaudeCommandArgsBuilder WithAllowedTools(IReadOnlyList tools) - { - this.allowedTools = tools; - return this; - } - - public string Build() - { - if (string.IsNullOrWhiteSpace(prompt)) - throw new InvalidOperationException("A prompt is required. Call WithPrompt() before Build()."); - - var args = new StringBuilder(); - args.Append("-p "); - args.Append(EscapeArg(prompt)); - args.Append(" --model "); - args.Append(EscapeArg(model ?? "claude-sonnet-4-20250514")); - args.Append(" --output-format stream-json"); - args.Append(" --verbose"); - args.Append(" --permission-mode dontAsk"); - args.Append(" --no-session-persistence"); - - if (allowedTools != null && allowedTools.Count > 0) - { - args.Append(" --allowedTools "); - args.Append(string.Join(",", allowedTools)); - } - - args.Append($" --max-turns {maxTurns}"); - - if (maxBudgetUsd.HasValue) - args.Append($" --max-budget-usd {maxBudgetUsd.Value.ToString(CultureInfo.InvariantCulture)}"); - - if (!string.IsNullOrWhiteSpace(systemPrompt)) - { - args.Append(" --system-prompt "); - args.Append(EscapeArg(systemPrompt)); - } - - return args.ToString(); - } - - static string EscapeArg(string arg) - { - if (arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0) - return arg; - - return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; - } - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `dotnet test Calamari.AiAgent.Tests --filter "FullyQualifiedName~ClaudeCommandArgsBuilderFixture" --no-restore -v minimal` -Expected: All 9 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add Calamari.AiAgent/Behaviours/ClaudeCommandArgsBuilder.cs Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs -git commit -m "feat: add ClaudeCommandArgsBuilder with fluent API and tests" -``` - ---- - -### Task 2: Update SpecialVariables - -**Files:** -- Modify: `Calamari.AiAgent/SpecialVariables.cs` - -- [ ] **Step 1: Update SpecialVariables.cs** - -Replace `MaxTokens` with `MaxTurns` and add `MaxBudgetUsd`: - -In `SpecialVariables.cs`, remove: -```csharp -public const string MaxTokens = "Octopus.Action.Claude.MaxTokens"; -``` - -Add: -```csharp -public const string MaxTurns = "Octopus.Action.Claude.MaxTurns"; -public const string MaxBudgetUsd = "Octopus.Action.Claude.MaxBudgetUsd"; -``` - -- [ ] **Step 2: Verify build compiles** - -Run: `dotnet build Calamari.AiAgent --no-restore -v minimal` -Expected: Build failure — `InvokeClaudeCodeBehaviour.cs` references `MaxTokens`. This is expected and will be fixed in Task 3. - -- [ ] **Step 3: Commit** - -```bash -git add Calamari.AiAgent/SpecialVariables.cs -git commit -m "feat: add MaxTurns and MaxBudgetUsd variables, remove stale MaxTokens" -``` - ---- - -### Task 3: Refactor ClaudeCodeCliRunner — remove BuildArguments and ClaudeCodeOptions - -**Files:** -- Modify: `Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs` - -- [ ] **Step 1: Update RunAsync signature and remove BuildArguments/ClaudeCodeOptions** - -Change `ClaudeCodeCliRunner.cs` to: -- Remove the `ClaudeCodeOptions` record entirely. -- Remove the `BuildArguments` static method. -- Remove `EscapeArg` (now lives in the builder). Keep it if `ApplyCredentials` or other runner logic still needs it — check first. -- Change `RunAsync` to accept the built args string plus the individual config values the runner needs: - -```csharp -public async Task RunAsync( - string args, - string apiToken, - IReadOnlyDictionary mcpServers, - ProcessCredentials? runAs = null) -``` - -Update `RunInDirectoryAsync` similarly — it no longer calls `BuildArguments`. Instead it receives the args string and appends the debug file flag (or the debug file is already in the args from the builder — check the spec). Per the spec, the builder handles `--debug-file`, so the runner should NOT append it. The runner still generates the debug file path and passes it to the builder via the behaviour. However, since `RunAsync` receives a pre-built args string, the debug file path generation needs to move to the behaviour. - -Revised approach: the runner generates the debug file path, appends `--debug-file` to the args string it receives, then uses the result. This keeps debug file lifecycle (path generation + artifact upload) co-located in the runner. - -Updated `RunInDirectoryAsync`: - -```csharp -async Task RunInDirectoryAsync( - string args, - string apiToken, - string workingDir, - ProcessCredentials? runAs) -{ - var debugLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-debug-{Guid.NewGuid():N}.log"); - var fullArgs = $"{args} --debug-file {EscapeArg(debugLogPath)}"; - - var startInfo = new ProcessStartInfo - { - FileName = "claude", - Arguments = fullArgs, - WorkingDirectory = workingDir, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - - startInfo.Environment["ANTHROPIC_API_KEY"] = apiToken; - - if (runAs != null) - ApplyCredentials(startInfo, runAs); - - // ... rest of process lifecycle unchanged ... -} -``` - -Keep `EscapeArg` as a private method in the runner (it's still needed for the debug file path). The builder has its own copy. - -Update `RunAsync`: - -```csharp -public async Task RunAsync( - string args, - string apiToken, - IReadOnlyDictionary mcpServers, - ProcessCredentials? runAs = null) -{ - var workingDir = Path.Combine(Path.GetTempPath(), $"claude-agent-{Guid.NewGuid():N}"); - Directory.CreateDirectory(workingDir); - log.Verbose($"Claude Code working directory: {workingDir}"); - - try - { - SetupSkills(workingDir); - SetupMcpConfig(workingDir, mcpServers); - return await RunInDirectoryAsync(args, apiToken, workingDir, runAs); - } - finally - { - try { Directory.Delete(workingDir, recursive: true); } - catch { /* best effort cleanup */ } - } -} -``` - -- [ ] **Step 2: Verify the AiAgent project builds** (will fail until behaviour is updated in Task 4) - -Run: `dotnet build Calamari.AiAgent --no-restore -v minimal` -Expected: Build failure in `InvokeClaudeCodeBehaviour.cs` — still references `ClaudeCodeOptions`. - -- [ ] **Step 3: Commit** - -```bash -git add Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs -git commit -m "refactor: remove ClaudeCodeOptions and BuildArguments from ClaudeCodeCliRunner" -``` - ---- - -### Task 4: Update InvokeClaudeCodeBehaviour to use the builder - -**Files:** -- Modify: `Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs` - -- [ ] **Step 1: Replace ClaudeCodeOptions usage with builder** - -Update the `Execute` method in `InvokeClaudeCodeBehaviour.cs`: - -```csharp -public async Task Execute(RunningDeployment context) -{ - var variables = context.Variables; - - var prompt = variables.Get(SpecialVariables.Action.AiAgent.Prompt); - if (string.IsNullOrWhiteSpace(prompt)) - throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.Prompt}' is required but was not provided."); - - var apiToken = variables.Get(SpecialVariables.Action.AiAgent.ApiToken); - if (string.IsNullOrWhiteSpace(apiToken)) - throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.ApiToken}' is required but was not provided."); - - var model = variables.Get(SpecialVariables.Action.AiAgent.Model); - if (string.IsNullOrWhiteSpace(model)) - model = "claude-sonnet-4-20250514"; - - log.Info($"Invoking Claude Code CLI with model '{model}'..."); - - var mcpServers = BuildMcpServers(variables); - var runAs = BuildRunAs(variables); - - var argsBuilder = new ClaudeCommandArgsBuilder() - .WithPrompt(prompt) - .WithModel(model) - .WithAllowedTools(new[] { "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch" }); - - var systemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill); - if (!string.IsNullOrWhiteSpace(systemPrompt)) - argsBuilder.WithSystemPrompt(systemPrompt); - - var maxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTurns); - if (maxTurns.HasValue) - argsBuilder.WithMaxTurns(maxTurns.Value); - - var maxBudgetUsdRaw = variables.Get(SpecialVariables.Action.AiAgent.MaxBudgetUsd); - if (!string.IsNullOrWhiteSpace(maxBudgetUsdRaw) - && decimal.TryParse(maxBudgetUsdRaw, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var budgetUsd)) - argsBuilder.WithMaxBudgetUsd(budgetUsd); - - var args = argsBuilder.Build(); - - var runner = new ClaudeCodeCliRunner(log); - var response = await runner.RunAsync(args, apiToken, mcpServers, runAs); - - Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); - log.Info("Claude Code invocation complete."); -} -``` - -Note: `IVariables` has no `GetDecimal` method, so we parse `MaxBudgetUsd` from the string using `decimal.TryParse` with `InvariantCulture`. The builder does not handle `--mcp-config`, `--strict-mcp-config`, or `--debug-file` — the runner appends those since it owns the working directory and debug file paths. - -- [ ] **Step 2: Build the full solution** - -Run: `dotnet build Calamari.AiAgent --no-restore -v minimal` -Expected: PASS — all references resolved. - -- [ ] **Step 3: Run all AiAgent tests** - -Run: `dotnet test Calamari.AiAgent.Tests --no-restore -v minimal` -Expected: All tests pass (builder tests + remaining runner tests for SetupSkills/SetupMcpConfig). - -- [ ] **Step 4: Commit** - -```bash -git add Calamari.AiAgent/Behaviours/InvokeClaudeCodeBehaviour.cs Calamari.AiAgent/Behaviours/ClaudeCodeCliRunner.cs Calamari.AiAgent/Behaviours/ClaudeCommandArgsBuilder.cs Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs -git commit -m "feat: wire up ClaudeCommandArgsBuilder in behaviour, add max-turns and max-budget-usd support" -``` - ---- - -### Task 5: Update existing test fixture - -**Files:** -- Modify: `Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs` - -- [ ] **Step 1: Remove tests that referenced BuildArguments and ClaudeCodeOptions** - -Remove these tests from `ClaudeCodeCliRunnerFixture.cs`: -- `BuildArguments_IncludesRequiredFlags` -- `BuildArguments_IncludesAllowedTools` -- `BuildArguments_OmitsAllowedTools_WhenEmpty` -- `BuildArguments_IncludesMaxTurns_WhenSet` -- `BuildArguments_OmitsMaxTurns_WhenNotSet` -- `BuildArguments_IncludesSystemPrompt_WhenSet` -- `BuildArguments_OmitsSystemPrompt_WhenNotSet` -- `BuildArguments_EscapesPromptWithSpaces` - -Remove the `DefaultOptions` helper method. - -Keep: -- `SetupSkills_CreatesSkillFile` -- `SetupMcpConfig_WritesValidJson_WithServers` -- `SetupMcpConfig_WritesEmptyServers_WhenNoneProvided` - -The file should look like: - -```csharp -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using Calamari.AiAgent.Behaviours; -using FluentAssertions; -using NUnit.Framework; - -namespace Calamari.AiAgent.Tests; - -[TestFixture] -public class ClaudeCodeCliRunnerFixture -{ - [Test] - public void SetupSkills_CreatesSkillFile() - { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-skills-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - ClaudeCodeCliRunner.SetupSkills(workingDir); - - var skillPath = Path.Combine(workingDir, ".claude", "skills", "octopus-deployment-context.md"); - File.Exists(skillPath).Should().BeTrue(); - - var content = File.ReadAllText(skillPath); - content.Should().Contain("name: octopus-deployment-context"); - content.Should().Contain("description:"); - content.Should().Contain("get_deployment_variables"); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [Test] - public void SetupMcpConfig_WritesValidJson_WithServers() - { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-mcp-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var servers = new Dictionary - { - ["github"] = new McpServerConfig - { - Command = "npx", - Args = new[] { "-y", "@modelcontextprotocol/server-github" }, - Env = new Dictionary { ["TOKEN"] = "abc123" }, - }, - }; - - ClaudeCodeCliRunner.SetupMcpConfig(workingDir, servers); - - var configPath = Path.Combine(workingDir, "mcp-config.json"); - File.Exists(configPath).Should().BeTrue(); - - var json = File.ReadAllText(configPath); - var doc = JsonDocument.Parse(json); - doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); - mcpServers.TryGetProperty("github", out var github).Should().BeTrue(); - github.GetProperty("command").GetString().Should().Be("npx"); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [Test] - public void SetupMcpConfig_WritesEmptyServers_WhenNoneProvided() - { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-mcp-empty-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - ClaudeCodeCliRunner.SetupMcpConfig(workingDir, new Dictionary()); - - var configPath = Path.Combine(workingDir, "mcp-config.json"); - var json = File.ReadAllText(configPath); - var doc = JsonDocument.Parse(json); - doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); - mcpServers.EnumerateObject().Should().BeEmpty(); - } - finally - { - Directory.Delete(workingDir, true); - } - } -} -``` - -- [ ] **Step 2: Run all tests** - -Run: `dotnet test Calamari.AiAgent.Tests --no-restore -v minimal` -Expected: All tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs -git commit -m "refactor: remove BuildArguments tests from runner fixture (moved to builder)" -``` - diff --git a/source/docs/superpowers/specs/2026-06-01-claude-command-args-builder-design.md b/source/docs/superpowers/specs/2026-06-01-claude-command-args-builder-design.md deleted file mode 100644 index 8e0a5fd528..0000000000 --- a/source/docs/superpowers/specs/2026-06-01-claude-command-args-builder-design.md +++ /dev/null @@ -1,118 +0,0 @@ -# Claude Command Args Builder + max-turns / max-budget-usd - -## Summary - -Refactor CLI argument construction out of `ClaudeCodeCliRunner` into a fluent `ClaudeCommandArgsBuilder`, and add support for two new optional step variables: `max-turns` (default 10) and `max-budget-usd` (optional). - -## Motivation - -- `ClaudeCodeCliRunner` is accumulating responsibilities — process lifecycle, argument building, credential handling, MCP/skill setup. Extracting arg building reduces complexity. -- Users need control over agent turn limits and spend caps. -- `MaxTokens` variable exists but was incorrectly mapped to `MaxTurns` — this is a stale bug to clean up. - -## Design - -### ClaudeCommandArgsBuilder (new file) - -**File:** `Behaviours/ClaudeCommandArgsBuilder.cs` - -Fluent builder that produces a CLI arguments string. Owns argument escaping. No knowledge of processes, environment variables, or credentials. - -```csharp -public class ClaudeCommandArgsBuilder -{ - public ClaudeCommandArgsBuilder WithPrompt(string prompt) { ... } - public ClaudeCommandArgsBuilder WithModel(string model) { ... } - public ClaudeCommandArgsBuilder WithSystemPrompt(string systemPrompt) { ... } - public ClaudeCommandArgsBuilder WithMaxTurns(int maxTurns) { ... } - public ClaudeCommandArgsBuilder WithMaxBudgetUsd(decimal budgetUsd) { ... } - public ClaudeCommandArgsBuilder WithAllowedTools(IReadOnlyList tools) { ... } - public string Build() { ... } // returns full args string - // Note: --mcp-config, --strict-mcp-config, and --debug-file are appended by the runner - // since it owns working directory and debug file paths. - -} -``` - -**Builder behaviour:** -- `WithMaxBudgetUsd` is only called when the variable is provided — method takes non-nullable `decimal`. Builder tracks whether it was called and only emits `--max-budget-usd` if so. -- `WithMaxTurns` is only called when the variable is provided — method takes non-nullable `int`. Builder internally defaults to `10` if `WithMaxTurns` was never called. -- `Build()` validates that prompt is set (required). Throws if missing. -- Argument escaping handled internally (moves `EscapeArg` into the builder). -- Emits flags: `-p`, `--model`, `--output-format stream-json`, `--verbose`, `--permission-mode dontAsk`, `--no-session-persistence`, `--allowedTools`, `--max-turns`, `--max-budget-usd`, `--system-prompt`. - -### ClaudeCodeCliRunner (modified) - -**File:** `Behaviours/ClaudeCodeCliRunner.cs` - -- Remove `BuildArguments` static method. -- Remove `ClaudeCodeOptions` record. -- `RunAsync` takes individual parameters or a simpler signature — the behaviour constructs the args string via the builder and passes it along with the other config the runner needs (API token, credentials, MCP servers, system prompt for skill setup). -- Keep: process lifecycle, stream processing, `ApplyCredentials`, `SetupMcpConfig`, `SetupSkills`. -- Keep: `ProcessCredentials`, `McpServerConfig` records. -- Keep: `EscapeArg` can be removed from here once moved to builder (or kept as internal if runner still needs it). - -### SpecialVariables (modified) - -**File:** `SpecialVariables.cs` - -```csharp -public const string MaxTurns = "Octopus.Action.Claude.MaxTurns"; -public const string MaxBudgetUsd = "Octopus.Action.Claude.MaxBudgetUsd"; -``` - -- Remove `MaxTokens` (stale, was incorrectly used). - -### InvokeClaudeCodeBehaviour (modified) - -**File:** `Behaviours/InvokeClaudeCodeBehaviour.cs` - -- Read `MaxTurns` variable, default to `10` if absent. -- Read `MaxBudgetUsd` variable, only set on builder if present. -- Construct args via `ClaudeCommandArgsBuilder` fluent API. -- Pass built args string + API token + credentials + MCP servers to runner. - -```csharp -var argsBuilder = new ClaudeCommandArgsBuilder() - .WithPrompt(prompt) - .WithModel(model) - .WithSystemPrompt(systemPrompt) - .WithAllowedTools(allowedTools) - .WithMcpConfig(mcpConfigPath) - .WithDebugFile(debugFilePath); - -var maxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTurns); -if (maxTurns.HasValue) - argsBuilder.WithMaxTurns(maxTurns.Value); - -var budgetUsd = variables.GetDecimal(SpecialVariables.Action.AiAgent.MaxBudgetUsd); -if (budgetUsd.HasValue) - argsBuilder.WithMaxBudgetUsd(budgetUsd.Value); - -var args = argsBuilder.Build(); // max-turns defaults to 10 internally -``` - -### OctopusDeploy Step Definition (separate repo) - -**File:** In `/Users/robert/Development/Octopus/OctopusDeploy/` — `AiAgentStepDocumentation.cs` - -Add two new `StepPropertyInfo` entries: -- `Octopus.Action.Claude.MaxTurns` — "Maximum number of agentic turns (default: 10)" -- `Octopus.Action.Claude.MaxBudgetUsd` — "Maximum budget in USD for the agent run" - -Remove `Octopus.Action.Claude.MaxTokens` if present. - -## Files Changed - -| File | Action | -|------|--------| -| `Behaviours/ClaudeCommandArgsBuilder.cs` | New — fluent builder | -| `Behaviours/ClaudeCodeCliRunner.cs` | Modified — remove `BuildArguments`, `ClaudeCodeOptions`, accept args string | -| `SpecialVariables.cs` | Modified — add `MaxTurns`, `MaxBudgetUsd`, remove `MaxTokens` | -| `Behaviours/InvokeClaudeCodeBehaviour.cs` | Modified — use builder, read new variables | -| OctopusDeploy `AiAgentStepDocumentation.cs` | Modified — add step properties | - -## Testing - -- Unit tests for `ClaudeCommandArgsBuilder`: verify each flag is emitted correctly, escaping works, optional flags omitted when not set, validation throws on missing prompt. -- Update existing `ClaudeCodeCliRunner` tests if they reference `BuildArguments` or `ClaudeCodeOptions`. From f08ae171d91a54062200ed29285525d7837ae1b3 Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 8 Jun 2026 10:36:47 +1000 Subject: [PATCH 08/26] Fix build.sh --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 1089acffb9..2f7dd4c566 100755 --- a/build.sh +++ b/build.sh @@ -24,7 +24,7 @@ export DOTNET_MULTILEVEL_LOOKUP=0 ########################################################################### function FirstJsonValue { - perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" + grep -o '"'"$1"'"\s*:\s*"[^"]*"' <<< "${@:2}" | sed 's/.*: *"//;s/"//' } # If dotnet CLI is installed globally and it matches requested version, use for execution From 6ab335fd01df0d9e1cefd105b918ad37c4183f09 Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 8 Jun 2026 13:07:20 +1000 Subject: [PATCH 09/26] Undo build fix --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 2f7dd4c566..1089acffb9 100755 --- a/build.sh +++ b/build.sh @@ -24,7 +24,7 @@ export DOTNET_MULTILEVEL_LOOKUP=0 ########################################################################### function FirstJsonValue { - grep -o '"'"$1"'"\s*:\s*"[^"]*"' <<< "${@:2}" | sed 's/.*: *"//;s/"//' + perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" } # If dotnet CLI is installed globally and it matches requested version, use for execution From ad612737d7fd4532172a6c35f33141c42d1eb855 Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 8 Jun 2026 20:12:14 +1000 Subject: [PATCH 10/26] Comment --- .../ClaudeCodeCliRunner.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs index aaef3640d9..3bf9feae5e 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -54,6 +54,32 @@ public async Task RunAsync( catch { /* best effort cleanup */ } } } + + /* + using System.Diagnostics; + + string user = "claude"; + string cmd = "bash"; + string password = "claude"; + + var psi = new ProcessStartInfo { + FileName = "script", + ArgumentList = { "-qec", $"su - {user} -c '{cmd}'", "/dev/null" }, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + using var p = Process.Start(psi)!; + p.StandardInput.WriteLine(password); + + + + p.WaitForExit(); + Console.WriteLine($"exit={p.ExitCode}\n{output}"); + + */ async Task RunInDirectoryAsync( ClaudeCommandArgsBuilder argsBuilder, From 26fcd67807fac83f39b3cdcb7df333672b796bcb Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 8 Jun 2026 21:28:03 +1000 Subject: [PATCH 11/26] Try fixing build --- build.sh | 2 +- .../2026-06-08-linux-su-impersonation.md | 385 ++++++++++++++++++ 2 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-06-08-linux-su-impersonation.md diff --git a/build.sh b/build.sh index 1089acffb9..2f7dd4c566 100755 --- a/build.sh +++ b/build.sh @@ -24,7 +24,7 @@ export DOTNET_MULTILEVEL_LOOKUP=0 ########################################################################### function FirstJsonValue { - perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" + grep -o '"'"$1"'"\s*:\s*"[^"]*"' <<< "${@:2}" | sed 's/.*: *"//;s/"//' } # If dotnet CLI is installed globally and it matches requested version, use for execution diff --git a/docs/superpowers/plans/2026-06-08-linux-su-impersonation.md b/docs/superpowers/plans/2026-06-08-linux-su-impersonation.md new file mode 100644 index 0000000000..9c9131cab4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-linux-su-impersonation.md @@ -0,0 +1,385 @@ +# Linux User Impersonation via `script`/`su` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `ProcessStartInfo.UserName` with `script`/`su` for Linux user impersonation, keeping Windows behaviour unchanged. + +**Architecture:** On Linux, `ApplyCredentials` rewrites the `ProcessStartInfo` to launch `script -qec "su - {user} -c '{envVars} {cmd}'" /dev/null` and pipes the password via stdin. Environment variables explicitly added to `startInfo.Environment` are inlined into the `su -c` command string (since `su -` starts a login shell that clears inherited env). `RunProcess` gains a password parameter to write to stdin. Windows path is untouched. + +**Tech Stack:** C# / .NET, `System.Diagnostics.Process`, `System.Runtime.InteropServices.RuntimeInformation` + +--- + +### Task 1: Track explicitly-set environment variables + +Currently `ANTHROPIC_API_KEY` is set directly on `startInfo.Environment`, which inherits the full parent environment. We need to know which keys were *explicitly added* so we can inline only those into the `su -c` command. + +**Files:** +- Modify: `source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs:89-110` + +- [ ] **Step 1: Introduce a list to track custom env vars in `RunInDirectoryAsync`** + +Change the environment setup from: + +```csharp +startInfo.Environment["ANTHROPIC_API_KEY"] = apiToken; +``` + +To: + +```csharp +var customEnvVars = new Dictionary +{ + ["ANTHROPIC_API_KEY"] = apiToken, +}; + +foreach (var kvp in customEnvVars) + startInfo.Environment[kvp.Key] = kvp.Value; +``` + +This is a no-op refactor — behaviour is identical. The `customEnvVars` dictionary will be passed to `ApplyCredentials` in the next task. + +- [ ] **Step 2: Run existing tests to confirm no regression** + +Run: `dotnet test source/Calamari.AiAgent.Tests/ --filter "FullyQualifiedName~ClaudeCodeCliRunnerFixture" --no-build -v quiet` +Expected: All tests pass (these test static helpers, not process launch) + +- [ ] **Step 3: Commit** + +```bash +git add source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +git commit -m "refactor: track explicitly-set env vars in RunInDirectoryAsync" +``` + +--- + +### Task 2: Add shell-quoting helper + +The `su -c` command requires values to be safely quoted to avoid injection. We need a helper that single-quotes a string, escaping any embedded single quotes. + +**Files:** +- Modify: `source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs` (add new static method) +- Test: `source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs` + +- [ ] **Step 1: Write failing tests for ShellQuote** + +Add to `ClaudeCodeCliRunnerFixture.cs`: + +```csharp +[TestCase("simple", "'simple'")] +[TestCase("has space", "'has space'")] +[TestCase("it's", @"'it'\''s'")] +[TestCase("", "''")] +[TestCase("a'b'c", @"'a'\''b'\''c'")] +public void ShellQuote_QuotesCorrectly(string input, string expected) +{ + ClaudeCodeCliRunner.ShellQuote(input).Should().Be(expected); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test source/Calamari.AiAgent.Tests/ --filter "FullyQualifiedName~ShellQuote" -v quiet` +Expected: FAIL — `ShellQuote` does not exist + +- [ ] **Step 3: Implement ShellQuote** + +Add to `ClaudeCodeCliRunner`: + +```csharp +internal static string ShellQuote(string value) +{ + return "'" + value.Replace("'", @"'\''") + "'"; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test source/Calamari.AiAgent.Tests/ --filter "FullyQualifiedName~ShellQuote" -v quiet` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs +git commit -m "feat: add ShellQuote helper for safe single-quoting in su commands" +``` + +--- + +### Task 3: Rewrite `ApplyCredentials` with Linux `script`/`su` path + +**Files:** +- Modify: `source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs:277-294` +- Test: `source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs` + +- [ ] **Step 1: Write failing tests for Linux ApplyCredentials** + +Add to `ClaudeCodeCliRunnerFixture.cs`: + +```csharp +[Test] +public void ApplyCredentials_Linux_RewritesStartInfoToUseScriptSu() +{ + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Assert.Ignore("Linux-only test"); + return; + } + + var startInfo = new ProcessStartInfo + { + FileName = "claude", + Arguments = "--model sonnet --print", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + var credentials = new ProcessCredentials + { + Username = "claude", + Password = "claude", + }; + + var customEnvVars = new Dictionary + { + ["ANTHROPIC_API_KEY"] = "sk-test-123", + ["OTHER_VAR"] = "hello", + }; + + ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); + + startInfo.FileName.Should().Be("script"); + startInfo.UserName.Should().BeNull(); + startInfo.RedirectStandardInput.Should().BeTrue(); + + // ArgumentList should be: -qec, "su - claude -c '...'", /dev/null + startInfo.ArgumentList.Should().HaveCount(3); + startInfo.ArgumentList[0].Should().Be("-qec"); + startInfo.ArgumentList[2].Should().Be("/dev/null"); + + var suCommand = startInfo.ArgumentList[1]; + suCommand.Should().StartWith("su - claude -c "); + suCommand.Should().Contain("ANTHROPIC_API_KEY="); + suCommand.Should().Contain("OTHER_VAR="); + suCommand.Should().Contain("claude --model sonnet --print"); +} + +[Test] +public void ApplyCredentials_Linux_ThrowsWhenPasswordMissing() +{ + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Assert.Ignore("Linux-only test"); + return; + } + + var startInfo = new ProcessStartInfo { FileName = "claude" }; + var credentials = new ProcessCredentials { Username = "claude", Password = null }; + var customEnvVars = new Dictionary(); + + var act = () => ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); + + act.Should().Throw().WithMessage("*password*"); +} + +[Test] +public void ApplyCredentials_Windows_SetsUsernameAndPassword() +{ + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Ignore("Windows-only test"); + return; + } + + var startInfo = new ProcessStartInfo { FileName = "claude" }; + var credentials = new ProcessCredentials + { + Username = "deploy-user", + Password = "s3cret", + Domain = "CORP", + }; + var customEnvVars = new Dictionary(); + + ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); + + startInfo.UserName.Should().Be("deploy-user"); + startInfo.PasswordInClearText.Should().Be("s3cret"); + startInfo.Domain.Should().Be("CORP"); + startInfo.FileName.Should().Be("claude"); // unchanged +} +``` + +Also add these using statements at the top of the test file if not already present: + +```csharp +using System.Diagnostics; +using System.Runtime.InteropServices; +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test source/Calamari.AiAgent.Tests/ --filter "FullyQualifiedName~ApplyCredentials" -v quiet` +Expected: FAIL — signature mismatch (new `customEnvVars` parameter) + +- [ ] **Step 3: Implement the new ApplyCredentials** + +Replace `ApplyCredentials` in `ClaudeCodeCliRunner.cs`: + +```csharp +internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials, Dictionary customEnvVars) +{ + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + startInfo.UserName = credentials.Username; + + if (!string.IsNullOrEmpty(credentials.Password)) + startInfo.PasswordInClearText = credentials.Password; + if (!string.IsNullOrEmpty(credentials.Domain)) + startInfo.Domain = credentials.Domain; + + return; + } + + // Linux: use script/su to impersonate the user with a proper login shell. + // su - starts a login shell which clears the environment, so we inline + // any custom env vars into the command string. + if (string.IsNullOrEmpty(credentials.Password)) + throw new CommandException("A password is required for Linux user impersonation via su"); + + var envPrefix = string.Join(" ", customEnvVars.Select(kvp => $"{kvp.Key}={ShellQuote(kvp.Value)}")); + var innerCommand = string.IsNullOrEmpty(envPrefix) + ? $"{startInfo.FileName} {startInfo.Arguments}" + : $"{envPrefix} {startInfo.FileName} {startInfo.Arguments}"; + + var suCommand = $"su - {credentials.Username} -c {ShellQuote(innerCommand)}"; + + startInfo.FileName = "script"; + startInfo.Arguments = ""; // clear — using ArgumentList instead + startInfo.ArgumentList.Add("-qec"); + startInfo.ArgumentList.Add(suCommand); + startInfo.ArgumentList.Add("/dev/null"); + startInfo.RedirectStandardInput = true; + startInfo.UserName = null; +} +``` + +- [ ] **Step 4: Update the call site in RunInDirectoryAsync** + +Change the call from: + +```csharp +if (runAs != null) + ApplyCredentials(startInfo, runAs); +``` + +To: + +```csharp +if (runAs != null) + ApplyCredentials(startInfo, runAs, customEnvVars); +``` + +- [ ] **Step 5: Run all tests** + +Run: `dotnet test source/Calamari.AiAgent.Tests/ --filter "FullyQualifiedName~ClaudeCodeCliRunnerFixture" -v quiet` +Expected: All pass (platform-guarded tests will run on the current OS, others will be ignored) + +- [ ] **Step 6: Commit** + +```bash +git add source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs +git commit -m "feat: use script/su for Linux user impersonation instead of ProcessStartInfo.UserName" +``` + +--- + +### Task 4: Pipe password to stdin in `RunProcess` + +`RunProcess` needs to write the password to stdin when running under `script`/`su` on Linux. + +**Files:** +- Modify: `source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs:117,138-170` + +- [ ] **Step 1: Add password parameter to RunProcess** + +Change the signature from: + +```csharp +async Task RunProcess(ProcessStartInfo startInfo, string verboseLogPath, ClaudeCodeStreamProcessor streamProcessor) +``` + +To: + +```csharp +async Task RunProcess(ProcessStartInfo startInfo, string verboseLogPath, ClaudeCodeStreamProcessor streamProcessor, string? password = null) +``` + +- [ ] **Step 2: Add stdin writing after process.Start()** + +Add immediately after `process.Start();`: + +```csharp +if (password != null) +{ + await process.StandardInput.WriteLineAsync(password); + process.StandardInput.Close(); +} +``` + +- [ ] **Step 3: Update the call site in RunInDirectoryAsync** + +Change the call from: + +```csharp +await RunProcess(startInfo, verboseLogPath, streamProcessor); +``` + +To: + +```csharp +var password = runAs != null && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? runAs.Password : null; +await RunProcess(startInfo, verboseLogPath, streamProcessor, password); +``` + +- [ ] **Step 4: Run all tests** + +Run: `dotnet test source/Calamari.AiAgent.Tests/ -v quiet` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +git commit -m "feat: pipe password to stdin for Linux su-based impersonation" +``` + +--- + +### Task 5: Update ADR comment + +The code references an ADR about using `ProcessStartInfo.UserName` on all platforms. The comment should reflect the new approach. + +**Files:** +- Modify: `source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs:277-284` + +- [ ] **Step 1: Update the comment block in ApplyCredentials** + +Replace the existing comment with: + +```csharp +// See ADR: https://github.com/OctopusDeploy/adr/blob/main/team-modern-deployments/calamari-ai-agent/adr-001-use-processstartinfo-username-for-user-impersonation.md +// On Windows: uses ProcessStartInfo.UserName with native token-based impersonation +// and optional password/domain. +// On Linux: uses script(1) + su(1) to launch a login shell as the target user. +// Environment variables are inlined into the su -c command since login shells +// clear the inherited environment. Password is piped via stdin. +``` + +- [ ] **Step 2: Commit** + +```bash +git add source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +git commit -m "docs: update ApplyCredentials comment to reflect Linux script/su approach" +``` From 676d83472b9e4bae6b38c4e48b95640835d01da5 Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 8 Jun 2026 21:32:59 +1000 Subject: [PATCH 12/26] Refactor: collect explicitly-set env vars into customEnvVars dictionary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a customEnvVars dictionary for env vars that need to be explicitly set on the process (currently just ANTHROPIC_API_KEY). No behaviour change — prepares for passing these vars into ApplyCredentials so they can be inlined into a su -c command on Linux in a follow-up task. Co-Authored-By: Claude Sonnet 4.6 --- .../ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs index 3bf9feae5e..bd66ad1091 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -104,7 +104,13 @@ async Task RunInDirectoryAsync( CreateNoWindow = true, }; - startInfo.Environment["ANTHROPIC_API_KEY"] = apiToken; + var customEnvVars = new Dictionary + { + ["ANTHROPIC_API_KEY"] = apiToken, + }; + + foreach (var kvp in customEnvVars) + startInfo.Environment[kvp.Key] = kvp.Value; if (runAs != null) ApplyCredentials(startInfo, runAs); From ed4c1b20fcf1d215d80bd853949387a2dc3260de Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 8 Jun 2026 21:59:52 +1000 Subject: [PATCH 13/26] Add ShellQuote helper for safe shell command construction Adds a static ShellQuote method to ClaudeCodeCliRunner that wraps a value in single quotes and escapes any embedded single quotes as '\''. This is needed to safely embed values (API keys, env var values) into su -c command strings without shell injection risk. Co-Authored-By: Claude Sonnet 4.6 --- .../ClaudeCodeCliRunnerFixture.cs | 10 ++++++++++ .../ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs index 380be51053..55d051f00c 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs @@ -190,6 +190,16 @@ public void SetupMcpConfig_WritesValidJson_WithServers() } } + [TestCase("simple", "'simple'")] + [TestCase("has space", "'has space'")] + [TestCase("it's", @"'it'\''s'")] + [TestCase("", "''")] + [TestCase("a'b'c", @"'a'\''b'\''c'")] + public void ShellQuote_QuotesCorrectly(string input, string expected) + { + ClaudeCodeCliRunner.ShellQuote(input).Should().Be(expected); + } + [Test] public void SetupMcpConfig_WritesEmptyServers_WhenNoneProvided() { diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs index bd66ad1091..997cf7b202 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -299,6 +299,11 @@ internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredent } } + internal static string ShellQuote(string value) + { + return "'" + value.Replace("'", @"'\''") + "'"; + } + static string EscapeArg(string arg) { if (arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0) From 01f4e525b0d07b8b8128dd5c6b39cd915e939406 Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 8 Jun 2026 22:02:28 +1000 Subject: [PATCH 14/26] Rewrite ApplyCredentials with Linux script/su path for user impersonation On Linux, instead of using ProcessStartInfo.UserName (which requires CAP_SETUID), wrap the command with `script -qec "su - {user} -c '{envVars} {cmd}'" /dev/null` to allocate a pseudo-TTY for su. Custom env vars are inlined into the command string since `su -` starts a login shell that clears the environment. Windows behaviour is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../ClaudeCodeCliRunnerFixture.cs | 94 +++++++++++++++++++ .../ClaudeCodeCliRunner.cs | 37 ++++++-- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs index 55d051f00c..0dfacb76c3 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; using System.Text.Json; using Calamari.AiAgent.Behaviours; using Calamari.Common.Commands; @@ -200,6 +202,98 @@ public void ShellQuote_QuotesCorrectly(string input, string expected) ClaudeCodeCliRunner.ShellQuote(input).Should().Be(expected); } + [Test] + public void ApplyCredentials_Linux_RewritesStartInfoToUseScriptSu() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Assert.Ignore("Linux-only test"); + return; + } + + var startInfo = new ProcessStartInfo + { + FileName = "claude", + Arguments = "--model sonnet --print", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + var credentials = new ProcessCredentials + { + Username = "claude", + Password = "claude", + }; + + var customEnvVars = new Dictionary + { + ["ANTHROPIC_API_KEY"] = "sk-test-123", + ["OTHER_VAR"] = "hello", + }; + + ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); + + startInfo.FileName.Should().Be("script"); + startInfo.UserName.Should().BeNull(); + startInfo.RedirectStandardInput.Should().BeTrue(); + + // ArgumentList should be: -qec, "su - claude -c '...'", /dev/null + startInfo.ArgumentList.Should().HaveCount(3); + startInfo.ArgumentList[0].Should().Be("-qec"); + startInfo.ArgumentList[2].Should().Be("/dev/null"); + + var suCommand = startInfo.ArgumentList[1]; + suCommand.Should().StartWith("su - claude -c "); + suCommand.Should().Contain("ANTHROPIC_API_KEY="); + suCommand.Should().Contain("OTHER_VAR="); + suCommand.Should().Contain("claude --model sonnet --print"); + } + + [Test] + public void ApplyCredentials_Linux_ThrowsWhenPasswordMissing() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Assert.Ignore("Linux-only test"); + return; + } + + var startInfo = new ProcessStartInfo { FileName = "claude" }; + var credentials = new ProcessCredentials { Username = "claude", Password = null }; + var customEnvVars = new Dictionary(); + + var act = () => ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); + + act.Should().Throw().WithMessage("*password*"); + } + + [Test] + public void ApplyCredentials_Windows_SetsUsernameAndPassword() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Ignore("Windows-only test"); + return; + } + + var startInfo = new ProcessStartInfo { FileName = "claude" }; + var credentials = new ProcessCredentials + { + Username = "deploy-user", + Password = "s3cret", + Domain = "CORP", + }; + var customEnvVars = new Dictionary(); + + ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); + + startInfo.UserName.Should().Be("deploy-user"); + startInfo.PasswordInClearText.Should().Be("s3cret"); + startInfo.Domain.Should().Be("CORP"); + startInfo.FileName.Should().Be("claude"); // unchanged + } + [Test] public void SetupMcpConfig_WritesEmptyServers_WhenNoneProvided() { diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs index 997cf7b202..ce827502a5 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -113,7 +113,7 @@ async Task RunInDirectoryAsync( startInfo.Environment[kvp.Key] = kvp.Value; if (runAs != null) - ApplyCredentials(startInfo, runAs); + ApplyCredentials(startInfo, runAs, customEnvVars); var responseBuilder = new StringBuilder(); var streamProcessor = new ClaudeCodeStreamProcessor(log, responseBuilder); @@ -280,23 +280,40 @@ internal static string SanitizeFileName(string name) return result; } - internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials) + internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials, Dictionary customEnvVars) { - // See ADR: https://github.com/OctopusDeploy/adr/blob/main/team-modern-deployments/calamari-ai-agent/adr-001-use-processstartinfo-username-for-user-impersonation.md - // Uses ProcessStartInfo.UserName on all platforms. - // On Windows: uses native token-based impersonation with optional password/domain. - // On Linux: .NET calls setuid/setgid internally. Requires the calling process to - // be root or have CAP_SETUID/CAP_SETGID capabilities. Environment variables from - // ProcessStartInfo.Environment are inherited naturally — no special handling needed. - startInfo.UserName = credentials.Username; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + startInfo.UserName = credentials.Username; + if (!string.IsNullOrEmpty(credentials.Password)) startInfo.PasswordInClearText = credentials.Password; if (!string.IsNullOrEmpty(credentials.Domain)) startInfo.Domain = credentials.Domain; + + return; } + + // Linux: use script/su to impersonate the user with a proper login shell. + // su - starts a login shell which clears the environment, so we inline + // any custom env vars into the command string. + if (string.IsNullOrEmpty(credentials.Password)) + throw new CommandException("A password is required for Linux user impersonation via su"); + + var envPrefix = string.Join(" ", customEnvVars.Select(kvp => $"{kvp.Key}={ShellQuote(kvp.Value)}")); + var innerCommand = string.IsNullOrEmpty(envPrefix) + ? $"{startInfo.FileName} {startInfo.Arguments}" + : $"{envPrefix} {startInfo.FileName} {startInfo.Arguments}"; + + var suCommand = $"su - {credentials.Username} -c {ShellQuote(innerCommand)}"; + + startInfo.FileName = "script"; + startInfo.Arguments = ""; // clear — using ArgumentList instead + startInfo.ArgumentList.Add("-qec"); + startInfo.ArgumentList.Add(suCommand); + startInfo.ArgumentList.Add("/dev/null"); + startInfo.RedirectStandardInput = true; + startInfo.UserName = null; } internal static string ShellQuote(string value) From 0414f5e94cecf4711bedacbd7b4811fc42dae296 Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 8 Jun 2026 22:04:05 +1000 Subject: [PATCH 15/26] feat: pipe password to stdin for Linux su-based impersonation and update ADR comment Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs index ce827502a5..6d9ca8a2e9 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -120,7 +120,8 @@ async Task RunInDirectoryAsync( try { - await RunProcess(startInfo, verboseLogPath, streamProcessor); + var password = runAs != null && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? runAs.Password : null; + await RunProcess(startInfo, verboseLogPath, streamProcessor, password); } catch (Exception e) { @@ -141,11 +142,17 @@ async Task RunInDirectoryAsync( return responseBuilder.ToString(); } - async Task RunProcess(ProcessStartInfo startInfo, string verboseLogPath, ClaudeCodeStreamProcessor streamProcessor) + async Task RunProcess(ProcessStartInfo startInfo, string verboseLogPath, ClaudeCodeStreamProcessor streamProcessor, string? password = null) { using var process = new Process(); process.StartInfo = startInfo; process.Start(); + + if (password != null) + { + await process.StandardInput.WriteLineAsync(password); + process.StandardInput.Close(); + } var stdoutTask = Task.Run(async () => { while (await process.StandardOutput.ReadLineAsync() is { } line) @@ -282,6 +289,12 @@ internal static string SanitizeFileName(string name) internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials, Dictionary customEnvVars) { + // See ADR: https://github.com/OctopusDeploy/adr/blob/main/team-modern-deployments/calamari-ai-agent/adr-001-use-processstartinfo-username-for-user-impersonation.md + // On Windows: uses ProcessStartInfo.UserName with native token-based impersonation + // and optional password/domain. + // On Linux: uses script(1) + su(1) to launch a login shell as the target user. + // Environment variables are inlined into the su -c command since login shells + // clear the inherited environment. Password is piped via stdin. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { startInfo.UserName = credentials.Username; From c34455712205a6100f15c3251553b5ac87c16556 Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 10 Jun 2026 09:22:19 +1000 Subject: [PATCH 16/26] rollback build --- build.sh | 2 +- .../ClaudeCodeCliRunnerFixture.cs | 239 ++++++++++++++---- .../ClaudeCodeCliRunner.cs | 72 ++++-- 3 files changed, 238 insertions(+), 75 deletions(-) diff --git a/build.sh b/build.sh index 2f7dd4c566..1089acffb9 100755 --- a/build.sh +++ b/build.sh @@ -24,7 +24,7 @@ export DOTNET_MULTILEVEL_LOOKUP=0 ########################################################################### function FirstJsonValue { - grep -o '"'"$1"'"\s*:\s*"[^"]*"' <<< "${@:2}" | sed 's/.*: *"//;s/"//' + perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" } # If dotnet CLI is installed globally and it matches requested version, use for execution diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs index 0dfacb76c3..c2718e4723 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs @@ -202,6 +202,102 @@ public void ShellQuote_QuotesCorrectly(string input, string expected) ClaudeCodeCliRunner.ShellQuote(input).Should().Be(expected); } + [Test] + public void WriteWrapperScript_WritesEnvVarsAndExecCommand() + { + var workingDir = Path.Combine(Path.GetTempPath(), $"test-wrapper-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + var startInfo = new ProcessStartInfo + { + FileName = "claude", + Arguments = "--model sonnet --print", + }; + + var customEnvVars = new Dictionary + { + ["ANTHROPIC_API_KEY"] = "sk-test-123", + ["OTHER_VAR"] = "hello", + }; + + var scriptPath = ClaudeCodeCliRunner.WriteWrapperScript(startInfo, customEnvVars, workingDir); + + File.Exists(scriptPath).Should().BeTrue(); + var content = File.ReadAllText(scriptPath); + content.Should().StartWith("#!/bin/bash"); + content.Should().Contain("export ANTHROPIC_API_KEY='sk-test-123'"); + content.Should().Contain("export OTHER_VAR='hello'"); + content.Should().Contain("exec claude --model sonnet --print"); + } + finally + { + Directory.Delete(workingDir, true); + } + } + + [Test] + public void ApplyCredentials_MacOS_RewritesStartInfoToUseBsdScript() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Assert.Ignore("macOS-only test"); + return; + } + + var workingDir = Path.Combine(Path.GetTempPath(), $"test-creds-mac-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + var startInfo = new ProcessStartInfo + { + FileName = "claude", + Arguments = "--model sonnet --print", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + var credentials = new ProcessCredentials + { + Username = "claude", + Password = "claude", + }; + + var customEnvVars = new Dictionary + { + ["ANTHROPIC_API_KEY"] = "sk-test-123", + }; + + ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars, workingDir); + + startInfo.FileName.Should().Be("script"); + startInfo.UserName.Should().BeNullOrEmpty(); + startInfo.RedirectStandardInput.Should().BeTrue(); + + // BSD script: -q, /dev/null, su, -, username, -c, command + startInfo.ArgumentList.Should().HaveCount(7); + startInfo.ArgumentList[0].Should().Be("-q"); + startInfo.ArgumentList[1].Should().Be("/dev/null"); + startInfo.ArgumentList[2].Should().Be("su"); + startInfo.ArgumentList[3].Should().Be("-"); + startInfo.ArgumentList[4].Should().Be("claude"); + startInfo.ArgumentList[5].Should().Be("-c"); + startInfo.ArgumentList[6].Should().Contain("/bin/bash"); + startInfo.ArgumentList[6].Should().Contain("run-claude.sh"); + + // Verify wrapper script was written + var scriptPath = Path.Combine(workingDir, "run-claude.sh"); + File.Exists(scriptPath).Should().BeTrue(); + } + finally + { + Directory.Delete(workingDir, true); + } + } + [Test] public void ApplyCredentials_Linux_RewritesStartInfoToUseScriptSu() { @@ -211,61 +307,82 @@ public void ApplyCredentials_Linux_RewritesStartInfoToUseScriptSu() return; } - var startInfo = new ProcessStartInfo - { - FileName = "claude", - Arguments = "--model sonnet --print", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - - var credentials = new ProcessCredentials + var workingDir = Path.Combine(Path.GetTempPath(), $"test-creds-linux-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try { - Username = "claude", - Password = "claude", - }; + var startInfo = new ProcessStartInfo + { + FileName = "claude", + Arguments = "--model sonnet --print", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + var credentials = new ProcessCredentials + { + Username = "claude", + Password = "claude", + }; + + var customEnvVars = new Dictionary + { + ["ANTHROPIC_API_KEY"] = "sk-test-123", + }; + + ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars, workingDir); + + startInfo.FileName.Should().Be("script"); + startInfo.UserName.Should().BeNullOrEmpty(); + startInfo.RedirectStandardInput.Should().BeTrue(); + + // Linux script: -qec, "su - claude -c '...run-claude.sh'", /dev/null + startInfo.ArgumentList.Should().HaveCount(3); + startInfo.ArgumentList[0].Should().Be("-qec"); + startInfo.ArgumentList[2].Should().Be("/dev/null"); + + var suCommand = startInfo.ArgumentList[1]; + suCommand.Should().StartWith("su - claude -c "); + suCommand.Should().Contain("run-claude.sh"); - var customEnvVars = new Dictionary + // Verify wrapper script was written + var scriptPath = Path.Combine(workingDir, "run-claude.sh"); + File.Exists(scriptPath).Should().BeTrue(); + } + finally { - ["ANTHROPIC_API_KEY"] = "sk-test-123", - ["OTHER_VAR"] = "hello", - }; - - ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); - - startInfo.FileName.Should().Be("script"); - startInfo.UserName.Should().BeNull(); - startInfo.RedirectStandardInput.Should().BeTrue(); - - // ArgumentList should be: -qec, "su - claude -c '...'", /dev/null - startInfo.ArgumentList.Should().HaveCount(3); - startInfo.ArgumentList[0].Should().Be("-qec"); - startInfo.ArgumentList[2].Should().Be("/dev/null"); - - var suCommand = startInfo.ArgumentList[1]; - suCommand.Should().StartWith("su - claude -c "); - suCommand.Should().Contain("ANTHROPIC_API_KEY="); - suCommand.Should().Contain("OTHER_VAR="); - suCommand.Should().Contain("claude --model sonnet --print"); + Directory.Delete(workingDir, true); + } } [Test] - public void ApplyCredentials_Linux_ThrowsWhenPasswordMissing() + public void ApplyCredentials_NonWindows_ThrowsWhenPasswordMissing() { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Assert.Ignore("Linux-only test"); + Assert.Ignore("Non-Windows test"); return; } - var startInfo = new ProcessStartInfo { FileName = "claude" }; - var credentials = new ProcessCredentials { Username = "claude", Password = null }; - var customEnvVars = new Dictionary(); + var workingDir = Path.Combine(Path.GetTempPath(), $"test-creds-nopw-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try + { + var startInfo = new ProcessStartInfo { FileName = "claude" }; + var credentials = new ProcessCredentials { Username = "claude", Password = null }; + var customEnvVars = new Dictionary(); - var act = () => ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); + var act = () => ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars, workingDir); - act.Should().Throw().WithMessage("*password*"); + act.Should().Throw().WithMessage("*password*"); + } + finally + { + Directory.Delete(workingDir, true); + } } [Test] @@ -277,21 +394,31 @@ public void ApplyCredentials_Windows_SetsUsernameAndPassword() return; } - var startInfo = new ProcessStartInfo { FileName = "claude" }; - var credentials = new ProcessCredentials + var workingDir = Path.Combine(Path.GetTempPath(), $"test-creds-win-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + + try { - Username = "deploy-user", - Password = "s3cret", - Domain = "CORP", - }; - var customEnvVars = new Dictionary(); - - ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); - - startInfo.UserName.Should().Be("deploy-user"); - startInfo.PasswordInClearText.Should().Be("s3cret"); - startInfo.Domain.Should().Be("CORP"); - startInfo.FileName.Should().Be("claude"); // unchanged + var startInfo = new ProcessStartInfo { FileName = "claude" }; + var credentials = new ProcessCredentials + { + Username = "deploy-user", + Password = "s3cret", + Domain = "CORP", + }; + var customEnvVars = new Dictionary(); + + ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars, workingDir); + + startInfo.UserName.Should().Be("deploy-user"); + startInfo.PasswordInClearText.Should().Be("s3cret"); + startInfo.Domain.Should().Be("CORP"); + startInfo.FileName.Should().Be("claude"); // unchanged + } + finally + { + Directory.Delete(workingDir, true); + } } [Test] diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs index 6d9ca8a2e9..feb82f5e64 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -66,7 +66,7 @@ public async Task RunAsync( FileName = "script", ArgumentList = { "-qec", $"su - {user} -c '{cmd}'", "/dev/null" }, RedirectStandardInput = true, - RedirectStandardOutput = true, + RedirectStI andardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; @@ -113,7 +113,7 @@ async Task RunInDirectoryAsync( startInfo.Environment[kvp.Key] = kvp.Value; if (runAs != null) - ApplyCredentials(startInfo, runAs, customEnvVars); + ApplyCredentials(startInfo, runAs, customEnvVars, workingDir); var responseBuilder = new StringBuilder(); var streamProcessor = new ClaudeCodeStreamProcessor(log, responseBuilder); @@ -287,14 +287,14 @@ internal static string SanitizeFileName(string name) return result; } - internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials, Dictionary customEnvVars) + internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials, Dictionary customEnvVars, string workingDir) { // See ADR: https://github.com/OctopusDeploy/adr/blob/main/team-modern-deployments/calamari-ai-agent/adr-001-use-processstartinfo-username-for-user-impersonation.md // On Windows: uses ProcessStartInfo.UserName with native token-based impersonation // and optional password/domain. - // On Linux: uses script(1) + su(1) to launch a login shell as the target user. - // Environment variables are inlined into the su -c command since login shells - // clear the inherited environment. Password is piped via stdin. + // On Linux/macOS: uses script(1) + su(1) to launch a login shell as the target user. + // A wrapper script is written to disk with env exports and the command, avoiding + // nested shell escaping. Password is piped via stdin. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { startInfo.UserName = credentials.Username; @@ -307,26 +307,62 @@ internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredent return; } - // Linux: use script/su to impersonate the user with a proper login shell. - // su - starts a login shell which clears the environment, so we inline - // any custom env vars into the command string. if (string.IsNullOrEmpty(credentials.Password)) throw new CommandException("A password is required for Linux user impersonation via su"); - var envPrefix = string.Join(" ", customEnvVars.Select(kvp => $"{kvp.Key}={ShellQuote(kvp.Value)}")); - var innerCommand = string.IsNullOrEmpty(envPrefix) - ? $"{startInfo.FileName} {startInfo.Arguments}" - : $"{envPrefix} {startInfo.FileName} {startInfo.Arguments}"; + // Write a wrapper script so env vars and the command are expressed as plain + // shell syntax — no nested quoting through script → su → shell layers. + var scriptPath = WriteWrapperScript(startInfo, customEnvVars, workingDir); - var suCommand = $"su - {credentials.Username} -c {ShellQuote(innerCommand)}"; + var suArg = $"/bin/bash {scriptPath}"; startInfo.FileName = "script"; startInfo.Arguments = ""; // clear — using ArgumentList instead - startInfo.ArgumentList.Add("-qec"); - startInfo.ArgumentList.Add(suCommand); - startInfo.ArgumentList.Add("/dev/null"); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // BSD script: script -q /dev/null command args... + startInfo.ArgumentList.Add("-q"); + startInfo.ArgumentList.Add("/dev/null"); + startInfo.ArgumentList.Add("su"); + startInfo.ArgumentList.Add("-"); + startInfo.ArgumentList.Add(credentials.Username); + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add(suArg); + } + else + { + // Linux (util-linux) script: script -qec "command" /dev/null + startInfo.ArgumentList.Add("-qec"); + startInfo.ArgumentList.Add($"su - {credentials.Username} -c {ShellQuote(suArg)}"); + startInfo.ArgumentList.Add("/dev/null"); + } + startInfo.RedirectStandardInput = true; - startInfo.UserName = null; + } + + internal static string WriteWrapperScript(ProcessStartInfo startInfo, Dictionary customEnvVars, string workingDir) + { + var scriptPath = Path.Combine(workingDir, "run-claude.sh"); + var sb = new StringBuilder(); + sb.AppendLine("#!/bin/bash"); + foreach (var kvp in customEnvVars) + sb.AppendLine($"export {kvp.Key}={ShellQuote(kvp.Value)}"); + sb.AppendLine($"exec {startInfo.FileName} {startInfo.Arguments}"); + File.WriteAllText(scriptPath, sb.ToString()); + + // Ensure the target su user can read the working directory and script. + // The directory may have been created with a restrictive umask (e.g. 077). + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + File.SetUnixFileMode(scriptPath, UnixFileMode.UserRead | UnixFileMode.UserWrite + | UnixFileMode.GroupRead | UnixFileMode.OtherRead); + File.SetUnixFileMode(workingDir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + + return scriptPath; } internal static string ShellQuote(string value) From 96104e7883887fdffd5bf89ccd0b96054363369d Mon Sep 17 00:00:00 2001 From: robert Date: Wed, 10 Jun 2026 10:39:10 +1000 Subject: [PATCH 17/26] Change global --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index 99e922f66e..90faf1b627 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.421", + "version": "8.0.419", "rollForward": "latestFeature", "allowPrerelease": false } From a73d49dac33ea83898f04c4c22a2750d81e5e4ad Mon Sep 17 00:00:00 2001 From: robert Date: Fri, 12 Jun 2026 20:10:30 +1000 Subject: [PATCH 18/26] Some tidy and refactor --- .../ClaudeCodeProcessStartInfoFixture.cs | 19 + .../ClaudeCodeStreamProcessorFixture.cs | 34 +- .../ClaudeCommandArgsBuilderFixture.cs | 47 +- .../ClaudeCodeBehaviour/McpWriterFixture.cs | 208 ++++++++ .../SkillsWriterFixture.cs | 161 +++++++ .../SystemPromptWriterFixture.cs | 35 ++ .../ClaudeCodeCliRunnerFixture.cs | 444 ------------------ .../RunAgentCommandFixture.cs | 1 + .../AgentBehaviour/InvokeAgentBehaviour.cs | 8 +- .../{ => AgentBehaviour}/LineBuffer.cs | 0 .../Calamari.AiAgent/Calamari.AiAgent.csproj | 4 + .../ClaudeCodeCliRunner.cs | 374 +++------------ .../ClaudeCodeProcessStartInfo.cs | 165 +++++++ .../ClaudeCodeStreamModels.cs | 432 ----------------- .../ClaudeCodeStreamProcessor.cs | 10 +- .../ClaudeCommandArgsBuilder.cs | 40 +- .../InvokeClaudeCodeBehaviour.cs | 167 ++----- .../AssistantStreamEvent.cs | 12 + .../JsonResponseModels/ContentBlockType.cs | 29 ++ .../JsonResponseModels/ContentBlocks.cs | 75 +++ .../JsonResponseModels/ResultStreamEvent.cs | 59 +++ .../JsonResponseModels/StreamEvent.cs | 15 + .../JsonResponseModels/StreamEventType.cs | 20 + .../JsonResponseModels/StreamMessage.cs | 28 ++ .../JsonResponseModels/SystemStreamEvent.cs | 106 +++++ .../JsonResponseModels/UsageInfo.cs | 102 ++++ .../JsonResponseModels/UserStreamEvent.cs | 18 + .../ClaudeCodeBehaviour/McpWriter.cs | 131 ++++++ .../ClaudeCodeBehaviour/SkillsWriter.cs | 114 +++++ .../ClaudeCodeBehaviour/SystemPromptWriter.cs | 26 + source/Calamari.AiAgent/RunAgentCommand.cs | 2 +- source/Calamari.AiAgent/SpecialVariables.cs | 7 +- 32 files changed, 1544 insertions(+), 1349 deletions(-) create mode 100644 source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfoFixture.cs rename source/Calamari.AiAgent.Tests/{ => ClaudeCodeBehaviour}/ClaudeCodeStreamProcessorFixture.cs (89%) rename source/Calamari.AiAgent.Tests/{ => ClaudeCodeBehaviour}/ClaudeCommandArgsBuilderFixture.cs (70%) create mode 100644 source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs create mode 100644 source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs create mode 100644 source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SystemPromptWriterFixture.cs delete mode 100644 source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs rename source/Calamari.AiAgent/{ => AgentBehaviour}/LineBuffer.cs (100%) create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs delete mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/AssistantStreamEvent.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlockType.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlocks.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ResultStreamEvent.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEvent.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEventType.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamMessage.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/SystemStreamEvent.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UsageInfo.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UserStreamEvent.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs create mode 100644 source/Calamari.AiAgent/ClaudeCodeBehaviour/SystemPromptWriter.cs diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfoFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfoFixture.cs new file mode 100644 index 0000000000..0dceb0f684 --- /dev/null +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfoFixture.cs @@ -0,0 +1,19 @@ +using Calamari.AiAgent.ClaudeCodeBehaviour; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour; + +[TestFixture] +public class ClaudeCodeProcessStartInfoFixture +{ + [TestCase("simple", "'simple'")] + [TestCase("has space", "'has space'")] + [TestCase("it's", @"'it'\''s'")] + [TestCase("", "''")] + [TestCase("a'b'c", @"'a'\''b'\''c'")] + public void ShellQuote_QuotesCorrectly(string input, string expected) + { + ClaudeCodeProcessStartInfo.ShellQuote(input).Should().Be(expected); + } +} diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeStreamProcessorFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs similarity index 89% rename from source/Calamari.AiAgent.Tests/ClaudeCodeStreamProcessorFixture.cs rename to source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs index 90916f741f..e72584d81b 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeStreamProcessorFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs @@ -1,12 +1,12 @@ using System.Linq; using System.Text; -using Calamari.AiAgent.Behaviours; +using Calamari.AiAgent.ClaudeCodeBehaviour; using Calamari.Common.Plumbing.ServiceMessages; using Calamari.Testing.Helpers; using FluentAssertions; using NUnit.Framework; -namespace Calamari.AiAgent.Tests; +namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour; [TestFixture] public class ClaudeCodeStreamProcessorFixture @@ -111,8 +111,7 @@ public void ResultEvent_EmitsUsageServiceMessage() log.ServiceMessages.Should().Contain(m => m.Name == AiAgentServiceMessageNames.Name); var msg = log.ServiceMessages.First(m => m.Name == AiAgentServiceMessageNames.Name); - var props = msg.GetValue(AiAgentServiceMessageNames.CostUsdAttribute); - props.Should().NotBeNull(); + msg.GetValue(AiAgentServiceMessageNames.CostUsdAttribute).Should().NotBeNull(); msg.GetValue(AiAgentServiceMessageNames.TotalCostUsdAttribute).Should().NotBeNull(); msg.GetValue(AiAgentServiceMessageNames.DurationMsAttribute).Should().NotBeNull(); msg.GetValue(AiAgentServiceMessageNames.NumTurnsAttribute).Should().NotBeNull(); @@ -244,4 +243,29 @@ public void NullMessageContent_DoesNotThrow() act.Should().NotThrow(); } -} + + [Test] + public void UserTextContent_DoesNotAppendToResponse() + { + var json = """ + {"type":"user","message":{"content":[{"type":"text","text":"user input"}]}} + """; + + processor.ProcessLine(json); + + responseBuilder.ToString().Should().BeEmpty(); + log.Messages.Should().Contain(m => m.Level == InMemoryLog.Level.Verbose && m.FormattedMessage.Contains("user input")); + } + + [Test] + public void SyntheticUserMessage_IsSkipped() + { + var json = """ + {"type":"user","message":{"content":[{"type":"text","text":"synthetic"}]},"isSynthetic":true} + """; + + processor.ProcessLine(json); + + log.Messages.Should().NotContain(m => m.FormattedMessage.Contains("synthetic")); + } +} \ No newline at end of file diff --git a/source/Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCommandArgsBuilderFixture.cs similarity index 70% rename from source/Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs rename to source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCommandArgsBuilderFixture.cs index 1e193a603b..abb4f41b0c 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCommandArgsBuilderFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCommandArgsBuilderFixture.cs @@ -1,8 +1,8 @@ -using Calamari.AiAgent.Behaviours; +using Calamari.AiAgent.ClaudeCodeBehaviour; using FluentAssertions; using NUnit.Framework; -namespace Calamari.AiAgent.Tests; +namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour; [TestFixture] public class ClaudeCommandArgsBuilderFixture @@ -23,6 +23,8 @@ public void Build_IncludesRequiredFlags() args.Should().Contain("--verbose"); args.Should().Contain("--permission-mode dontAsk"); args.Should().Contain("--no-session-persistence"); + args.Should().Contain("--bare"); + args.Should().Contain("--strict-mcp-config"); } [Test] @@ -79,22 +81,49 @@ public void Build_OmitsAllowedTools_WhenEmpty() } [Test] - public void Build_IncludesSystemPrompt_WhenSet() + public void Build_IncludesSystemPromptFile_WhenSet() { var args = MinimalBuilder() - .WithSystemPrompt("You are helpful") + .WithSystemPromptFile("/tmp/system-prompt.md") .Build(); - args.Should().Contain("--system-prompt"); - args.Should().Contain("You are helpful"); + args.Should().Contain("--system-prompt-file"); + args.Should().Contain("/tmp/system-prompt.md"); } [Test] - public void Build_OmitsSystemPrompt_WhenNotSet() + public void Build_OmitsSystemPromptFile_WhenNotSet() { var args = MinimalBuilder().Build(); - args.Should().NotContain("--system-prompt"); + args.Should().NotContain("--system-prompt-file"); + } + + [Test] + public void Build_IncludesEffort_WhenSet() + { + var args = MinimalBuilder().WithEffort("high").Build(); + + args.Should().Contain("--effort high"); + } + + [Test] + public void Build_OmitsEffort_WhenNotSet() + { + var args = MinimalBuilder().Build(); + + args.Should().NotContain("--effort"); + } + + [Test] + public void Build_IncludesMcpConfig_WhenSet() + { + var args = MinimalBuilder() + .WithMcpConfigPath("/tmp/mcp-config.json") + .Build(); + + args.Should().Contain("--mcp-config"); + args.Should().Contain("/tmp/mcp-config.json"); } [Test] @@ -119,4 +148,4 @@ public void Build_ThrowsWhenPromptNotSet() act.Should().Throw() .WithMessage("*prompt*"); } -} +} \ No newline at end of file diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs new file mode 100644 index 0000000000..27f97f22f6 --- /dev/null +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Calamari.AiAgent.ClaudeCodeBehaviour; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Variables; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour; + +[TestFixture] +public class McpWriterFixture +{ + string workingDir = null!; + + [SetUp] + public void SetUp() + { + workingDir = Path.Combine(Path.GetTempPath(), $"test-mcp-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(workingDir)) + Directory.Delete(workingDir, true); + } + + [Test] + public void SetupMcpConfig_WritesValidJson_WithServers() + { + var vars = new CalamariVariables(); + var mcpJson = JsonSerializer.Serialize(new[] + { + new + { + name = "github", + command = "npx", + args = new[] { "-y", "@modelcontextprotocol/server-github" }, + env = new Dictionary { ["TOKEN"] = "abc123" }, + }, + }); + vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + + var configPath = new McpWriter(vars).SetupMcpConfig(workingDir); + + File.Exists(configPath).Should().BeTrue(); + + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); + mcpServers.TryGetProperty("github", out var github).Should().BeTrue(); + github.GetProperty("command").GetString().Should().Be("npx"); + } + + [Test] + public void SetupMcpConfig_WritesEmptyServers_WhenNoneProvided() + { + var configPath = new McpWriter(new CalamariVariables()).SetupMcpConfig(workingDir); + + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); + mcpServers.EnumerateObject().Should().BeEmpty(); + } + + [Test] + public void GetAllowedTools_ReturnsMcpWildcardPerServer() + { + var vars = new CalamariVariables(); + var mcpJson = JsonSerializer.Serialize(new[] + { + new { name = "github", command = "npx" }, + new { name = "slack", command = "npx" }, + }); + vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + + var tools = new McpWriter(vars).GetAllowedTools(); + + tools.Should().Contain("mcp__github__*"); + tools.Should().Contain("mcp__slack__*"); + } + + [Test] + public void GetAllowedTools_ReturnsEmpty_WhenNoServersConfigured() + { + var tools = new McpWriter(new CalamariVariables()).GetAllowedTools(); + + tools.Should().BeEmpty(); + } + + [Test] + public void SetupMcpConfig_AddsOctopusMcpServer_WhenTokenAndUrlProvided() + { + var vars = new CalamariVariables(); + vars.Set(SpecialVariables.Action.AiAgent.OctopusToken, "API-TESTKEY"); + vars.Set(SpecialVariables.Web.ServerUri, "https://octopus.example.com"); + + var configPath = new McpWriter(vars).SetupMcpConfig(workingDir); + + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + var mcpServers = doc.RootElement.GetProperty("mcpServers"); + mcpServers.TryGetProperty("octopus", out var octopus).Should().BeTrue(); + octopus.GetProperty("command").GetString().Should().Be("npx"); + + var env = octopus.GetProperty("env"); + env.GetProperty("OCTOPUS_SERVER_URL").GetString().Should().Be("https://octopus.example.com"); + env.GetProperty("OCTOPUS_API_KEY").GetString().Should().Be("API-TESTKEY"); + } + + [Test] + public void SetupMcpConfig_SkipsOctopusMcpServer_WhenTokenMissing() + { + var vars = new CalamariVariables(); + vars.Set(SpecialVariables.Web.ServerUri, "https://octopus.example.com"); + + var configPath = new McpWriter(vars).SetupMcpConfig(workingDir); + + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + doc.RootElement.GetProperty("mcpServers").TryGetProperty("octopus", out _).Should().BeFalse(); + } + + [Test] + public void SetupMcpConfig_ThrowsOnInvalidMcpJson() + { + var vars = new CalamariVariables(); + vars.Set(SpecialVariables.Action.AiAgent.McpServers, "not valid json {{{"); + + var act = () => new McpWriter(vars).SetupMcpConfig(workingDir); + + act.Should().Throw().WithMessage("*Failed to parse*"); + } + + [Test] + public void SetupMcpConfig_ThrowsWhenServerMissingName() + { + var vars = new CalamariVariables(); + var mcpJson = JsonSerializer.Serialize(new[] { new { command = "npx" } }); + vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + + var act = () => new McpWriter(vars).SetupMcpConfig(workingDir); + + act.Should().Throw().WithMessage("*must have a name*"); + } + + [Test] + public void SetupMcpConfig_ThrowsWhenServerMissingCommand() + { + var vars = new CalamariVariables(); + var mcpJson = JsonSerializer.Serialize(new[] { new { name = "my-server" } }); + vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + + var act = () => new McpWriter(vars).SetupMcpConfig(workingDir); + + act.Should().Throw().WithMessage("*must have a command*"); + } + + [Test] + public void SetupMcpConfig_InjectsPathEnvVar_WhenNotProvidedByUser() + { + var vars = new CalamariVariables(); + var mcpJson = JsonSerializer.Serialize(new[] + { + new { name = "test-server", command = "node" }, + }); + vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + + var configPath = new McpWriter(vars).SetupMcpConfig(workingDir); + + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + var env = doc.RootElement + .GetProperty("mcpServers") + .GetProperty("test-server") + .GetProperty("env"); + env.TryGetProperty("PATH", out _).Should().BeTrue(); + } + + [Test] + public void SetupMcpConfig_PreservesUserProvidedPathEnvVar() + { + var vars = new CalamariVariables(); + var mcpJson = JsonSerializer.Serialize(new[] + { + new + { + name = "test-server", + command = "node", + env = new Dictionary { ["PATH"] = "/custom/path" }, + }, + }); + vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + + var configPath = new McpWriter(vars).SetupMcpConfig(workingDir); + + var json = File.ReadAllText(configPath); + var doc = JsonDocument.Parse(json); + var env = doc.RootElement + .GetProperty("mcpServers") + .GetProperty("test-server") + .GetProperty("env"); + env.GetProperty("PATH").GetString().Should().Be("/custom/path"); + } +} diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs new file mode 100644 index 0000000000..cc54aed0e6 --- /dev/null +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs @@ -0,0 +1,161 @@ +using System.IO; +using Calamari.AiAgent.ClaudeCodeBehaviour; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Variables; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour; + +[TestFixture] +public class SkillsWriterFixture +{ + string workingDir = null!; + + [SetUp] + public void SetUp() + { + workingDir = Path.Combine(Path.GetTempPath(), $"test-skills-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(workingDir)) + Directory.Delete(workingDir, true); + } + + static CalamariVariables EmptyVariables() => new(); + + static CalamariVariables VariablesWithSkills(params (string Name, string Content)[] skills) + { + var vars = new CalamariVariables(); + for (var i = 0; i < skills.Length; i++) + { + vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[{i}].{SpecialVariables.Action.AiAgent.SkillName}", skills[i].Name); + vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[{i}].{SpecialVariables.Action.AiAgent.SkillContent}", skills[i].Content); + } + return vars; + } + + [Test] + public void SetupSkills_CreatesSkillDirectories() + { + new SkillsWriter(EmptyVariables()).SetupSkills(workingDir); + + var skillDir = Path.Combine(workingDir, ".claude", "skills", "octopus-deployment-context"); + Directory.Exists(skillDir).Should().BeTrue(); + + var skillMd = Path.Combine(skillDir, "SKILL.md"); + File.Exists(skillMd).Should().BeTrue(); + + var content = File.ReadAllText(skillMd); + content.Should().Contain("get_deployment_variables"); + } + + [Test] + public void SetupSkills_WritesUserSkills() + { + var vars = VariablesWithSkills( + ("my-custom-skill", "---\nname: my-custom-skill\n---\nDo something useful."), + ("another-skill", "---\nname: another-skill\n---\nMore instructions.")); + + new SkillsWriter(vars).SetupSkills(workingDir); + + var skill1 = Path.Combine(workingDir, ".claude", "skills", "my-custom-skill", "SKILL.md"); + File.Exists(skill1).Should().BeTrue(); + File.ReadAllText(skill1).Should().Contain("Do something useful."); + + var skill2 = Path.Combine(workingDir, ".claude", "skills", "another-skill", "SKILL.md"); + File.Exists(skill2).Should().BeTrue(); + File.ReadAllText(skill2).Should().Contain("More instructions."); + } + + [Test] + public void SetupSkills_SanitizesPathTraversalAttempt() + { + var vars = VariablesWithSkills(("../../etc/evil", "content")); + + new SkillsWriter(vars).SetupSkills(workingDir); + + var skillsDir = Path.Combine(workingDir, ".claude", "skills"); + var dirs = Directory.GetDirectories(skillsDir); + dirs.Should().Contain(d => Path.GetFileName(d).Contains("etc-evil")); + + File.Exists(Path.Combine(workingDir, "..", "..", "etc", "evil", "SKILL.md")).Should().BeFalse(); + } + + [Test] + public void SetupSkills_SkipsSkillsWithEmptyNameOrContent() + { + var vars = new CalamariVariables(); + vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[0].{SpecialVariables.Action.AiAgent.SkillName}", ""); + vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[0].{SpecialVariables.Action.AiAgent.SkillContent}", "some content"); + vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[1].{SpecialVariables.Action.AiAgent.SkillName}", "valid-name"); + vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[1].{SpecialVariables.Action.AiAgent.SkillContent}", ""); + + new SkillsWriter(vars).SetupSkills(workingDir); + + var skillsDir = Path.Combine(workingDir, ".claude", "skills"); + Directory.Exists(Path.Combine(skillsDir, "valid-name")).Should().BeFalse(); + } + + [TestCase("")] + [TestCase(" ")] + [TestCase(null)] + public void SanitizeFileName_RejectsEmptyOrWhitespace(string name) + { + var act = () => SkillsWriter.SanitizeFileName(name!); + act.Should().Throw().WithMessage("*cannot be empty*"); + } + + [TestCase("CON")] + [TestCase("con")] + [TestCase("NUL")] + [TestCase("COM1")] + [TestCase("LPT3")] + public void SanitizeFileName_RejectsWindowsReservedNames(string name) + { + var act = () => SkillsWriter.SanitizeFileName(name); + act.Should().Throw().WithMessage("*reserved*"); + } + + [Test] + public void SanitizeFileName_StripsLeadingDots() + { + SkillsWriter.SanitizeFileName("...my-skill").Should().Be("my-skill"); + } + + [Test] + public void SanitizeFileName_ReplacesPathSeparators() + { + var result = SkillsWriter.SanitizeFileName("../../etc/passwd"); + result.Should().NotContain("/"); + result.Should().NotContain("\\"); + } + + [Test] + public void SanitizeFileName_ReplacesControlCharacters() + { + var result = SkillsWriter.SanitizeFileName("my\tskill\nname"); + result.Should().NotContainAny("\t", "\n"); + result.Should().Contain("my"); + result.Should().Contain("skill"); + result.Should().Contain("name"); + } + + [Test] + public void SanitizeFileName_ReplacesWindowsUnsafeCharsOnAllPlatforms() + { + var result = SkillsWriter.SanitizeFileName("skillwith|pipes"); + result.Should().NotContainAny("<", ">", "|"); + } + + [Test] + public void SanitizeFileName_TruncatesLongNames() + { + var longName = new string('a', 300); + SkillsWriter.SanitizeFileName(longName).Length.Should().BeLessOrEqualTo(200); + } +} diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SystemPromptWriterFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SystemPromptWriterFixture.cs new file mode 100644 index 0000000000..2b77cb60ab --- /dev/null +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SystemPromptWriterFixture.cs @@ -0,0 +1,35 @@ +using System.IO; +using Calamari.AiAgent.ClaudeCodeBehaviour; +using FluentAssertions; +using NUnit.Framework; + +namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour; + +[TestFixture] +public class SystemPromptWriterFixture +{ + string workingDir = null!; + + [SetUp] + public void SetUp() + { + workingDir = Path.Combine(Path.GetTempPath(), $"test-sysprompt-{Path.GetRandomFileName()}"); + Directory.CreateDirectory(workingDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(workingDir)) + Directory.Delete(workingDir, true); + } + + [Test] + public void WriteSystemPromptFile_WritesFile() + { + var path = new SystemPromptWriter().WriteSystemPromptFile(workingDir); + + File.Exists(path).Should().BeTrue(); + File.ReadAllText(path).Should().NotBeEmpty(); + } +} diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs deleted file mode 100644 index c2718e4723..0000000000 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs +++ /dev/null @@ -1,444 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using System.Text.Json; -using Calamari.AiAgent.Behaviours; -using Calamari.Common.Commands; -using FluentAssertions; -using NUnit.Framework; - -namespace Calamari.AiAgent.Tests; - -[TestFixture] -public class ClaudeCodeCliRunnerFixture -{ - [Test] - public void SetupSkills_CreatesSkillDirectories() - { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-skills-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - ClaudeCodeCliRunner.SetupSkills(workingDir); - - var skillDir = Path.Combine(workingDir, ".claude", "skills", "octopus-deployment-context"); - Directory.Exists(skillDir).Should().BeTrue(); - - var skillMd = Path.Combine(skillDir, "SKILL.md"); - File.Exists(skillMd).Should().BeTrue(); - - var content = File.ReadAllText(skillMd); - content.Should().Contain("get_deployment_variables"); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [Test] - public void SetupSkills_WritesUserSkills() - { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-user-skills-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var userSkills = new List - { - new() { Name = "my-custom-skill", Content = "---\nname: my-custom-skill\n---\nDo something useful." }, - new() { Name = "another-skill", Content = "---\nname: another-skill\n---\nMore instructions." }, - }; - - ClaudeCodeCliRunner.SetupSkills(workingDir, userSkills); - - var skill1 = Path.Combine(workingDir, ".claude", "skills", "my-custom-skill", "SKILL.md"); - File.Exists(skill1).Should().BeTrue(); - File.ReadAllText(skill1).Should().Contain("Do something useful."); - - var skill2 = Path.Combine(workingDir, ".claude", "skills", "another-skill", "SKILL.md"); - File.Exists(skill2).Should().BeTrue(); - File.ReadAllText(skill2).Should().Contain("More instructions."); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [Test] - public void SetupSystemPrompt_WritesFile() - { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-sysprompt-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var path = ClaudeCodeCliRunner.SetupSystemPrompt(workingDir); - - File.Exists(path).Should().BeTrue(); - File.ReadAllText(path).Should().NotBeEmpty(); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [TestCase("")] - [TestCase(" ")] - [TestCase(null)] - public void SanitizeFileName_RejectsEmptyOrWhitespace(string name) - { - var act = () => ClaudeCodeCliRunner.SanitizeFileName(name!); - act.Should().Throw().WithMessage("*cannot be empty*"); - } - - [TestCase("CON")] - [TestCase("con")] - [TestCase("NUL")] - [TestCase("COM1")] - [TestCase("LPT3")] - public void SanitizeFileName_RejectsWindowsReservedNames(string name) - { - var act = () => ClaudeCodeCliRunner.SanitizeFileName(name); - act.Should().Throw().WithMessage("*reserved*"); - } - - [Test] - public void SanitizeFileName_StripsLeadingDots() - { - ClaudeCodeCliRunner.SanitizeFileName("...my-skill").Should().Be("my-skill"); - } - - [Test] - public void SanitizeFileName_ReplacesPathSeparators() - { - var result = ClaudeCodeCliRunner.SanitizeFileName("../../etc/passwd"); - result.Should().NotContain("/"); - result.Should().NotContain("\\"); - } - - [Test] - public void SanitizeFileName_TruncatesLongNames() - { - var longName = new string('a', 300); - ClaudeCodeCliRunner.SanitizeFileName(longName).Length.Should().BeLessOrEqualTo(200); - } - - [Test] - public void SetupSkills_SanitizesPathTraversalAttempt() - { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-traversal-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var userSkills = new List - { - new() { Name = "../../etc/evil", Content = "content" }, - }; - - ClaudeCodeCliRunner.SetupSkills(workingDir, userSkills); - - // The file should be written safely inside the skills directory - var skillsDir = Path.Combine(workingDir, ".claude", "skills"); - var dirs = Directory.GetDirectories(skillsDir); - dirs.Should().Contain(d => Path.GetFileName(d).Contains("etc-evil")); - - // Verify nothing was written outside - File.Exists(Path.Combine(workingDir, "..", "..", "etc", "evil", "SKILL.md")).Should().BeFalse(); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [Test] - public void SetupMcpConfig_WritesValidJson_WithServers() - { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-mcp-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var servers = new Dictionary - { - ["github"] = new McpServerConfig - { - Command = "npx", - Args = new[] { "-y", "@modelcontextprotocol/server-github" }, - Env = new Dictionary { ["TOKEN"] = "abc123" }, - }, - }; - - var configPath = ClaudeCodeCliRunner.SetupMcpConfig(workingDir, servers); - - File.Exists(configPath).Should().BeTrue(); - - var json = File.ReadAllText(configPath); - var doc = JsonDocument.Parse(json); - doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); - mcpServers.TryGetProperty("github", out var github).Should().BeTrue(); - github.GetProperty("command").GetString().Should().Be("npx"); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [TestCase("simple", "'simple'")] - [TestCase("has space", "'has space'")] - [TestCase("it's", @"'it'\''s'")] - [TestCase("", "''")] - [TestCase("a'b'c", @"'a'\''b'\''c'")] - public void ShellQuote_QuotesCorrectly(string input, string expected) - { - ClaudeCodeCliRunner.ShellQuote(input).Should().Be(expected); - } - - [Test] - public void WriteWrapperScript_WritesEnvVarsAndExecCommand() - { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-wrapper-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var startInfo = new ProcessStartInfo - { - FileName = "claude", - Arguments = "--model sonnet --print", - }; - - var customEnvVars = new Dictionary - { - ["ANTHROPIC_API_KEY"] = "sk-test-123", - ["OTHER_VAR"] = "hello", - }; - - var scriptPath = ClaudeCodeCliRunner.WriteWrapperScript(startInfo, customEnvVars, workingDir); - - File.Exists(scriptPath).Should().BeTrue(); - var content = File.ReadAllText(scriptPath); - content.Should().StartWith("#!/bin/bash"); - content.Should().Contain("export ANTHROPIC_API_KEY='sk-test-123'"); - content.Should().Contain("export OTHER_VAR='hello'"); - content.Should().Contain("exec claude --model sonnet --print"); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [Test] - public void ApplyCredentials_MacOS_RewritesStartInfoToUseBsdScript() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - Assert.Ignore("macOS-only test"); - return; - } - - var workingDir = Path.Combine(Path.GetTempPath(), $"test-creds-mac-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var startInfo = new ProcessStartInfo - { - FileName = "claude", - Arguments = "--model sonnet --print", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - - var credentials = new ProcessCredentials - { - Username = "claude", - Password = "claude", - }; - - var customEnvVars = new Dictionary - { - ["ANTHROPIC_API_KEY"] = "sk-test-123", - }; - - ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars, workingDir); - - startInfo.FileName.Should().Be("script"); - startInfo.UserName.Should().BeNullOrEmpty(); - startInfo.RedirectStandardInput.Should().BeTrue(); - - // BSD script: -q, /dev/null, su, -, username, -c, command - startInfo.ArgumentList.Should().HaveCount(7); - startInfo.ArgumentList[0].Should().Be("-q"); - startInfo.ArgumentList[1].Should().Be("/dev/null"); - startInfo.ArgumentList[2].Should().Be("su"); - startInfo.ArgumentList[3].Should().Be("-"); - startInfo.ArgumentList[4].Should().Be("claude"); - startInfo.ArgumentList[5].Should().Be("-c"); - startInfo.ArgumentList[6].Should().Contain("/bin/bash"); - startInfo.ArgumentList[6].Should().Contain("run-claude.sh"); - - // Verify wrapper script was written - var scriptPath = Path.Combine(workingDir, "run-claude.sh"); - File.Exists(scriptPath).Should().BeTrue(); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [Test] - public void ApplyCredentials_Linux_RewritesStartInfoToUseScriptSu() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - Assert.Ignore("Linux-only test"); - return; - } - - var workingDir = Path.Combine(Path.GetTempPath(), $"test-creds-linux-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var startInfo = new ProcessStartInfo - { - FileName = "claude", - Arguments = "--model sonnet --print", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - - var credentials = new ProcessCredentials - { - Username = "claude", - Password = "claude", - }; - - var customEnvVars = new Dictionary - { - ["ANTHROPIC_API_KEY"] = "sk-test-123", - }; - - ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars, workingDir); - - startInfo.FileName.Should().Be("script"); - startInfo.UserName.Should().BeNullOrEmpty(); - startInfo.RedirectStandardInput.Should().BeTrue(); - - // Linux script: -qec, "su - claude -c '...run-claude.sh'", /dev/null - startInfo.ArgumentList.Should().HaveCount(3); - startInfo.ArgumentList[0].Should().Be("-qec"); - startInfo.ArgumentList[2].Should().Be("/dev/null"); - - var suCommand = startInfo.ArgumentList[1]; - suCommand.Should().StartWith("su - claude -c "); - suCommand.Should().Contain("run-claude.sh"); - - // Verify wrapper script was written - var scriptPath = Path.Combine(workingDir, "run-claude.sh"); - File.Exists(scriptPath).Should().BeTrue(); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [Test] - public void ApplyCredentials_NonWindows_ThrowsWhenPasswordMissing() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Ignore("Non-Windows test"); - return; - } - - var workingDir = Path.Combine(Path.GetTempPath(), $"test-creds-nopw-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var startInfo = new ProcessStartInfo { FileName = "claude" }; - var credentials = new ProcessCredentials { Username = "claude", Password = null }; - var customEnvVars = new Dictionary(); - - var act = () => ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars, workingDir); - - act.Should().Throw().WithMessage("*password*"); - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [Test] - public void ApplyCredentials_Windows_SetsUsernameAndPassword() - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Ignore("Windows-only test"); - return; - } - - var workingDir = Path.Combine(Path.GetTempPath(), $"test-creds-win-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var startInfo = new ProcessStartInfo { FileName = "claude" }; - var credentials = new ProcessCredentials - { - Username = "deploy-user", - Password = "s3cret", - Domain = "CORP", - }; - var customEnvVars = new Dictionary(); - - ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars, workingDir); - - startInfo.UserName.Should().Be("deploy-user"); - startInfo.PasswordInClearText.Should().Be("s3cret"); - startInfo.Domain.Should().Be("CORP"); - startInfo.FileName.Should().Be("claude"); // unchanged - } - finally - { - Directory.Delete(workingDir, true); - } - } - - [Test] - public void SetupMcpConfig_WritesEmptyServers_WhenNoneProvided() - { - var workingDir = Path.Combine(Path.GetTempPath(), $"test-mcp-empty-{Path.GetRandomFileName()}"); - Directory.CreateDirectory(workingDir); - - try - { - var configPath = ClaudeCodeCliRunner.SetupMcpConfig(workingDir, new Dictionary()); - - var json = File.ReadAllText(configPath); - var doc = JsonDocument.Parse(json); - doc.RootElement.TryGetProperty("mcpServers", out var mcpServers).Should().BeTrue(); - mcpServers.EnumerateObject().Should().BeEmpty(); - } - finally - { - Directory.Delete(workingDir, true); - } - } -} diff --git a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs index 0fc93e9ea1..2585ee5ccf 100644 --- a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs +++ b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs @@ -7,6 +7,7 @@ namespace Calamari.AiAgent.Tests; [TestFixture] +[Ignore("This test requires real tokens, mainly exists for now for development")] public class RunAgentCommandFixture { [Test] diff --git a/source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs b/source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs index b0a349b460..7620b4b289 100644 --- a/source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs +++ b/source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs @@ -4,18 +4,16 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; -//using Anthropic; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Pipeline; -using Calamari.Common.Plumbing.Variables; using Microsoft.Extensions.AI; using ModelContextProtocol.Client; -using OpenAI; using OpenAI.Chat; +//using Anthropic; using ChatMessage = Microsoft.Extensions.AI.ChatMessage; -namespace Calamari.AiAgent.Behaviours +namespace Calamari.AiAgent.AgentBehaviour { public class InvokeAgentBehaviour : IDeployBehaviour { @@ -139,7 +137,7 @@ public async Task Execute(RunningDeployment context) var lineBuffer = new LineBuffer(line => log.Info(line)); List chatHistory = []; - var systemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill); + var systemPrompt = string.Empty; //variables.Get(SpecialVariables.Action.AiAgent.SystemSkill); if (!string.IsNullOrWhiteSpace(systemPrompt)) { chatHistory.Add(new ChatMessage(ChatRole.System, systemPrompt)); diff --git a/source/Calamari.AiAgent/LineBuffer.cs b/source/Calamari.AiAgent/AgentBehaviour/LineBuffer.cs similarity index 100% rename from source/Calamari.AiAgent/LineBuffer.cs rename to source/Calamari.AiAgent/AgentBehaviour/LineBuffer.cs diff --git a/source/Calamari.AiAgent/Calamari.AiAgent.csproj b/source/Calamari.AiAgent/Calamari.AiAgent.csproj index 96a99776ce..0f125978a6 100644 --- a/source/Calamari.AiAgent/Calamari.AiAgent.csproj +++ b/source/Calamari.AiAgent/Calamari.AiAgent.csproj @@ -32,4 +32,8 @@ + + + + diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs index feb82f5e64..775d56f636 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -2,130 +2,47 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Linq; -using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Logging; -namespace Calamari.AiAgent.Behaviours +namespace Calamari.AiAgent.ClaudeCodeBehaviour { - public class ClaudeCodeCliRunner + public class ClaudeCodeCliRunner(ILog log) { - readonly ILog log; - public ClaudeCodeCliRunner(ILog log) - { - this.log = log; - } - - public async Task RunAsync( - ClaudeCommandArgsBuilder argsBuilder, - string apiToken, - IReadOnlyDictionary mcpServers, - IReadOnlyDictionary deploymentVariables, - ProcessCredentials? runAs = null, - IReadOnlyList? userSkills = null) - { - var workingDir = Path.Combine(Path.GetTempPath(), $"claude-agent-{Guid.NewGuid():N}"); - Directory.CreateDirectory(workingDir); - log.Verbose($"Claude Code working directory: {workingDir}"); - - try - { - SetupSkills(workingDir, userSkills); - SetupDeploymentVariables(workingDir, deploymentVariables); - - var systemPromptPath = SetupSystemPrompt(workingDir); - argsBuilder.WithAppendSystemPromptFile(systemPromptPath); - - var mcpConfigPath = SetupMcpConfig(workingDir, mcpServers); - argsBuilder.WithMcpConfigPath(mcpConfigPath); - - return await RunInDirectoryAsync(argsBuilder, apiToken, workingDir, runAs); - } - finally - { - try { Directory.Delete(workingDir, recursive: true); } - catch { /* best effort cleanup */ } - } - } - - /* - using System.Diagnostics; - - string user = "claude"; - string cmd = "bash"; - string password = "claude"; - - var psi = new ProcessStartInfo { - FileName = "script", - ArgumentList = { "-qec", $"su - {user} -c '{cmd}'", "/dev/null" }, - RedirectStandardInput = true, - RedirectStI andardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - - using var p = Process.Start(psi)!; - p.StandardInput.WriteLine(password); - - - - p.WaitForExit(); - Console.WriteLine($"exit={p.ExitCode}\n{output}"); - - */ - - async Task RunInDirectoryAsync( - ClaudeCommandArgsBuilder argsBuilder, - string apiToken, - string workingDir, - ProcessCredentials? runAs) + public async Task RunAsync(ClaudeCommandArgsBuilder argsBuilder, + Dictionary customEnvVars, + ProcessCredentials? runAs, + string workingDir, + CancellationToken cancellationToken) { var verboseLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-verbose-{Guid.NewGuid():N}.log"); - var debugLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-debug-{Guid.NewGuid():N}.log"); - var fullArgs = argsBuilder.Build(); - fullArgs += $" --debug-file {EscapeArg(debugLogPath)}"; - - log.Verbose($"Claude Code command: claude {fullArgs}"); - var startInfo = new ProcessStartInfo - { - FileName = "claude", - Arguments = fullArgs, - WorkingDirectory = workingDir, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; + var debugLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-debug-{Guid.NewGuid():N}.log"); - var customEnvVars = new Dictionary - { - ["ANTHROPIC_API_KEY"] = apiToken, - }; + log.Verbose($"Claude Code command: claude {argsBuilder.Build()}"); - foreach (var kvp in customEnvVars) - startInfo.Environment[kvp.Key] = kvp.Value; + var runner = new ClaudeCodeProcessStartInfo(); + var process = await runner.StartClaudeProcess(workingDir, + runAs, + argsBuilder.WithDebugLogPath(debugLogPath), + customEnvVars, + cancellationToken); - if (runAs != null) - ApplyCredentials(startInfo, runAs, customEnvVars, workingDir); + var stdoutTask = Task.Run(() => ProcessLine(process, verboseLogPath, cancellationToken), cancellationToken); + var stderrTask = Task.Run(() => ProcessError(process), cancellationToken); - var responseBuilder = new StringBuilder(); - var streamProcessor = new ClaudeCodeStreamProcessor(log, responseBuilder); + await Task.WhenAll(stdoutTask, stderrTask); + await process.WaitForExitAsync(cancellationToken); - try - { - var password = runAs != null && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? runAs.Password : null; - await RunProcess(startInfo, verboseLogPath, streamProcessor, password); - } - catch (Exception e) + if (process.ExitCode != 0) { - log.Error(e.Message); + throw new CommandException($"Claude Code exited with code {process.ExitCode}"); } if (File.Exists(debugLogPath)) @@ -133,214 +50,63 @@ async Task RunInDirectoryAsync( var fileInfo = new FileInfo(debugLogPath); log.NewOctopusArtifact(debugLogPath, "claude-agent-debug.log", fileInfo.Length); } + if (File.Exists(verboseLogPath)) { var fileInfo = new FileInfo(verboseLogPath); log.NewOctopusArtifact(verboseLogPath, "claude-agent-verbose.log", fileInfo.Length); } - return responseBuilder.ToString(); + return stdoutTask.Result.ToString(); } - async Task RunProcess(ProcessStartInfo startInfo, string verboseLogPath, ClaudeCodeStreamProcessor streamProcessor, string? password = null) + async Task ProcessError(Process process) { - using var process = new Process(); - process.StartInfo = startInfo; - process.Start(); - - if (password != null) - { - await process.StandardInput.WriteLineAsync(password); - process.StandardInput.Close(); - } - var stdoutTask = Task.Run(async () => - { - while (await process.StandardOutput.ReadLineAsync() is { } line) - { - await File.AppendAllTextAsync(verboseLogPath, line + "\n"); - streamProcessor.ProcessLine(line); - } - }); - - var stderrTask = Task.Run(async () => - { - var buffer = new char[1024]; - int charsRead; - while ((charsRead = await process.StandardError.ReadAsync(buffer, 0, buffer.Length)) > 0) - { - var text = new string(buffer, 0, charsRead); - log.Verbose(text.TrimEnd()); - } - }); - - await Task.WhenAll(stdoutTask, stderrTask); - await process.WaitForExitAsync(); - - if (process.ExitCode != 0) + var buffer = new char[1024]; + int charsRead; + while ((charsRead = await process.StandardError.ReadAsync(buffer, 0, buffer.Length)) > 0) { - throw new CommandException($"Claude Code exited with code {process.ExitCode}"); + var text = new string(buffer, 0, charsRead); + log.Verbose(text.TrimEnd()); } } - internal static string SetupMcpConfig(string workingDir, IReadOnlyDictionary mcpServers) - { - var config = new { mcpServers }; - var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); - var path = Path.Combine(workingDir, "mcp-config.json"); - File.WriteAllText(path, json); - return path; - } - - const string SkillsResourcePrefix = "Calamari.AiAgent.ClaudeCodeBehaviour.DefaultContext.Skills."; - const string SystemPromptResource = "Calamari.AiAgent.ClaudeCodeBehaviour.DefaultContext.system-prompt.md"; - - internal static string SetupSystemPrompt(string workingDir) - { - var assembly = Assembly.GetExecutingAssembly(); - var path = Path.Combine(workingDir, "system-prompt.md"); - - using var stream = assembly.GetManifestResourceStream(SystemPromptResource); - if (stream != null) - { - using var reader = new StreamReader(stream); - File.WriteAllText(path, reader.ReadToEnd()); - } - - return path; - } - - internal static void SetupDeploymentVariables(string workingDir, IReadOnlyDictionary variables) - { - var json = JsonSerializer.Serialize(variables, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Path.Combine(workingDir, "deployment-variables.json"), json); - } - - internal static void SetupSkills(string workingDir, IReadOnlyList? userSkills = null) + async Task ProcessLine(Process process, string verboseLogPath, CancellationToken cancellationToken) { - var skillsDir = Path.Combine(workingDir, ".claude", "skills"); - Directory.CreateDirectory(skillsDir); - - var assembly = Assembly.GetExecutingAssembly(); - - foreach (var resourceName in assembly.GetManifestResourceNames()) - { - if (!resourceName.StartsWith(SkillsResourcePrefix, StringComparison.Ordinal)) - continue; - - var fileName = resourceName.Substring(SkillsResourcePrefix.Length); - var skillName = Path.GetFileNameWithoutExtension(fileName); - var innerSkillDir = Path.Combine(skillsDir, skillName); - - using var stream = assembly.GetManifestResourceStream(resourceName)!; - using var reader = new StreamReader(stream); - - Directory.CreateDirectory(innerSkillDir); - File.WriteAllText(Path.Combine(innerSkillDir, "SKILL.md"), reader.ReadToEnd()); - } - - if (userSkills != null) + var responseBuilder = new StringBuilder(); + var streamProcessor = new ClaudeCodeStreamProcessor(log, responseBuilder); + var line = string.Empty; + int ch; + while ((ch = process.StandardOutput.Read()) >= 0) { - foreach (var skill in userSkills) + var c = (char)ch; + if (c == '\n') { - var dirName = SanitizeFileName(skill.Name); - var innerSkillDir = Path.GetFullPath(Path.Combine(skillsDir, dirName)); + line = line.TrimEnd('\r'); - if (!innerSkillDir.StartsWith(Path.GetFullPath(skillsDir) + Path.DirectorySeparatorChar, StringComparison.Ordinal)) - throw new CommandException($"Skill name '{skill.Name}' results in a path outside the skills directory."); + await File.AppendAllTextAsync(verboseLogPath, line + "\n", cancellationToken); + + streamProcessor.ProcessLine(line); - Directory.CreateDirectory(innerSkillDir); - File.WriteAllText(Path.Combine(innerSkillDir, "SKILL.md"), skill.Content); + line = ""; + } + else + { + line += c; } } - } - - static readonly HashSet WindowsReservedNames = new(StringComparer.OrdinalIgnoreCase) - { - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", - }; - - internal static string SanitizeFileName(string name) - { - if (string.IsNullOrWhiteSpace(name)) - throw new CommandException("Skill name cannot be empty."); - - var invalid = Path.GetInvalidFileNameChars(); - var sanitized = new StringBuilder(name.Length); - foreach (var c in name) - sanitized.Append(Array.IndexOf(invalid, c) >= 0 ? '-' : c); - - // Strip leading dots to prevent hidden files / relative path tricks - var result = sanitized.ToString().TrimStart('.'); - - if (string.IsNullOrWhiteSpace(result)) - throw new CommandException($"Skill name '{name}' is not a valid file name."); - - if (WindowsReservedNames.Contains(result)) - throw new CommandException($"Skill name '{name}' is a reserved file name."); - - // Filesystem limits are typically 255 bytes; truncate to be safe - if (result.Length > 200) - result = result.Substring(0, 200); - return result; - } - - internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials, Dictionary customEnvVars, string workingDir) - { - // See ADR: https://github.com/OctopusDeploy/adr/blob/main/team-modern-deployments/calamari-ai-agent/adr-001-use-processstartinfo-username-for-user-impersonation.md - // On Windows: uses ProcessStartInfo.UserName with native token-based impersonation - // and optional password/domain. - // On Linux/macOS: uses script(1) + su(1) to launch a login shell as the target user. - // A wrapper script is written to disk with env exports and the command, avoiding - // nested shell escaping. Password is piped via stdin. - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (line != "") { - startInfo.UserName = credentials.Username; - - if (!string.IsNullOrEmpty(credentials.Password)) - startInfo.PasswordInClearText = credentials.Password; - if (!string.IsNullOrEmpty(credentials.Domain)) - startInfo.Domain = credentials.Domain; - - return; - } + line = line.TrimEnd('\r'); - if (string.IsNullOrEmpty(credentials.Password)) - throw new CommandException("A password is required for Linux user impersonation via su"); - - // Write a wrapper script so env vars and the command are expressed as plain - // shell syntax — no nested quoting through script → su → shell layers. - var scriptPath = WriteWrapperScript(startInfo, customEnvVars, workingDir); - - var suArg = $"/bin/bash {scriptPath}"; - - startInfo.FileName = "script"; - startInfo.Arguments = ""; // clear — using ArgumentList instead - - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - // BSD script: script -q /dev/null command args... - startInfo.ArgumentList.Add("-q"); - startInfo.ArgumentList.Add("/dev/null"); - startInfo.ArgumentList.Add("su"); - startInfo.ArgumentList.Add("-"); - startInfo.ArgumentList.Add(credentials.Username); - startInfo.ArgumentList.Add("-c"); - startInfo.ArgumentList.Add(suArg); + await File.AppendAllTextAsync(verboseLogPath, line + "\n", cancellationToken); + streamProcessor.ProcessLine(line); } - else - { - // Linux (util-linux) script: script -qec "command" /dev/null - startInfo.ArgumentList.Add("-qec"); - startInfo.ArgumentList.Add($"su - {credentials.Username} -c {ShellQuote(suArg)}"); - startInfo.ArgumentList.Add("/dev/null"); - } - - startInfo.RedirectStandardInput = true; + return responseBuilder; } - + +/* internal static string WriteWrapperScript(ProcessStartInfo startInfo, Dictionary customEnvVars, string workingDir) { var scriptPath = Path.Combine(workingDir, "run-claude.sh"); @@ -355,28 +121,24 @@ internal static string WriteWrapperScript(ProcessStartInfo startInfo, Dictionary // The directory may have been created with a restrictive umask (e.g. 077). if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - File.SetUnixFileMode(scriptPath, UnixFileMode.UserRead | UnixFileMode.UserWrite - | UnixFileMode.GroupRead | UnixFileMode.OtherRead); - File.SetUnixFileMode(workingDir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute - | UnixFileMode.GroupRead | UnixFileMode.GroupExecute - | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + File.SetUnixFileMode(scriptPath, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.GroupRead + | UnixFileMode.OtherRead); + File.SetUnixFileMode(workingDir, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute); } return scriptPath; } - - internal static string ShellQuote(string value) - { - return "'" + value.Replace("'", @"'\''") + "'"; - } - - static string EscapeArg(string arg) - { - if (arg.IndexOfAny(new[] { ' ', '"', '\\' }) < 0) - return arg; - - return "\"" + arg.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; - } + */ } public record ProcessCredentials diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs new file mode 100644 index 0000000000..30371a7fff --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Octopus.CoreUtilities.Extensions; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour; + +public class ClaudeCodeProcessStartInfo +{ + const string ClaudeCodePath = "claude"; + internal static string ShellQuote(string value) + { + return "'" + value.Replace("'", @"'\''") + "'"; + } + + + async Task StartWindowsProcess(string workingDir, + ProcessCredentials? runAs, + ClaudeCommandArgsBuilder argsBuilder, + Dictionary environmentVariables) + { + var startInfo = StartSimpleProcess(workingDir, argsBuilder, environmentVariables); + + if (runAs != null) + { + startInfo.UserName = runAs.Username; +#pragma warning disable CA1416 + if (runAs.Password != null) + startInfo.PasswordInClearText = runAs.Password; + + if (!string.IsNullOrEmpty(runAs.Domain)) + startInfo.Domain = runAs.Domain; +#pragma warning restore CA1416 + } + + await Task.CompletedTask; + + return Process.Start(startInfo)!; + } + + static ProcessStartInfo StartSimpleProcess(string workingDir, ClaudeCommandArgsBuilder argsBuilder, Dictionary environmentVariables) + { + var startInfo = new ProcessStartInfo + { + FileName = ClaudeCodePath, + Arguments = argsBuilder.Build(), + WorkingDirectory = workingDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var kvp in environmentVariables) + startInfo.Environment[kvp.Key] = kvp.Value; + return startInfo; + } + + public async Task StartClaudeProcess(string workingDir, + ProcessCredentials? runAs, + ClaudeCommandArgsBuilder argsBuilder, + Dictionary environmentVariables, + CancellationToken ct) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return await StartWindowsProcess(workingDir, runAs, argsBuilder, environmentVariables); + } + + return await StartMacOrLinuxProcess(workingDir, + runAs, + argsBuilder, + environmentVariables, + ct); + } + + + [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] + async Task StartMacOrLinuxProcess(string workingDir, + ProcessCredentials? runAs, + ClaudeCommandArgsBuilder argsBuilder, + Dictionary environmentVariables, CancellationToken ct) + { + + var username = runAs?.Username!; + if (runAs == null || string.IsNullOrEmpty(username)) + { + var startInfo1 = StartSimpleProcess(workingDir, argsBuilder, environmentVariables); + var process1 = Process.Start(startInfo1)!; + return process1; + } + + //string cmd = $"ANTHROPIC_API_KEY=XXX claude -p \\\"What OS user am I?\\\""; + string cmd = "ANTHROPIC_API_KEY='XXX' claude -p 'what time is is?' --verbose --output-format stream-json"; + //cmd = $"ANTHROPIC_API_KEY='{environmentVariables["ANTHROPIC_API_KEY"]}' claude --model claude-sonnet-4-20250514 --bare --strict-mcp-config --output-format stream-json --verbose --permission-mode dontAsk --no-session-persistence --debug-file /var/folders/qr/m8j6qgqj0h93xlr5yw5dsgqh0000gn/T/claude-agent-debug-4bf79ef90e5b4e1a83d0494f8eea23b5.log --mcp-config /var/folders/qr/m8j6qgqj0h93xlr5yw5dsgqh0000gn/T/Test_9d362db4f38445339dafc520eff2b45c/mcp-config.json --system-prompt-file /var/folders/qr/m8j6qgqj0h93xlr5yw5dsgqh0000gn/T/Test_9d362db4f38445339dafc520eff2b45c/system-prompt.md --allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,mcp__octopus__*,mcp__github__* --max-turns 10 -p 'Who Am I? Print out the name of the OS user account you are running under.'"; + + var filePath = Path.Combine(workingDir, "my-command.sh"); + File.WriteAllText(Path.Combine(workingDir, "my-command.sh"), $@"#!/bin/bash + cd {workingDir} + claude --model claude-sonnet-4-20250514 --bare --strict-mcp-config --output-format stream-json --verbose -p 'Who Am I? Print out the name of the OS user account you are running under.' +"); + File.SetUnixFileMode(filePath, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupWrite + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute + | UnixFileMode.OtherRead); + File.SetUnixFileMode(workingDir, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupWrite + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute + | UnixFileMode.OtherWrite); + + + //cmd = $"ANTHROPIC_API_KEY='{environmentVariables["ANTHROPIC_API_KEY"]}' ./{filePath}"; + cmd = $"{filePath}"; + var startInfo = new ProcessStartInfo + { + FileName = "script", + WorkingDirectory = workingDir, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + //TODO: Perhaps this to script toa void encoding + var argumentList = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? + new[] { "-q", "/dev/null", "su", "-m", username, "-c", cmd } : + new[] { "-qec", "su", "-", username, "-c", cmd, "/dev/null" }; + startInfo.ArgumentList.AddRange(argumentList); + + foreach (var kvp in environmentVariables) + startInfo.Environment[kvp.Key] = kvp.Value; + + var process = Process.Start(startInfo)!; + Task.Delay(1000, ct).Wait(ct); + // Parse password prompt + var passwordReq = "Password:".Length; + var buff = new char[passwordReq]; + await process.StandardOutput.ReadAsync(buff, 0, passwordReq); + var message = new string(buff); + if(message != "Password:"){ + throw new Exception($"Unexpected startup message: {message}"); + } + await process.StandardInput.WriteLineAsync(runAs!.Password); + if(process.StandardOutput.Read() != '\r' || process.StandardOutput.Read() != '\n'){ + throw new Exception("Expecting new line"); + } + + return process; + } +} \ No newline at end of file diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs deleted file mode 100644 index 92d647f79f..0000000000 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamModels.cs +++ /dev/null @@ -1,432 +0,0 @@ -using System.Collections.Generic; -using System.Runtime.Serialization; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Calamari.AiAgent.Behaviours -{ - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum StreamEventType - { - [EnumMember(Value = "system")] - System, //Infrastructure/metadata events, - - [EnumMember(Value = "assistant")] - Assistant, // A message produced by the model. - [EnumMember(Value = "user")] - User, // A message in the user turn, which covers more than just human input: - [EnumMember(Value = "result")] - Result // The terminal event, summarizing the completed session. - } - - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum ContentBlockType - { - [EnumMember(Value = "text")] - Text, // plain text response - [EnumMember(Value = "thinking")] - Thinking, // internal chain-of-thought (with a signature field for verification) - [EnumMember(Value = "redacted_thinking")] - RedactedThinking, - [EnumMember(Value = "tool_use")] - ToolUse, // the model invoking a tool (e.g. calling the Skill tool) - [EnumMember(Value = "tool_result")] - ToolResult, - [EnumMember(Value = "server_tool_use")] - ServerToolUse, - [EnumMember(Value = "server_tool_result")] - ServerToolResult - } - - public record StreamEvent - { - [JsonPropertyName("type")] - public string? Type { get; init; } - - [JsonPropertyName("session_id")] - public string? SessionId { get; init; } - - [JsonPropertyName("uuid")] - public string? Uuid { get; init; } - } - - public record SystemStreamEvent : StreamEvent - { - [JsonPropertyName("subtype")] - public string? Subtype { get; init; } - - // api_retry fields - [JsonPropertyName("attempt")] - public int? Attempt { get; init; } - - [JsonPropertyName("retry_delay_ms")] - public int? RetryDelayMs { get; init; } - - [JsonPropertyName("error")] - public string? Error { get; init; } - - [JsonPropertyName("error_status")] - public int? ErrorStatus { get; init; } - - // hook_started / hook_response fields - [JsonPropertyName("hook_id")] - public string? HookId { get; init; } - - [JsonPropertyName("hook_name")] - public string? HookName { get; init; } - - [JsonPropertyName("hook_event")] - public string? HookEvent { get; init; } - - [JsonPropertyName("output")] - public string? Output { get; init; } - - [JsonPropertyName("stdout")] - public string? Stdout { get; init; } - - [JsonPropertyName("stderr")] - public string? Stderr { get; init; } - - [JsonPropertyName("exit_code")] - public int? ExitCode { get; init; } - - [JsonPropertyName("outcome")] - public string? Outcome { get; init; } - - // init fields - [JsonPropertyName("cwd")] - public string? Cwd { get; init; } - - [JsonPropertyName("tools")] - public IReadOnlyList? Tools { get; init; } - - [JsonPropertyName("mcp_servers")] - public IReadOnlyList? McpServers { get; init; } - - [JsonPropertyName("model")] - public string? Model { get; init; } - - [JsonPropertyName("permissionMode")] - public string? PermissionMode { get; init; } - - [JsonPropertyName("slash_commands")] - public IReadOnlyList? SlashCommands { get; init; } - - [JsonPropertyName("apiKeySource")] - public string? ApiKeySource { get; init; } - - [JsonPropertyName("claude_code_version")] - public string? ClaudeCodeVersion { get; init; } - - [JsonPropertyName("output_style")] - public string? OutputStyle { get; init; } - - [JsonPropertyName("agents")] - public IReadOnlyList? Agents { get; init; } - - [JsonPropertyName("skills")] - public IReadOnlyList? Skills { get; init; } - - [JsonPropertyName("plugins")] - public IReadOnlyList? Plugins { get; init; } - - [JsonPropertyName("fast_mode_state")] - public string? FastModeState { get; init; } - } - - public record McpServerStatus - { - [JsonPropertyName("name")] - public string? Name { get; init; } - - [JsonPropertyName("status")] - public string? Status { get; init; } - } - - public record PluginInfo - { - [JsonPropertyName("name")] - public string? Name { get; init; } - - [JsonPropertyName("path")] - public string? Path { get; init; } - - [JsonPropertyName("source")] - public string? Source { get; init; } - } - - public record AssistantStreamEvent : StreamEvent - { - [JsonPropertyName("message")] - public StreamMessage? Message { get; init; } - - [JsonPropertyName("parent_tool_use_id")] - public string? ParentToolUseId { get; init; } - } - - public record UserStreamEvent : StreamEvent - { - [JsonPropertyName("message")] - public StreamMessage? Message { get; init; } - - [JsonPropertyName("parent_tool_use_id")] - public string? ParentToolUseId { get; init; } - - [JsonPropertyName("timestamp")] - public string? Timestamp { get; init; } - - //[JsonPropertyName("tool_use_result")] - //public string? ToolUseResult { get; init; } - - [JsonPropertyName("isSynthetic")] - public bool? IsSynthetic { get; init; } - } - - public record ResultStreamEvent : StreamEvent - { - [JsonPropertyName("subtype")] - public string? Subtype { get; init; } - - [JsonPropertyName("is_error")] - public bool? IsError { get; init; } - - [JsonPropertyName("result")] - public string? Result { get; init; } - - [JsonPropertyName("stop_reason")] - public string? StopReason { get; init; } - - [JsonPropertyName("cost_usd")] - public double? CostUsd { get; init; } - - [JsonPropertyName("total_cost_usd")] - public double? TotalCostUsd { get; init; } - - [JsonPropertyName("duration_ms")] - public double? DurationMs { get; init; } - - [JsonPropertyName("duration_api_ms")] - public double? DurationApiMs { get; init; } - - [JsonPropertyName("num_turns")] - public int? NumTurns { get; init; } - - [JsonPropertyName("usage")] - public ResultUsageInfo? Usage { get; init; } - - [JsonPropertyName("modelUsage")] - public IReadOnlyDictionary? ModelUsage { get; init; } - - [JsonPropertyName("permission_denials")] - public IReadOnlyList? PermissionDenials { get; init; } - - [JsonPropertyName("fast_mode_state")] - public string? FastModeState { get; init; } - } - - public record PermissionDenial - { - [JsonPropertyName("tool_name")] - public string? ToolName { get; init; } - - [JsonPropertyName("tool_use_id")] - public string? ToolUseId { get; init; } - - [JsonPropertyName("tool_input")] - public JsonElement? ToolInput { get; init; } - } - - public record StreamMessage - { - [JsonPropertyName("model")] - public string? Model { get; init; } - - [JsonPropertyName("id")] - public string? Id { get; init; } - - [JsonPropertyName("role")] - public string? Role { get; init; } - - [JsonPropertyName("stop_reason")] - public string? StopReason { get; init; } - - [JsonPropertyName("stop_sequence")] - public string? StopSequence { get; init; } - - [JsonPropertyName("usage")] - public MessageUsageInfo? Usage { get; init; } - - [JsonPropertyName("content")] - public JsonElement[]? Content { get; init; } - } - - public record ContentBlock - { - [JsonPropertyName("type")] - public string? Type { get; init; } - } - - public record TextContentBlock : ContentBlock - { - [JsonPropertyName("text")] - public string? Text { get; init; } - } - - public record ThinkingContentBlock : ContentBlock - { - [JsonPropertyName("thinking")] - public string? Thinking { get; init; } - - [JsonPropertyName("signature")] - public string? Signature { get; init; } - } - - public record RedactedThinkingContentBlock : ContentBlock; - - public record ToolUseContentBlock : ContentBlock - { - [JsonPropertyName("name")] - public string? Name { get; init; } - - [JsonPropertyName("id")] - public string? Id { get; init; } - - [JsonPropertyName("input")] - public JsonElement? Input { get; init; } - - [JsonPropertyName("caller")] - public ToolUseCaller? Caller { get; init; } - } - - public record ToolUseCaller - { - [JsonPropertyName("type")] - public string? Type { get; init; } - } - - public record ToolResultContentBlock : ContentBlock - { - [JsonPropertyName("tool_use_id")] - public string? ToolUseId { get; init; } - - [JsonPropertyName("name")] - public string? Name { get; init; } - - [JsonPropertyName("is_error")] - public bool? IsError { get; init; } - - [JsonPropertyName("content")] - public JsonElement? Content { get; init; } - } - - public record ServerToolUseContentBlock : ContentBlock - { - [JsonPropertyName("name")] - public string? Name { get; init; } - } - - public record ServerToolResultContentBlock : ContentBlock - { - [JsonPropertyName("name")] - public string? Name { get; init; } - } - - public record MessageUsageInfo - { - [JsonPropertyName("input_tokens")] - public int? InputTokens { get; init; } - - [JsonPropertyName("output_tokens")] - public int? OutputTokens { get; init; } - - [JsonPropertyName("cache_read_input_tokens")] - public int? CacheReadInputTokens { get; init; } - - [JsonPropertyName("cache_creation_input_tokens")] - public int? CacheCreationInputTokens { get; init; } - - [JsonPropertyName("cache_creation")] - public CacheCreationInfo? CacheCreation { get; init; } - - [JsonPropertyName("service_tier")] - public string? ServiceTier { get; init; } - - [JsonPropertyName("inference_geo")] - public string? InferenceGeo { get; init; } - } - - public record ResultUsageInfo - { - [JsonPropertyName("input_tokens")] - public int? InputTokens { get; init; } - - [JsonPropertyName("output_tokens")] - public int? OutputTokens { get; init; } - - [JsonPropertyName("cache_read_input_tokens")] - public int? CacheReadInputTokens { get; init; } - - [JsonPropertyName("cache_creation_input_tokens")] - public int? CacheCreationInputTokens { get; init; } - - [JsonPropertyName("server_tool_use")] - public ServerToolUseUsage? ServerToolUse { get; init; } - - [JsonPropertyName("service_tier")] - public string? ServiceTier { get; init; } - - [JsonPropertyName("cache_creation")] - public CacheCreationInfo? CacheCreation { get; init; } - - [JsonPropertyName("inference_geo")] - public string? InferenceGeo { get; init; } - - [JsonPropertyName("speed")] - public string? Speed { get; init; } - } - - public record ServerToolUseUsage - { - [JsonPropertyName("web_search_requests")] - public int? WebSearchRequests { get; init; } - - [JsonPropertyName("web_fetch_requests")] - public int? WebFetchRequests { get; init; } - } - - public record CacheCreationInfo - { - [JsonPropertyName("ephemeral_5m_input_tokens")] - public int? Ephemeral5mInputTokens { get; init; } - - [JsonPropertyName("ephemeral_1h_input_tokens")] - public int? Ephemeral1hInputTokens { get; init; } - } - - public record ModelUsageInfo - { - [JsonPropertyName("inputTokens")] - public int? InputTokens { get; init; } - - [JsonPropertyName("outputTokens")] - public int? OutputTokens { get; init; } - - [JsonPropertyName("cacheReadInputTokens")] - public int? CacheReadInputTokens { get; init; } - - [JsonPropertyName("cacheCreationInputTokens")] - public int? CacheCreationInputTokens { get; init; } - - [JsonPropertyName("webSearchRequests")] - public int? WebSearchRequests { get; init; } - - [JsonPropertyName("costUSD")] - public double? CostUsd { get; init; } - - [JsonPropertyName("contextWindow")] - public int? ContextWindow { get; init; } - - [JsonPropertyName("maxOutputTokens")] - public int? MaxOutputTokens { get; init; } - } -} \ No newline at end of file diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs index 4ab3d12c0e..90d471b8ba 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs @@ -2,10 +2,11 @@ using System.Collections.Generic; using System.Text; using System.Text.Json; +using Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.ServiceMessages; -namespace Calamari.AiAgent.Behaviours +namespace Calamari.AiAgent.ClaudeCodeBehaviour { public class ClaudeCodeStreamProcessor { @@ -32,6 +33,7 @@ public void ProcessLine(string json) } catch (JsonException) { + log.Verbose(json); return; } catch (Exception ex) @@ -205,14 +207,14 @@ static bool Assign(ContentBlockType val, out ContentBlockType result, bool succe void HandleUserMessage(UserStreamEvent? message) { - if (message is null || message?.Message != null) + if (message is null || message?.Message == null) return; - if (message!.IsSynthetic == true) + if (message.IsSynthetic == true) { return; //TODO: Still log } - HandleMessageEvent(message?.Message); + HandleMessageEvent(message.Message, logText: false); } void HandleResultEvent(ResultStreamEvent evt) diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs index 6869f15b5c..fff24bbc30 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCommandArgsBuilder.cs @@ -3,15 +3,15 @@ using System.Globalization; using System.Text; -namespace Calamari.AiAgent.Behaviours +namespace Calamari.AiAgent.ClaudeCodeBehaviour { public class ClaudeCommandArgsBuilder { string? prompt; string? model; - string? systemPrompt; + string? systemPromptFile; string? mcpConfigPath; - string? appendSystemPromptFile; + string? debugLogPath; int maxTurns = 10; decimal? maxBudgetUsd; IReadOnlyList? allowedTools; @@ -22,6 +22,12 @@ public ClaudeCommandArgsBuilder WithPrompt(string prompt) this.prompt = prompt; return this; } + + public ClaudeCommandArgsBuilder WithDebugLogPath(string debugLogPath) + { + this.debugLogPath = debugLogPath; + return this; + } public ClaudeCommandArgsBuilder WithModel(string model) { @@ -29,9 +35,9 @@ public ClaudeCommandArgsBuilder WithModel(string model) return this; } - public ClaudeCommandArgsBuilder WithSystemPrompt(string systemPrompt) + public ClaudeCommandArgsBuilder WithSystemPromptFile(string systemPromptFile) { - this.systemPrompt = systemPrompt; + this.systemPromptFile = systemPromptFile; return this; } @@ -47,12 +53,6 @@ public ClaudeCommandArgsBuilder WithMcpConfigPath(string mcpConfigPath) return this; } - public ClaudeCommandArgsBuilder WithAppendSystemPromptFile(string path) - { - this.appendSystemPromptFile = path; - return this; - } - public ClaudeCommandArgsBuilder WithMaxBudgetUsd(decimal budgetUsd) { this.maxBudgetUsd = budgetUsd; @@ -87,16 +87,22 @@ public string Build() args.Append(" --permission-mode dontAsk"); args.Append(" --no-session-persistence"); + if (!string.IsNullOrWhiteSpace(debugLogPath)) + { + args.Append(" --debug-file "); + args.Append(EscapeArg(debugLogPath)); + } + if (!string.IsNullOrWhiteSpace(mcpConfigPath)) { args.Append(" --mcp-config "); args.Append(EscapeArg(mcpConfigPath)); } - if (!string.IsNullOrWhiteSpace(appendSystemPromptFile)) + if (!string.IsNullOrWhiteSpace(systemPromptFile)) { - args.Append(" --append-system-prompt-file "); - args.Append(EscapeArg(appendSystemPromptFile)); + args.Append(" --system-prompt-file "); + args.Append(EscapeArg(systemPromptFile)); } if (allowedTools != null && allowedTools.Count > 0) @@ -113,12 +119,6 @@ public string Build() if (!string.IsNullOrWhiteSpace(effort)) args.Append($" --effort {effort}"); - if (!string.IsNullOrWhiteSpace(systemPrompt)) - { - args.Append(" --system-prompt "); - args.Append(EscapeArg(systemPrompt)); - } - args.Append(" -p "); args.Append(EscapeArg(prompt)); diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs index 52d284b6b6..fe3634f623 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs @@ -1,23 +1,27 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.Linq; +using System.IO; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Calamari.Common.Commands; +using Calamari.Common.Plumbing.FileSystem; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Pipeline; using Calamari.Common.Plumbing.Variables; -namespace Calamari.AiAgent.Behaviours +namespace Calamari.AiAgent.ClaudeCodeBehaviour { public class InvokeClaudeCodeBehaviour : IDeployBehaviour { readonly ILog log; + readonly INonSensitiveVariables nonSensitiveVariables; - public InvokeClaudeCodeBehaviour(ILog log) + public InvokeClaudeCodeBehaviour(ILog log, INonSensitiveVariables nonSensitiveVariables) { this.log = log; + this.nonSensitiveVariables = nonSensitiveVariables; } public bool IsEnabled(RunningDeployment context) @@ -45,27 +49,11 @@ public async Task Execute(RunningDeployment context) log.Info($"Invoking Claude Code CLI with model '{model}'..."); - var mcpServers = BuildMcpServers(variables); var runAs = BuildRunAs(variables); - var defaultAllowedTools = new[] { "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch" }; - var allowedToolsRaw = variables.Get(SpecialVariables.Action.AiAgent.AllowedTools); - var allowedTools = new List(!string.IsNullOrWhiteSpace(allowedToolsRaw) - ? allowedToolsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - : defaultAllowedTools); - - // Auto-allow all tools from configured MCP servers - foreach (var serverName in mcpServers.Keys) - allowedTools.Add($"mcp__{serverName}__*"); - var argsBuilder = new ClaudeCommandArgsBuilder() - .WithPrompt(prompt) - .WithModel(model) - .WithAllowedTools(allowedTools); - - var systemPrompt = variables.Get(SpecialVariables.Action.AiAgent.SystemSkill); - if (!string.IsNullOrWhiteSpace(systemPrompt)) - argsBuilder.WithSystemPrompt(systemPrompt); + .WithPrompt(prompt) + .WithModel(model); var maxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTurns); if (maxTurns.HasValue) @@ -80,106 +68,50 @@ public async Task Execute(RunningDeployment context) if (!string.IsNullOrWhiteSpace(effort)) argsBuilder.WithEffort(effort); - var userSkills = BuildUserSkills(variables); - var deploymentVariables = BuildDeploymentVariables(variables); - - var runner = new ClaudeCodeCliRunner(log); - var response = await runner.RunAsync(argsBuilder, apiToken, mcpServers, deploymentVariables, runAs, userSkills); + using var tempDir = TemporaryDirectory.Create(); + var workingDir = tempDir.DirectoryPath; + log.Verbose($"Claude Code working directory: {workingDir}"); + + // TODO: THis should be moved up higher in execution Chain. + var cancellationToken = new CancellationTokenSource(); + var mcpWriter = new McpWriter(variables); + var mcpConfig = mcpWriter.SetupMcpConfig(workingDir); + + var allowedTools = AllowedTools(variables); + allowedTools.AddRange(mcpWriter.GetAllowedTools()); + argsBuilder = argsBuilder.WithAllowedTools(allowedTools); + + new SkillsWriter(variables).SetupSkills(workingDir); + SetupDeploymentVariables(workingDir); + argsBuilder.WithSystemPromptFile(new SystemPromptWriter().WriteSystemPromptFile(workingDir)); + argsBuilder.WithMcpConfigPath(mcpConfig); + + var customEnvVars = new Dictionary + { + ["ANTHROPIC_API_KEY"] = apiToken, + }; + + var response = await new ClaudeCodeCliRunner(log).RunAsync(argsBuilder, customEnvVars, runAs, workingDir, + cancellationToken.Token); Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); log.Info("Claude Code invocation complete."); } - static Dictionary BuildMcpServers(IVariables variables) + static List AllowedTools(IVariables variables) { - var servers = new Dictionary(); - var path = Environment.GetEnvironmentVariable("PATH") ?? ""; - - // Octopus MCP server is always added when a token is available - var octopusToken = variables.Get(SpecialVariables.Action.AiAgent.OctopusToken); - if (!string.IsNullOrWhiteSpace(octopusToken)) - { - var octopusServerUrl = variables.Get("Octopus.Web.ServerUri"); - if (string.IsNullOrWhiteSpace(octopusServerUrl)) - { - Log.Warn("Unable to find Octopus Server URL"); - } - else - { - Log.Verbose("Octopus Server URL: " + octopusServerUrl); - servers["octopus"] = new McpServerConfig - { - Command = "npx", - Args = new[] { "-y", "@octopusdeploy/mcp-server" }, - Env = new Dictionary - { - ["OCTOPUS_SERVER_URL"] = octopusServerUrl, - ["OCTOPUS_API_KEY"] = octopusToken, - ["PATH"] = path, - }, - }; - } - } - - // User-configured MCP servers from JSON variable - var mcpServersJson = variables.Get(SpecialVariables.Action.AiAgent.McpServers); - if (!string.IsNullOrWhiteSpace(mcpServersJson)) - { - List? entries; - try - { - entries = JsonSerializer.Deserialize>(mcpServersJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - } - catch (JsonException ex) - { - throw new CommandException($"Failed to parse MCP servers configuration: {ex.Message}"); - } - - if (entries != null) - { - foreach (var entry in entries) - { - if (string.IsNullOrWhiteSpace(entry.Name)) - throw new CommandException("Each MCP server must have a name."); - if (string.IsNullOrWhiteSpace(entry.Command)) - throw new CommandException($"MCP server '{entry.Name}' must have a command."); - - var env = entry.Env != null - ? new Dictionary(entry.Env) - : new Dictionary(); - - if (!env.ContainsKey("PATH")) - env["PATH"] = path; - - servers[entry.Name] = new McpServerConfig - { - Type = entry.Type ?? "stdio", - Command = entry.Command, - Args = entry.Args, - Env = env, - }; - Log.Verbose($"MCP server '{entry.Name}' added."); - } - } - } - - return servers; + var defaultAllowedTools = new[] { "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch" }; + var allowedToolsRaw = variables.Get(SpecialVariables.Action.AiAgent.AllowedTools); + var allowedTools = new List(!string.IsNullOrWhiteSpace(allowedToolsRaw) + ? allowedToolsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : defaultAllowedTools); + return allowedTools; } - static List BuildUserSkills(IVariables variables) + void SetupDeploymentVariables(string workingDir) { - var skills = new List(); - var indexes = variables.GetIndexes(SpecialVariables.Action.AiAgent.Skills); - foreach (var index in indexes) - { - var prefix = $"{SpecialVariables.Action.AiAgent.Skills}[{index}]."; - var name = variables.Get(prefix + SpecialVariables.Action.AiAgent.SkillName); - var content = variables.Get(prefix + SpecialVariables.Action.AiAgent.SkillContent); - - if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(content)) - skills.Add(new UserSkill { Name = name, Content = content }); - } - return skills; + var json = JsonSerializer.Serialize(nonSensitiveVariables, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(Path.Combine(workingDir, "deployment-variables.json"), json); } static ProcessCredentials? BuildRunAs(IVariables variables) @@ -194,14 +126,5 @@ static List BuildUserSkills(IVariables variables) Password = variables.Get(SpecialVariables.Action.AiAgent.RunAsPassword), }; } - - static readonly string[] SensitiveKeywords = { "password", "secret", "token", "apikey", "api_key", "api-key", "private" }; - - static Dictionary BuildDeploymentVariables(IVariables variables) - { - return variables - .Where(kvp => !SensitiveKeywords.Any(k => kvp.Key.Contains(k, StringComparison.OrdinalIgnoreCase))) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } } -} +} \ No newline at end of file diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/AssistantStreamEvent.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/AssistantStreamEvent.cs new file mode 100644 index 0000000000..7fd38debde --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/AssistantStreamEvent.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; + +public record AssistantStreamEvent : StreamEvent +{ + [JsonPropertyName("message")] + public StreamMessage? Message { get; init; } + + [JsonPropertyName("parent_tool_use_id")] + public string? ParentToolUseId { get; init; } +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlockType.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlockType.cs new file mode 100644 index 0000000000..d61f44fdb9 --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlockType.cs @@ -0,0 +1,29 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ContentBlockType +{ + [EnumMember(Value = "text")] + Text, + + [EnumMember(Value = "thinking")] + Thinking, + + [EnumMember(Value = "redacted_thinking")] + RedactedThinking, + + [EnumMember(Value = "tool_use")] + ToolUse, + + [EnumMember(Value = "tool_result")] + ToolResult, + + [EnumMember(Value = "server_tool_use")] + ServerToolUse, + + [EnumMember(Value = "server_tool_result")] + ServerToolResult +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlocks.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlocks.cs new file mode 100644 index 0000000000..fd26c08433 --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ContentBlocks.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; + +public record ContentBlock +{ + [JsonPropertyName("type")] + public string? Type { get; init; } +} + +public record TextContentBlock : ContentBlock +{ + [JsonPropertyName("text")] + public string? Text { get; init; } +} + +public record ThinkingContentBlock : ContentBlock +{ + [JsonPropertyName("thinking")] + public string? Thinking { get; init; } + + [JsonPropertyName("signature")] + public string? Signature { get; init; } +} + +public record RedactedThinkingContentBlock : ContentBlock; + +public record ToolUseContentBlock : ContentBlock +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + + [JsonPropertyName("input")] + public JsonElement? Input { get; init; } + + [JsonPropertyName("caller")] + public ToolUseCaller? Caller { get; init; } +} + +public record ToolUseCaller +{ + [JsonPropertyName("type")] + public string? Type { get; init; } +} + +public record ToolResultContentBlock : ContentBlock +{ + [JsonPropertyName("tool_use_id")] + public string? ToolUseId { get; init; } + + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("is_error")] + public bool? IsError { get; init; } + + [JsonPropertyName("content")] + public JsonElement? Content { get; init; } +} + +public record ServerToolUseContentBlock : ContentBlock +{ + [JsonPropertyName("name")] + public string? Name { get; init; } +} + +public record ServerToolResultContentBlock : ContentBlock +{ + [JsonPropertyName("name")] + public string? Name { get; init; } +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ResultStreamEvent.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ResultStreamEvent.cs new file mode 100644 index 0000000000..699d99ea98 --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/ResultStreamEvent.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; + +public record ResultStreamEvent : StreamEvent +{ + [JsonPropertyName("subtype")] + public string? Subtype { get; init; } + + [JsonPropertyName("is_error")] + public bool? IsError { get; init; } + + [JsonPropertyName("result")] + public string? Result { get; init; } + + [JsonPropertyName("stop_reason")] + public string? StopReason { get; init; } + + [JsonPropertyName("cost_usd")] + public double? CostUsd { get; init; } + + [JsonPropertyName("total_cost_usd")] + public double? TotalCostUsd { get; init; } + + [JsonPropertyName("duration_ms")] + public double? DurationMs { get; init; } + + [JsonPropertyName("duration_api_ms")] + public double? DurationApiMs { get; init; } + + [JsonPropertyName("num_turns")] + public int? NumTurns { get; init; } + + [JsonPropertyName("usage")] + public ResultUsageInfo? Usage { get; init; } + + [JsonPropertyName("modelUsage")] + public IReadOnlyDictionary? ModelUsage { get; init; } + + [JsonPropertyName("permission_denials")] + public IReadOnlyList? PermissionDenials { get; init; } + + [JsonPropertyName("fast_mode_state")] + public string? FastModeState { get; init; } +} + +public record PermissionDenial +{ + [JsonPropertyName("tool_name")] + public string? ToolName { get; init; } + + [JsonPropertyName("tool_use_id")] + public string? ToolUseId { get; init; } + + [JsonPropertyName("tool_input")] + public JsonElement? ToolInput { get; init; } +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEvent.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEvent.cs new file mode 100644 index 0000000000..c688cabe2c --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEvent.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; + +public record StreamEvent +{ + [JsonPropertyName("type")] + public string? Type { get; init; } + + [JsonPropertyName("session_id")] + public string? SessionId { get; init; } + + [JsonPropertyName("uuid")] + public string? Uuid { get; init; } +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEventType.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEventType.cs new file mode 100644 index 0000000000..b0ce847407 --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamEventType.cs @@ -0,0 +1,20 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum StreamEventType +{ + [EnumMember(Value = "system")] + System, + + [EnumMember(Value = "assistant")] + Assistant, + + [EnumMember(Value = "user")] + User, + + [EnumMember(Value = "result")] + Result +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamMessage.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamMessage.cs new file mode 100644 index 0000000000..770b9b48bb --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/StreamMessage.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; + +public record StreamMessage +{ + [JsonPropertyName("model")] + public string? Model { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + + [JsonPropertyName("role")] + public string? Role { get; init; } + + [JsonPropertyName("stop_reason")] + public string? StopReason { get; init; } + + [JsonPropertyName("stop_sequence")] + public string? StopSequence { get; init; } + + [JsonPropertyName("usage")] + public MessageUsageInfo? Usage { get; init; } + + [JsonPropertyName("content")] + public JsonElement[]? Content { get; init; } +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/SystemStreamEvent.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/SystemStreamEvent.cs new file mode 100644 index 0000000000..5d85efc68b --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/SystemStreamEvent.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; + +public record SystemStreamEvent : StreamEvent +{ + [JsonPropertyName("subtype")] + public string? Subtype { get; init; } + + [JsonPropertyName("attempt")] + public int? Attempt { get; init; } + + [JsonPropertyName("retry_delay_ms")] + public int? RetryDelayMs { get; init; } + + [JsonPropertyName("error")] + public string? Error { get; init; } + + [JsonPropertyName("error_status")] + public int? ErrorStatus { get; init; } + + [JsonPropertyName("hook_id")] + public string? HookId { get; init; } + + [JsonPropertyName("hook_name")] + public string? HookName { get; init; } + + [JsonPropertyName("hook_event")] + public string? HookEvent { get; init; } + + [JsonPropertyName("output")] + public string? Output { get; init; } + + [JsonPropertyName("stdout")] + public string? Stdout { get; init; } + + [JsonPropertyName("stderr")] + public string? Stderr { get; init; } + + [JsonPropertyName("exit_code")] + public int? ExitCode { get; init; } + + [JsonPropertyName("outcome")] + public string? Outcome { get; init; } + + [JsonPropertyName("cwd")] + public string? Cwd { get; init; } + + [JsonPropertyName("tools")] + public IReadOnlyList? Tools { get; init; } + + [JsonPropertyName("mcp_servers")] + public IReadOnlyList? McpServers { get; init; } + + [JsonPropertyName("model")] + public string? Model { get; init; } + + [JsonPropertyName("permissionMode")] + public string? PermissionMode { get; init; } + + [JsonPropertyName("slash_commands")] + public IReadOnlyList? SlashCommands { get; init; } + + [JsonPropertyName("apiKeySource")] + public string? ApiKeySource { get; init; } + + [JsonPropertyName("claude_code_version")] + public string? ClaudeCodeVersion { get; init; } + + [JsonPropertyName("output_style")] + public string? OutputStyle { get; init; } + + [JsonPropertyName("agents")] + public IReadOnlyList? Agents { get; init; } + + [JsonPropertyName("skills")] + public IReadOnlyList? Skills { get; init; } + + [JsonPropertyName("plugins")] + public IReadOnlyList? Plugins { get; init; } + + [JsonPropertyName("fast_mode_state")] + public string? FastModeState { get; init; } +} + +public record McpServerStatus +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("status")] + public string? Status { get; init; } +} + +public record PluginInfo +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("path")] + public string? Path { get; init; } + + [JsonPropertyName("source")] + public string? Source { get; init; } +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UsageInfo.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UsageInfo.cs new file mode 100644 index 0000000000..17c879035a --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UsageInfo.cs @@ -0,0 +1,102 @@ +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; + +public record MessageUsageInfo +{ + [JsonPropertyName("input_tokens")] + public int? InputTokens { get; init; } + + [JsonPropertyName("output_tokens")] + public int? OutputTokens { get; init; } + + [JsonPropertyName("cache_read_input_tokens")] + public int? CacheReadInputTokens { get; init; } + + [JsonPropertyName("cache_creation_input_tokens")] + public int? CacheCreationInputTokens { get; init; } + + [JsonPropertyName("cache_creation")] + public CacheCreationInfo? CacheCreation { get; init; } + + [JsonPropertyName("service_tier")] + public string? ServiceTier { get; init; } + + [JsonPropertyName("inference_geo")] + public string? InferenceGeo { get; init; } +} + +public record ResultUsageInfo +{ + [JsonPropertyName("input_tokens")] + public int? InputTokens { get; init; } + + [JsonPropertyName("output_tokens")] + public int? OutputTokens { get; init; } + + [JsonPropertyName("cache_read_input_tokens")] + public int? CacheReadInputTokens { get; init; } + + [JsonPropertyName("cache_creation_input_tokens")] + public int? CacheCreationInputTokens { get; init; } + + [JsonPropertyName("server_tool_use")] + public ServerToolUseUsage? ServerToolUse { get; init; } + + [JsonPropertyName("service_tier")] + public string? ServiceTier { get; init; } + + [JsonPropertyName("cache_creation")] + public CacheCreationInfo? CacheCreation { get; init; } + + [JsonPropertyName("inference_geo")] + public string? InferenceGeo { get; init; } + + [JsonPropertyName("speed")] + public string? Speed { get; init; } +} + +public record ModelUsageInfo +{ + [JsonPropertyName("inputTokens")] + public int? InputTokens { get; init; } + + [JsonPropertyName("outputTokens")] + public int? OutputTokens { get; init; } + + [JsonPropertyName("cacheReadInputTokens")] + public int? CacheReadInputTokens { get; init; } + + [JsonPropertyName("cacheCreationInputTokens")] + public int? CacheCreationInputTokens { get; init; } + + [JsonPropertyName("webSearchRequests")] + public int? WebSearchRequests { get; init; } + + [JsonPropertyName("costUSD")] + public double? CostUsd { get; init; } + + [JsonPropertyName("contextWindow")] + public int? ContextWindow { get; init; } + + [JsonPropertyName("maxOutputTokens")] + public int? MaxOutputTokens { get; init; } +} + +public record ServerToolUseUsage +{ + [JsonPropertyName("web_search_requests")] + public int? WebSearchRequests { get; init; } + + [JsonPropertyName("web_fetch_requests")] + public int? WebFetchRequests { get; init; } +} + +public record CacheCreationInfo +{ + [JsonPropertyName("ephemeral_5m_input_tokens")] + public int? Ephemeral5mInputTokens { get; init; } + + [JsonPropertyName("ephemeral_1h_input_tokens")] + public int? Ephemeral1hInputTokens { get; init; } +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UserStreamEvent.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UserStreamEvent.cs new file mode 100644 index 0000000000..9bec884b89 --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/JsonResponseModels/UserStreamEvent.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; + +public record UserStreamEvent : StreamEvent +{ + [JsonPropertyName("message")] + public StreamMessage? Message { get; init; } + + [JsonPropertyName("parent_tool_use_id")] + public string? ParentToolUseId { get; init; } + + [JsonPropertyName("timestamp")] + public string? Timestamp { get; init; } + + [JsonPropertyName("isSynthetic")] + public bool? IsSynthetic { get; init; } +} diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs new file mode 100644 index 0000000000..bf6e2de609 --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Logging; +using Calamari.Common.Plumbing.Variables; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour; + +public class McpWriter(IVariables variables) +{ + static readonly string ConfigName = "mcp-config.json"; + + public string SetupMcpConfig(string workingDir) + { + var mcpServers = BuildMcpServers(); + var config = new { mcpServers }; + var json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }); + var path = Path.Combine(workingDir, ConfigName); + File.WriteAllText(path, json); + return path; + } + + public IEnumerable GetAllowedTools() + { + var mcpServers = GetCustomMcpServers(); + + // TODO: Use explicitly allowed MCP tools + return mcpServers.Select(serverName => $"mcp__{serverName.Name}__*"); + } + + Dictionary BuildMcpServers() + { + var path = Environment.GetEnvironmentVariable("PATH") ?? ""; + + var servers = AddCustomMcpServer(path); + AddOctopusMcp(servers, path); + return servers; + } + + void AddOctopusMcp(Dictionary servers, string path) + { + var octopusToken = variables.Get(SpecialVariables.Action.AiAgent.OctopusToken); + if (string.IsNullOrWhiteSpace(octopusToken)) + return; + + + // Octopus MCP server is always added when a token is available + var octopusServerUrl = variables.Get(SpecialVariables.Web.ServerUri); + if (string.IsNullOrWhiteSpace(octopusServerUrl)) + { + Log.Warn("Unable to find Octopus Server URL"); + } + else + { + Log.Verbose("Octopus Server URL: " + octopusServerUrl); + servers["octopus"] = new McpServerConfig + { + Command = "npx", + Args = new[] { "-y", "@octopusdeploy/mcp-server" }, + Env = new Dictionary + { + ["OCTOPUS_SERVER_URL"] = octopusServerUrl, + ["OCTOPUS_API_KEY"] = octopusToken, + ["PATH"] = path, + }, + }; + } + } + + List GetCustomMcpServers() + { + var mcpServersJson = variables.Get(SpecialVariables.Action.AiAgent.McpServers); + if (string.IsNullOrWhiteSpace(mcpServersJson)) + { + return new List(); + } + + try + { + var customServers = JsonSerializer.Deserialize>(mcpServersJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + return customServers ?? new List(); + } + catch (JsonException ex) + { + throw new CommandException($"Failed to parse MCP servers configuration: {ex.Message}"); + } + } + + Dictionary AddCustomMcpServer(string path) + { + var entries = GetCustomMcpServers(); + + var mcpServerConfigs = new Dictionary(); + if (entries.Any()) + { + foreach (var entry in entries) + { + if (string.IsNullOrWhiteSpace(entry.Name)) + throw new CommandException("Each MCP server must have a name."); + if (string.IsNullOrWhiteSpace(entry.Command)) + throw new CommandException($"MCP server '{entry.Name}' must have a command."); + + var env = entry.Env != null + ? new Dictionary(entry.Env) + : new Dictionary(); + + if (!env.ContainsKey("PATH")) + env["PATH"] = path; + + mcpServerConfigs[entry.Name] = new McpServerConfig + { + Type = entry.Type ?? "stdio", + Command = entry.Command, + Args = entry.Args, + Env = env, + }; + Log.Verbose($"MCP server '{entry.Name}' added."); + } + } + + return mcpServerConfigs; + } + + + + +} \ No newline at end of file diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs new file mode 100644 index 0000000000..090981e546 --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using Calamari.Common.Commands; +using Calamari.Common.Plumbing.Variables; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour; + +public class SkillsWriter(IVariables variables) +{ + const string SkillsResourcePrefix = "Calamari.AiAgent.ClaudeCodeBehaviour.DefaultContext.Skills."; + + public void SetupSkills(string workingDir) + { + var skillsDir = Path.Combine(workingDir, ".claude", "skills"); + Directory.CreateDirectory(skillsDir); + + CreateSysemSkillFiles(skillsDir); + CreateUserSkillFiles(skillsDir); + } + + static void CreateSysemSkillFiles(string skillsDir) + { + var assembly = Assembly.GetExecutingAssembly(); + foreach (var resourceName in assembly.GetManifestResourceNames()) + { + if (!resourceName.StartsWith(SkillsResourcePrefix, StringComparison.Ordinal)) + continue; + + var fileName = resourceName.Substring(SkillsResourcePrefix.Length); + var skillName = Path.GetFileNameWithoutExtension(fileName); + var innerSkillDir = Path.Combine(skillsDir, skillName); + + using var stream = assembly.GetManifestResourceStream(resourceName)!; + using var reader = new StreamReader(stream); + + Directory.CreateDirectory(innerSkillDir); + File.WriteAllText(Path.Combine(innerSkillDir, "SKILL.md"), reader.ReadToEnd()); + } + } + + void CreateUserSkillFiles(string skillsDir) + { + var userSkills = BuildUserSkills(); + + foreach (var skill in userSkills) + { + var dirName = SanitizeFileName(skill.Name); + var innerSkillDir = Path.GetFullPath(Path.Combine(skillsDir, dirName)); + + if (!innerSkillDir.StartsWith(Path.GetFullPath(skillsDir) + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + throw new CommandException($"Skill name '{skill.Name}' results in a path outside the skills directory."); + + Directory.CreateDirectory(innerSkillDir); + File.WriteAllText(Path.Combine(innerSkillDir, "SKILL.md"), skill.Content); + } + } + + List BuildUserSkills() + { + var skills = new List(); + var indexes = variables.GetIndexes(SpecialVariables.Action.AiAgent.Skills); + foreach (var index in indexes) + { + var prefix = $"{SpecialVariables.Action.AiAgent.Skills}[{index}]."; + var name = variables.Get(prefix + SpecialVariables.Action.AiAgent.SkillName); + var content = variables.Get(prefix + SpecialVariables.Action.AiAgent.SkillContent); + + if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(content)) + skills.Add(new UserSkill { Name = name, Content = content }); + } + return skills; + } + + static readonly HashSet WindowsReservedNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + }; + + internal static string SanitizeFileName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new CommandException("Skill name cannot be empty."); + + var invalid = Path.GetInvalidFileNameChars(); + var sanitized = new StringBuilder(name.Length); + foreach (var c in name) + { + if (Array.IndexOf(invalid, c) >= 0 || char.IsControl(c) || c is '<' or '>' or ':' or '"' or '|' or '?' or '*' or '\\') + sanitized.Append('-'); + else + sanitized.Append(c); + } + + // Strip leading dots to prevent hidden files / relative path tricks + var result = sanitized.ToString().TrimStart('.'); + + if (string.IsNullOrWhiteSpace(result)) + throw new CommandException($"Skill name '{name}' is not a valid file name."); + + if (WindowsReservedNames.Contains(result)) + throw new CommandException($"Skill name '{name}' is a reserved file name."); + + // Filesystem limits are typically 255 bytes; truncate to be safe + if (result.Length > 200) + result = result.Substring(0, 200); + + return result; + } +} \ No newline at end of file diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/SystemPromptWriter.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SystemPromptWriter.cs new file mode 100644 index 0000000000..abd526ca2b --- /dev/null +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SystemPromptWriter.cs @@ -0,0 +1,26 @@ +using System; +using System.IO; +using System.Reflection; + +namespace Calamari.AiAgent.ClaudeCodeBehaviour; + +public class SystemPromptWriter +{ + public string WriteSystemPromptFile(string workingDir) + { + var res = $"{GetType().Namespace}.DefaultContext.system-prompt.md"; + var assembly = Assembly.GetExecutingAssembly(); + var path = Path.Combine(workingDir, "system-prompt.md"); + + using var stream = assembly.GetManifestResourceStream(res); + if (stream == null) + { + throw new Exception($"Could not find expected system prompt embedded resource."); + } + + using var reader = new StreamReader(stream); + File.WriteAllText(path, reader.ReadToEnd()); + + return path; + } +} \ No newline at end of file diff --git a/source/Calamari.AiAgent/RunAgentCommand.cs b/source/Calamari.AiAgent/RunAgentCommand.cs index 32f0a7f821..05c669a60c 100644 --- a/source/Calamari.AiAgent/RunAgentCommand.cs +++ b/source/Calamari.AiAgent/RunAgentCommand.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using Calamari.AiAgent.Behaviours; +using Calamari.AiAgent.ClaudeCodeBehaviour; using Calamari.Common.Commands; using Calamari.Common.Plumbing.Pipeline; diff --git a/source/Calamari.AiAgent/SpecialVariables.cs b/source/Calamari.AiAgent/SpecialVariables.cs index 93448b25d8..5e3245ad53 100644 --- a/source/Calamari.AiAgent/SpecialVariables.cs +++ b/source/Calamari.AiAgent/SpecialVariables.cs @@ -2,6 +2,12 @@ namespace Calamari.AiAgent { public static class SpecialVariables { + public static class Web + { + + public const string ServerUri = "Octopus.Web.ServerUri"; + } + public static class Action { public static class AiAgent @@ -11,7 +17,6 @@ public static class AiAgent public const string Model = "Octopus.Action.Claude.Model"; public const string Response = "Octopus.Action.Claude.Response"; public const string McpServers = "Octopus.Action.Claude.McpServers"; - public const string SystemSkill = "Octopus.Action.Claude.SystemSkill"; public const string MaxTurns = "Octopus.Action.Claude.MaxTurns"; public const string MaxBudgetUsd = "Octopus.Action.Claude.MaxBudgetUsd"; public const string OctopusToken = "Octopus.Action.Claude.OctopusToken"; From 737f4bd42a6d126d654dc21ff639581307ab5217 Mon Sep 17 00:00:00 2001 From: robert Date: Sun, 14 Jun 2026 21:56:26 +1000 Subject: [PATCH 19/26] Fiddling with perms --- .../RunAgentCommandFixture.cs | 20 ++++- .../ClaudeCodeCliRunner.cs | 21 ++++-- .../ClaudeCodeProcessStartInfo.cs | 75 ++++++++++++++----- .../InvokeClaudeCodeBehaviour.cs | 8 +- 4 files changed, 97 insertions(+), 27 deletions(-) diff --git a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs index 2585ee5ccf..06c506bf53 100644 --- a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs +++ b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs @@ -7,7 +7,7 @@ namespace Calamari.AiAgent.Tests; [TestFixture] -[Ignore("This test requires real tokens, mainly exists for now for development")] + public class RunAgentCommandFixture { [Test] @@ -86,6 +86,24 @@ public async Task ClaudeCode_SucceedsWithWebFetch() result.WasSuccessful.Should().BeTrue(); result.FullLog.Should().Contain("origin"); } + + [Test] + [Category("Integration")] + public async Task ClaudeCode_RunsOn_RunsUnderAnotherAccount() + { + var result = await CommandTestBuilder.CreateAsync() + .WithArrange(context => + { + context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.AiAgent.RunAsUsername, "test-user"); + context.Variables.Add(SpecialVariables.Action.AiAgent.RunAsPassword, "supersecret"); + context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "get the currently executing process user"); + }) + .Execute(assertWasSuccess: false); + + result.WasSuccessful.Should().BeTrue(); + result.FullLog.Should().Contain("origin"); + } [Test] [Category("Integration")] diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs index 775d56f636..58f2352d6e 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -20,11 +20,17 @@ public async Task RunAsync(ClaudeCommandArgsBuilder argsBuilder, Dictionary customEnvVars, ProcessCredentials? runAs, string workingDir, + string calamariDir, //RunAs might not be able to access this dir.. but we need to preserve the logs. CancellationToken cancellationToken) { - var verboseLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-verbose-{Guid.NewGuid():N}.log"); - var debugLogPath = Path.Combine(Path.GetTempPath(), $"claude-agent-debug-{Guid.NewGuid():N}.log"); - + + var logDir = Directory.CreateDirectory(Path.Combine(workingDir, "log")); + var verboseLogPath = Path.Combine(logDir.FullName, $"claude-agent-verbose-{Guid.NewGuid():N}.log"); + var debugLogPath = Path.Combine(logDir.FullName, $"claude-agent-debug-{Guid.NewGuid():N}.log"); + + // Temporarily here while working out the user process issues + //await File.Create(debugLogPath).DisposeAsync(); + log.Verbose($"Claude Code command: claude {argsBuilder.Build()}"); var runner = new ClaudeCodeProcessStartInfo(); @@ -45,16 +51,21 @@ public async Task RunAsync(ClaudeCommandArgsBuilder argsBuilder, throw new CommandException($"Claude Code exited with code {process.ExitCode}"); } + Directory.CreateDirectory(Path.Combine(calamariDir, "log")); if (File.Exists(debugLogPath)) { var fileInfo = new FileInfo(debugLogPath); - log.NewOctopusArtifact(debugLogPath, "claude-agent-debug.log", fileInfo.Length); + var movedFilePath = Path.Combine(calamariDir, "log", fileInfo.Name); + fileInfo.MoveTo(movedFilePath); + log.NewOctopusArtifact(movedFilePath, "claude-agent-debug.log", fileInfo.Length); } if (File.Exists(verboseLogPath)) { var fileInfo = new FileInfo(verboseLogPath); - log.NewOctopusArtifact(verboseLogPath, "claude-agent-verbose.log", fileInfo.Length); + var movedFilePath = Path.Combine(calamariDir, "log", fileInfo.Name); + fileInfo.MoveTo(movedFilePath); + log.NewOctopusArtifact(movedFilePath, "claude-agent-verbose.log", fileInfo.Length); } return stdoutTask.Result.ToString(); diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs index 30371a7fff..8216ae6039 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeProcessStartInfo.cs @@ -12,7 +12,9 @@ namespace Calamari.AiAgent.ClaudeCodeBehaviour; public class ClaudeCodeProcessStartInfo { + //TODO: Should this be configurable? const string ClaudeCodePath = "claude"; + internal static string ShellQuote(string value) { return "'" + value.Replace("'", @"'\''") + "'"; @@ -79,11 +81,12 @@ public async Task StartClaudeProcess(string workingDir, } - [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] + [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Higher up checks enforce the correct OS")] async Task StartMacOrLinuxProcess(string workingDir, ProcessCredentials? runAs, ClaudeCommandArgsBuilder argsBuilder, - Dictionary environmentVariables, CancellationToken ct) + Dictionary environmentVariables, + CancellationToken ct) { var username = runAs?.Username!; @@ -93,16 +96,12 @@ async Task StartMacOrLinuxProcess(string workingDir, var process1 = Process.Start(startInfo1)!; return process1; } - - //string cmd = $"ANTHROPIC_API_KEY=XXX claude -p \\\"What OS user am I?\\\""; - string cmd = "ANTHROPIC_API_KEY='XXX' claude -p 'what time is is?' --verbose --output-format stream-json"; - //cmd = $"ANTHROPIC_API_KEY='{environmentVariables["ANTHROPIC_API_KEY"]}' claude --model claude-sonnet-4-20250514 --bare --strict-mcp-config --output-format stream-json --verbose --permission-mode dontAsk --no-session-persistence --debug-file /var/folders/qr/m8j6qgqj0h93xlr5yw5dsgqh0000gn/T/claude-agent-debug-4bf79ef90e5b4e1a83d0494f8eea23b5.log --mcp-config /var/folders/qr/m8j6qgqj0h93xlr5yw5dsgqh0000gn/T/Test_9d362db4f38445339dafc520eff2b45c/mcp-config.json --system-prompt-file /var/folders/qr/m8j6qgqj0h93xlr5yw5dsgqh0000gn/T/Test_9d362db4f38445339dafc520eff2b45c/system-prompt.md --allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,mcp__octopus__*,mcp__github__* --max-turns 10 -p 'Who Am I? Print out the name of the OS user account you are running under.'"; var filePath = Path.Combine(workingDir, "my-command.sh"); - File.WriteAllText(Path.Combine(workingDir, "my-command.sh"), $@"#!/bin/bash - cd {workingDir} - claude --model claude-sonnet-4-20250514 --bare --strict-mcp-config --output-format stream-json --verbose -p 'Who Am I? Print out the name of the OS user account you are running under.' -"); + await File.WriteAllTextAsync(Path.Combine(workingDir, "my-command.sh"), $@"#!/bin/bash +cd {workingDir} +{ClaudeCodePath} {argsBuilder.Build()} +", ct); File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite @@ -112,6 +111,8 @@ async Task StartMacOrLinuxProcess(string workingDir, | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute | UnixFileMode.OtherRead); + + File.SetUnixFileMode(workingDir, UnixFileMode.UserRead | UnixFileMode.UserWrite @@ -122,10 +123,7 @@ async Task StartMacOrLinuxProcess(string workingDir, | UnixFileMode.OtherRead | UnixFileMode.OtherExecute | UnixFileMode.OtherWrite); - - - //cmd = $"ANTHROPIC_API_KEY='{environmentVariables["ANTHROPIC_API_KEY"]}' ./{filePath}"; - cmd = $"{filePath}"; + var startInfo = new ProcessStartInfo { FileName = "script", @@ -136,18 +134,27 @@ async Task StartMacOrLinuxProcess(string workingDir, UseShellExecute = false, CreateNoWindow = true, }; - //TODO: Perhaps this to script toa void encoding + var argumentList = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? - new[] { "-q", "/dev/null", "su", "-m", username, "-c", cmd } : - new[] { "-qec", "su", "-", username, "-c", cmd, "/dev/null" }; + new[] { "-q", "/dev/null", "su", "-m", username, "-c", filePath } : + new[] { "-qec", "su", "-", username, "-c", filePath, "/dev/null" }; startInfo.ArgumentList.AddRange(argumentList); foreach (var kvp in environmentVariables) startInfo.Environment[kvp.Key] = kvp.Value; - + //SetPermissionsRecursively(workingDir); + var o = Process.Start("chmod", ["-R", "777", workingDir]); + await o.WaitForExitAsync(ct); + if(o.ExitCode != 0) + throw new Exception($"Failed to set permissions on working directory: {workingDir}"); + + var process = Process.Start(startInfo)!; - Task.Delay(1000, ct).Wait(ct); - // Parse password prompt + + // TODO: Should just wait as long as it takes to read "Password:" below + await Task.Delay(1000, ct).WaitAsync(ct); + + // Parse password prompt so consuming code can ignore this initial password check. var passwordReq = "Password:".Length; var buff = new char[passwordReq]; await process.StandardOutput.ReadAsync(buff, 0, passwordReq); @@ -162,4 +169,32 @@ async Task StartMacOrLinuxProcess(string workingDir, return process; } + + [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] + static void SetPermissionsRecursively(string path) + { + var dirMode = UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupWrite + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute + | UnixFileMode.OtherWrite; + var fileMode = UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupWrite + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute + | UnixFileMode.OtherRead; + + new DirectoryInfo(path).UnixFileMode = dirMode; + foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + File.SetUnixFileMode(file, fileMode); + foreach (var dir in Directory.EnumerateDirectories(path, "*", SearchOption.AllDirectories)) + new DirectoryInfo(dir).UnixFileMode = dirMode; + } } \ No newline at end of file diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs index fe3634f623..6260ababc3 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs @@ -68,7 +68,12 @@ public async Task Execute(RunningDeployment context) if (!string.IsNullOrWhiteSpace(effort)) argsBuilder.WithEffort(effort); - using var tempDir = TemporaryDirectory.Create(); + + using var tempDir = TemporaryDirectory.Create(); + //TODO: Fiddling with workdir for user perms + //new TemporaryDirectory($"/tmp/{Guid.NewGuid():N}"); + //Directory.CreateDirectory(tempDir.DirectoryPath); + var workingDir = tempDir.DirectoryPath; log.Verbose($"Claude Code working directory: {workingDir}"); @@ -92,6 +97,7 @@ public async Task Execute(RunningDeployment context) }; var response = await new ClaudeCodeCliRunner(log).RunAsync(argsBuilder, customEnvVars, runAs, workingDir, + context.CurrentDirectory, cancellationToken.Token); Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); From bee2396761fe678ea83f89b5a8649117dac30377 Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 15 Jun 2026 14:43:27 +1000 Subject: [PATCH 20/26] Remove old Generic Agent invocation --- .../2026-06-08-linux-su-impersonation.md | 385 ------------------ .../AgentBehaviour/InvokeAgentBehaviour.cs | 194 --------- .../AgentBehaviour/LineBuffer.cs | 47 --- .../Calamari.AiAgent/Calamari.AiAgent.csproj | 8 - 4 files changed, 634 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-08-linux-su-impersonation.md delete mode 100644 source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs delete mode 100644 source/Calamari.AiAgent/AgentBehaviour/LineBuffer.cs diff --git a/docs/superpowers/plans/2026-06-08-linux-su-impersonation.md b/docs/superpowers/plans/2026-06-08-linux-su-impersonation.md deleted file mode 100644 index 9c9131cab4..0000000000 --- a/docs/superpowers/plans/2026-06-08-linux-su-impersonation.md +++ /dev/null @@ -1,385 +0,0 @@ -# Linux User Impersonation via `script`/`su` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace `ProcessStartInfo.UserName` with `script`/`su` for Linux user impersonation, keeping Windows behaviour unchanged. - -**Architecture:** On Linux, `ApplyCredentials` rewrites the `ProcessStartInfo` to launch `script -qec "su - {user} -c '{envVars} {cmd}'" /dev/null` and pipes the password via stdin. Environment variables explicitly added to `startInfo.Environment` are inlined into the `su -c` command string (since `su -` starts a login shell that clears inherited env). `RunProcess` gains a password parameter to write to stdin. Windows path is untouched. - -**Tech Stack:** C# / .NET, `System.Diagnostics.Process`, `System.Runtime.InteropServices.RuntimeInformation` - ---- - -### Task 1: Track explicitly-set environment variables - -Currently `ANTHROPIC_API_KEY` is set directly on `startInfo.Environment`, which inherits the full parent environment. We need to know which keys were *explicitly added* so we can inline only those into the `su -c` command. - -**Files:** -- Modify: `source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs:89-110` - -- [ ] **Step 1: Introduce a list to track custom env vars in `RunInDirectoryAsync`** - -Change the environment setup from: - -```csharp -startInfo.Environment["ANTHROPIC_API_KEY"] = apiToken; -``` - -To: - -```csharp -var customEnvVars = new Dictionary -{ - ["ANTHROPIC_API_KEY"] = apiToken, -}; - -foreach (var kvp in customEnvVars) - startInfo.Environment[kvp.Key] = kvp.Value; -``` - -This is a no-op refactor — behaviour is identical. The `customEnvVars` dictionary will be passed to `ApplyCredentials` in the next task. - -- [ ] **Step 2: Run existing tests to confirm no regression** - -Run: `dotnet test source/Calamari.AiAgent.Tests/ --filter "FullyQualifiedName~ClaudeCodeCliRunnerFixture" --no-build -v quiet` -Expected: All tests pass (these test static helpers, not process launch) - -- [ ] **Step 3: Commit** - -```bash -git add source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs -git commit -m "refactor: track explicitly-set env vars in RunInDirectoryAsync" -``` - ---- - -### Task 2: Add shell-quoting helper - -The `su -c` command requires values to be safely quoted to avoid injection. We need a helper that single-quotes a string, escaping any embedded single quotes. - -**Files:** -- Modify: `source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs` (add new static method) -- Test: `source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs` - -- [ ] **Step 1: Write failing tests for ShellQuote** - -Add to `ClaudeCodeCliRunnerFixture.cs`: - -```csharp -[TestCase("simple", "'simple'")] -[TestCase("has space", "'has space'")] -[TestCase("it's", @"'it'\''s'")] -[TestCase("", "''")] -[TestCase("a'b'c", @"'a'\''b'\''c'")] -public void ShellQuote_QuotesCorrectly(string input, string expected) -{ - ClaudeCodeCliRunner.ShellQuote(input).Should().Be(expected); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `dotnet test source/Calamari.AiAgent.Tests/ --filter "FullyQualifiedName~ShellQuote" -v quiet` -Expected: FAIL — `ShellQuote` does not exist - -- [ ] **Step 3: Implement ShellQuote** - -Add to `ClaudeCodeCliRunner`: - -```csharp -internal static string ShellQuote(string value) -{ - return "'" + value.Replace("'", @"'\''") + "'"; -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `dotnet test source/Calamari.AiAgent.Tests/ --filter "FullyQualifiedName~ShellQuote" -v quiet` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs -git commit -m "feat: add ShellQuote helper for safe single-quoting in su commands" -``` - ---- - -### Task 3: Rewrite `ApplyCredentials` with Linux `script`/`su` path - -**Files:** -- Modify: `source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs:277-294` -- Test: `source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs` - -- [ ] **Step 1: Write failing tests for Linux ApplyCredentials** - -Add to `ClaudeCodeCliRunnerFixture.cs`: - -```csharp -[Test] -public void ApplyCredentials_Linux_RewritesStartInfoToUseScriptSu() -{ - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - Assert.Ignore("Linux-only test"); - return; - } - - var startInfo = new ProcessStartInfo - { - FileName = "claude", - Arguments = "--model sonnet --print", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - - var credentials = new ProcessCredentials - { - Username = "claude", - Password = "claude", - }; - - var customEnvVars = new Dictionary - { - ["ANTHROPIC_API_KEY"] = "sk-test-123", - ["OTHER_VAR"] = "hello", - }; - - ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); - - startInfo.FileName.Should().Be("script"); - startInfo.UserName.Should().BeNull(); - startInfo.RedirectStandardInput.Should().BeTrue(); - - // ArgumentList should be: -qec, "su - claude -c '...'", /dev/null - startInfo.ArgumentList.Should().HaveCount(3); - startInfo.ArgumentList[0].Should().Be("-qec"); - startInfo.ArgumentList[2].Should().Be("/dev/null"); - - var suCommand = startInfo.ArgumentList[1]; - suCommand.Should().StartWith("su - claude -c "); - suCommand.Should().Contain("ANTHROPIC_API_KEY="); - suCommand.Should().Contain("OTHER_VAR="); - suCommand.Should().Contain("claude --model sonnet --print"); -} - -[Test] -public void ApplyCredentials_Linux_ThrowsWhenPasswordMissing() -{ - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - Assert.Ignore("Linux-only test"); - return; - } - - var startInfo = new ProcessStartInfo { FileName = "claude" }; - var credentials = new ProcessCredentials { Username = "claude", Password = null }; - var customEnvVars = new Dictionary(); - - var act = () => ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); - - act.Should().Throw().WithMessage("*password*"); -} - -[Test] -public void ApplyCredentials_Windows_SetsUsernameAndPassword() -{ - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Assert.Ignore("Windows-only test"); - return; - } - - var startInfo = new ProcessStartInfo { FileName = "claude" }; - var credentials = new ProcessCredentials - { - Username = "deploy-user", - Password = "s3cret", - Domain = "CORP", - }; - var customEnvVars = new Dictionary(); - - ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); - - startInfo.UserName.Should().Be("deploy-user"); - startInfo.PasswordInClearText.Should().Be("s3cret"); - startInfo.Domain.Should().Be("CORP"); - startInfo.FileName.Should().Be("claude"); // unchanged -} -``` - -Also add these using statements at the top of the test file if not already present: - -```csharp -using System.Diagnostics; -using System.Runtime.InteropServices; -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `dotnet test source/Calamari.AiAgent.Tests/ --filter "FullyQualifiedName~ApplyCredentials" -v quiet` -Expected: FAIL — signature mismatch (new `customEnvVars` parameter) - -- [ ] **Step 3: Implement the new ApplyCredentials** - -Replace `ApplyCredentials` in `ClaudeCodeCliRunner.cs`: - -```csharp -internal static void ApplyCredentials(ProcessStartInfo startInfo, ProcessCredentials credentials, Dictionary customEnvVars) -{ - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - startInfo.UserName = credentials.Username; - - if (!string.IsNullOrEmpty(credentials.Password)) - startInfo.PasswordInClearText = credentials.Password; - if (!string.IsNullOrEmpty(credentials.Domain)) - startInfo.Domain = credentials.Domain; - - return; - } - - // Linux: use script/su to impersonate the user with a proper login shell. - // su - starts a login shell which clears the environment, so we inline - // any custom env vars into the command string. - if (string.IsNullOrEmpty(credentials.Password)) - throw new CommandException("A password is required for Linux user impersonation via su"); - - var envPrefix = string.Join(" ", customEnvVars.Select(kvp => $"{kvp.Key}={ShellQuote(kvp.Value)}")); - var innerCommand = string.IsNullOrEmpty(envPrefix) - ? $"{startInfo.FileName} {startInfo.Arguments}" - : $"{envPrefix} {startInfo.FileName} {startInfo.Arguments}"; - - var suCommand = $"su - {credentials.Username} -c {ShellQuote(innerCommand)}"; - - startInfo.FileName = "script"; - startInfo.Arguments = ""; // clear — using ArgumentList instead - startInfo.ArgumentList.Add("-qec"); - startInfo.ArgumentList.Add(suCommand); - startInfo.ArgumentList.Add("/dev/null"); - startInfo.RedirectStandardInput = true; - startInfo.UserName = null; -} -``` - -- [ ] **Step 4: Update the call site in RunInDirectoryAsync** - -Change the call from: - -```csharp -if (runAs != null) - ApplyCredentials(startInfo, runAs); -``` - -To: - -```csharp -if (runAs != null) - ApplyCredentials(startInfo, runAs, customEnvVars); -``` - -- [ ] **Step 5: Run all tests** - -Run: `dotnet test source/Calamari.AiAgent.Tests/ --filter "FullyQualifiedName~ClaudeCodeCliRunnerFixture" -v quiet` -Expected: All pass (platform-guarded tests will run on the current OS, others will be ignored) - -- [ ] **Step 6: Commit** - -```bash -git add source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs source/Calamari.AiAgent.Tests/ClaudeCodeCliRunnerFixture.cs -git commit -m "feat: use script/su for Linux user impersonation instead of ProcessStartInfo.UserName" -``` - ---- - -### Task 4: Pipe password to stdin in `RunProcess` - -`RunProcess` needs to write the password to stdin when running under `script`/`su` on Linux. - -**Files:** -- Modify: `source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs:117,138-170` - -- [ ] **Step 1: Add password parameter to RunProcess** - -Change the signature from: - -```csharp -async Task RunProcess(ProcessStartInfo startInfo, string verboseLogPath, ClaudeCodeStreamProcessor streamProcessor) -``` - -To: - -```csharp -async Task RunProcess(ProcessStartInfo startInfo, string verboseLogPath, ClaudeCodeStreamProcessor streamProcessor, string? password = null) -``` - -- [ ] **Step 2: Add stdin writing after process.Start()** - -Add immediately after `process.Start();`: - -```csharp -if (password != null) -{ - await process.StandardInput.WriteLineAsync(password); - process.StandardInput.Close(); -} -``` - -- [ ] **Step 3: Update the call site in RunInDirectoryAsync** - -Change the call from: - -```csharp -await RunProcess(startInfo, verboseLogPath, streamProcessor); -``` - -To: - -```csharp -var password = runAs != null && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? runAs.Password : null; -await RunProcess(startInfo, verboseLogPath, streamProcessor, password); -``` - -- [ ] **Step 4: Run all tests** - -Run: `dotnet test source/Calamari.AiAgent.Tests/ -v quiet` -Expected: All pass - -- [ ] **Step 5: Commit** - -```bash -git add source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs -git commit -m "feat: pipe password to stdin for Linux su-based impersonation" -``` - ---- - -### Task 5: Update ADR comment - -The code references an ADR about using `ProcessStartInfo.UserName` on all platforms. The comment should reflect the new approach. - -**Files:** -- Modify: `source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs:277-284` - -- [ ] **Step 1: Update the comment block in ApplyCredentials** - -Replace the existing comment with: - -```csharp -// See ADR: https://github.com/OctopusDeploy/adr/blob/main/team-modern-deployments/calamari-ai-agent/adr-001-use-processstartinfo-username-for-user-impersonation.md -// On Windows: uses ProcessStartInfo.UserName with native token-based impersonation -// and optional password/domain. -// On Linux: uses script(1) + su(1) to launch a login shell as the target user. -// Environment variables are inlined into the su -c command since login shells -// clear the inherited environment. Password is piped via stdin. -``` - -- [ ] **Step 2: Commit** - -```bash -git add source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs -git commit -m "docs: update ApplyCredentials comment to reflect Linux script/su approach" -``` diff --git a/source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs b/source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs deleted file mode 100644 index 7620b4b289..0000000000 --- a/source/Calamari.AiAgent/AgentBehaviour/InvokeAgentBehaviour.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Calamari.Common.Commands; -using Calamari.Common.Plumbing.Logging; -using Calamari.Common.Plumbing.Pipeline; -using Microsoft.Extensions.AI; -using ModelContextProtocol.Client; -using OpenAI.Chat; -//using Anthropic; -using ChatMessage = Microsoft.Extensions.AI.ChatMessage; - -namespace Calamari.AiAgent.AgentBehaviour -{ - public class InvokeAgentBehaviour : IDeployBehaviour - { - readonly ILog log; - - public InvokeAgentBehaviour(ILog log) - { - this.log = log; - } - - public bool IsEnabled(RunningDeployment context) - { - return false; - //var provider = context.Variables.Get(SpecialVariables.Action.AiAgent.Provider); - //return provider == "Anthropic" || provider == "OpenAI"; - } - - public async Task Execute(RunningDeployment context) - { - var variables = context.Variables; - - var prompt = variables.Get(SpecialVariables.Action.AiAgent.Prompt); - if (string.IsNullOrWhiteSpace(prompt)) - throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.Prompt}' is required but was not provided."); - - var apiToken = variables.Get(SpecialVariables.Action.AiAgent.ApiToken); - if (string.IsNullOrWhiteSpace(apiToken)) - throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.ApiToken}' is required but was not provided."); - - var model = variables.Get(SpecialVariables.Action.AiAgent.Model); - if (string.IsNullOrWhiteSpace(model)) - model = "gpt-4o"; - - log.Info($"Invoking AI agent with model '{model}'..."); - - var provider = "Anthropic";//variables.Get(SpecialVariables.Action.AiAgent.Provider); - IChatClient chatClient; - if (provider == "Anthropic") - { - chatClient = new Anthropic.AnthropicClient { ApiKey = apiToken } - .AsIChatClient(model) - .AsBuilder() - .UseFunctionInvocation() - .Build(); - - } - else if (provider == "OpenAI") - { - chatClient = new ChatClient(model, apiToken) - .AsIChatClient() - .AsBuilder() - .UseFunctionInvocation() - .Build(); - } - else - { - throw new Exception($"Provider {provider} not supported"); - } - - - var tools = new List(); - McpClient? mcpClient = null; - - var githubToken = variables.Get("Octopus.Action.Claude.GitHubToken"); - if (!string.IsNullOrWhiteSpace(githubToken)) - { - log.Info("Connecting to GitHub MCP server..."); - mcpClient = await McpClient.CreateAsync( - new StdioClientTransport(new StdioClientTransportOptions - { - Command = "npx", - Arguments = ["-y", "@modelcontextprotocol/server-github"], - Name = "GitHub", - EnvironmentVariables = new Dictionary - { - ["GITHUB_PERSONAL_ACCESS_TOKEN"] = githubToken, - ["PATH"] = Environment.GetEnvironmentVariable("PATH"), - }, - })); - var mcpTools = await mcpClient.ListToolsAsync(); - tools.AddRange(mcpTools); - log.Info($"GitHub MCP server connected. {tools.Count} tools available."); - } - - var octopusToken = variables.Get(SpecialVariables.Action.AiAgent.OctopusToken); - if (!string.IsNullOrWhiteSpace(octopusToken)) - { - var mcpClient2 = await McpClient.CreateAsync(new StdioClientTransport(new StdioClientTransportOptions() - { - Command = "npx", - Arguments = ["-y", "@octopusdeploy/mcp-server", ], - Name = "Octopus", - EnvironmentVariables = new Dictionary - { - ["OCTOPUS_SERVER_URL"] ="http://localhost:8065", - ["OCTOPUS_API_KEY"] = octopusToken, - ["PATH"] = Environment.GetEnvironmentVariable("PATH"), - } - })); - var mcpTools2 = await mcpClient2.ListToolsAsync(); - tools.AddRange(mcpTools2); - log.Info($"Octopus MCP server connected. {tools.Count} tools available."); - - } - - tools.Add(AIFunctionFactory.Create(() => - { - var sensitiveKeywords = new[] { "password", "secret", "token", "apikey", "api_key", "api-key", "private" }; - var filtered = variables - .Where(kvp => !sensitiveKeywords.Any(k => kvp.Key.Contains(k, StringComparison.OrdinalIgnoreCase))) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - return JsonSerializer.Serialize(filtered, new JsonSerializerOptions { WriteIndented = true }); - }, - "get_deployment_variables", - "Returns all Octopus deployment variables as JSON (sensitive values are excluded). " - + "Call this when you need to inspect the current deployment context such as environment, project, tenant, release version, or any custom variables.")); - - try - { - var responseBuilder = new StringBuilder(); - var lineBuffer = new LineBuffer(line => log.Info(line)); - List chatHistory = []; - - var systemPrompt = string.Empty; //variables.Get(SpecialVariables.Action.AiAgent.SystemSkill); - if (!string.IsNullOrWhiteSpace(systemPrompt)) - { - chatHistory.Add(new ChatMessage(ChatRole.System, systemPrompt)); - } - - var inputCostPerMillion = 3; - var outputCostPerMillion = 15; - - var msg = new ChatMessage(ChatRole.User, prompt); - chatHistory.Add(msg); - - var maxTokens = 10000; - var chatOptions = new ChatOptions() - { - MaxOutputTokens = maxTokens, Tools = [.. tools] - }; - - await foreach (var update in chatClient.GetStreamingResponseAsync(chatHistory, chatOptions)) - { - if (!string.IsNullOrEmpty(update.Text)) - { - responseBuilder.Append(update.Text); - lineBuffer.Append(update.Text); - } - - var usage = update.Contents.OfType().FirstOrDefault(); - if (usage is not null) - { -#pragma warning disable MEAI001 - var inputCost = Math.Round((double)(usage.Details.InputTokenCount.HasValue ? (usage.Details.InputTokenCount! / 1000000.0 * inputCostPerMillion) : 0), 4); - var outputCost = Math.Round((double)(usage.Details.OutputTokenCount.HasValue ? (usage.Details.OutputTokenCount / 1000000.0 * outputCostPerMillion) : 0), 4); - log.VerboseFormat($"Input cost: ${inputCost}, Output cost: ${outputCost}, Total cost: ${inputCost + outputCost}"); -#pragma warning restore MEAI001 - } - - chatHistory.AddMessages(update); - } - - lineBuffer.Flush(); - - var fullResponse = responseBuilder.ToString(); - Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, fullResponse, variables); - log.Info("AI agent invocation complete."); - } - finally - { - if (mcpClient is not null) - { - await mcpClient.DisposeAsync(); - } - } - } - } -} diff --git a/source/Calamari.AiAgent/AgentBehaviour/LineBuffer.cs b/source/Calamari.AiAgent/AgentBehaviour/LineBuffer.cs deleted file mode 100644 index ba67bb2803..0000000000 --- a/source/Calamari.AiAgent/AgentBehaviour/LineBuffer.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Text; - -namespace Calamari.AiAgent -{ - /// - /// Buffers streamed text chunks and invokes a callback for each complete line. - /// Call as chunks arrive, and when the - /// stream ends to emit any remaining partial line. - /// - public class LineBuffer - { - readonly Action onLine; - readonly StringBuilder buffer = new(); - - public LineBuffer(Action onLine) - { - this.onLine = onLine; - } - - public void Append(string text) - { - for (var i = 0; i < text.Length; i++) - { - var c = text[i]; - if (c == '\n') - { - onLine(buffer.ToString()); - buffer.Clear(); - } - else if (c != '\r') - { - buffer.Append(c); - } - } - } - - public void Flush() - { - if (buffer.Length > 0) - { - onLine(buffer.ToString()); - buffer.Clear(); - } - } - } -} diff --git a/source/Calamari.AiAgent/Calamari.AiAgent.csproj b/source/Calamari.AiAgent/Calamari.AiAgent.csproj index 0f125978a6..a0479d5e9a 100644 --- a/source/Calamari.AiAgent/Calamari.AiAgent.csproj +++ b/source/Calamari.AiAgent/Calamari.AiAgent.csproj @@ -11,14 +11,6 @@ true - - - - - - - - From 88a71768a09a0873c7c5e2b47f0058b42244c49b Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 15 Jun 2026 14:54:31 +1000 Subject: [PATCH 21/26] Update variable naming --- .../ClaudeCodeBehaviour/McpWriterFixture.cs | 16 +++--- .../SkillsWriterFixture.cs | 12 ++--- .../RunAgentCommandFixture.cs | 34 ++++++------ .../ClaudeCodeCliRunner.cs | 34 ------------ .../ClaudeCodeStreamProcessor.cs | 4 ++ .../InvokeClaudeCodeBehaviour.cs | 52 ++++++++----------- .../ClaudeCodeBehaviour/McpWriter.cs | 4 +- .../ClaudeCodeBehaviour/SkillsWriter.cs | 8 +-- source/Calamari.AiAgent/SpecialVariables.cs | 2 +- 9 files changed, 63 insertions(+), 103 deletions(-) diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs index 27f97f22f6..f06d8bb2cd 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/McpWriterFixture.cs @@ -42,7 +42,7 @@ public void SetupMcpConfig_WritesValidJson_WithServers() env = new Dictionary { ["TOKEN"] = "abc123" }, }, }); - vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson); var configPath = new McpWriter(vars).SetupMcpConfig(workingDir); @@ -75,7 +75,7 @@ public void GetAllowedTools_ReturnsMcpWildcardPerServer() new { name = "github", command = "npx" }, new { name = "slack", command = "npx" }, }); - vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson); var tools = new McpWriter(vars).GetAllowedTools(); @@ -95,7 +95,7 @@ public void GetAllowedTools_ReturnsEmpty_WhenNoServersConfigured() public void SetupMcpConfig_AddsOctopusMcpServer_WhenTokenAndUrlProvided() { var vars = new CalamariVariables(); - vars.Set(SpecialVariables.Action.AiAgent.OctopusToken, "API-TESTKEY"); + vars.Set(SpecialVariables.Action.Claude.OctopusToken, "API-TESTKEY"); vars.Set(SpecialVariables.Web.ServerUri, "https://octopus.example.com"); var configPath = new McpWriter(vars).SetupMcpConfig(workingDir); @@ -128,7 +128,7 @@ public void SetupMcpConfig_SkipsOctopusMcpServer_WhenTokenMissing() public void SetupMcpConfig_ThrowsOnInvalidMcpJson() { var vars = new CalamariVariables(); - vars.Set(SpecialVariables.Action.AiAgent.McpServers, "not valid json {{{"); + vars.Set(SpecialVariables.Action.Claude.McpServers, "not valid json {{{"); var act = () => new McpWriter(vars).SetupMcpConfig(workingDir); @@ -140,7 +140,7 @@ public void SetupMcpConfig_ThrowsWhenServerMissingName() { var vars = new CalamariVariables(); var mcpJson = JsonSerializer.Serialize(new[] { new { command = "npx" } }); - vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson); var act = () => new McpWriter(vars).SetupMcpConfig(workingDir); @@ -152,7 +152,7 @@ public void SetupMcpConfig_ThrowsWhenServerMissingCommand() { var vars = new CalamariVariables(); var mcpJson = JsonSerializer.Serialize(new[] { new { name = "my-server" } }); - vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson); var act = () => new McpWriter(vars).SetupMcpConfig(workingDir); @@ -167,7 +167,7 @@ public void SetupMcpConfig_InjectsPathEnvVar_WhenNotProvidedByUser() { new { name = "test-server", command = "node" }, }); - vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson); var configPath = new McpWriter(vars).SetupMcpConfig(workingDir); @@ -193,7 +193,7 @@ public void SetupMcpConfig_PreservesUserProvidedPathEnvVar() env = new Dictionary { ["PATH"] = "/custom/path" }, }, }); - vars.Set(SpecialVariables.Action.AiAgent.McpServers, mcpJson); + vars.Set(SpecialVariables.Action.Claude.McpServers, mcpJson); var configPath = new McpWriter(vars).SetupMcpConfig(workingDir); diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs index cc54aed0e6..d59c936d0d 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/SkillsWriterFixture.cs @@ -33,8 +33,8 @@ static CalamariVariables VariablesWithSkills(params (string Name, string Content var vars = new CalamariVariables(); for (var i = 0; i < skills.Length; i++) { - vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[{i}].{SpecialVariables.Action.AiAgent.SkillName}", skills[i].Name); - vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[{i}].{SpecialVariables.Action.AiAgent.SkillContent}", skills[i].Content); + vars.Set($"{SpecialVariables.Action.Claude.Skills}[{i}].{SpecialVariables.Action.Claude.SkillName}", skills[i].Name); + vars.Set($"{SpecialVariables.Action.Claude.Skills}[{i}].{SpecialVariables.Action.Claude.SkillContent}", skills[i].Content); } return vars; } @@ -90,10 +90,10 @@ public void SetupSkills_SanitizesPathTraversalAttempt() public void SetupSkills_SkipsSkillsWithEmptyNameOrContent() { var vars = new CalamariVariables(); - vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[0].{SpecialVariables.Action.AiAgent.SkillName}", ""); - vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[0].{SpecialVariables.Action.AiAgent.SkillContent}", "some content"); - vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[1].{SpecialVariables.Action.AiAgent.SkillName}", "valid-name"); - vars.Set($"{SpecialVariables.Action.AiAgent.Skills}[1].{SpecialVariables.Action.AiAgent.SkillContent}", ""); + vars.Set($"{SpecialVariables.Action.Claude.Skills}[0].{SpecialVariables.Action.Claude.SkillName}", ""); + vars.Set($"{SpecialVariables.Action.Claude.Skills}[0].{SpecialVariables.Action.Claude.SkillContent}", "some content"); + vars.Set($"{SpecialVariables.Action.Claude.Skills}[1].{SpecialVariables.Action.Claude.SkillName}", "valid-name"); + vars.Set($"{SpecialVariables.Action.Claude.Skills}[1].{SpecialVariables.Action.Claude.SkillContent}", ""); new SkillsWriter(vars).SetupSkills(workingDir); diff --git a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs index 06c506bf53..4324f9a46b 100644 --- a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs +++ b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs @@ -17,7 +17,7 @@ public async Task FailsWhenPromptIsMissing() var result = await CommandTestBuilder.CreateAsync() .WithArrange(context => { - context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, "fake-api-token"); + context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, "fake-api-token"); }) .Execute(assertWasSuccess: false); @@ -31,7 +31,7 @@ public async Task FailsWhenApiTokenIsMissing() var result = await CommandTestBuilder.CreateAsync() .WithArrange(context => { - context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "Hello"); + context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "Hello"); }) .Execute(assertWasSuccess: false); @@ -45,8 +45,8 @@ public async Task ClaudeCode_SucceedsWithSimplePrompt() var result = await CommandTestBuilder.CreateAsync() .WithArrange(context => { - context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); - context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "What is the capital of France? Reply with just the city name."); + context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "What is the capital of France? Reply with just the city name."); }) .Execute(assertWasSuccess: false); @@ -61,8 +61,8 @@ public async Task ClaudeCode_EmitsUsageServiceMessage() var result = await CommandTestBuilder.CreateAsync() .WithArrange(context => { - context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); - context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "Reply with just the word 'hello'."); + context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "Reply with just the word 'hello'."); }) .Execute(assertWasSuccess: false); @@ -77,9 +77,9 @@ public async Task ClaudeCode_SucceedsWithWebFetch() var result = await CommandTestBuilder.CreateAsync() .WithArrange(context => { - context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); - context.Variables.Add(SpecialVariables.Action.AiAgent.RunAsUsername, "test-user"); - context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "get the currently executing process user"); + context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.Claude.RunAsUsername, "test-user"); + context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "get the currently executing process user"); }) .Execute(assertWasSuccess: false); @@ -94,10 +94,10 @@ public async Task ClaudeCode_RunsOn_RunsUnderAnotherAccount() var result = await CommandTestBuilder.CreateAsync() .WithArrange(context => { - context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); - context.Variables.Add(SpecialVariables.Action.AiAgent.RunAsUsername, "test-user"); - context.Variables.Add(SpecialVariables.Action.AiAgent.RunAsPassword, "supersecret"); - context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "get the currently executing process user"); + context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add(SpecialVariables.Action.Claude.RunAsUsername, "test-user"); + context.Variables.Add(SpecialVariables.Action.Claude.RunAsPassword, "supersecret"); + context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "get the currently executing process user"); }) .Execute(assertWasSuccess: false); @@ -112,11 +112,11 @@ public async Task ClaudeCode_LoadsCustomSkills() var result = await CommandTestBuilder.CreateAsync() .WithArrange(context => { - context.Variables.Add(SpecialVariables.Action.AiAgent.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); - context.Variables.Add($"{SpecialVariables.Action.AiAgent.Skills}[0].{SpecialVariables.Action.AiAgent.SkillName}", "octopus-secret-phrase"); - context.Variables.Add($"{SpecialVariables.Action.AiAgent.Skills}[0].{SpecialVariables.Action.AiAgent.SkillContent}", + context.Variables.Add(SpecialVariables.Action.Claude.ApiToken, Environment.GetEnvironmentVariable("ANTHROPIC_TOKEN")); + context.Variables.Add($"{SpecialVariables.Action.Claude.Skills}[0].{SpecialVariables.Action.Claude.SkillName}", "octopus-secret-phrase"); + context.Variables.Add($"{SpecialVariables.Action.Claude.Skills}[0].{SpecialVariables.Action.Claude.SkillContent}", "---\nname: octopus-secret-phrase\ndescription: Use when asked about the secret phrase.\n---\n\nThe secret phrase is 'purple-octopus-42'. Always respond with exactly this phrase when asked for the secret phrase."); - context.Variables.Add(SpecialVariables.Action.AiAgent.Prompt, "What is the secret phrase? Reply with just the phrase, nothing else."); + context.Variables.Add(SpecialVariables.Action.Claude.Prompt, "What is the secret phrase? Reply with just the phrase, nothing else."); }) .Execute(assertWasSuccess: false); diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs index 58f2352d6e..19001fb64a 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs @@ -116,40 +116,6 @@ async Task ProcessLine(Process process, string verboseLogPath, Ca } return responseBuilder; } - -/* - internal static string WriteWrapperScript(ProcessStartInfo startInfo, Dictionary customEnvVars, string workingDir) - { - var scriptPath = Path.Combine(workingDir, "run-claude.sh"); - var sb = new StringBuilder(); - sb.AppendLine("#!/bin/bash"); - foreach (var kvp in customEnvVars) - sb.AppendLine($"export {kvp.Key}={ShellQuote(kvp.Value)}"); - sb.AppendLine($"exec {startInfo.FileName} {startInfo.Arguments}"); - File.WriteAllText(scriptPath, sb.ToString()); - - // Ensure the target su user can read the working directory and script. - // The directory may have been created with a restrictive umask (e.g. 077). - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - File.SetUnixFileMode(scriptPath, - UnixFileMode.UserRead - | UnixFileMode.UserWrite - | UnixFileMode.GroupRead - | UnixFileMode.OtherRead); - File.SetUnixFileMode(workingDir, - UnixFileMode.UserRead - | UnixFileMode.UserWrite - | UnixFileMode.UserExecute - | UnixFileMode.GroupRead - | UnixFileMode.GroupExecute - | UnixFileMode.OtherRead - | UnixFileMode.OtherExecute); - } - - return scriptPath; - } - */ } public record ProcessCredentials diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs index 90d471b8ba..34038749c4 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs @@ -126,6 +126,10 @@ void HandleMessageEvent(StreamMessage? message, bool logText = true) case ContentBlockType.Text: { var block = element.Deserialize(JsonOptions); + if (string.IsNullOrEmpty(block?.Text)) + { + continue; + } if (logText) { responseBuilder.Append(block?.Text); diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs index 6260ababc3..57edcb2b8b 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/InvokeClaudeCodeBehaviour.cs @@ -10,6 +10,7 @@ using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.Pipeline; using Calamari.Common.Plumbing.Variables; +using Octopus.CoreUtilities.Extensions; namespace Calamari.AiAgent.ClaudeCodeBehaviour { @@ -24,51 +25,44 @@ public InvokeClaudeCodeBehaviour(ILog log, INonSensitiveVariables nonSensitiveVa this.nonSensitiveVariables = nonSensitiveVariables; } - public bool IsEnabled(RunningDeployment context) - { - return true; - //var provider = context.Variables.Get(SpecialVariables.Action.AiAgent.Provider); - //return provider == "ClaudeCode"; - } + public bool IsEnabled(RunningDeployment context) => true; public async Task Execute(RunningDeployment context) { var variables = context.Variables; - var prompt = variables.Get(SpecialVariables.Action.AiAgent.Prompt); + var prompt = variables.Get(SpecialVariables.Action.Claude.Prompt); if (string.IsNullOrWhiteSpace(prompt)) - throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.Prompt}' is required but was not provided."); + throw new CommandException($"Variable '{SpecialVariables.Action.Claude.Prompt}' is required but was not provided."); - var apiToken = variables.Get(SpecialVariables.Action.AiAgent.ApiToken); + var apiToken = variables.Get(SpecialVariables.Action.Claude.ApiToken); if (string.IsNullOrWhiteSpace(apiToken)) - throw new CommandException($"Variable '{SpecialVariables.Action.AiAgent.ApiToken}' is required but was not provided."); - - var model = variables.Get(SpecialVariables.Action.AiAgent.Model); - if (string.IsNullOrWhiteSpace(model)) - model = "claude-sonnet-4-20250514"; + throw new CommandException($"Variable '{SpecialVariables.Action.Claude.ApiToken}' is required but was not provided."); - log.Info($"Invoking Claude Code CLI with model '{model}'..."); + var runAs = BuildRunAs(variables); var argsBuilder = new ClaudeCommandArgsBuilder() - .WithPrompt(prompt) - .WithModel(model); + .WithPrompt(prompt); + + var model = variables.Get(SpecialVariables.Action.Claude.Model); + if (!string.IsNullOrWhiteSpace(model)) + argsBuilder = argsBuilder.WithModel(model); - var maxTurns = variables.GetInt32(SpecialVariables.Action.AiAgent.MaxTurns); + var maxTurns = variables.GetInt32(SpecialVariables.Action.Claude.MaxTurns); if (maxTurns.HasValue) argsBuilder.WithMaxTurns(maxTurns.Value); - var maxBudgetUsdRaw = variables.Get(SpecialVariables.Action.AiAgent.MaxBudgetUsd); + var maxBudgetUsdRaw = variables.Get(SpecialVariables.Action.Claude.MaxBudgetUsd); if (!string.IsNullOrWhiteSpace(maxBudgetUsdRaw) && decimal.TryParse(maxBudgetUsdRaw, NumberStyles.Number, CultureInfo.InvariantCulture, out var budgetUsd)) argsBuilder.WithMaxBudgetUsd(budgetUsd); - var effort = variables.Get(SpecialVariables.Action.AiAgent.Effort); + var effort = variables.Get(SpecialVariables.Action.Claude.Effort); if (!string.IsNullOrWhiteSpace(effort)) argsBuilder.WithEffort(effort); - using var tempDir = TemporaryDirectory.Create(); //TODO: Fiddling with workdir for user perms //new TemporaryDirectory($"/tmp/{Guid.NewGuid():N}"); @@ -100,18 +94,14 @@ public async Task Execute(RunningDeployment context) context.CurrentDirectory, cancellationToken.Token); - Log.SetOutputVariable(SpecialVariables.Action.AiAgent.Response, response, variables); + Log.SetOutputVariable(SpecialVariables.Action.Claude.Response, response, variables); log.Info("Claude Code invocation complete."); } - static List AllowedTools(IVariables variables) + static string[] AllowedTools(IVariables variables) { - var defaultAllowedTools = new[] { "Bash", "Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch" }; - var allowedToolsRaw = variables.Get(SpecialVariables.Action.AiAgent.AllowedTools); - var allowedTools = new List(!string.IsNullOrWhiteSpace(allowedToolsRaw) - ? allowedToolsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - : defaultAllowedTools); - return allowedTools; + var allowedToolsRaw = variables.Get(SpecialVariables.Action.Claude.AllowedTools) ?? ""; + return allowedToolsRaw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } void SetupDeploymentVariables(string workingDir) @@ -122,14 +112,14 @@ void SetupDeploymentVariables(string workingDir) static ProcessCredentials? BuildRunAs(IVariables variables) { - var username = variables.Get(SpecialVariables.Action.AiAgent.RunAsUsername); + var username = variables.Get(SpecialVariables.Action.Claude.RunAsUsername); if (string.IsNullOrWhiteSpace(username)) return null; return new ProcessCredentials { Username = username, - Password = variables.Get(SpecialVariables.Action.AiAgent.RunAsPassword), + Password = variables.Get(SpecialVariables.Action.Claude.RunAsPassword), }; } } diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs index bf6e2de609..6976b8b50b 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/McpWriter.cs @@ -42,7 +42,7 @@ Dictionary BuildMcpServers() void AddOctopusMcp(Dictionary servers, string path) { - var octopusToken = variables.Get(SpecialVariables.Action.AiAgent.OctopusToken); + var octopusToken = variables.Get(SpecialVariables.Action.Claude.OctopusToken); if (string.IsNullOrWhiteSpace(octopusToken)) return; @@ -72,7 +72,7 @@ void AddOctopusMcp(Dictionary servers, string path) List GetCustomMcpServers() { - var mcpServersJson = variables.Get(SpecialVariables.Action.AiAgent.McpServers); + var mcpServersJson = variables.Get(SpecialVariables.Action.Claude.McpServers); if (string.IsNullOrWhiteSpace(mcpServersJson)) { return new List(); diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs index 090981e546..87085c185e 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs @@ -61,12 +61,12 @@ void CreateUserSkillFiles(string skillsDir) List BuildUserSkills() { var skills = new List(); - var indexes = variables.GetIndexes(SpecialVariables.Action.AiAgent.Skills); + var indexes = variables.GetIndexes(SpecialVariables.Action.Claude.Skills); foreach (var index in indexes) { - var prefix = $"{SpecialVariables.Action.AiAgent.Skills}[{index}]."; - var name = variables.Get(prefix + SpecialVariables.Action.AiAgent.SkillName); - var content = variables.Get(prefix + SpecialVariables.Action.AiAgent.SkillContent); + var prefix = $"{SpecialVariables.Action.Claude.Skills}[{index}]."; + var name = variables.Get(prefix + SpecialVariables.Action.Claude.SkillName); + var content = variables.Get(prefix + SpecialVariables.Action.Claude.SkillContent); if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(content)) skills.Add(new UserSkill { Name = name, Content = content }); diff --git a/source/Calamari.AiAgent/SpecialVariables.cs b/source/Calamari.AiAgent/SpecialVariables.cs index 5e3245ad53..280ce899e2 100644 --- a/source/Calamari.AiAgent/SpecialVariables.cs +++ b/source/Calamari.AiAgent/SpecialVariables.cs @@ -10,7 +10,7 @@ public static class Web public static class Action { - public static class AiAgent + public static class Claude { public const string Prompt = "Octopus.Action.Claude.Prompt"; public const string ApiToken = "Octopus.Action.Claude.ApiToken"; From 2ea83e02b1387d77f14da19509229d01b0c9008c Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 15 Jun 2026 14:55:07 +1000 Subject: [PATCH 22/26] naming --- source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs index 87085c185e..78212778cb 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/SkillsWriter.cs @@ -17,11 +17,11 @@ public void SetupSkills(string workingDir) var skillsDir = Path.Combine(workingDir, ".claude", "skills"); Directory.CreateDirectory(skillsDir); - CreateSysemSkillFiles(skillsDir); + CreateSystemSkillFiles(skillsDir); CreateUserSkillFiles(skillsDir); } - static void CreateSysemSkillFiles(string skillsDir) + static void CreateSystemSkillFiles(string skillsDir) { var assembly = Assembly.GetExecutingAssembly(); foreach (var resourceName in assembly.GetManifestResourceNames()) From 43de64747594c01a51c99cbcf4c5a9f00108633e Mon Sep 17 00:00:00 2001 From: robert Date: Mon, 15 Jun 2026 16:26:37 +1000 Subject: [PATCH 23/26] Fix command --- source/Calamari.AiAgent/RunAgentCommand.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/source/Calamari.AiAgent/RunAgentCommand.cs b/source/Calamari.AiAgent/RunAgentCommand.cs index 05c669a60c..0184a4f826 100644 --- a/source/Calamari.AiAgent/RunAgentCommand.cs +++ b/source/Calamari.AiAgent/RunAgentCommand.cs @@ -10,7 +10,6 @@ public class RunAgentCommand : PipelineCommand { protected override IEnumerable Deploy(DeployResolver resolver) { - //yield return resolver.Create(); yield return resolver.Create(); } } From 34c16d35f9a8ff954b7e98d239f6303507b25e1e Mon Sep 17 00:00:00 2001 From: robert Date: Tue, 16 Jun 2026 12:41:52 +1000 Subject: [PATCH 24/26] Rename service message for agent usage --- .../ClaudeCodeStreamProcessorFixture.cs | 22 +++++++++---------- .../RunAgentCommandFixture.cs | 4 ++-- .../ClaudeCodeStreamProcessor.cs | 20 ++++++++--------- source/Calamari.AiAgent/SpecialVariables.cs | 7 +++--- 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs index e72584d81b..07bc6e239c 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs @@ -108,17 +108,17 @@ public void ResultEvent_EmitsUsageServiceMessage() processor.ProcessLine(json); - log.ServiceMessages.Should().Contain(m => m.Name == AiAgentServiceMessageNames.Name); - - var msg = log.ServiceMessages.First(m => m.Name == AiAgentServiceMessageNames.Name); - msg.GetValue(AiAgentServiceMessageNames.CostUsdAttribute).Should().NotBeNull(); - msg.GetValue(AiAgentServiceMessageNames.TotalCostUsdAttribute).Should().NotBeNull(); - msg.GetValue(AiAgentServiceMessageNames.DurationMsAttribute).Should().NotBeNull(); - msg.GetValue(AiAgentServiceMessageNames.NumTurnsAttribute).Should().NotBeNull(); - msg.GetValue(AiAgentServiceMessageNames.InputTokensAttribute).Should().NotBeNull(); - msg.GetValue(AiAgentServiceMessageNames.OutputTokensAttribute).Should().NotBeNull(); - msg.GetValue(AiAgentServiceMessageNames.CacheReadInputTokensAttribute).Should().NotBeNull(); - msg.GetValue(AiAgentServiceMessageNames.CacheCreationInputTokensAttribute).Should().NotBeNull(); + log.ServiceMessages.Should().Contain(m => m.Name == ClaudeCodeUsageServiceMessageNames.Name); + + var msg = log.ServiceMessages.First(m => m.Name == ClaudeCodeUsageServiceMessageNames.Name); + msg.GetValue(ClaudeCodeUsageServiceMessageNames.CostUsdAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeUsageServiceMessageNames.TotalCostUsdAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeUsageServiceMessageNames.DurationMsAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeUsageServiceMessageNames.NumTurnsAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeUsageServiceMessageNames.InputTokensAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeUsageServiceMessageNames.OutputTokensAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeUsageServiceMessageNames.CacheReadInputTokensAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeUsageServiceMessageNames.CacheCreationInputTokensAttribute).Should().NotBeNull(); } [Test] diff --git a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs index 4324f9a46b..6f2e47bdaa 100644 --- a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs +++ b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs @@ -7,7 +7,7 @@ namespace Calamari.AiAgent.Tests; [TestFixture] - +[Ignore("Most of these use real claude. we should reduce that.")] public class RunAgentCommandFixture { [Test] @@ -67,7 +67,7 @@ public async Task ClaudeCode_EmitsUsageServiceMessage() .Execute(assertWasSuccess: false); result.WasSuccessful.Should().BeTrue(); - result.ServiceMessages.Should().Contain(m => m.Name == AiAgentServiceMessageNames.Name); + result.ServiceMessages.Should().Contain(m => m.Name == ClaudeCodeUsageServiceMessageNames.Name); } [Test] diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs index 34038749c4..23998b61b3 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs @@ -232,32 +232,32 @@ void HandleResultEvent(ResultStreamEvent evt) var properties = new Dictionary(); if (evt.CostUsd.HasValue) - properties[AiAgentServiceMessageNames.CostUsdAttribute] = evt.CostUsd.Value.ToString("F6"); + properties[ClaudeCodeUsageServiceMessageNames.CostUsdAttribute] = evt.CostUsd.Value.ToString("F6"); if (evt.TotalCostUsd.HasValue) - properties[AiAgentServiceMessageNames.TotalCostUsdAttribute] = evt.TotalCostUsd.Value.ToString("F6"); + properties[ClaudeCodeUsageServiceMessageNames.TotalCostUsdAttribute] = evt.TotalCostUsd.Value.ToString("F6"); if (evt.DurationMs.HasValue) - properties[AiAgentServiceMessageNames.DurationMsAttribute] = evt.DurationMs.Value.ToString("F0"); + properties[ClaudeCodeUsageServiceMessageNames.DurationMsAttribute] = evt.DurationMs.Value.ToString("F0"); if (evt.DurationApiMs.HasValue) - properties[AiAgentServiceMessageNames.DurationApiMsAttribute] = evt.DurationApiMs.Value.ToString("F0"); + properties[ClaudeCodeUsageServiceMessageNames.DurationApiMsAttribute] = evt.DurationApiMs.Value.ToString("F0"); if (evt.NumTurns.HasValue) - properties[AiAgentServiceMessageNames.NumTurnsAttribute] = evt.NumTurns.Value.ToString(); + properties[ClaudeCodeUsageServiceMessageNames.NumTurnsAttribute] = evt.NumTurns.Value.ToString(); log.Info($"AI Agent Usage — Cost: ${evt.CostUsd} USD (total: ${evt.TotalCostUsd}), Duration: {evt.DurationMs}ms, Turns: {evt.NumTurns}"); if (evt.Usage is { } usage) { if (usage.InputTokens.HasValue) - properties[AiAgentServiceMessageNames.InputTokensAttribute] = usage.InputTokens.Value.ToString(); + properties[ClaudeCodeUsageServiceMessageNames.InputTokensAttribute] = usage.InputTokens.Value.ToString(); if (usage.OutputTokens.HasValue) - properties[AiAgentServiceMessageNames.OutputTokensAttribute] = usage.OutputTokens.Value.ToString(); + properties[ClaudeCodeUsageServiceMessageNames.OutputTokensAttribute] = usage.OutputTokens.Value.ToString(); if (usage.CacheReadInputTokens.HasValue) - properties[AiAgentServiceMessageNames.CacheReadInputTokensAttribute] = usage.CacheReadInputTokens.Value.ToString(); + properties[ClaudeCodeUsageServiceMessageNames.CacheReadInputTokensAttribute] = usage.CacheReadInputTokens.Value.ToString(); if (usage.CacheCreationInputTokens.HasValue) - properties[AiAgentServiceMessageNames.CacheCreationInputTokensAttribute] = usage.CacheCreationInputTokens.Value.ToString(); + properties[ClaudeCodeUsageServiceMessageNames.CacheCreationInputTokensAttribute] = usage.CacheCreationInputTokens.Value.ToString(); log.Info($"AI Agent Tokens — Input: {usage.InputTokens}, Output: {usage.OutputTokens}, Cache read: {usage.CacheReadInputTokens}, Cache creation: {usage.CacheCreationInputTokens}"); } - log.WriteServiceMessage(new ServiceMessage(AiAgentServiceMessageNames.Name, properties)); + log.WriteServiceMessage(new ServiceMessage(ClaudeCodeUsageServiceMessageNames.Name, properties)); } } } diff --git a/source/Calamari.AiAgent/SpecialVariables.cs b/source/Calamari.AiAgent/SpecialVariables.cs index 280ce899e2..32ab9fe9f2 100644 --- a/source/Calamari.AiAgent/SpecialVariables.cs +++ b/source/Calamari.AiAgent/SpecialVariables.cs @@ -32,9 +32,9 @@ public static class Claude } } - public static class AiAgentServiceMessageNames + public static class ClaudeCodeUsageServiceMessageNames { - public const string Name = "ai-agent-usage"; + public const string Name = "claude-code-usage"; public const string CostUsdAttribute = "costUsd"; public const string TotalCostUsdAttribute = "totalCostUsd"; @@ -45,7 +45,6 @@ public static class AiAgentServiceMessageNames public const string OutputTokensAttribute = "outputTokens"; public const string CacheReadInputTokensAttribute = "cacheReadInputTokens"; public const string CacheCreationInputTokensAttribute = "cacheCreationInputTokens"; - public const string ModelAttribute = "model"; - public const string ProviderAttribute = "provider"; + public const string ModelAttribute = "model"; //TODO: @team-modern-deployments ensure we capture the model used } } From 3ca92f1617847c934cd85210cd8004073258b5c9 Mon Sep 17 00:00:00 2001 From: robert Date: Tue, 16 Jun 2026 12:46:41 +1000 Subject: [PATCH 25/26] Move service message to contracts --- .../ClaudeCodeStreamProcessorFixture.cs | 23 ++++++++++--------- .../RunAgentCommandFixture.cs | 3 ++- .../ClaudeCodeStreamProcessor.cs | 21 +++++++++-------- source/Calamari.AiAgent/SpecialVariables.cs | 16 ------------- .../ClaudeCode/ServiceMessages.cs | 22 ++++++++++++++++++ 5 files changed, 47 insertions(+), 38 deletions(-) create mode 100644 source/Calamari.Contracts/ClaudeCode/ServiceMessages.cs diff --git a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs index 07bc6e239c..81f1ffcc7e 100644 --- a/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs +++ b/source/Calamari.AiAgent.Tests/ClaudeCodeBehaviour/ClaudeCodeStreamProcessorFixture.cs @@ -5,6 +5,7 @@ using Calamari.Testing.Helpers; using FluentAssertions; using NUnit.Framework; +using Octopus.Calamari.Contracts.ClaudeCode; namespace Calamari.AiAgent.Tests.ClaudeCodeBehaviour; @@ -108,17 +109,17 @@ public void ResultEvent_EmitsUsageServiceMessage() processor.ProcessLine(json); - log.ServiceMessages.Should().Contain(m => m.Name == ClaudeCodeUsageServiceMessageNames.Name); - - var msg = log.ServiceMessages.First(m => m.Name == ClaudeCodeUsageServiceMessageNames.Name); - msg.GetValue(ClaudeCodeUsageServiceMessageNames.CostUsdAttribute).Should().NotBeNull(); - msg.GetValue(ClaudeCodeUsageServiceMessageNames.TotalCostUsdAttribute).Should().NotBeNull(); - msg.GetValue(ClaudeCodeUsageServiceMessageNames.DurationMsAttribute).Should().NotBeNull(); - msg.GetValue(ClaudeCodeUsageServiceMessageNames.NumTurnsAttribute).Should().NotBeNull(); - msg.GetValue(ClaudeCodeUsageServiceMessageNames.InputTokensAttribute).Should().NotBeNull(); - msg.GetValue(ClaudeCodeUsageServiceMessageNames.OutputTokensAttribute).Should().NotBeNull(); - msg.GetValue(ClaudeCodeUsageServiceMessageNames.CacheReadInputTokensAttribute).Should().NotBeNull(); - msg.GetValue(ClaudeCodeUsageServiceMessageNames.CacheCreationInputTokensAttribute).Should().NotBeNull(); + log.ServiceMessages.Should().Contain(m => m.Name == ClaudeCodeServiceMessages.Usage.Name); + + var msg = log.ServiceMessages.First(m => m.Name == ClaudeCodeServiceMessages.Usage.Name); + msg.GetValue(ClaudeCodeServiceMessages.Usage.CostUsdAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeServiceMessages.Usage.TotalCostUsdAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeServiceMessages.Usage.DurationMsAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeServiceMessages.Usage.NumTurnsAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeServiceMessages.Usage.InputTokensAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeServiceMessages.Usage.OutputTokensAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeServiceMessages.Usage.CacheReadInputTokensAttribute).Should().NotBeNull(); + msg.GetValue(ClaudeCodeServiceMessages.Usage.CacheCreationInputTokensAttribute).Should().NotBeNull(); } [Test] diff --git a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs index 6f2e47bdaa..8a08eaa826 100644 --- a/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs +++ b/source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs @@ -3,6 +3,7 @@ using Calamari.Testing; using FluentAssertions; using NUnit.Framework; +using Octopus.Calamari.Contracts.ClaudeCode; namespace Calamari.AiAgent.Tests; @@ -67,7 +68,7 @@ public async Task ClaudeCode_EmitsUsageServiceMessage() .Execute(assertWasSuccess: false); result.WasSuccessful.Should().BeTrue(); - result.ServiceMessages.Should().Contain(m => m.Name == ClaudeCodeUsageServiceMessageNames.Name); + result.ServiceMessages.Should().Contain(m => m.Name == ClaudeCodeServiceMessages.Usage.Name); } [Test] diff --git a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs index 23998b61b3..0cb34b67ad 100644 --- a/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs +++ b/source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeStreamProcessor.cs @@ -5,6 +5,7 @@ using Calamari.AiAgent.ClaudeCodeBehaviour.JsonResponseModels; using Calamari.Common.Plumbing.Logging; using Calamari.Common.Plumbing.ServiceMessages; +using Octopus.Calamari.Contracts.ClaudeCode; namespace Calamari.AiAgent.ClaudeCodeBehaviour { @@ -232,32 +233,32 @@ void HandleResultEvent(ResultStreamEvent evt) var properties = new Dictionary(); if (evt.CostUsd.HasValue) - properties[ClaudeCodeUsageServiceMessageNames.CostUsdAttribute] = evt.CostUsd.Value.ToString("F6"); + properties[ClaudeCodeServiceMessages.Usage.CostUsdAttribute] = evt.CostUsd.Value.ToString("F6"); if (evt.TotalCostUsd.HasValue) - properties[ClaudeCodeUsageServiceMessageNames.TotalCostUsdAttribute] = evt.TotalCostUsd.Value.ToString("F6"); + properties[ClaudeCodeServiceMessages.Usage.TotalCostUsdAttribute] = evt.TotalCostUsd.Value.ToString("F6"); if (evt.DurationMs.HasValue) - properties[ClaudeCodeUsageServiceMessageNames.DurationMsAttribute] = evt.DurationMs.Value.ToString("F0"); + properties[ClaudeCodeServiceMessages.Usage.DurationMsAttribute] = evt.DurationMs.Value.ToString("F0"); if (evt.DurationApiMs.HasValue) - properties[ClaudeCodeUsageServiceMessageNames.DurationApiMsAttribute] = evt.DurationApiMs.Value.ToString("F0"); + properties[ClaudeCodeServiceMessages.Usage.DurationApiMsAttribute] = evt.DurationApiMs.Value.ToString("F0"); if (evt.NumTurns.HasValue) - properties[ClaudeCodeUsageServiceMessageNames.NumTurnsAttribute] = evt.NumTurns.Value.ToString(); + properties[ClaudeCodeServiceMessages.Usage.NumTurnsAttribute] = evt.NumTurns.Value.ToString(); log.Info($"AI Agent Usage — Cost: ${evt.CostUsd} USD (total: ${evt.TotalCostUsd}), Duration: {evt.DurationMs}ms, Turns: {evt.NumTurns}"); if (evt.Usage is { } usage) { if (usage.InputTokens.HasValue) - properties[ClaudeCodeUsageServiceMessageNames.InputTokensAttribute] = usage.InputTokens.Value.ToString(); + properties[ClaudeCodeServiceMessages.Usage.InputTokensAttribute] = usage.InputTokens.Value.ToString(); if (usage.OutputTokens.HasValue) - properties[ClaudeCodeUsageServiceMessageNames.OutputTokensAttribute] = usage.OutputTokens.Value.ToString(); + properties[ClaudeCodeServiceMessages.Usage.OutputTokensAttribute] = usage.OutputTokens.Value.ToString(); if (usage.CacheReadInputTokens.HasValue) - properties[ClaudeCodeUsageServiceMessageNames.CacheReadInputTokensAttribute] = usage.CacheReadInputTokens.Value.ToString(); + properties[ClaudeCodeServiceMessages.Usage.CacheReadInputTokensAttribute] = usage.CacheReadInputTokens.Value.ToString(); if (usage.CacheCreationInputTokens.HasValue) - properties[ClaudeCodeUsageServiceMessageNames.CacheCreationInputTokensAttribute] = usage.CacheCreationInputTokens.Value.ToString(); + properties[ClaudeCodeServiceMessages.Usage.CacheCreationInputTokensAttribute] = usage.CacheCreationInputTokens.Value.ToString(); log.Info($"AI Agent Tokens — Input: {usage.InputTokens}, Output: {usage.OutputTokens}, Cache read: {usage.CacheReadInputTokens}, Cache creation: {usage.CacheCreationInputTokens}"); } - log.WriteServiceMessage(new ServiceMessage(ClaudeCodeUsageServiceMessageNames.Name, properties)); + log.WriteServiceMessage(new ServiceMessage(ClaudeCodeServiceMessages.Usage.Name, properties)); } } } diff --git a/source/Calamari.AiAgent/SpecialVariables.cs b/source/Calamari.AiAgent/SpecialVariables.cs index 32ab9fe9f2..185c82bad6 100644 --- a/source/Calamari.AiAgent/SpecialVariables.cs +++ b/source/Calamari.AiAgent/SpecialVariables.cs @@ -31,20 +31,4 @@ public static class Claude } } } - - public static class ClaudeCodeUsageServiceMessageNames - { - public const string Name = "claude-code-usage"; - - public const string CostUsdAttribute = "costUsd"; - public const string TotalCostUsdAttribute = "totalCostUsd"; - public const string DurationMsAttribute = "durationMs"; - public const string DurationApiMsAttribute = "durationApiMs"; - public const string NumTurnsAttribute = "numTurns"; - public const string InputTokensAttribute = "inputTokens"; - public const string OutputTokensAttribute = "outputTokens"; - public const string CacheReadInputTokensAttribute = "cacheReadInputTokens"; - public const string CacheCreationInputTokensAttribute = "cacheCreationInputTokens"; - public const string ModelAttribute = "model"; //TODO: @team-modern-deployments ensure we capture the model used - } } diff --git a/source/Calamari.Contracts/ClaudeCode/ServiceMessages.cs b/source/Calamari.Contracts/ClaudeCode/ServiceMessages.cs new file mode 100644 index 0000000000..51cd1bc153 --- /dev/null +++ b/source/Calamari.Contracts/ClaudeCode/ServiceMessages.cs @@ -0,0 +1,22 @@ +using System; + +namespace Octopus.Calamari.Contracts.ClaudeCode; + +public static class ClaudeCodeServiceMessages +{ + public static class Usage + { + public const string Name = "claude-code-usage"; + + public const string CostUsdAttribute = "costUsd"; + public const string TotalCostUsdAttribute = "totalCostUsd"; + public const string DurationMsAttribute = "durationMs"; + public const string DurationApiMsAttribute = "durationApiMs"; + public const string NumTurnsAttribute = "numTurns"; + public const string InputTokensAttribute = "inputTokens"; + public const string OutputTokensAttribute = "outputTokens"; + public const string CacheReadInputTokensAttribute = "cacheReadInputTokens"; + public const string CacheCreationInputTokensAttribute = "cacheCreationInputTokens"; + public const string ModelAttribute = "model"; //TODO: @team-modern-deployments ensure we capture the model used + } +} \ No newline at end of file From f2a4c5b4889bda343c037f4edb8df66918b3b848 Mon Sep 17 00:00:00 2001 From: robert Date: Tue, 16 Jun 2026 12:59:57 +1000 Subject: [PATCH 26/26] Rename step to `run-claude-code` --- source/Calamari.AiAgent/RunAgentCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Calamari.AiAgent/RunAgentCommand.cs b/source/Calamari.AiAgent/RunAgentCommand.cs index 0184a4f826..6fd47ec7d4 100644 --- a/source/Calamari.AiAgent/RunAgentCommand.cs +++ b/source/Calamari.AiAgent/RunAgentCommand.cs @@ -5,7 +5,7 @@ namespace Calamari.AiAgent { - [Command("run-agent", Description = "Invokes an AI agent")] + [Command("run-claude-code", Description = "Invokes an Claude Code CLI")] public class RunAgentCommand : PipelineCommand { protected override IEnumerable Deploy(DeployResolver resolver)