-
Notifications
You must be signed in to change notification settings - Fork 116
Add Claude Code AI Agent step with CLI harness and Linux user impersonation #1996
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 19 commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
b65f633
Initial AIAgent Calamari
zentron 93c4ad4
Add OpenAI provider support and multi-provider architecture
zentron b72fead
Add Claude Code CLI provider with stream-json parsing and usage servi…
zentron ba10f25
Add MCP config support, enums for stream types, and fix deserialization
zentron 4566c7b
Add Run As user impersonation, tests, and stream processor fixes
zentron de17788
Add MCP servers JSON config, stream model updates, and command args b…
zentron bd86210
Add deployment variables file, effort level, MCP tool permissions, an…
zentron f08ae17
Fix build.sh
zentron 6ab335f
Undo build fix
zentron ad61273
Comment
zentron 26fcd67
Try fixing build
zentron 676d834
Refactor: collect explicitly-set env vars into customEnvVars dictionary
zentron ed4c1b2
Add ShellQuote helper for safe shell command construction
zentron 01f4e52
Rewrite ApplyCredentials with Linux script/su path for user impersona…
zentron 0414f5e
feat: pipe password to stdin for Linux su-based impersonation and upd…
zentron c344557
rollback build
zentron 96104e7
Change global
zentron a73d49d
Some tidy and refactor
zentron 737f4bd
Fiddling with perms
zentron bee2396
Remove old Generic Agent invocation
zentron 88a7176
Update variable naming
zentron 2ea83e0
naming
zentron 43de647
Fix command
zentron 780817a
Merge branch 'main' into robe/poc-aiagent
flin-8 34c16d3
Rename service message for agent usage
zentron 3ca92f1
Move service message to contracts
zentron f2a4c5b
Rename step to `run-claude-code`
zentron File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
385 changes: 385 additions & 0 deletions
385
docs/superpowers/plans/2026-06-08-linux-su-impersonation.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string> | ||
| { | ||
| ["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<string, string> | ||
| { | ||
| ["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<string, string>(); | ||
|
|
||
| var act = () => ClaudeCodeCliRunner.ApplyCredentials(startInfo, credentials, customEnvVars); | ||
|
|
||
| act.Should().Throw<CommandException>().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<string, string>(); | ||
|
|
||
| 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<string, string> 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" | ||
| ``` |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.