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:
+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