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:
Federico Jaramillo Martinez
2026-06-13 11:40:47 +02:00
parent 38cf334c40
commit c0d12222a2
14 changed files with 2342 additions and 2261 deletions
+4 -2
View File
@@ -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");
+11 -2
View File
@@ -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 {
+2 -1
View File
@@ -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";
+4 -2
View File
@@ -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 }),
+14 -10
View File
@@ -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> {
+9 -4
View File
@@ -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) });
}
+56
View File
@@ -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);
});
});
+45
View File
@@ -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);
}