fix: handle Windows drive-letter paths across the codebase (#148)

* Add metrics collection and terminal performance tests

* fix: handle Windows drive-letter paths across the codebase

Windows paths like C:\Users\foo\project were broken in multiple places:
- agent-storage slugified D:\MyProject as D:-MyProject (illegal colon)
- terminal-manager rejected all non-/ paths as relative
- bootstrap parser misparsed drive colons as TCP host:port
- daemon/client connection helpers misclassified Windows paths
- CLI cwd filtering used hardcoded / separators
- checkout-git worktree detection used hardcoded / in path checks
- worktree archive used split("/").pop() instead of path.basename()

All path helpers now normalize separators and handle Windows
drive letters with case-insensitive comparison where needed.
This commit is contained in:
Mohamed Boudra
2026-03-27 18:17:48 +07:00
committed by GitHub
parent c667aca3e0
commit ca443b3ecf
25 changed files with 980 additions and 49 deletions
+2 -4
View File
@@ -1,5 +1,6 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addDeleteOptions(cmd: Command): Command {
return cmd
@@ -69,12 +70,9 @@ export async function runDeleteCommand(
if (options.all) {
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
const fetchResult = await client.fetchAgent(id);
+2 -5
View File
@@ -3,6 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addLsOptions(cmd: Command): Command {
return cmd
@@ -193,11 +194,7 @@ export async function runLsCommand(
// Optional cwd filter.
if (options.cwd) {
const targetCwd = options.cwd.replace(/\/$/, "");
agents = agents.filter((a) => {
const agentCwd = a.cwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
}
// Apply label filtering only when explicitly requested.
+2 -4
View File
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type {
CommandOptions,
SingleResult,
@@ -75,12 +76,9 @@ export async function runStopCommand(
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
// Stop agents in directory
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
return isSameOrDescendantPath(options.cwd!, a.cwd);
});
} else if (id) {
// Stop specific agent
@@ -294,7 +294,8 @@ export function resolveTcpHostFromListen(listen: string): string | null {
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\")
normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
) {
return null;
}
@@ -1,3 +1,4 @@
import path from "path";
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
@@ -77,7 +78,7 @@ export async function runArchiveCommand(
// Find the worktree by name or branch
const worktree = listResponse.worktrees.find((wt) => {
const name = wt.worktreePath.split("/").pop();
const name = path.basename(wt.worktreePath);
return name === nameArg || wt.branchName === nameArg;
});
@@ -105,7 +106,7 @@ export async function runArchiveCommand(
throw error;
}
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
const worktreeName = path.basename(worktree.worktreePath) || nameArg;
return {
type: "single",
+6 -1
View File
@@ -45,10 +45,15 @@ export function normalizeDaemonHost(raw: string): string | null {
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
}
if (path.isAbsolute(trimmed)) {
if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
return `unix://${trimmed}`;
}
// Windows absolute paths (e.g. C:\Users\foo) are filesystem paths, not TCP or IPC targets.
if (/^[A-Za-z]:[/\\]/.test(trimmed)) {
return null;
}
if (/^\d+$/.test(trimmed)) {
return `127.0.0.1:${trimmed}`;
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Path utilities for cwd filtering in agent commands.
*/
/**
* Check if `candidatePath` is the same directory as `basePath` or a descendant of it.
*
* Handles both Unix (/) and Windows (\) path separators, including mixed separators.
* This is important because agent cwd paths come from the agent's OS (could be Windows)
* while the CLI filter path comes from the user (could also be Windows or Unix).
*/
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
// Normalize both paths: replace all backslashes with forward slashes, strip trailing separator
let normalizedBase = basePath.replace(/\\/g, "/").replace(/\/$/, "");
let normalizedCandidate = candidatePath.replace(/\\/g, "/").replace(/\/$/, "");
// Windows paths are case-insensitive — detect by drive letter prefix (e.g. "C:/")
if (/^[a-zA-Z]:\//.test(normalizedBase) || /^[a-zA-Z]:\//.test(normalizedCandidate)) {
normalizedBase = normalizedBase.toLowerCase();
normalizedCandidate = normalizedCandidate.toLowerCase();
}
return (
normalizedCandidate === normalizedBase ||
normalizedCandidate.startsWith(normalizedBase + "/")
);
}
@@ -42,4 +42,12 @@ console.log("=== Local Daemon Utility Helpers ===\n");
console.log("✓ rejects empty and non-host listen values\n");
}
{
console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)");
assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null);
assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null);
console.log("✓ rejects Windows absolute paths\n");
}
console.log("=== All local daemon utility tests passed ===");
@@ -41,6 +41,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
}
{
console.log("Test 3b: Windows absolute paths are NOT treated as unix sockets");
assert.strictEqual(normalizeDaemonHost("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
console.log("✓ Windows absolute paths are not treated as unix sockets\n");
}
{
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
@@ -0,0 +1,213 @@
#!/usr/bin/env npx tsx
/**
* CWD Filter Path Tests
*
* Tests that cwd filtering in agent commands works correctly
* with both Unix and Windows-style paths.
*
* Bug: The original code used hardcoded "/" separators for:
* 1. Stripping trailing separators (only stripped "/", not "\")
* 2. Descendant matching (appended "/" instead of platform separator)
*
* This causes Windows paths like "C:\Users\dev\project" to fail matching
* against "C:\Users\dev\project\sub" because the code appends "/" instead
* of "\" for the startsWith check.
*/
import assert from "node:assert";
import { isSameOrDescendantPath } from "../src/utils/paths.ts";
console.log("=== CWD Filter Path Tests ===\n");
// Test 1: Unix exact match
{
console.log("Test 1: Unix exact match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project"),
true,
"exact Unix paths should match",
);
console.log("✓ Unix exact match\n");
}
// Test 2: Unix descendant match
{
console.log("Test 2: Unix descendant match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project/src"),
true,
"Unix descendant should match",
);
console.log("✓ Unix descendant match\n");
}
// Test 3: Unix non-match (sibling)
{
console.log("Test 3: Unix non-match (sibling)");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/other"),
false,
"sibling directories should not match",
);
console.log("✓ Unix non-match (sibling)\n");
}
// Test 4: Unix prefix overlap (project vs project2)
{
console.log("Test 4: Unix prefix overlap (project vs project2)");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project2"),
false,
"prefix overlap without separator should not match",
);
console.log("✓ Unix prefix overlap\n");
}
// Test 5: Unix trailing slash on base
{
console.log("Test 5: Unix trailing slash on base");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project/", "/home/user/project/src"),
true,
"trailing slash on base should still match descendants",
);
console.log("✓ Unix trailing slash on base\n");
}
// Test 6: Unix trailing slash on candidate
{
console.log("Test 6: Unix trailing slash on candidate");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project", "/home/user/project/"),
true,
"trailing slash on candidate should match as same dir",
);
console.log("✓ Unix trailing slash on candidate\n");
}
// Test 7: Windows exact match
{
console.log("Test 7: Windows exact match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project"),
true,
"exact Windows paths should match",
);
console.log("✓ Windows exact match\n");
}
// Test 8: Windows descendant match
{
console.log("Test 8: Windows descendant match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\src"),
true,
"Windows descendant should match",
);
console.log("✓ Windows descendant match\n");
}
// Test 9: Windows non-match (sibling)
{
console.log("Test 9: Windows non-match (sibling)");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\other"),
false,
"Windows sibling directories should not match",
);
console.log("✓ Windows non-match (sibling)\n");
}
// Test 10: Windows prefix overlap (project vs project2)
{
console.log("Test 10: Windows prefix overlap (project vs project2)");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project2"),
false,
"Windows prefix overlap without separator should not match",
);
console.log("✓ Windows prefix overlap\n");
}
// Test 11: Windows trailing backslash on base
{
console.log("Test 11: Windows trailing backslash on base");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project\\", "C:\\Users\\dev\\project\\src"),
true,
"trailing backslash on base should still match descendants",
);
console.log("✓ Windows trailing backslash on base\n");
}
// Test 12: Windows trailing backslash on candidate
{
console.log("Test 12: Windows trailing backslash on candidate");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\dev\\project", "C:\\Users\\dev\\project\\"),
true,
"trailing backslash on candidate should match as same dir",
);
console.log("✓ Windows trailing backslash on candidate\n");
}
// Test 13: Mixed separators (agent might use \ while CLI sends /)
{
console.log("Test 13: Mixed separators");
assert.strictEqual(
isSameOrDescendantPath("C:/Users/dev/project", "C:\\Users\\dev\\project\\src"),
true,
"mixed separators should still match",
);
console.log("✓ Mixed separators\n");
}
// Test 14: Deep Windows descendant
{
console.log("Test 14: Deep Windows descendant");
assert.strictEqual(
isSameOrDescendantPath(
"C:\\Users\\dev\\project",
"C:\\Users\\dev\\project\\src\\components\\Button.tsx",
),
true,
"deep Windows descendant should match",
);
console.log("✓ Deep Windows descendant\n");
}
// Test 15: Case-insensitive Windows descendant match
{
console.log("Test 15: Case-insensitive Windows descendant match");
assert.strictEqual(
isSameOrDescendantPath("C:\\Users\\Dev\\Project", "c:\\users\\dev\\project\\src"),
true,
"Windows paths with different casing should match (case-insensitive)",
);
console.log("✓ Case-insensitive Windows descendant match\n");
}
// Test 16: Case-insensitive Windows exact match
{
console.log("Test 16: Case-insensitive Windows exact match");
assert.strictEqual(
isSameOrDescendantPath("c:\\repo", "C:\\Repo"),
true,
"Windows paths with different casing should match exactly (case-insensitive)",
);
console.log("✓ Case-insensitive Windows exact match\n");
}
// Test 17: Parent should not match
{
console.log("Test 15: Parent should not match");
assert.strictEqual(
isSameOrDescendantPath("/home/user/project/src", "/home/user/project"),
false,
"parent directory should not match (only same-or-descendant)",
);
console.log("✓ Parent should not match\n");
}
console.log("=== All CWD filter path tests passed ===");