diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index e5bc6a5d1..a3d9672e2 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -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); diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index 29e2a9b71..866869672 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -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. diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index 5f4c4903c..13366eb06 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -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 diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 2ab0c596a..f2c92ad1d 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -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; } diff --git a/packages/cli/src/commands/worktree/archive.ts b/packages/cli/src/commands/worktree/archive.ts index 695fc90f4..f75350fd4 100644 --- a/packages/cli/src/commands/worktree/archive.ts +++ b/packages/cli/src/commands/worktree/archive.ts @@ -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", diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 6517c0c74..22ce604d9 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -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}`; } diff --git a/packages/cli/src/utils/paths.ts b/packages/cli/src/utils/paths.ts new file mode 100644 index 000000000..e8339e1e6 --- /dev/null +++ b/packages/cli/src/utils/paths.ts @@ -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 + "/") + ); +} diff --git a/packages/cli/tests/18-local-daemon-utils.test.ts b/packages/cli/tests/18-local-daemon-utils.test.ts index 81a1886ab..89780a673 100644 --- a/packages/cli/tests/18-local-daemon-utils.test.ts +++ b/packages/cli/tests/18-local-daemon-utils.test.ts @@ -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 ==="); diff --git a/packages/cli/tests/28-client-ipc-targets.test.ts b/packages/cli/tests/28-client-ipc-targets.test.ts index ad9511237..09b999ba6 100644 --- a/packages/cli/tests/28-client-ipc-targets.test.ts +++ b/packages/cli/tests/28-client-ipc-targets.test.ts @@ -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-")); diff --git a/packages/cli/tests/30-cwd-filter-paths.test.ts b/packages/cli/tests/30-cwd-filter-paths.test.ts new file mode 100644 index 000000000..939adc7b6 --- /dev/null +++ b/packages/cli/tests/30-cwd-filter-paths.test.ts @@ -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 ==="); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index f3055b357..607ac91a4 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -189,7 +189,8 @@ 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; } diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 43f0ab527..00db5a635 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, beforeEach, afterEach } from "vitest"; import os from "node:os"; import path from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { promises as fs } from "node:fs"; import { createTestLogger } from "../../test-utils/test-logger.js"; @@ -352,6 +352,24 @@ describe("AgentStorage", () => { expect(records[0]?.internal).toBe(true); }); + test("Windows drive-letter paths produce valid directory names", async () => { + await storage.applySnapshot( + createManagedAgent({ + id: "win-agent", + cwd: "D:\\Users\\dev\\MyProject", + }), + ); + + const record = await storage.get("win-agent"); + expect(record).not.toBeNull(); + + // The persisted directory must not contain a colon (invalid on Windows) + const dirs = readdirSync(storagePath); + expect(dirs).toHaveLength(1); + expect(dirs[0]).not.toContain(":"); + expect(dirs[0]).toBe("D-Users-dev-MyProject"); + }); + test("remove deletes all duplicate record files across project directories", async () => { const agentId = "agent-duplicate"; diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 75c7ac843..6c0ebd54d 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -335,11 +335,16 @@ export class AgentStorage { } function projectDirNameFromCwd(cwd: string): string { - const trimmed = cwd.replace(/^[\\/]+/, "").replace(/[\\/]+$/, ""); - if (!trimmed) { - return "root"; + // path.win32.parse handles drive letters, UNC roots, and Unix roots on all platforms + const { root } = path.win32.parse(cwd); + const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, ""); + // Sanitize root: strip colons and separators, keep letters (e.g. "C:\" → "C", "\\server\share\" → "server-share") + const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, ""); + const prefix = sanitizedRoot ? sanitizedRoot + "-" : ""; + if (!withoutRoot) { + return sanitizedRoot || "root"; } - return trimmed.replace(/[\\/]+/g, "-"); + return prefix + withoutRoot.replace(/[\\/]+/g, "-"); } async function writeFileAtomically(targetPath: string, payload: string) { diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 70c951520..ee2b4e520 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -122,6 +122,23 @@ describe("paseo daemon bootstrap", () => { } }); + test("parses whitespace-padded numeric port strings", () => { + expect(parseListenString(" 6767 ")).toEqual({ + type: "tcp", + host: "127.0.0.1", + port: 6767, + }); + }); + + test("rejects Windows absolute paths that are not named pipes", () => { + // A Windows drive path like C:\daemon must NOT be silently parsed as TCP + // (split(":") would yield host="C" and port="\\daemon" which is nonsensical). + expect(() => parseListenString(String.raw`C:\daemon`)).toThrow(); + expect(() => parseListenString(String.raw`D:\Users\foo\.paseo\daemon.sock`)).toThrow(); + // Single-letter "host" with no valid port is not a valid listen string + expect(() => parseListenString(String.raw`C:\some\path`)).toThrow(); + }); + test("parses Windows named pipes as managed IPC listen targets", () => { expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({ type: "pipe", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2485db8d0..0027fedb8 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -35,34 +35,43 @@ function resolveBoundListenTarget( }; } +// Matches a Windows drive-letter path like C:\ or D:\ +const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/; + export function parseListenString(listen: string): ListenTarget { + // 1. Windows named pipes: \\.\pipe\... or pipe://... if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) { return { type: "pipe", path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen, }; } - // Unix socket: starts with / or ~ or contains .sock - if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) { - return { type: "socket", path: listen }; - } - // Explicit unix:// prefix + // 2. Explicit unix:// prefix if (listen.startsWith("unix://")) { return { type: "socket", path: listen.slice(7) }; } - // TCP: host:port or just port + // 3. Reject Windows absolute drive paths — they are not Unix sockets + if (WINDOWS_DRIVE_RE.test(listen)) { + throw new Error(`Invalid listen string (Windows path is not a valid listen target): ${listen}`); + } + // 4. POSIX absolute path (/ or ~) — Unix socket + if (listen.startsWith("/") || listen.startsWith("~")) { + return { type: "socket", path: listen }; + } + // 5. Pure numeric — TCP port on 127.0.0.1 + const trimmed = listen.trim(); + if (/^\d+$/.test(trimmed)) { + const port = parseInt(trimmed, 10); + return { type: "tcp", host: "127.0.0.1", port }; + } + // 6. host:port — TCP if (listen.includes(":")) { const [host, portStr] = listen.split(":"); - const port = parseInt(portStr, 10); - if (!Number.isFinite(port)) { + const parsedPort = parseInt(portStr, 10); + if (!Number.isFinite(parsedPort)) { throw new Error(`Invalid port in listen string: ${listen}`); } - return { type: "tcp", host: host || "127.0.0.1", port }; - } - // Just a port number - const port = parseInt(listen, 10); - if (Number.isFinite(port)) { - return { type: "tcp", host: "127.0.0.1", port }; + return { type: "tcp", host: host || "127.0.0.1", port: parsedPort }; } throw new Error(`Invalid listen string: ${listen}`); } diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index f1033e4e1..20dadc4e7 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -73,6 +73,12 @@ describe("TerminalManager", () => { await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path"); }); + it("accepts Windows absolute paths", async () => { + manager = createTerminalManager(); + await expect(manager.getTerminals("C:\\Users\\foo\\project")).resolves.not.toThrow(); + await expect(manager.getTerminals("D:\\MyProject")).resolves.not.toThrow(); + }); + it("creates separate terminals for different cwds", async () => { manager = createTerminalManager(); const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })]; @@ -121,6 +127,18 @@ describe("TerminalManager", () => { ); }); + it("does not reject Windows absolute paths as relative", async () => { + manager = createTerminalManager(); + // Should pass path validation (not throw "cwd must be absolute path"). + // The terminal may or may not spawn successfully on non-Windows hosts, + // so we only assert the validation error is absent. + try { + await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" }); + } catch (error) { + expect((error as Error).message).not.toBe("cwd must be absolute path"); + } + }); + it("inherits registered env for the worktree root cwd", async () => { await withShell("/bin/sh", async () => { manager = createTerminalManager(); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index dc243b681..22e016875 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -1,5 +1,5 @@ import { createTerminal, type TerminalSession } from "./terminal.js"; -import { resolve, sep } from "node:path"; +import { resolve, sep, win32, posix } from "node:path"; export interface TerminalListItem { id: string; @@ -37,7 +37,7 @@ export function createTerminalManager(): TerminalManager { const defaultEnvByRootCwd = new Map>(); function assertAbsolutePath(cwd: string): void { - if (!cwd.startsWith("/")) { + if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) { throw new Error("cwd must be absolute path"); } } diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 8ded45d91..2edeef819 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -18,6 +18,9 @@ import { NotGitRepoError, pushCurrentBranch, resolveRepositoryDefaultBranch, + parseWorktreeList, + isPaseoWorktreePath, + isDescendantPath, } from "./checkout-git.js"; import { createWorktree } from "./worktree.js"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; @@ -812,4 +815,73 @@ const x = 1; ).rejects.toThrow(/base/i); await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i); }); + + describe("parseWorktreeList", () => { + it("parses porcelain worktree output", () => { + const output = [ + "worktree /home/user/repo", + "branch refs/heads/main", + "", + "worktree /home/user/.paseo/worktrees/feature", + "branch refs/heads/feature", + "", + ].join("\n"); + + const entries = parseWorktreeList(output); + expect(entries).toHaveLength(2); + expect(entries[0]).toEqual({ path: "/home/user/repo", branchRef: "refs/heads/main" }); + expect(entries[1]).toEqual({ + path: "/home/user/.paseo/worktrees/feature", + branchRef: "refs/heads/feature", + }); + }); + + it("detects bare repos", () => { + const output = ["worktree /home/user/repo.git", "bare", ""].join("\n"); + const entries = parseWorktreeList(output); + expect(entries).toHaveLength(1); + expect(entries[0]?.isBare).toBe(true); + }); + }); + + describe("isPaseoWorktreePath", () => { + it("matches Unix .paseo/worktrees/ paths", () => { + expect(isPaseoWorktreePath("/home/user/.paseo/worktrees/feature")).toBe(true); + }); + + it("matches Windows .paseo\\worktrees\\ paths", () => { + expect(isPaseoWorktreePath("C:\\Users\\dev\\.paseo\\worktrees\\feature")).toBe(true); + }); + + it("rejects paths without .paseo/worktrees segment", () => { + expect(isPaseoWorktreePath("/home/user/repo")).toBe(false); + expect(isPaseoWorktreePath("C:\\Users\\dev\\repo")).toBe(false); + }); + }); + + describe("isDescendantPath", () => { + it("detects children with Unix separators", () => { + expect(isDescendantPath("/home/user/repo/child", "/home/user/repo")).toBe(true); + }); + + it("detects children with Windows separators", () => { + expect(isDescendantPath("C:\\repos\\child", "C:\\repos")).toBe(true); + }); + + it("rejects the parent itself", () => { + expect(isDescendantPath("/home/user/repo", "/home/user/repo")).toBe(false); + }); + + it("rejects siblings that share a prefix", () => { + expect(isDescendantPath("/home/user/repo-extra", "/home/user/repo")).toBe(false); + }); + + it("handles mixed separators", () => { + expect(isDescendantPath("C:/repo/child", "C:\\repo")).toBe(true); + }); + + it("is case insensitive on Windows paths", () => { + expect(isDescendantPath("c:\\repo\\child", "C:\\repo")).toBe(true); + }); + }); }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 6685934ae..ded5c96cd 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -603,20 +603,39 @@ async function getMainRepoRoot(cwd: string): Promise { }); const worktrees = parseWorktreeList(worktreeOut); const nonBareNonPaseo = worktrees.filter( - (wt) => !wt.isBare && !wt.path.includes("/.paseo/worktrees/"), + (wt) => !wt.isBare && !isPaseoWorktreePath(wt.path), ); - const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => wt.path.startsWith(normalized + "/")); + const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized)); const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main"); return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized; } -type GitWorktreeEntry = { +export type GitWorktreeEntry = { path: string; branchRef?: string; isBare?: boolean; }; -function parseWorktreeList(output: string): GitWorktreeEntry[] { +/** Check whether a path contains a `.paseo/worktrees/` segment (both `/` and `\`). */ +export function isPaseoWorktreePath(p: string): boolean { + return /[/\\]\.paseo[/\\]worktrees[/\\]/.test(p); +} + +/** True when `child` is strictly inside `parent` (handles both `/` and `\`). */ +export function isDescendantPath(child: string, parent: string): boolean { + let c = child.replace(/\\/g, "/").replace(/\/+$/, ""); + let p = parent.replace(/\\/g, "/").replace(/\/+$/, ""); + // Case-insensitive on Windows (drive letter like C: or D:) + if (/^[A-Za-z]:/.test(c) || /^[A-Za-z]:/.test(p)) { + c = c.toLowerCase(); + p = p.toLowerCase(); + } + if (!c.startsWith(p)) return false; + if (c.length === p.length) return false; + return c[p.length] === "/"; +} + +export function parseWorktreeList(output: string): GitWorktreeEntry[] { const entries: GitWorktreeEntry[] = []; let current: GitWorktreeEntry | null = null; for (const line of output.split("\n")) {