Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
|
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||||
|
|
||||||
export function addDeleteOptions(cmd: Command): Command {
|
export function addDeleteOptions(cmd: Command): Command {
|
||||||
return cmd
|
return cmd
|
||||||
@@ -69,12 +70,9 @@ export async function runDeleteCommand(
|
|||||||
if (options.all) {
|
if (options.all) {
|
||||||
agents = agents.filter((a) => !a.archivedAt);
|
agents = agents.filter((a) => !a.archivedAt);
|
||||||
} else if (options.cwd) {
|
} else if (options.cwd) {
|
||||||
const filterCwd = options.cwd;
|
|
||||||
agents = agents.filter((a) => {
|
agents = agents.filter((a) => {
|
||||||
if (a.archivedAt) return false;
|
if (a.archivedAt) return false;
|
||||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
|
||||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
|
||||||
});
|
});
|
||||||
} else if (id) {
|
} else if (id) {
|
||||||
const fetchResult = await client.fetchAgent(id);
|
const fetchResult = await client.fetchAgent(id);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
|
|||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||||
import { collectMultiple } from "../../utils/command-options.js";
|
import { collectMultiple } from "../../utils/command-options.js";
|
||||||
|
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||||
|
|
||||||
export function addLsOptions(cmd: Command): Command {
|
export function addLsOptions(cmd: Command): Command {
|
||||||
return cmd
|
return cmd
|
||||||
@@ -193,11 +194,7 @@ export async function runLsCommand(
|
|||||||
|
|
||||||
// Optional cwd filter.
|
// Optional cwd filter.
|
||||||
if (options.cwd) {
|
if (options.cwd) {
|
||||||
const targetCwd = options.cwd.replace(/\/$/, "");
|
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
|
||||||
agents = agents.filter((a) => {
|
|
||||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
|
||||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply label filtering only when explicitly requested.
|
// Apply label filtering only when explicitly requested.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
|
import { isSameOrDescendantPath } from "../../utils/paths.js";
|
||||||
import type {
|
import type {
|
||||||
CommandOptions,
|
CommandOptions,
|
||||||
SingleResult,
|
SingleResult,
|
||||||
@@ -75,12 +76,9 @@ export async function runStopCommand(
|
|||||||
agents = agents.filter((a) => !a.archivedAt);
|
agents = agents.filter((a) => !a.archivedAt);
|
||||||
} else if (options.cwd) {
|
} else if (options.cwd) {
|
||||||
// Stop agents in directory
|
// Stop agents in directory
|
||||||
const filterCwd = options.cwd;
|
|
||||||
agents = agents.filter((a) => {
|
agents = agents.filter((a) => {
|
||||||
if (a.archivedAt) return false;
|
if (a.archivedAt) return false;
|
||||||
const agentCwd = a.cwd.replace(/\/$/, "");
|
return isSameOrDescendantPath(options.cwd!, a.cwd);
|
||||||
const targetCwd = filterCwd.replace(/\/$/, "");
|
|
||||||
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
|
|
||||||
});
|
});
|
||||||
} else if (id) {
|
} else if (id) {
|
||||||
// Stop specific agent
|
// Stop specific agent
|
||||||
|
|||||||
@@ -294,7 +294,8 @@ export function resolveTcpHostFromListen(listen: string): string | null {
|
|||||||
normalized.startsWith("/") ||
|
normalized.startsWith("/") ||
|
||||||
normalized.startsWith("unix://") ||
|
normalized.startsWith("unix://") ||
|
||||||
normalized.startsWith("pipe://") ||
|
normalized.startsWith("pipe://") ||
|
||||||
normalized.startsWith("\\\\.\\pipe\\")
|
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||||
|
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import path from "path";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -77,7 +78,7 @@ export async function runArchiveCommand(
|
|||||||
|
|
||||||
// Find the worktree by name or branch
|
// Find the worktree by name or branch
|
||||||
const worktree = listResponse.worktrees.find((wt) => {
|
const worktree = listResponse.worktrees.find((wt) => {
|
||||||
const name = wt.worktreePath.split("/").pop();
|
const name = path.basename(wt.worktreePath);
|
||||||
return name === nameArg || wt.branchName === nameArg;
|
return name === nameArg || wt.branchName === nameArg;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ export async function runArchiveCommand(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
|
const worktreeName = path.basename(worktree.worktreePath) || nameArg;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "single",
|
type: "single",
|
||||||
|
|||||||
@@ -45,10 +45,15 @@ export function normalizeDaemonHost(raw: string): string | null {
|
|||||||
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
|
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.isAbsolute(trimmed)) {
|
if (trimmed.startsWith("/") || trimmed.startsWith("~")) {
|
||||||
return `unix://${trimmed}`;
|
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)) {
|
if (/^\d+$/.test(trimmed)) {
|
||||||
return `127.0.0.1:${trimmed}`;
|
return `127.0.0.1:${trimmed}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("✓ 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 ===");
|
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("✓ 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");
|
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
|
||||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
|
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 ===");
|
||||||
@@ -189,7 +189,8 @@ function resolveTcpHostFromListen(listen: string): string | null {
|
|||||||
normalized.startsWith("/") ||
|
normalized.startsWith("/") ||
|
||||||
normalized.startsWith("unix://") ||
|
normalized.startsWith("unix://") ||
|
||||||
normalized.startsWith("pipe://") ||
|
normalized.startsWith("pipe://") ||
|
||||||
normalized.startsWith("\\\\.\\pipe\\")
|
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||||
|
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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 { promises as fs } from "node:fs";
|
||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
@@ -352,6 +352,24 @@ describe("AgentStorage", () => {
|
|||||||
expect(records[0]?.internal).toBe(true);
|
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 () => {
|
test("remove deletes all duplicate record files across project directories", async () => {
|
||||||
const agentId = "agent-duplicate";
|
const agentId = "agent-duplicate";
|
||||||
|
|
||||||
|
|||||||
@@ -335,11 +335,16 @@ export class AgentStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function projectDirNameFromCwd(cwd: string): string {
|
function projectDirNameFromCwd(cwd: string): string {
|
||||||
const trimmed = cwd.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
|
// path.win32.parse handles drive letters, UNC roots, and Unix roots on all platforms
|
||||||
if (!trimmed) {
|
const { root } = path.win32.parse(cwd);
|
||||||
return "root";
|
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) {
|
async function writeFileAtomically(targetPath: string, payload: string) {
|
||||||
|
|||||||
@@ -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", () => {
|
test("parses Windows named pipes as managed IPC listen targets", () => {
|
||||||
expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({
|
expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({
|
||||||
type: "pipe",
|
type: "pipe",
|
||||||
|
|||||||
@@ -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 {
|
export function parseListenString(listen: string): ListenTarget {
|
||||||
|
// 1. Windows named pipes: \\.\pipe\... or pipe://...
|
||||||
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
|
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
|
||||||
return {
|
return {
|
||||||
type: "pipe",
|
type: "pipe",
|
||||||
path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen,
|
path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Unix socket: starts with / or ~ or contains .sock
|
// 2. Explicit unix:// prefix
|
||||||
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
|
|
||||||
return { type: "socket", path: listen };
|
|
||||||
}
|
|
||||||
// Explicit unix:// prefix
|
|
||||||
if (listen.startsWith("unix://")) {
|
if (listen.startsWith("unix://")) {
|
||||||
return { type: "socket", path: listen.slice(7) };
|
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(":")) {
|
if (listen.includes(":")) {
|
||||||
const [host, portStr] = listen.split(":");
|
const [host, portStr] = listen.split(":");
|
||||||
const port = parseInt(portStr, 10);
|
const parsedPort = parseInt(portStr, 10);
|
||||||
if (!Number.isFinite(port)) {
|
if (!Number.isFinite(parsedPort)) {
|
||||||
throw new Error(`Invalid port in listen string: ${listen}`);
|
throw new Error(`Invalid port in listen string: ${listen}`);
|
||||||
}
|
}
|
||||||
return { type: "tcp", host: host || "127.0.0.1", port };
|
return { type: "tcp", host: host || "127.0.0.1", port: parsedPort };
|
||||||
}
|
|
||||||
// Just a port number
|
|
||||||
const port = parseInt(listen, 10);
|
|
||||||
if (Number.isFinite(port)) {
|
|
||||||
return { type: "tcp", host: "127.0.0.1", port };
|
|
||||||
}
|
}
|
||||||
throw new Error(`Invalid listen string: ${listen}`);
|
throw new Error(`Invalid listen string: ${listen}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ describe("TerminalManager", () => {
|
|||||||
await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path");
|
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 () => {
|
it("creates separate terminals for different cwds", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })];
|
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 () => {
|
it("inherits registered env for the worktree root cwd", async () => {
|
||||||
await withShell("/bin/sh", async () => {
|
await withShell("/bin/sh", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
import { createTerminal, type TerminalSession } from "./terminal.js";
|
||||||
import { resolve, sep } from "node:path";
|
import { resolve, sep, win32, posix } from "node:path";
|
||||||
|
|
||||||
export interface TerminalListItem {
|
export interface TerminalListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,7 +37,7 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||||
|
|
||||||
function assertAbsolutePath(cwd: string): void {
|
function assertAbsolutePath(cwd: string): void {
|
||||||
if (!cwd.startsWith("/")) {
|
if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) {
|
||||||
throw new Error("cwd must be absolute path");
|
throw new Error("cwd must be absolute path");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
NotGitRepoError,
|
NotGitRepoError,
|
||||||
pushCurrentBranch,
|
pushCurrentBranch,
|
||||||
resolveRepositoryDefaultBranch,
|
resolveRepositoryDefaultBranch,
|
||||||
|
parseWorktreeList,
|
||||||
|
isPaseoWorktreePath,
|
||||||
|
isDescendantPath,
|
||||||
} from "./checkout-git.js";
|
} from "./checkout-git.js";
|
||||||
import { createWorktree } from "./worktree.js";
|
import { createWorktree } from "./worktree.js";
|
||||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||||
@@ -812,4 +815,73 @@ const x = 1;
|
|||||||
).rejects.toThrow(/base/i);
|
).rejects.toThrow(/base/i);
|
||||||
await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -603,20 +603,39 @@ async function getMainRepoRoot(cwd: string): Promise<string> {
|
|||||||
});
|
});
|
||||||
const worktrees = parseWorktreeList(worktreeOut);
|
const worktrees = parseWorktreeList(worktreeOut);
|
||||||
const nonBareNonPaseo = worktrees.filter(
|
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");
|
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
|
||||||
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
|
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitWorktreeEntry = {
|
export type GitWorktreeEntry = {
|
||||||
path: string;
|
path: string;
|
||||||
branchRef?: string;
|
branchRef?: string;
|
||||||
isBare?: boolean;
|
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[] = [];
|
const entries: GitWorktreeEntry[] = [];
|
||||||
let current: GitWorktreeEntry | null = null;
|
let current: GitWorktreeEntry | null = null;
|
||||||
for (const line of output.split("\n")) {
|
for (const line of output.split("\n")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user