Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions source/Calamari.AiAgent.Tests/RunAgentCommandFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ public async Task ClaudeCode_SucceedsWithWebFetch()
.WithArrange(context =>
{
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);
Expand All @@ -118,8 +117,6 @@ public async Task ClaudeCode_RunsOn_RunsUnderAnotherAccount()
.WithArrange(context =>
{
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);
Expand Down
14 changes: 2 additions & 12 deletions source/Calamari.AiAgent/ClaudeCodeBehaviour/ClaudeCodeCliRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ public class ClaudeCodeCliRunner(ILog log)

public async Task<string> RunAsync(ClaudeCommandArgsBuilder argsBuilder,
Dictionary<string, string> 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)
Expand All @@ -34,11 +33,9 @@ public async Task<string> RunAsync(ClaudeCommandArgsBuilder argsBuilder,
log.Verbose($"Claude Code command: {logFileName} {logArgs}");

var runner = new ClaudeCodeProcessStartInfo();
var process = await runner.StartClaudeProcess(workingDir,
runAs,
var process = runner.StartClaudeProcess(workingDir,
argsBuilder.WithDebugLogPath(debugLogPath),
customEnvVars,
cancellationToken);
customEnvVars);

var responseBuilder = new StringBuilder();
var streamProcessor = new ClaudeCodeStreamProcessor(log, responseBuilder);
Expand Down Expand Up @@ -114,13 +111,6 @@ async Task ProcessLine(Process process, ClaudeCodeStreamProcessor streamProcesso
}
}

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; }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
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;

Expand Down Expand Up @@ -34,32 +29,7 @@ SandboxMode.SandboxRuntime when string.IsNullOrWhiteSpace(argsBuilder.SandboxRun
};
}

async Task<Process> StartWindowsProcess(
string workingDir,
ProcessCredentials? runAs,
ClaudeCommandArgsBuilder argsBuilder,
Dictionary<string, string> 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(
public Process StartClaudeProcess(
string workingDir,
ClaudeCommandArgsBuilder argsBuilder,
Dictionary<string, string> environmentVariables)
Expand All @@ -81,150 +51,6 @@ static ProcessStartInfo StartSimpleProcess(
startInfo.Environment[kvp.Key] = kvp.Value;
}

return startInfo;
}

public async Task<Process> StartClaudeProcess(
string workingDir,
ProcessCredentials? runAs,
ClaudeCommandArgsBuilder argsBuilder,
Dictionary<string, string> 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", Justification = "Higher up checks enforce the correct OS")]
async Task<Process> StartMacOrLinuxProcess(
string workingDir,
ProcessCredentials? runAs,
ClaudeCommandArgsBuilder argsBuilder,
Dictionary<string, string> environmentVariables,
CancellationToken ct)
{
var username = runAs?.Username!;
if (runAs == null || string.IsNullOrEmpty(username))
{
return Process.Start(StartSimpleProcess(workingDir, argsBuilder, environmentVariables))
?? throw new Exception("Failed to start the claude code process");
}

var (scriptFileName, scriptArgs) = ResolveInvocation(argsBuilder);
var filePath = Path.Combine(workingDir, "my-command.sh");
await File.WriteAllTextAsync(
filePath,
$"""
#!/bin/bash
cd {workingDir}
{scriptFileName} {scriptArgs}
""",
ct);

var startInfo = new ProcessStartInfo
{
FileName = "script",
WorkingDirectory = workingDir,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};

var argumentList = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
? new[] { "-q", "/dev/null", "su", "-m", username, "-c", filePath }
: new[] { "-qec", "su", "-m", username, "-c", filePath, "/dev/null" };

startInfo.ArgumentList.AddRange(argumentList);

startInfo.Environment.Clear();
foreach (var kvp in environmentVariables)
{
startInfo.Environment[kvp.Key] = kvp.Value;
}

GrantRunAsAccess(workingDir);

var process = Process.Start(startInfo)!;

// 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);
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;
}

[SuppressMessage("Interoperability", "CA1416:Validate platform compatibility", Justification = "Only invoked on the macOS/Linux run-as path")]
static void GrantRunAsAccess(string workingDir)
{
var dirMode = UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupWrite
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherWrite
| UnixFileMode.OtherExecute;

var fileMode = UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute
| UnixFileMode.GroupRead
| UnixFileMode.GroupWrite
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherWrite
| UnixFileMode.OtherExecute;

// The sandbox policy must stay readable but never writable by the run-as user or co-tenants.
var policyMode = UnixFileMode.UserRead | UnixFileMode.GroupRead | UnixFileMode.OtherRead;

new DirectoryInfo(workingDir).UnixFileMode = dirMode;
foreach (var dir in Directory.EnumerateDirectories(workingDir, "*", SearchOption.AllDirectories))
{
new DirectoryInfo(dir).UnixFileMode = dirMode;
}

foreach (var file in Directory.EnumerateFiles(workingDir, "*", SearchOption.AllDirectories))
{
File.SetUnixFileMode(file, fileMode);
}

var claudeDir = Path.Combine(workingDir, ".claude");
if (Directory.Exists(claudeDir))
{
foreach (var policyFile in Directory.EnumerateFiles(claudeDir, "*.json", SearchOption.TopDirectoryOnly))
{
File.SetUnixFileMode(policyFile, policyMode);
}
}
return Process.Start(startInfo)!;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ public async Task Execute(RunningDeployment context)
if (string.IsNullOrWhiteSpace(apiToken))
throw new CommandException($"Variable '{SpecialVariables.Action.Claude.ApiToken}' is required but was not provided.");

var runAs = BuildRunAs(variables);

var argsBuilder = new ClaudeCommandArgsBuilder().WithPrompt(prompt);

var model = variables.Get(SpecialVariables.Action.Claude.Model);
Expand Down Expand Up @@ -135,7 +133,6 @@ public async Task Execute(RunningDeployment context)
var response = await new ClaudeCodeCliRunner(log).RunAsync(
argsBuilder,
environment,
runAs,
workingDir,
context.CurrentDirectory,
cancellationToken.Token);
Expand Down Expand Up @@ -181,17 +178,4 @@ static SandboxMode ResolveSandboxMode(IVariables variables)

throw new CommandException($"Unknown value '{raw}' for '{SpecialVariables.Action.Claude.SandboxMode}'. Expected one of: None, Bash, SandboxRuntime.");
}

static ProcessCredentials? BuildRunAs(IVariables variables)
{
var username = variables.Get(SpecialVariables.Action.Claude.RunAsUsername);
if (string.IsNullOrWhiteSpace(username))
return null;

return new ProcessCredentials
{
Username = username,
Password = variables.Get(SpecialVariables.Action.Claude.RunAsPassword),
};
}
}
2 changes: 0 additions & 2 deletions source/Calamari.AiAgent/SpecialVariables.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ public static class Claude
public const string PermissionMode = "Octopus.Action.Claude.PermissionMode";
public const string Effort = "Octopus.Action.Claude.Effort";
public const string PassEnvironmentVariables = "Octopus.Action.Claude.PassEnvironmentVariables";
public const string RunAsUsername = "Octopus.Action.Claude.RunAsUsername";
public const string RunAsPassword = "Octopus.Action.Claude.RunAsPassword";

public const string SandboxMode = "Octopus.Action.Claude.SandboxMode";
public const string SandboxSettings = "Octopus.Action.Claude.SandboxSettings";
Expand Down