Archived
fix: list and open sessions across project directories
Sessions outside the server's launch directory were invisible and returned 404 on open, leaving the model picker empty. List without the SDK's process-cwd filter and normalize working directories at the API boundary and when reading stored session data, tolerating separator/normalization differences (including Windows backslash vs forward slash). Requires Pi coding agent SDK 0.78.0 or newer.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Fix sessions outside the server's launch directory being invisible: listing returned no sessions and opening them failed with 404 "Session not found", leaving the model picker empty. Working directories are now normalized at the API boundary and when reading stored session data, so path differences (trailing slashes, redundant segments, and Windows backslash vs forward-slash forms) no longer hide live or archived sessions. Requests with a relative `cwd` are now rejected with a 400 error instead of being resolved against the server's own working directory. Requires Pi coding agent SDK 0.78.0 or newer.
|
||||
Generated
+2136
-2232
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -71,7 +71,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@earendil-works/pi-coding-agent": "^0.74.0",
|
||||
"@earendil-works/pi-coding-agent": "^0.79.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
@@ -82,7 +82,7 @@
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.1.5",
|
||||
"@earendil-works/pi-ai": "^0.74.0"
|
||||
"@earendil-works/pi-ai": "^0.79.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -100,8 +100,8 @@
|
||||
"homepage": "https://pi-web.dev/",
|
||||
"packageManager": "[email protected]",
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": ">=0.74.0 <1",
|
||||
"@earendil-works/pi-ai": ">=0.74.0 <1"
|
||||
"@earendil-works/pi-coding-agent": ">=0.78.0 <1",
|
||||
"@earendil-works/pi-ai": ">=0.78.0 <1"
|
||||
},
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
|
||||
+4
-2
@@ -8,6 +8,7 @@ import { ProjectStore } from "./storage/projectStore.js";
|
||||
import { ProjectService } from "./projects/projectService.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { normalizeRequestCwd } from "./workingDirectory.js";
|
||||
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
import { registerSessionProxyRoutes, type SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||
@@ -77,8 +78,9 @@ function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string)
|
||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/files`, async (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
try {
|
||||
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
|
||||
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope });
|
||||
const cwd = normalizeRequestCwd(request.query.cwd);
|
||||
if (request.query.mode === "path") return await listPathSuggestions(cwd, request.query.q ?? "");
|
||||
return await listFileSuggestions(cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope });
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ describe("OAuthLoginFlowService", () => {
|
||||
providerId: "test-provider",
|
||||
providerName: "Test Provider",
|
||||
authStorage: fakeAuthStorage(async (_providerId, callbacks) => {
|
||||
const select = callbacks.onSelect;
|
||||
if (select === undefined) throw new Error("Expected select callback");
|
||||
selectedValue = await select({
|
||||
selectedValue = await callbacks.onSelect({
|
||||
message: "Choose account",
|
||||
options: [{ id: "work", label: "Work" }, { id: "personal", label: "Personal" }],
|
||||
});
|
||||
|
||||
@@ -72,6 +72,12 @@ export class OAuthLoginFlowService {
|
||||
if (!this.isCurrentRunning(record)) return;
|
||||
this.updateState(record, { ...record.state, auth: info });
|
||||
},
|
||||
// Device-code flows have no redirect URL; reuse the auth field so the web UI
|
||||
// shows the verification link and user code without a dedicated API shape.
|
||||
onDeviceCode: (info) => {
|
||||
if (!this.isCurrentRunning(record)) return;
|
||||
this.updateState(record, { ...record.state, auth: { url: info.verificationUri, instructions: `Enter code: ${info.userCode}` } });
|
||||
},
|
||||
onPrompt: (prompt) => this.waitForPrompt(record, prompt, "prompt"),
|
||||
onManualCodeInput: () => this.waitForPrompt(record, { message: "Paste the callback URL or authorization code", allowEmpty: false }, "manual"),
|
||||
onSelect: (prompt) => this.waitForSelect(record, prompt),
|
||||
|
||||
@@ -2,8 +2,10 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, SessionDirResolver } from "./piSessionManagerGateway.js";
|
||||
import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, filterSessionsForCwd, SessionDirResolver } from "./piSessionManagerGateway.js";
|
||||
import type { PiSessionListEntry } from "./piSessionService.js";
|
||||
import type { PiSessionManager } from "./piSessionService.js";
|
||||
import { sep } from "node:path";
|
||||
|
||||
let tempDir: string;
|
||||
let agentDir: string;
|
||||
@@ -83,12 +85,54 @@ describe("Pi session manager gateway", () => {
|
||||
if (!hasSessionDir(created)) throw new Error("Expected SDK session manager");
|
||||
expect(created.getSessionDir()).toBe(sharedSessionDir);
|
||||
});
|
||||
|
||||
it("lists sessions for cwds that differ from the server process cwd", async () => {
|
||||
// Regression: SessionManager.list("", dir) filtered against process.cwd(),
|
||||
// hiding every session outside the daemon's own launch directory.
|
||||
expect(cwd).not.toBe(process.cwd());
|
||||
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-elsewhere", cwd);
|
||||
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
|
||||
|
||||
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-elsewhere", cwd }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterSessionsForCwd", () => {
|
||||
it("matches cwds that differ only by trailing separator or redundant segments", () => {
|
||||
const sessions = [sessionEntry("a", cwd)];
|
||||
|
||||
expect(filterSessionsForCwd(sessions, `${cwd}${sep}`)).toHaveLength(1);
|
||||
expect(filterSessionsForCwd(sessions, join(cwd, "."))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("excludes sessions with an empty cwd instead of matching the process cwd", () => {
|
||||
expect(filterSessionsForCwd([sessionEntry("a", "")], process.cwd())).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes sessions from other cwds", () => {
|
||||
expect(filterSessionsForCwd([sessionEntry("a", join(tempDir, "other"))], cwd)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session listing canonicalization", () => {
|
||||
it("canonicalizes session header cwds written by external tools", async () => {
|
||||
// Headers are written by the Pi CLI / SDK consumers and may contain
|
||||
// unnormalized paths (trailing separators, redundant segments).
|
||||
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-messy", `${cwd}${sep}.${sep}`);
|
||||
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
|
||||
|
||||
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-messy", cwd }]);
|
||||
});
|
||||
});
|
||||
|
||||
function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } {
|
||||
return "getSessionDir" in manager && typeof manager.getSessionDir === "function";
|
||||
}
|
||||
|
||||
function sessionEntry(id: string, sessionCwd: string): PiSessionListEntry {
|
||||
return { path: join(tempDir, `${id}.jsonl`), id, cwd: sessionCwd, created: new Date(), modified: new Date(), messageCount: 0, firstMessage: "", allMessagesText: "" };
|
||||
}
|
||||
|
||||
async function writeSessionFile(dir: string, id: string, sessionCwd: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, `${id}.jsonl`), `${JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-01-01T00:00:00.000Z", cwd: sessionCwd })}\n`, "utf8");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { getAgentDir, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js";
|
||||
|
||||
export const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||
@@ -77,7 +78,13 @@ class SettingsAwarePiSessionManagerGateway implements PiSessionManagerGateway {
|
||||
}
|
||||
|
||||
export async function listSessionsInDir(sessionDir: string): Promise<PiSessionListEntry[]> {
|
||||
return SessionManager.list("", sessionDir);
|
||||
// listAll(sessionDir) lists without the SDK's internal cwd filter, which would
|
||||
// otherwise compare against this process's cwd and drop other projects' sessions.
|
||||
// Cwd filtering is applied explicitly by filterSessionsForCwd where needed.
|
||||
// Session file headers are written by external tools (Pi CLI, SDK consumers),
|
||||
// so their cwd is canonicalized here before it enters pi-web.
|
||||
const sessions = await SessionManager.listAll(sessionDir);
|
||||
return sessions.map((session) => ({ ...session, cwd: canonicalizeStoredCwd(session.cwd) }));
|
||||
}
|
||||
|
||||
export async function listSessionsInDefaultPiStore(storeRoot = defaultPiSessionsRoot()): Promise<PiSessionListEntry[]> {
|
||||
@@ -94,7 +101,9 @@ export async function listSessionsInDefaultPiStore(storeRoot = defaultPiSessions
|
||||
}
|
||||
|
||||
export function filterSessionsForCwd(sessions: readonly PiSessionListEntry[], cwd: string): PiSessionListEntry[] {
|
||||
return sessions.filter((session) => session.cwd === cwd);
|
||||
// Sessions with an empty cwd (old session files) are excluded: resolve("") would
|
||||
// resolve to this process's cwd and produce false matches.
|
||||
return sessions.filter((session) => session.cwd !== "" && cwdPathsEqual(session.cwd, cwd));
|
||||
}
|
||||
|
||||
export function defaultPiSessionsRoot(agentDir = getAgentDir()): string {
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { AuthChange } from "./authService.js";
|
||||
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
|
||||
function noop(): void {
|
||||
@@ -44,7 +45,7 @@ function isPiSessionRef(ref: PiSessionLookup): ref is PiSessionRef {
|
||||
}
|
||||
|
||||
function lookupMatchesActiveSession(ref: PiSessionLookup, active: ActiveSession<PiSessionRuntime>): boolean {
|
||||
return !isPiSessionRef(ref) || active.runtime.cwd === ref.cwd;
|
||||
return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd);
|
||||
}
|
||||
|
||||
type QueuedPromptKind = "steer" | "followUp";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
|
||||
import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { canonicalizeStoredCwd } from "../workingDirectory.js";
|
||||
|
||||
export interface ArchiveSessionInput {
|
||||
sessionId: string;
|
||||
@@ -148,7 +149,7 @@ export class SessionArchiveStore {
|
||||
function archiveRecordFromInput(session: ArchiveSessionInput, archive: { archivedAt: string; originalPath: string; archivePath: string }): ArchivedSessionRecord {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
cwd: session.cwd,
|
||||
cwd: canonicalizeStoredCwd(session.cwd),
|
||||
archivedAt: archive.archivedAt,
|
||||
originalPath: archive.originalPath,
|
||||
archivePath: archive.archivePath,
|
||||
@@ -211,6 +212,7 @@ function parseArchivedSessionRecord(value: unknown): ArchivedSessionRecord {
|
||||
const cwd = value["cwd"];
|
||||
const archivedAt = value["archivedAt"];
|
||||
if (typeof sessionId !== "string" || typeof cwd !== "string" || typeof archivedAt !== "string") throw new Error("Invalid archived session record");
|
||||
const canonicalCwd = canonicalizeStoredCwd(cwd);
|
||||
const originalPath = optionalString(value, "originalPath");
|
||||
const archivePath = optionalString(value, "archivePath");
|
||||
const created = optionalString(value, "created");
|
||||
@@ -221,7 +223,7 @@ function parseArchivedSessionRecord(value: unknown): ArchivedSessionRecord {
|
||||
const parentSessionPath = optionalString(value, "parentSessionPath");
|
||||
return {
|
||||
sessionId,
|
||||
cwd,
|
||||
cwd: canonicalCwd,
|
||||
archivedAt,
|
||||
...(originalPath === undefined ? {} : { originalPath }),
|
||||
...(archivePath === undefined ? {} : { archivePath }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { normalizeRequestCwd } from "../workingDirectory.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
|
||||
|
||||
@@ -22,13 +23,17 @@ interface PromptRequestBody {
|
||||
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
|
||||
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
return sessions.list(request.query.cwd);
|
||||
try {
|
||||
return await sessions.list(normalizeRequestCwd(request.query.cwd));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => {
|
||||
try {
|
||||
const body = requireRecord(request.body);
|
||||
return await sessions.start(requireString(body, "cwd"));
|
||||
return await sessions.start(normalizeRequestCwd(requireString(body, "cwd")));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
@@ -214,8 +219,9 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/events`, { websocket: true }, (socket, request) => {
|
||||
const lookup = sessionLookupFromQuery(request.params.sessionId, request.query);
|
||||
eventHub.add(sessionIdFromLookup(lookup), socket);
|
||||
// Only the id matters for event subscription; cwd is intentionally ignored
|
||||
// so a malformed value cannot throw inside the websocket handler.
|
||||
eventHub.add(request.params.sessionId, socket);
|
||||
});
|
||||
|
||||
app.get(`${prefix}/sessions/events`, { websocket: true }, (socket) => {
|
||||
@@ -235,15 +241,13 @@ function sessionLookupFromBody(id: string, body: Record<string, unknown>): Sessi
|
||||
const cwd = body["cwd"];
|
||||
if (cwd === undefined || cwd === "") return id;
|
||||
if (typeof cwd !== "string") throw new Error("cwd field must be a string");
|
||||
return { id, cwd };
|
||||
return { id, cwd: normalizeRequestCwd(cwd) };
|
||||
}
|
||||
|
||||
function sessionLookupFromCwd(id: string, cwd: string | undefined): SessionLookup {
|
||||
return cwd === undefined || cwd === "" ? id : { id, cwd };
|
||||
}
|
||||
|
||||
function sessionIdFromLookup(lookup: SessionLookup): string {
|
||||
return typeof lookup === "string" ? lookup : lookup.id;
|
||||
// Legacy id-only lookups (no cwd) remain supported; a supplied cwd is
|
||||
// normalized here so everything past the route layer sees canonical paths.
|
||||
return cwd === undefined || cwd === "" ? id : { id, cwd: normalizeRequestCwd(cwd) };
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { RawData } from "ws";
|
||||
import { normalizeRequestCwd } from "../workingDirectory.js";
|
||||
import type { TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunStatus } from "../../shared/apiTypes.js";
|
||||
import type { RunTerminalCommandOptions, TerminalInfo } from "./terminalService.js";
|
||||
import { parseTerminalSize } from "./terminalSize.js";
|
||||
@@ -22,12 +23,16 @@ export interface TerminalRouteService {
|
||||
export function registerTerminalRoutes(app: FastifyInstance, terminals: TerminalRouteService, prefix = ""): void {
|
||||
app.get<{ Querystring: { cwd?: string } }>(`${prefix}/terminals`, (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
return terminals.list(request.query.cwd);
|
||||
try {
|
||||
return terminals.list(normalizeRequestCwd(request.query.cwd));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: { cwd: string; name?: string; cols?: number; rows?: number } }>(`${prefix}/terminals`, (request, reply) => {
|
||||
try {
|
||||
return terminals.create(request.body);
|
||||
return terminals.create({ ...request.body, cwd: normalizeRequestCwd(request.body.cwd) });
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -36,7 +41,7 @@ export function registerTerminalRoutes(app: FastifyInstance, terminals: Terminal
|
||||
app.delete<{ Querystring: { cwd?: string } }>(`${prefix}/terminals`, (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
try {
|
||||
terminals.closeForCwd(request.query.cwd);
|
||||
terminals.closeForCwd(normalizeRequestCwd(request.query.cwd));
|
||||
return { closed: true };
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
@@ -45,7 +50,7 @@ export function registerTerminalRoutes(app: FastifyInstance, terminals: Terminal
|
||||
|
||||
app.post<{ Body: RunTerminalCommandOptions }>(`${prefix}/terminal-command-runs`, (request, reply) => {
|
||||
try {
|
||||
return terminals.runCommand(request.body);
|
||||
return terminals.runCommand({ ...request.body, cwd: normalizeRequestCwd(request.body.cwd) });
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { join, sep } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalizeStoredCwd, cwdPathsEqual, normalizeRequestCwd } from "./workingDirectory.js";
|
||||
|
||||
const absoluteBase = join(sep, "srv", "projects", "demo");
|
||||
|
||||
describe("normalizeRequestCwd", () => {
|
||||
it("returns absolute paths in canonical form", () => {
|
||||
expect(normalizeRequestCwd(absoluteBase)).toBe(absoluteBase);
|
||||
expect(normalizeRequestCwd(`${absoluteBase}${sep}`)).toBe(absoluteBase);
|
||||
expect(normalizeRequestCwd(join(absoluteBase, ".", "nested", ".."))).toBe(absoluteBase);
|
||||
});
|
||||
|
||||
it("treats Windows backslash and forward-slash paths as equal", () => {
|
||||
if (process.platform !== "win32") return;
|
||||
expect(normalizeRequestCwd("C:/Users/dev/project")).toBe("C:\\Users\\dev\\project");
|
||||
});
|
||||
|
||||
it("rejects missing, empty, and non-string values", () => {
|
||||
expect(() => normalizeRequestCwd(undefined)).toThrow("cwd is required");
|
||||
expect(() => normalizeRequestCwd("")).toThrow("cwd is required");
|
||||
expect(() => normalizeRequestCwd(42)).toThrow("cwd is required");
|
||||
});
|
||||
|
||||
it("rejects relative paths instead of resolving them against the process cwd", () => {
|
||||
expect(() => normalizeRequestCwd("relative/path")).toThrow("cwd must be an absolute path");
|
||||
expect(() => normalizeRequestCwd(".")).toThrow("cwd must be an absolute path");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalizeStoredCwd", () => {
|
||||
it("canonicalizes absolute paths", () => {
|
||||
expect(canonicalizeStoredCwd(`${absoluteBase}${sep}`)).toBe(absoluteBase);
|
||||
});
|
||||
|
||||
it("preserves legacy empty and relative values instead of resolving against the process cwd", () => {
|
||||
expect(canonicalizeStoredCwd("")).toBe("");
|
||||
expect(canonicalizeStoredCwd("relative/path")).toBe("relative/path");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cwdPathsEqual", () => {
|
||||
it("matches paths that differ only by normalization", () => {
|
||||
expect(cwdPathsEqual(absoluteBase, `${absoluteBase}${sep}`)).toBe(true);
|
||||
expect(cwdPathsEqual(absoluteBase, join(absoluteBase, "."))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats Windows backslash and forward-slash paths as equal", () => {
|
||||
if (process.platform !== "win32") return;
|
||||
expect(cwdPathsEqual("C:\\Users\\dev\\project", "C:/Users/dev/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("distinguishes different paths", () => {
|
||||
expect(cwdPathsEqual(join(absoluteBase, "a"), join(absoluteBase, "b"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Working-directory normalization boundaries.
|
||||
*
|
||||
* Cwd strings reach the server from three kinds of sources with different trust:
|
||||
*
|
||||
* 1. HTTP requests (web UI, federation proxies): normalize strictly with
|
||||
* `normalizeRequestCwd` at route parsing. Relative paths are rejected instead
|
||||
* of being silently resolved against the daemon's own working directory.
|
||||
* 2. Data pi-web writes itself (archive store records): canonicalized on write
|
||||
* and on load with `canonicalizeStoredCwd`, so internal `===` comparisons are
|
||||
* safe by construction.
|
||||
* 3. Data other writers own (Pi session file headers via the SDK): canonicalized
|
||||
* on read at the gateway, and compared tolerantly with `cwdPathsEqual` where a
|
||||
* raw value can still appear (e.g. runtime cwd of sessions opened from files).
|
||||
*
|
||||
* Inside these boundaries, plain string equality on cwd values is safe.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strictly normalize a client-supplied working directory at an HTTP boundary.
|
||||
* Throws for non-string, empty, or relative input; returns the resolved
|
||||
* (separator- and trailing-slash-normalized) absolute path otherwise.
|
||||
*/
|
||||
export function normalizeRequestCwd(cwd: unknown): string {
|
||||
if (typeof cwd !== "string" || cwd === "") throw new Error("cwd is required");
|
||||
if (!isAbsolute(cwd)) throw new Error("cwd must be an absolute path");
|
||||
return resolve(cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leniently canonicalize a working directory loaded from stored data.
|
||||
* Absolute paths are resolved to canonical form; anything else (legacy empty or
|
||||
* relative values) is preserved as-is so a single bad record cannot fail a whole
|
||||
* load, and never silently resolves against this process's working directory.
|
||||
*/
|
||||
export function canonicalizeStoredCwd(cwd: string): string {
|
||||
return isAbsolute(cwd) ? resolve(cwd) : cwd;
|
||||
}
|
||||
|
||||
/** Compare two working-directory paths, tolerating separator and normalization differences (e.g. Windows backslash vs forward slash). */
|
||||
export function cwdPathsEqual(a: string, b: string): boolean {
|
||||
return resolve(a) === resolve(b);
|
||||
}
|
||||
Reference in New Issue
Block a user