fix(migrate): validate workspace connection targets

This commit is contained in:
Mohamed Boudra
2026-07-19 01:10:18 +02:00
parent e61c06706c
commit faccbaae59
4 changed files with 78 additions and 5 deletions

View File

@@ -43,6 +43,21 @@ test("discovers the running daemon TCP address from its PID record", () => {
]);
});
test("normalizes the daemon's bare IPv6 loopback PID address", () => {
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-node-client-pid-ipv6-"));
cleanup.push(paseoHome);
writeFileSync(path.join(paseoHome, "paseo.pid"), JSON.stringify({ listen: "::1:7789" }));
expect(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome })).toEqual([
"[::1]:7789",
"localhost:6767",
]);
expect(resolveDaemonTarget("::1:7789")).toEqual({
type: "tcp",
url: "ws://[::1]:7789/ws",
});
});
test("normalizes TCP, Unix, pipe, and Windows path-shaped targets", () => {
expect(normalizeDaemonHost("tcp://Example.com:6767?ssl=true&password=secret")).toBe(
"tcp://Example.com:6767?ssl=true&password=secret",

View File

@@ -89,6 +89,9 @@ export function normalizeDaemonHost(raw: string): string | null {
if (value.startsWith("/") || value.startsWith("~")) return `unix://${value}`;
if (/^[A-Za-z]:[/\\]/.test(value)) return null;
if (/^\d+$/.test(value)) return `127.0.0.1:${value}`;
const ipv6Loopback = normalizeBareIpv6Loopback(value);
if (ipv6Loopback) return ipv6Loopback;
if (value.startsWith("::1:")) return null;
return value.includes(":") ? value : null;
}
@@ -166,7 +169,18 @@ export function resolveDaemonTarget(host: string): DaemonTarget {
);
return { type: "tcp", url: buildDaemonWebSocketUrl(endpoint, { useTls: parsed.useTls }) };
}
return { type: "tcp", url: `ws://${value}/ws` };
const endpoint = normalizeBareIpv6Loopback(value) ?? normalizeHostPort(value);
return { type: "tcp", url: buildDaemonWebSocketUrl(endpoint, { useTls: false }) };
}
function normalizeBareIpv6Loopback(value: string): string | null {
const match = value.match(/^::1:(\d{1,5})$/);
if (!match) return null;
try {
return normalizeHostPort(`[::1]:${match[1]}`);
} catch {
return null;
}
}
export function resolveDaemonPassword(

View File

@@ -83,6 +83,41 @@ test("reports an unknown workspace state by its exact value", () => {
});
});
test("does not adopt an existing worktree on a different branch", () => {
const repo = createRepository("wrong-worktree-branch", null);
execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], {
cwd: repo,
env: {
...process.env,
GIT_AUTHOR_NAME: "Paseo Test",
GIT_AUTHOR_EMAIL: "test@paseo.local",
GIT_COMMITTER_NAME: "Paseo Test",
GIT_COMMITTER_EMAIL: "test@paseo.local",
},
});
execFileSync("git", ["branch", "recorded-branch"], { cwd: repo });
const inspected = inspectCatalog({
repos: [repoRecord("repo", repo)],
workspaces: [
{
id: "workspace-wrong-branch",
repoId: "repo",
branch: "recorded-branch",
state: "ready",
path: repo,
archiveCommit: null,
},
],
});
expect(inspected.projects[0]?.workspaces[0]).toMatchObject({
sourceId: "workspace-wrong-branch",
disposition: "invalid",
notices: [{ code: "invalid-worktree" }],
});
});
function createRepository(name: string, settings: string | null): string {
const repo = mkdtempSync(path.join(os.tmpdir(), `paseo-inspect-${name}-`));
cleanup.push(repo);

View File

@@ -112,7 +112,7 @@ function inspectWorkspace(
}
if (workspace.path && isDirectory(workspace.path)) {
const valid = isLinkedWorktree(repo.rootPath, workspace.path);
const valid = isLinkedWorktree(repo.rootPath, workspace.path, workspace.branch);
return {
sourceId: workspace.id,
state: "ready",
@@ -126,7 +126,9 @@ function inspectWorkspace(
: [
notice(
"invalid-worktree",
`Skipped ${workspace.path}: not linked to ${repo.rootPath}.`,
workspace.branch
? `Skipped ${workspace.path}: not linked to ${repo.rootPath} on recorded branch ${workspace.branch}.`
: `Skipped ${workspace.path}: not linked to ${repo.rootPath}.`,
),
],
};
@@ -174,14 +176,21 @@ function isGitRepository(rootPath: string): boolean {
}
}
function isLinkedWorktree(rootPath: string, workspacePath: string): boolean {
function isLinkedWorktree(
rootPath: string,
workspacePath: string,
expectedBranch: string | null,
): boolean {
try {
const rootCommon = resolveGitPath(rootPath, git(rootPath, ["rev-parse", "--git-common-dir"]));
const workspaceCommon = resolveGitPath(
workspacePath,
git(workspacePath, ["rev-parse", "--git-common-dir"]),
);
return realpathSync(rootCommon) === realpathSync(workspaceCommon);
if (realpathSync(rootCommon) !== realpathSync(workspaceCommon)) return false;
if (!expectedBranch) return true;
const actualBranch = git(workspacePath, ["symbolic-ref", "--short", "HEAD"]);
return actualBranch === expectedBranch.replace(/^refs\/heads\//, "");
} catch {
return false;
}