Interactive planning sessions launched with --permission-mode plan, which gates EVERY MCP tool call regardless of --allowedTools (verified: even a read-only mcp__claudedo__list_task_lists is denied under plan mode). So the session prompted the user on the first CreateChildTask -- the whole point of a planning session. Switch BuildPlanningStartArgs/BuildPlanningResumeArgs to --permission-mode default, which honours the allowlist. File edits stay blocked via the planning system prompt + AllowedTools omitting Write/Edit/Bash. Resume also re-passes --allowedTools, since the CLI does not restore it across --resume. The earlier 'glob does not match' hypothesis was empirically falsified: mcp__claudedo__*, the bare server name, and the explicit tool name all allow the tool with zero permission_denials in default mode.
129 lines
5.6 KiB
C#
129 lines
5.6 KiB
C#
using ClaudeDo.Worker.Planning;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Planning;
|
|
|
|
public sealed class WindowsTerminalLauncherTests
|
|
{
|
|
private static PlanningSessionStartContext MakeStartCtx(string? wd = null)
|
|
{
|
|
var workingDir = wd ?? Path.GetTempPath();
|
|
var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(dir);
|
|
return new PlanningSessionStartContext(
|
|
ParentTaskId: "task-1",
|
|
WorkingDir: workingDir,
|
|
Token: "test-token",
|
|
WorktreePath: workingDir,
|
|
BranchName: "claudedo/planning/task1",
|
|
Files: new PlanningSessionFiles(
|
|
SessionDirectory: dir,
|
|
SystemPromptPath: Path.Combine(dir, "system-prompt.md"),
|
|
InitialPromptPath: Path.Combine(dir, "initial-prompt.txt")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LaunchPlanningStartAsync_WorkingDirMissing_Throws()
|
|
{
|
|
var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid()));
|
|
var sut = new WindowsTerminalLauncher(wtPath: "wt", claudePath: "claude");
|
|
var ex = await Assert.ThrowsAsync<TerminalLaunchException>(() =>
|
|
sut.LaunchPlanningStartAsync(ctx, CancellationToken.None));
|
|
Assert.Contains("Working directory", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LaunchPlanningStartAsync_WtMissing_Throws()
|
|
{
|
|
var ctx = MakeStartCtx();
|
|
File.WriteAllText(ctx.Files.SystemPromptPath, "sp");
|
|
File.WriteAllText(ctx.Files.InitialPromptPath, "ip");
|
|
|
|
var sut = new WindowsTerminalLauncher(
|
|
wtPath: "C:/no/such/wt.exe",
|
|
claudePath: "claude");
|
|
var ex = await Assert.ThrowsAsync<TerminalLaunchException>(() =>
|
|
sut.LaunchPlanningStartAsync(ctx, CancellationToken.None));
|
|
Assert.Contains("Windows Terminal", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPlanningStartCommand_SeedsViaBriefFile_NotInlineText()
|
|
{
|
|
var ctx = MakeStartCtx();
|
|
|
|
var command = WindowsTerminalLauncher.BuildPlanningStartCommand("claude.exe", ctx);
|
|
|
|
// The brief is read from a file: the kickoff references the brief path, and its
|
|
// directory is exposed via --add-dir so the Read tool can open it.
|
|
Assert.Contains(ctx.Files.InitialPromptPath, command);
|
|
Assert.Contains($"--add-dir' '{ctx.Files.SessionDirectory}", command);
|
|
|
|
// The positional kickoff must follow the single-value --append-system-prompt-file
|
|
// flag, or a preceding variadic flag would swallow it.
|
|
var appendIdx = command.IndexOf("--append-system-prompt-file", StringComparison.Ordinal);
|
|
var addDirIdx = command.IndexOf("--add-dir", StringComparison.Ordinal);
|
|
Assert.True(appendIdx > addDirIdx, "--append-system-prompt-file must come after --add-dir");
|
|
Assert.True(command.IndexOf(ctx.Files.InitialPromptPath, StringComparison.Ordinal) > appendIdx,
|
|
"the kickoff prompt must be the last (positional) token");
|
|
|
|
// The legacy env-var indirection is gone.
|
|
Assert.DoesNotContain("CLAUDEDO_LAUNCH_PROMPT", command);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPlanningStartArgs_HasPlanningFlagsAndKickoffLast()
|
|
{
|
|
var ctx = MakeStartCtx();
|
|
|
|
var args = WindowsTerminalLauncher.BuildPlanningStartArgs(ctx);
|
|
|
|
Assert.Equal("--model", args[0]);
|
|
var permIdx = args.ToList().IndexOf("--permission-mode");
|
|
Assert.True(permIdx >= 0);
|
|
// Default mode, NOT plan mode: plan mode gates every MCP tool call regardless of the
|
|
// allowlist, which would prompt on the first CreateChildTask.
|
|
Assert.Equal("default", args[permIdx + 1]);
|
|
Assert.Contains("--allowedTools", args);
|
|
Assert.Contains(ctx.Files.SessionDirectory, args);
|
|
Assert.Contains(ctx.Files.SystemPromptPath, args);
|
|
// Kickoff positional is the last token and points at the brief file.
|
|
Assert.Contains(ctx.Files.InitialPromptPath, args[^1]);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPlanningResumeArgs_DefaultModeAllowlistsMcpAndResumes()
|
|
{
|
|
var args = WindowsTerminalLauncher.BuildPlanningResumeArgs("sess-9");
|
|
Assert.Equal("--permission-mode", args[0]);
|
|
Assert.Equal("default", args[1]);
|
|
Assert.Equal("--allowedTools", args[2]);
|
|
// The MCP planning tools must be re-allowlisted on resume: the CLI does not restore
|
|
// --allowedTools across a --resume, so without it CreateChildTask would prompt again.
|
|
Assert.Contains("mcp__claudedo__", args[3]);
|
|
Assert.Equal(new[] { "--resume", "sess-9" }, args.Skip(4).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildResumeCommand_ResumesSessionSingleQuoted()
|
|
{
|
|
var command = WindowsTerminalLauncher.BuildResumeCommand("claude.exe", "sess-42");
|
|
|
|
Assert.Contains("--resume", command);
|
|
// The session id is single-quoted so ';' and friends pass through untouched.
|
|
Assert.Contains("'--resume' 'sess-42'", command);
|
|
// A plain resume drives the real interactive TUI — no planning-only flags.
|
|
Assert.DoesNotContain("--permission-mode", command);
|
|
Assert.DoesNotContain("--append-system-prompt-file", command);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LaunchResumeAsync_WorkingDirMissing_Throws()
|
|
{
|
|
var sut = new WindowsTerminalLauncher(wtPath: "wt", claudePath: "claude");
|
|
var missing = Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid());
|
|
var ex = await Assert.ThrowsAsync<TerminalLaunchException>(() =>
|
|
sut.LaunchResumeAsync(missing, "sess-1", CancellationToken.None));
|
|
Assert.Contains("Working directory", ex.Message);
|
|
}
|
|
}
|