Skip to content

Commit 7fb6c8d

Browse files
feat(claudecode): add working directory isolation support
Add ExecutionWorkingDirectory, OriginalWorkingDirectory, and BootstrapOriginalWorkingDirectory properties to ClaudeCodeOptions for isolated session execution. Implement ResolveExecutionWorkingDirectory to prefer ExecutionWorkingDirectory over WorkingDirectory when set, and BuildEffectiveAppendSystemPrompt to combine custom prompts with auto-generated working directory bootstrap guidance. The bootstrap guidance informs the agent of the isolation context and original project path. Add tests verifying execution directory preference and conditional bootstrap prompt injection. Co-Authored-By: Hagicode <noreply@hagicode.com> Signed-off-by: newbe36524 <newbe36524@qq.com>
1 parent 8ce5903 commit 7fb6c8d

3 files changed

Lines changed: 120 additions & 2 deletions

File tree

src/HagiCode.Libs.Providers/ClaudeCode/ClaudeCodeOptions.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ public sealed record ClaudeCodeOptions
2727
/// </summary>
2828
public string? WorkingDirectory { get; init; }
2929

30+
/// <summary>
31+
/// Gets or sets the process working directory used for the current execution.
32+
/// When omitted, <see cref="WorkingDirectory" /> remains the execution root.
33+
/// </summary>
34+
public string? ExecutionWorkingDirectory { get; init; }
35+
36+
/// <summary>
37+
/// Gets or sets the canonical project working directory preserved outside the execution cwd.
38+
/// </summary>
39+
public string? OriginalWorkingDirectory { get; init; }
40+
41+
/// <summary>
42+
/// Gets or sets a value indicating whether the provider should append one-time guidance
43+
/// describing the preserved original working directory for the current execution.
44+
/// </summary>
45+
public bool BootstrapOriginalWorkingDirectory { get; init; }
46+
3047
/// <summary>
3148
/// Gets or sets the Claude model name.
3249
/// </summary>

src/HagiCode.Libs.Providers/ClaudeCode/ClaudeCodeProvider.cs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public async IAsyncEnumerable<CliMessage> ExecuteAsync(
6161
{
6262
ExecutablePath = executablePath,
6363
Arguments = BuildCommandArguments(options),
64-
WorkingDirectory = options.WorkingDirectory,
64+
WorkingDirectory = ResolveExecutionWorkingDirectory(options),
6565
EnvironmentVariables = BuildEnvironmentVariables(options, runtimeEnvironment),
6666
InputEncoding = Utf8NoBom,
6767
OutputEncoding = Utf8NoBom,
@@ -147,7 +147,7 @@ internal virtual IReadOnlyList<string> BuildCommandArguments(ClaudeCodeOptions o
147147
arguments.AddRange(["--system-prompt", systemPrompt]);
148148
}
149149

150-
var appendSystemPrompt = ArgumentValueNormalizer.NormalizeOptionalValue(options.AppendSystemPrompt);
150+
var appendSystemPrompt = BuildEffectiveAppendSystemPrompt(options);
151151
if (appendSystemPrompt is not null)
152152
{
153153
arguments.AddRange(["--append-system-prompt", appendSystemPrompt]);
@@ -419,6 +419,50 @@ private static string BuildRuntimeFingerprint(ProcessStartContext startContext)
419419
?? ArgumentValueNormalizer.NormalizeOptionalValue(options.Resume);
420420
}
421421

422+
private static string? ResolveExecutionWorkingDirectory(ClaudeCodeOptions options)
423+
{
424+
return ArgumentValueNormalizer.NormalizeOptionalValue(options.ExecutionWorkingDirectory)
425+
?? ArgumentValueNormalizer.NormalizeOptionalValue(options.WorkingDirectory);
426+
}
427+
428+
private static string? BuildEffectiveAppendSystemPrompt(ClaudeCodeOptions options)
429+
{
430+
var appendSystemPrompt = ArgumentValueNormalizer.NormalizeOptionalValue(options.AppendSystemPrompt);
431+
var bootstrapPrompt = BuildOriginalWorkingDirectoryBootstrap(options);
432+
433+
return (appendSystemPrompt, bootstrapPrompt) switch
434+
{
435+
(null, null) => null,
436+
(not null, null) => appendSystemPrompt,
437+
(null, not null) => bootstrapPrompt,
438+
(not null, not null) => $"{appendSystemPrompt}\n\n{bootstrapPrompt}"
439+
};
440+
}
441+
442+
private static string? BuildOriginalWorkingDirectoryBootstrap(ClaudeCodeOptions options)
443+
{
444+
if (!options.BootstrapOriginalWorkingDirectory)
445+
{
446+
return null;
447+
}
448+
449+
var originalWorkingDirectory = ArgumentValueNormalizer.NormalizeOptionalValue(options.OriginalWorkingDirectory);
450+
if (originalWorkingDirectory is null)
451+
{
452+
return null;
453+
}
454+
455+
var executionWorkingDirectory = ResolveExecutionWorkingDirectory(options) ?? "(unknown)";
456+
return string.Join(
457+
"\n",
458+
[
459+
"You are running in an isolated working directory for this session.",
460+
$"Current isolated working directory: {executionWorkingDirectory}",
461+
$"Original project working directory: {originalWorkingDirectory}",
462+
"Use the original project path whenever you need to inspect or reference the source repository."
463+
]);
464+
}
465+
422466
private static void UpsertString(JsonObject rootNode, string propertyName, string? value)
423467
{
424468
if (!string.IsNullOrWhiteSpace(value))

tests/HagiCode.Libs.Providers.Tests/ClaudeCodeProviderTests.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,41 @@ public void BuildCommandArguments_trims_optional_values_and_omits_empty_after_tr
108108
]);
109109
}
110110

