fix(data): normalize a list working dir's trailing separator on write

A list working_dir stored as "C:\Dev\Tests\StaplerTracking\" broke every consumer
that puts it on a Windows command line: argv rules read \" as an escaped quote, so
the token never closes. "Open in terminal" passed wt.exe a starting directory of
C:\Dev\Tests\StaplerTracking" and it failed with 0x8007010b; the same data had
already corrupted the ConPTY list handler's arg list in August.

Paths.TrimTrailingSeparator is now the single helper (replacing the copies in
InteractiveLaunchSpecService and ClaudeHelpLauncher) and ListRepository applies it
on Add/Update, which covers every writer: UI create, repo import, hub UpdateList,
and MCP CreateList/UpdateList. OpenInTerminal also switches to ArgumentList so its
quoting is correct regardless of what's stored.
This commit is contained in:
mika kuns
2026-08-10 17:12:46 +02:00
parent 6d8cbacc9b
commit d451edd367
7 changed files with 150 additions and 18 deletions
+10 -2
View File
@@ -54,8 +54,16 @@ so a token like `"C:\repo\"` never closes and every following argument is swallo
preceding **variadic** flag. A list working dir stored as `C:\Dev\Repos\Bandel.Hub\` therefore
fed `--add-dir` the repo, `--append-system-prompt-file`, its value **and** the positional kickoff:
the CLI warned `brief.md is not a directory` and the session opened with no prompt at all
(2026-08-06). `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` now run the repo
through `TrimTrailingSeparator`; session dirs the worker builds never carry one.
(2026-08-06). `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` run the repo through
`TrimTrailingSeparator`; session dirs the worker builds never carry one.
The same data bit the UI's "Open in terminal" on 2026-08-10 (`wt -d "C:\…\StaplerTracking\"`
`Could not access starting directory "C:\…\StaplerTracking""`), so the fix moved to the write side:
**`ListRepository.AddAsync`/`UpdateAsync` normalize `WorkingDir` via `Paths.TrimTrailingSeparator`**,
which covers every writer (UI create, repo import, hub `UpdateList`, MCP `CreateList`/`UpdateList`) —
the UI and worker keep their own defensive trim for rows written before that. Anything new that puts
a **user-supplied** path on a command line should use `Paths.TrimTrailingSeparator` and, on the
`ProcessStartInfo` side, `ArgumentList` rather than an interpolated `Arguments` string.
Diagnosing this from code or a PowerShell repro is a dead end — PowerShell quotes correctly, so
every repro passes. Read the real command line instead:
+16
View File
@@ -25,6 +25,22 @@ public static class Paths
return Path.GetFullPath(expanded);
}
/// <summary>
/// Strips trailing directory separators off a path bound for a Windows command line. Windows
/// argv rules read <c>\"</c> as an escaped quote, so a quoted token ending in '\' never closes
/// (<c>"C:\repo\"</c> parses as <c>C:\repo"</c>) and either the path itself or every argument
/// after it is corrupted. A bare root ("C:\", "/") is all separator and is returned untouched;
/// so is null/blank.
/// </summary>
public static string? TrimTrailingSeparator(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return path;
var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return trimmed.Length == 0 || trimmed.EndsWith(':') ? path : trimmed;
}
/// <summary>~/.todo-app — parent directory for db, logs, config, sandbox, worktrees.</summary>
public static string AppDataRoot() =>
Expand("~/.todo-app");
@@ -16,16 +16,25 @@ public sealed class ListRepository
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{
NormalizeWorkingDir(entity);
_context.Lists.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{
NormalizeWorkingDir(entity);
_context.Lists.Update(entity);
await _context.SaveChangesAsync(ct);
}
// A working dir is user-supplied (typed, folder-picked, imported, or set over MCP) and every
// consumer eventually hands it to the claude CLI or wt.exe as one quoted command-line token —
// where a trailing '\' escapes its own closing quote. Normalizing here covers all writers;
// see Paths.TrimTrailingSeparator.
private static void NormalizeWorkingDir(ListEntity entity) =>
entity.WorkingDir = Paths.TrimTrailingSeparator(entity.WorkingDir);
public async Task DeleteAsync(string listId, CancellationToken ct = default)
{
var taskIds = await _context.Tasks
@@ -178,5 +178,6 @@ public sealed class ClaudeHelpLauncher
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
private static string QuoteDirectory(string directory) => Quote(directory.TrimEnd('\\', '/'));
private static string QuoteDirectory(string directory) =>
Quote(ClaudeDo.Data.Paths.TrimTrailingSeparator(directory)!);
}
@@ -143,15 +143,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
// Trailing separator + ArgumentList, not string interpolation: "C:\repo\" would parse as
// C:\repo" and wt would refuse it as a starting directory (Paths.TrimTrailingSeparator).
// Rows loaded before ListRepository normalized on write can still carry one.
dir = Paths.TrimTrailingSeparator(dir)!;
ForegroundHelper.AllowAny();
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "wt.exe",
Arguments = $"-d \"{dir}\"",
UseShellExecute = true,
});
};
psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(dir);
System.Diagnostics.Process.Start(psi);
}
catch
{
@@ -352,18 +352,12 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
return new LaunchSpec(repoDir, resolvedClaude, args, env);
}
// Strips a trailing directory separator off a path bound for the CLI argument list. The ConPTY
// host flattens Args into ONE Windows command line and quotes each token, so a token ending in
// '\' escapes its own closing quote ("C:\repo\" parses as C:\repo" ...) and every following
// argument is absorbed into the preceding variadic flag -- for a list handler that means
// --add-dir swallows --append-system-prompt-file AND the positional kickoff, and the session
// opens with no prompt at all. Only user-supplied list working dirs can carry one; the session
// dirs we build never do. A bare root ("C:\", "/") is all separator and is left untouched.
private static string TrimTrailingSeparator(string dir)
{
var trimmed = dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return trimmed.Length == 0 || trimmed.EndsWith(':') ? dir : trimmed;
}
// The ConPTY host flattens Args into ONE Windows command line and quotes each token, so a token
// ending in '\' escapes its own closing quote and every following argument is absorbed into the
// preceding variadic flag -- for a list handler that means --add-dir swallows
// --append-system-prompt-file AND the positional kickoff, and the session opens with no prompt
// at all. ListRepository normalizes on write now, but rows written before that still carry one.
private static string TrimTrailingSeparator(string dir) => Paths.TrimTrailingSeparator(dir)!;
// Renders one task as a brief list item. A description can itself be arbitrary Markdown
// (headings, lists, fenced code) — those must not merge into the brief's own task list, so
@@ -0,0 +1,98 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Tests;
// A list working dir stored with a trailing separator ("C:\repo\") breaks every consumer that
// puts it on a Windows command line: the token's closing quote is escaped by the backslash, so
// "C:\repo\" parses as C:\repo" and the arg (or every arg after it) is corrupted. Observed twice
// -- ConPTY list handlers (2026-08-06) and "Open in terminal" (2026-08-10). Normalizing on write
// is the only place that covers all writers (UI, repo import, hub, MCP).
public sealed class WorkingDirNormalizationTests : IDisposable
{
private readonly string _dbPath;
private readonly ClaudeDoDbContext _ctx;
public WorkingDirNormalizationTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_workdir_{Guid.NewGuid():N}.db");
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
_ctx = new ClaudeDoDbContext(options);
_ctx.Database.EnsureCreated();
}
public void Dispose()
{
_ctx.Dispose();
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
[Theory]
[InlineData(@"C:\Dev\Tests\StaplerTracking\", @"C:\Dev\Tests\StaplerTracking")]
[InlineData(@"C:\Dev\Tests\StaplerTracking\\", @"C:\Dev\Tests\StaplerTracking")]
[InlineData("C:/Dev/Tests/StaplerTracking/", "C:/Dev/Tests/StaplerTracking")]
[InlineData(@"C:\Dev\Tests\StaplerTracking", @"C:\Dev\Tests\StaplerTracking")]
public void TrimTrailingSeparator_strips_trailing_separators(string input, string expected) =>
Assert.Equal(expected, Paths.TrimTrailingSeparator(input));
// A bare drive root IS its trailing separator -- "C:" without it means "current dir on C:",
// a different location. Same for the unix root.
[Theory]
[InlineData(@"C:\")]
[InlineData("/")]
public void TrimTrailingSeparator_leaves_a_bare_root_alone(string root) =>
Assert.Equal(root, Paths.TrimTrailingSeparator(root));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TrimTrailingSeparator_passes_blank_through(string? input) =>
Assert.Equal(input, Paths.TrimTrailingSeparator(input));
[Fact]
public async Task AddAsync_normalizes_a_trailing_separator()
{
var repo = new ListRepository(_ctx);
await repo.AddAsync(new ListEntity
{
Id = "l1",
Name = "Stapler Tracking",
WorkingDir = @"C:\Dev\Tests\StaplerTracking\",
CreatedAt = DateTime.UtcNow,
});
var stored = await repo.GetByIdAsync("l1");
Assert.Equal(@"C:\Dev\Tests\StaplerTracking", stored!.WorkingDir);
}
[Fact]
public async Task UpdateAsync_normalizes_a_trailing_separator()
{
var repo = new ListRepository(_ctx);
await repo.AddAsync(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
var entity = await repo.GetByIdAsync("l1");
entity!.WorkingDir = @"C:\Dev\Repos\Bandel.Hub\";
await repo.UpdateAsync(entity);
var stored = await repo.GetByIdAsync("l1");
Assert.Equal(@"C:\Dev\Repos\Bandel.Hub", stored!.WorkingDir);
}
[Fact]
public async Task AddAsync_leaves_a_null_working_dir_null()
{
var repo = new ListRepository(_ctx);
await repo.AddAsync(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
var stored = await repo.GetByIdAsync("l1");
Assert.Null(stored!.WorkingDir);
}
}