111+
[Fact]
112+
public void BuildCommandArguments_appends_isolated_workdir_bootstrap_only_when_requested()
113+
{
114+
var provider = CreateProvider();
115+
116+
var firstRunArguments = provider.BuildCommandArguments(new ClaudeCodeOptions
117+
{
118+
WorkingDirectory = "/repo/project",
119+
ExecutionWorkingDirectory = "/tmp/hagicode/claude/session-1",
120+
OriginalWorkingDirectory = "/repo/project",
121+
BootstrapOriginalWorkingDirectory = true,
122+
AppendSystemPrompt = "Keep responses terse."
123+
});
124+
125+
var appendPromptIndex = firstRunArguments
126+
.Select((argument, index) => new { argument, index })
127+
.FirstOrDefault(entry => entry.argument == "--append-system-prompt")?.index ?? -1;
128+
appendPromptIndex.ShouldBeGreaterThanOrEqualTo(0);
129+
var combinedPrompt = firstRunArguments[appendPromptIndex + 1];
130+
combinedPrompt.ShouldContain("Keep responses terse.");
131+
combinedPrompt.ShouldContain("isolated working directory");
132+
combinedPrompt.ShouldContain("/tmp/hagicode/claude/session-1");
133+
combinedPrompt.ShouldContain("/repo/project");
134+
135+
var resumedArguments = provider.BuildCommandArguments(new ClaudeCodeOptions
136+
{
137+
WorkingDirectory = "/repo/project",
138+
ExecutionWorkingDirectory = "/tmp/hagicode/claude/session-1",
139+
OriginalWorkingDirectory = "/repo/project",
140+
BootstrapOriginalWorkingDirectory = false
141+
});
142+
143+
resumedArguments.ShouldNotContain("--append-system-prompt");
144+
}
145+
111146
[Fact]
112147
public async Task ExecuteAsync_uses_custom_executable_and_streams_messages()
113148
{
@@ -134,6 +169,28 @@ public async Task ExecuteAsync_uses_custom_executable_and_streams_messages()
134169
provider.SentMessages[0].Content.GetProperty("message").GetProperty("content").GetString().ShouldBe("hello");
135170
}
136171

172+
[Fact]
173+
public async Task ExecuteAsync_prefers_execution_working_directory_when_isolation_is_enabled()
174+
{
175+
var provider = CreateProvider();
176+
177+
await foreach (var _ in provider.ExecuteAsync(
178+
new ClaudeCodeOptions
179+
{
180+
WorkingDirectory = "/repo/project",
181+
ExecutionWorkingDirectory = "/tmp/hagicode/claude/session-1",
182+
OriginalWorkingDirectory = "/repo/project",
183+
BootstrapOriginalWorkingDirectory = true,
184+
SessionId = "session-1"
185+
},
186+
"hello"))
187+
{
188+
}
189+
190+
provider.LastStartContext.ShouldNotBeNull();
191+
provider.LastStartContext.WorkingDirectory.ShouldBe("/tmp/hagicode/claude/session-1");
192+
}
193+
137194
[Fact]
138195
public async Task ExecuteAsync_uses_utf8_without_bom_for_stream_json_transport()
139196
{

0 commit comments

Comments
 (0)