fix: harden agent profile boundaries

This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 23:39:49 +02:00
parent 97e0afc6fa
commit adc2e297a4
25 changed files with 419 additions and 150 deletions
+5
View File
@@ -68,6 +68,11 @@ describe("agentCommandForChecks", () => {
delete process.env["PI_WEB_AGENT_COMMAND"]; delete process.env["PI_WEB_AGENT_COMMAND"];
expect(agentCommandForChecks()).toBe("acme-agent"); expect(agentCommandForChecks()).toBe("acme-agent");
expect(agentCommandForChecks({
PI_WEB_CONFIG: configPath,
PI_WEB_AGENT_COMMAND: "environment-agent",
PI_WEB_AGENT_DIR: join(dir, "environment-agent-state"),
})).toBe("environment-agent");
} finally { } finally {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
} }
+2 -2
View File
@@ -5,7 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os"; import { homedir, userInfo } from "node:os";
import { basename, dirname, join, resolve } from "node:path"; import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defaultPiWebConfigPath, defaultPiWebDataDir, effectiveAgentConfig, effectivePiWebConfig, examplePiWebConfig } from "./config.js"; import { defaultPiWebConfigPath, defaultPiWebDataDir, effectivePiWebConfig, examplePiWebConfig } from "./config.js";
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js"; import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
import { import {
@@ -780,7 +780,7 @@ function nodeVersionCheck(): string {
} }
export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string { export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string {
return effectiveAgentConfig(env, effectivePiWebConfig({ env }).config).command; return effectivePiWebConfig({ env }).config.agent.command;
} }
function generalDoctorChecks(): Check[] { function generalDoctorChecks(): Check[] {
+53 -18
View File
@@ -69,23 +69,50 @@ describe("PI WEB config persistence", () => {
expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "acme-agent", dir: "/opt/acme-agent/state" }); expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "acme-agent", dir: "/opt/acme-agent/state" });
}); });
it("defaults to the Pi agent directory only for Pi commands and launchers", () => { it("defaults to the Pi agent directory only for canonical Pi companion names", () => {
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "/tmp/pi.cmd" } })).toMatchObject({ for (const command of ["pi", "pi.cmd"]) {
command: "/tmp/pi.cmd", expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command } })).toMatchObject({
dir: join(tempDir, ".home", ".pi", "agent"), command,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], dir: join(tempDir, ".home", ".pi", "agent"),
}); sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"],
});
}
}); });
it("requires an explicit agent directory for non-Pi commands", () => { it("requires explicit state for alternate names and absolute Pi launchers", () => {
expect(() => effectiveAgentConfig({}, { agent: { command: "acme-agent" } })).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"'); const absolutePiCommand = join(tempDir, "bin", "pi");
expect(() => savePiWebConfig({ agent: { command: "acme-agent" } }, testOptions())).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"'); for (const command of ["acme-agent", absolutePiCommand]) {
expect(() => effectiveAgentConfig({}, { agent: { command } })).toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`);
expect(() => savePiWebConfig({ agent: { command } }, testOptions())).toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`);
}
});
it("accepts safe bare executable names and host-absolute executable paths", () => {
const absoluteCommand = join(tempDir, "bin", "acme-agent");
const agentDir = join(tempDir, "state", "acme");
expect(effectiveAgentConfig({}, { agent: { command: "acme-agent", dir: agentDir } })).toMatchObject({ command: "acme-agent", dir: agentDir });
expect(effectiveAgentConfig({}, { agent: { command: absoluteCommand, dir: agentDir } })).toMatchObject({ command: absoluteCommand, dir: agentDir });
});
it.each(["./acme-agent", "bin/acme-agent", "../acme-agent", "node acme-agent.js", "acme-agent;other", "-acme-agent"])("rejects unsafe or workspace-relative agent command %j", (command) => {
expect(() => savePiWebConfig({ agent: { command, dir: join(tempDir, "agent") } }, testOptions())).toThrow("safe bare executable name or host-absolute executable path");
});
it.skipIf(process.platform === "win32")("rejects foreign-platform absolute agent command and state paths", () => {
expect(() => effectiveAgentConfig({}, { agent: { command: "C:\\tools\\acme-agent.exe", dir: join(tempDir, "agent") } })).toThrow("safe bare executable name or host-absolute executable path");
expect(() => effectiveAgentConfig({}, { agent: { command: "acme-agent", dir: "C:\\profiles\\acme" } })).toThrow("agent.dir must be a host-absolute path");
});
it("rejects home expansion that would create a workspace-relative agent directory", () => {
expect(() => effectiveAgentConfig({ HOME: "relative-home" })).toThrow("agent.dir must be a host-absolute path");
}); });
it("resolves explicit alternate agent command and state directory settings", () => { it("resolves explicit alternate agent command and state directory settings", () => {
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({ expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({
command: "acme-agent", command: "acme-agent",
dir: join(tempDir, ".home", "agent-profiles", "acme"), dir: join(tempDir, ".home", "agent-profiles", "acme"),
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
}); });
}); });
@@ -118,21 +145,29 @@ describe("PI WEB config persistence", () => {
}); });
}); });
it("keeps legacy Pi env directory overrides scoped to Pi commands", () => { it("keeps legacy Pi env directory overrides scoped to the canonical Pi command", () => {
expect(effectiveAgentConfig({ const legacyDir = join(tempDir, "pi-env-agent");
PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), expect(effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: legacyDir });
}, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({
dir: join(tempDir, "pi-env-agent"),
});
expect(() => effectiveAgentConfig({ for (const command of ["acme-agent", join(tempDir, "bin", "pi")]) {
PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), expect(() => effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { command } }))
}, { agent: { command: "acme-agent" } })).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"'); .toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`);
}
}); });
it("uses only explicit session directory env keys", () => { it("uses only explicit session directory env keys", () => {
expect(agentSessionDirEnvKeys()).toEqual(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]); expect(agentSessionDirEnvKeys()).toEqual(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]);
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), PI_WEB_AGENT_COMMAND: "acme-agent", PI_WEB_AGENT_DIR: join(tempDir, "agent") }).sessionDirEnvKeys).toEqual(["PI_WEB_AGENT_SESSION_DIR"]); expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), PI_WEB_AGENT_COMMAND: "acme-agent", PI_WEB_AGENT_DIR: join(tempDir, "agent") }).sessionDirEnvKeys).toEqual(["PI_WEB_AGENT_SESSION_DIR"]);
expect(agentSessionDirEnvKeys(join(tempDir, "bin", "pi"))).toEqual(["PI_WEB_AGENT_SESSION_DIR"]);
});
it("rejects unknown nested agent keys instead of erasing them", async () => {
const original = { agent: { command: "acme-agent", dir: join(tempDir, "agent"), futureSetting: true } };
await writeFile(configPath, `${JSON.stringify(original, null, 2)}\n`, "utf8");
expect(() => loadPiWebConfig(testOptions())).toThrow('PI WEB config agent contains unknown key "futureSetting"');
expect(() => savePiWebConfig({ port: 9000 }, testOptions())).toThrow('PI WEB config agent contains unknown key "futureSetting"');
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual(original);
}); });
it("exposes the default upload folder in the effective config", () => { it("exposes the default upload folder in the effective config", () => {
+79 -26
View File
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path"; import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path";
import type { PiWebConfigValues } from "./shared/apiTypes.js"; import type { PiWebConfigValues } from "./shared/apiTypes.js";
import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js";
@@ -59,12 +59,12 @@ export interface EffectivePiWebAgentConfig {
sessionDirEnvKeys: string[]; sessionDirEnvKeys: string[];
} }
export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick<PiWebConfig, "agent"> = {}, cwd = process.cwd()): EffectivePiWebAgentConfig { export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick<PiWebConfig, "agent"> = {}): EffectivePiWebAgentConfig {
const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment"); const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment", "current");
const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (isPiCommand(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (usesDefaultPiStatePolicy(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env);
return { return {
command, command,
dir: resolveAgentDirPath(configuredDir, env, cwd, "agent.dir", "environment"), dir: resolveAgentDirPath(configuredDir, env, "agent.dir", "environment"),
sessionDirEnvKeys: agentSessionDirEnvKeys(command), sessionDirEnvKeys: agentSessionDirEnvKeys(command),
}; };
} }
@@ -72,12 +72,12 @@ export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, confi
export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] { export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] {
return uniqueStrings([ return uniqueStrings([
PI_WEB_AGENT_SESSION_DIR_ENV, PI_WEB_AGENT_SESSION_DIR_ENV,
...(isPiCommand(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []), ...(usesDefaultPiStatePolicy(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []),
]); ]);
} }
export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean {
return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || (isPiCommand(command) && isEnvSet(env[PI_CODING_AGENT_DIR_ENV])); return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || (usesDefaultPiStatePolicy(command) && isEnvSet(env[PI_CODING_AGENT_DIR_ENV]));
} }
export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean { export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean {
@@ -131,7 +131,7 @@ export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options:
const port = env["PI_WEB_PORT"] ?? env["PORT"]; const port = env["PI_WEB_PORT"] ?? env["PORT"];
const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"]; const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"];
const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"]; const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"];
const agent = effectiveAgentConfig(env, loaded.config, options.cwd ?? process.cwd()); const agent = effectiveAgentConfig(env, loaded.config);
return { return {
...loaded, ...loaded,
config: { config: {
@@ -155,8 +155,9 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
const env = options.env ?? process.env; const env = options.env ?? process.env;
const path = piWebConfigPath(env, options.cwd ?? process.cwd()); const path = piWebConfigPath(env, options.cwd ?? process.cwd());
const normalized = parsePiWebConfig(piWebConfigRecord(config), path); const normalized = parsePiWebConfig(piWebConfigRecord(config), path);
effectiveAgentConfig(env, normalized, options.cwd ?? process.cwd()); effectiveAgentConfig(env, normalized);
const existing = readExistingConfigObject(path); const existing = readExistingConfigObject(path);
if (existing["agent"] !== undefined) parseAgentConfig(existing["agent"], path);
delete existing["host"]; delete existing["host"];
delete existing["port"]; delete existing["port"];
delete existing["allowedHosts"]; delete existing["allowedHosts"];
@@ -260,33 +261,72 @@ function parseString(value: unknown, key: string, path: string): string {
return value; return value;
} }
function parseAgentConfig(value: unknown, path: string): NonNullable<PiWebConfig["agent"]> { const AGENT_CONFIG_KEYS = new Set(["command", "dir"]);
const SAFE_BARE_AGENT_COMMAND_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/u;
export type AgentPathHost = "current" | "portable";
export function parseAgentConfig(value: unknown, path: string, pathHost: AgentPathHost = "current"): NonNullable<PiWebConfig["agent"]> {
if (!isRecord(value)) throw new Error(`PI WEB config agent must be an object: ${path}`); if (!isRecord(value)) throw new Error(`PI WEB config agent must be an object: ${path}`);
const unknownKey = Object.keys(value).find((key) => !AGENT_CONFIG_KEYS.has(key));
if (unknownKey !== undefined) throw new Error(`PI WEB config agent contains unknown key ${JSON.stringify(unknownKey)}: ${path}`);
const command = value["command"]; const command = value["command"];
const dir = value["dir"]; const dir = value["dir"];
return { return {
...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path) } : {}), ...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path, pathHost) } : {}),
...(dir !== undefined ? { dir: parseAgentDir(dir, "agent.dir", path) } : {}), ...(dir !== undefined ? { dir: parseAgentDir(dir, "agent.dir", path, pathHost) } : {}),
}; };
} }
function parseAgentCommand(value: unknown, key: string, path: string): string { function parseAgentCommand(value: unknown, key: string, path: string, pathHost: AgentPathHost): string {
const command = parseString(value, key, path).trim(); const command = parseString(value, key, path).trim();
if (command === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`); if (!isSafeAgentCommand(command, pathHost)) {
if (/[\s;&|`$<>]/u.test(command)) throw new Error(`PI WEB config ${key} must be a single command name or path without shell metacharacters: ${path}`); const absoluteLabel = pathHost === "current" ? "host-absolute" : "absolute";
throw new Error(`PI WEB config ${key} must be a safe bare executable name or ${absoluteLabel} executable path: ${path}`);
}
return command; return command;
} }
function parseAgentDir(value: unknown, key: string, path: string): string { function parseAgentDir(value: unknown, key: string, path: string, pathHost: AgentPathHost): string {
const dir = parseString(value, key, path); const dir = parseString(value, key, path).trim();
if (!isAbsoluteOrHomePath(dir)) throw new Error(`PI WEB config ${key} must be an absolute path or start with ~: ${path}`); const isAbsoluteDir = pathHost === "current" ? isHostAbsoluteAgentDir(dir) : isPortableAbsoluteAgentPath(dir);
if (!isAbsoluteDir && !isHomePath(dir, pathHost)) {
const absoluteLabel = pathHost === "current" ? "a host-absolute" : "an absolute";
throw new Error(`PI WEB config ${key} must be ${absoluteLabel} path or start with ~: ${path}`);
}
return dir; return dir;
} }
function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, cwd: string, key: string, path: string): string { function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, key: string, path: string): string {
const parsed = parseAgentDir(value, key, path); const parsed = parseAgentDir(value, key, path, "current");
const expanded = expandHomePath(parsed, env); const expanded = expandHomePath(parsed, env);
return isAbsoluteLike(expanded) ? expanded : resolve(cwd, expanded); if (!isHostAbsoluteAgentDir(expanded)) {
throw new Error(`PI WEB config ${key} must resolve to a host-absolute path: ${path}`);
}
return normalize(expanded);
}
export function isSafeAgentCommandForHost(value: string): boolean {
return isSafeAgentCommand(value, "current");
}
function isSafeAgentCommand(value: string, pathHost: AgentPathHost): boolean {
if (value === "" || value !== value.trim() || value.includes("\0") || /[\s;&|`$<>]/u.test(value)) return false;
if (SAFE_BARE_AGENT_COMMAND_PATTERN.test(value)) return true;
if (pathHost === "current") return isAbsolute(value) && basename(value) !== "";
return isAbsoluteLike(value) && value.split(/[\\/]/u).at(-1) !== "";
}
export function isHostAbsoluteAgentDir(value: string): boolean {
return isSafeAgentDirPath(value) && isAbsolute(value);
}
function isPortableAbsoluteAgentPath(value: string): boolean {
return isSafeAgentDirPath(value) && isAbsoluteLike(value);
}
function isSafeAgentDirPath(value: string): boolean {
return value !== "" && value === value.trim() && !hasControlCharacter(value);
} }
function parsePort(value: unknown, key: string, path = "environment"): number { function parsePort(value: unknown, key: string, path = "environment"): number {
@@ -339,23 +379,27 @@ function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string)
} }
function isAbsoluteOrHomePath(value: string): boolean { function isHomePath(value: string, pathHost: AgentPathHost): boolean {
return value === "~" || value.startsWith("~/") || value.startsWith("~\\") || isAbsoluteLike(value); return value === "~" || value.startsWith("~/") || ((pathHost === "portable" || process.platform === "win32") && value.startsWith("~\\"));
} }
function expandHomePath(value: string, env: NodeJS.ProcessEnv): string { function expandHomePath(value: string, env: NodeJS.ProcessEnv): string {
const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir(); const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
if (value === "~") return home; if (value === "~") return home;
if (value.startsWith("~/") || value.startsWith("~\\")) return join(home, value.slice(2)); if (value.startsWith("~/") || (process.platform === "win32" && value.startsWith("~\\"))) return join(home, value.slice(2));
return value; return value;
} }
function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string { function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string {
if (isPiCommand(command)) return expandHomePath("~/.pi/agent", env); if (usesDefaultPiStatePolicy(command)) return expandHomePath("~/.pi/agent", env);
throw new Error(`PI WEB config agent.dir or ${PI_WEB_AGENT_DIR_ENV} is required when agent.command is ${JSON.stringify(command)}`); throw new Error(`PI WEB config agent.dir or ${PI_WEB_AGENT_DIR_ENV} is required when agent.command is ${JSON.stringify(command)}`);
} }
function isPiCommand(command: string): boolean { function usesDefaultPiStatePolicy(command: string): boolean {
return !command.includes("/") && !command.includes("\\") && isPiCompanionCommand(command);
}
export function isPiCompanionCommand(command: string): boolean {
const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase(); const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase();
return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === DEFAULT_AGENT_COMMAND; return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === DEFAULT_AGENT_COMMAND;
} }
@@ -372,6 +416,15 @@ function isEnvSet(value: string | undefined): boolean {
function uniqueStrings(values: readonly string[]): string[] { function uniqueStrings(values: readonly string[]): string[] {
return [...new Set(values)]; return [...new Set(values)];
} }
function hasControlCharacter(value: string): boolean {
for (const character of value) {
const code = character.charCodeAt(0);
if (code < 32 || code === 127) return true;
}
return false;
}
function isAbsoluteLike(value: string): boolean { function isAbsoluteLike(value: string): boolean {
const withForwardSlashes = value.replace(/\\/g, "/"); const withForwardSlashes = value.replace(/\\/g, "/");
return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes); return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes);
+30 -1
View File
@@ -1,6 +1,6 @@
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parsePiWebConfigResponseBody, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import { parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
let app: FastifyInstance; let app: FastifyInstance;
@@ -112,6 +112,21 @@ describe("config routes", () => {
expect(service.write).not.toHaveBeenCalled(); expect(service.write).not.toHaveBeenCalled();
}); });
it.each([
{ agent: { command: "./agent", dir: "/srv/agent" }, error: "safe bare executable name or host-absolute executable path" },
{ agent: { command: "agent", dir: "/srv/agent", futureSetting: true }, error: 'agent contains unknown key "futureSetting"' },
])("rejects unsafe agent profile payloads before writing", async ({ agent, error }) => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { agent } },
});
expect(response.statusCode).toBe(400);
expect(response.json<{ error: string }>().error).toContain(error);
expect(service.write).not.toHaveBeenCalled();
});
it("filters local machine config reads to selected-machine-safe keys", async () => { it("filters local machine config reads to selected-machine-safe keys", async () => {
savedConfig = fullConfig(); savedConfig = fullConfig();
@@ -161,6 +176,20 @@ describe("config routes", () => {
}); });
}); });
it("keeps foreign-platform agent paths portable at federation transport boundaries", () => {
const agent = { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\pi" };
const response = {
...responseFor({ agent }, true),
effectiveConfig: { agent },
};
expect(parsePiWebConfigResponseBody(response).config.agent).toEqual(agent);
expect(parseSelectedMachineConfigRequest({ agent }, "portable").agent).toEqual(agent);
if (process.platform !== "win32") {
expect(() => parseSelectedMachineConfigRequest({ agent })).toThrow("host-absolute executable path");
}
});
it("defaults missing agent override fields from older config responses", () => { it("defaults missing agent override fields from older config responses", () => {
const parsed = parsePiWebConfigResponseBody({ const parsed = parsePiWebConfigResponseBody({
path: "/tmp/pi-web/config.json", path: "/tmp/pi-web/config.json",
+9 -33
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; import { hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js";
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import { isPiWebPluginId } from "../shared/pluginIds.js"; import { isPiWebPluginId } from "../shared/pluginIds.js";
@@ -83,13 +83,13 @@ export function registerLocalMachineConfigRoutes(app: FastifyInstance, service:
}); });
} }
export function parseSelectedMachineConfigRequest(value: unknown): PiWebConfig { export function parseSelectedMachineConfigRequest(value: unknown, agentPathHost: AgentPathHost = "current"): PiWebConfig {
if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object"); if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object");
for (const key of Object.keys(value)) { for (const key of Object.keys(value)) {
if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`); if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`);
} }
try { try {
return pickSelectedMachineConfig(parseConfigRequest(value)); return pickSelectedMachineConfig(parseConfigRequest(value, agentPathHost));
} catch (error) { } catch (error) {
throw new Error(selectedMachineConfigErrorMessage(error), { cause: error }); throw new Error(selectedMachineConfigErrorMessage(error), { cause: error });
} }
@@ -112,13 +112,13 @@ export function parsePiWebConfigResponseBody(value: unknown, source = "PI WEB co
return { return {
path: requireResponseString(record, "path", source), path: requireResponseString(record, "path", source),
exists: requireResponseBoolean(record, "exists", source), exists: requireResponseBoolean(record, "exists", source),
config: parseConfigRequest(record["config"]), config: parseConfigRequest(record["config"], "portable"),
effectiveConfig: parseConfigRequest(record["effectiveConfig"]), effectiveConfig: parseConfigRequest(record["effectiveConfig"], "portable"),
envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source), envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source),
}; };
} }
function parseConfigRequest(value: unknown): PiWebConfig { function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "current"): PiWebConfig {
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object"); if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
const config: PiWebConfig = {}; const config: PiWebConfig = {};
const host = value["host"]; const host = value["host"];
@@ -154,7 +154,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean"); if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean");
config.subsessions = subsessions; config.subsessions = subsessions;
} }
if (agent !== undefined) config.agent = parseAgentRequest(agent); if (agent !== undefined) config.agent = parseAgentRequest(agent, agentPathHost);
return config; return config;
} }
@@ -216,28 +216,8 @@ function parseMaxUploadBytesRequest(value: unknown): number {
return value; return value;
} }
function parseAgentRequest(value: unknown): NonNullable<PiWebConfig["agent"]> { function parseAgentRequest(value: unknown, pathHost: AgentPathHost): NonNullable<PiWebConfig["agent"]> {
if (!isRecord(value)) throw new Error("PI WEB config agent must be an object"); return parseAgentConfig(value, "request", pathHost);
const command = value["command"];
const dir = value["dir"];
return {
...(command === undefined ? {} : { command: parseAgentCommandRequest(command) }),
...(dir === undefined ? {} : { dir: parseAgentDirRequest(dir) }),
};
}
function parseAgentCommandRequest(value: unknown): string {
if (typeof value !== "string" || value.trim() === "") throw new Error("PI WEB config agent.command must be a non-empty string");
const command = value.trim();
if (/[\s;&|`$<>]/u.test(command)) throw new Error("PI WEB config agent.command must be a single command name or path without shell metacharacters");
return command;
}
function parseAgentDirRequest(value: unknown): string {
if (typeof value !== "string" || value.trim() === "") throw new Error("PI WEB config agent.dir must be a non-empty string");
const dir = value.trim();
if (!isAbsoluteOrHomePath(dir)) throw new Error("PI WEB config agent.dir must be an absolute path or start with ~");
return dir;
} }
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> { function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
@@ -317,10 +297,6 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
function isAbsoluteOrHomePath(value: string): boolean {
return value === "~" || value.startsWith("~/") || value.startsWith("~\\") || value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(value);
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
+1 -1
View File
@@ -69,7 +69,7 @@ async function proxySelectedMachineConfigRequest(client: MachineClient, machineI
} }
if (method === "PUT") { if (method === "PUT") {
const patch = parseSelectedMachineConfigRequest(configPayload(body)); const patch = parseSelectedMachineConfigRequest(configPayload(body), "portable");
const currentResponse = await client.requestJson("GET", remotePath); const currentResponse = await client.requestJson("GET", remotePath);
if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId); if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId);
+23 -5
View File
@@ -192,24 +192,42 @@ describe("PI WEB status", () => {
const updateCommand = await updateCommandFor( const updateCommand = await updateCommandFor(
{ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
"pi-web restart", "pi-web restart",
{ agentCommand: undefined, hasCommand }, { activeAgentProfile: undefined, hasCommand },
); );
expect(updateCommand).toBeUndefined(); expect(updateCommand).toBeUndefined();
expect(hasCommand).not.toHaveBeenCalled(); expect(hasCommand).not.toHaveBeenCalled();
}); });
it("shell-quotes pi-package agent update commands", async () => { it("preserves and shell-quotes the active state profile in Pi-package update commands", async () => {
const command = "/tmp/agent's/pi";
const dir = "/tmp/profile's/state";
const updateCommand = await updateCommandFor( const updateCommand = await updateCommandFor(
{ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
"pi-web restart", "pi-web restart",
{ {
agentCommand: "/tmp/agent's/alt-agent", activeAgentProfile: activeProfile("a", command, dir),
hasCommand: (command) => Promise.resolve(command === "/tmp/agent's/alt-agent"), hasCommand: (candidate) => Promise.resolve(candidate === command),
}, },
); );
expect(updateCommand).toBe("'/tmp/agent'\\''s/alt-agent' update 'npm:@jmfederico/pi-web' && pi-web restart"); expect(updateCommand).toBe("PI_CODING_AGENT_DIR='/tmp/profile'\\''s/state' '/tmp/agent'\\''s/pi' update 'npm:@jmfederico/pi-web' && pi-web restart");
});
it.each([
activeProfile("a", "acme-agent", "/opt/acme/state"),
activeProfile("b", "pi", "relative/state"),
])("suppresses Pi-package updates when the active companion profile cannot be represented safely", async (profile) => {
const hasCommand = vi.fn(() => Promise.resolve(true));
const updateCommand = await updateCommandFor(
{ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
"pi-web restart",
{ activeAgentProfile: profile, hasCommand },
);
expect(updateCommand).toBeUndefined();
expect(hasCommand).not.toHaveBeenCalled();
}); });
it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => { it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => {
+8 -5
View File
@@ -11,6 +11,7 @@ import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js"; import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import { isHostAbsoluteAgentDir, isPiCompanionCommand, isSafeAgentCommandForHost, PI_CODING_AGENT_DIR_ENV } from "../config.js";
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js"; import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web"; const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
@@ -139,7 +140,7 @@ export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaem
const { web, sessiond } = versionStatus.components; const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true); const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
const components = { web, sessiond }; const components = { web, sessiond };
const commands = await commandsFor(components, { agentCommand: options.activeAgentProfile?.command, hasCommand: options.hasCommand ?? hasCommand }); const commands = await commandsFor(components, { activeAgentProfile: options.activeAgentProfile, hasCommand: options.hasCommand ?? hasCommand });
const messages = buildMessages(components, release, commands); const messages = buildMessages(components, release, commands);
return { return {
...versionStatus, ...versionStatus,
@@ -415,7 +416,7 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
return version; return version;
} }
async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise<boolean> }): Promise<PiWebStatusResponse["commands"]> { async function commandsFor(components: PiWebStatusResponse["components"], options: { activeAgentProfile: ActiveAgentProfileDescriptor | undefined; hasCommand: (command: string) => Promise<boolean> }): Promise<PiWebStatusResponse["commands"]> {
const installation = preferredInstallation(components); const installation = preferredInstallation(components);
if (installation?.kind === "docker") return dockerCommands(installation); if (installation?.kind === "docker") return dockerCommands(installation);
@@ -466,11 +467,13 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
return cliCommands.restart ?? serviceCommands.restart; return cliCommands.restart ?? serviceCommands.restart;
} }
export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise<boolean> }): Promise<string | undefined> { export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { activeAgentProfile: ActiveAgentProfileDescriptor | undefined; hasCommand: (command: string) => Promise<boolean> }): Promise<string | undefined> {
if (restartCommand === undefined) return undefined; if (restartCommand === undefined) return undefined;
if (installation?.kind === "pi-package") { if (installation?.kind === "pi-package") {
if (options.agentCommand === undefined || !(await options.hasCommand(options.agentCommand))) return undefined; const profile = options.activeAgentProfile;
return `${shellQuote(options.agentCommand)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`; if (profile === undefined || !isSafeAgentCommandForHost(profile.command) || !isHostAbsoluteAgentDir(profile.dir) || !isPiCompanionCommand(profile.command)) return undefined;
if (!(await options.hasCommand(profile.command))) return undefined;
return `${PI_CODING_AGENT_DIR_ENV}=${shellQuote(profile.dir)} ${shellQuote(profile.command)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`;
} }
if (installation?.kind === "local" && installation.path !== undefined) { if (installation?.kind === "local" && installation.path !== undefined) {
if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined; if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined;
+9 -7
View File
@@ -20,17 +20,18 @@ import { TerminalService } from "./terminals/terminalService.js";
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js"; import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
import { getPiWebRuntimeComponent } from "./piWebStatus.js"; import { getPiWebRuntimeComponent } from "./piWebStatus.js";
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js"; import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js"; import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes } from "../config.js";
import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js"; import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js";
import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js"; import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";
const { config } = effectivePiWebConfig(); const daemonEnvironment: NodeJS.ProcessEnv = Object.freeze({ ...process.env });
const { config } = effectivePiWebConfig({ env: daemonEnvironment });
const activeAgentProfile = createActiveAgentProfileDescriptor({ const activeAgentProfile = createActiveAgentProfileDescriptor({
command: config.agent.command, command: config.agent.command,
dir: config.agent.dir, dir: config.agent.dir,
sessionDirEnvKeys: agentSessionDirEnvKeys(config.agent.command), sessionDirEnvKeys: agentSessionDirEnvKeys(config.agent.command),
}); });
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) }); const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(daemonEnvironment, config) });
await app.register(fastifyWebsocket); await app.register(fastifyWebsocket);
await runSessionDaemonStartup({ await runSessionDaemonStartup({
@@ -39,7 +40,7 @@ await runSessionDaemonStartup({
const eventHub = new SessionEventHub(); const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub); const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = new AuthService({ agentDir: activeAgentProfile.dir }); const auth = new AuthService({ agentDir: activeAgentProfile.dir });
const spawnTargets = spawnSessionsEnabled(process.env, config) const spawnTargets = config.spawnSessions
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined; : undefined;
const sessions = new PiSessionService(eventHub, { const sessions = new PiSessionService(eventHub, {
@@ -48,9 +49,10 @@ await runSessionDaemonStartup({
workspaceActivity, workspaceActivity,
logger: app.log, logger: app.log,
...(spawnTargets === undefined ? {} : { spawnTargets }), ...(spawnTargets === undefined ? {} : { spawnTargets }),
subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config), subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
sessionManager: createPiSessionManagerGateway({ sessionManager: createPiSessionManagerGateway({
agentDir: activeAgentProfile.dir, agentDir: activeAgentProfile.dir,
env: daemonEnvironment,
sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys, sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys,
}), }),
}); });
@@ -98,9 +100,9 @@ await runSessionDaemonStartup({
process.once("SIGINT", (signal) => { void shutdown(signal); }); process.once("SIGINT", (signal) => { void shutdown(signal); });
process.once("SIGTERM", (signal) => { void shutdown(signal); }); process.once("SIGTERM", (signal) => { void shutdown(signal); });
const portValue = process.env["PI_WEB_SESSIOND_PORT"]; const portValue = daemonEnvironment["PI_WEB_SESSIOND_PORT"];
const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined; const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined;
const host = process.env["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1"; const host = daemonEnvironment["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";
if (port !== undefined) { if (port !== undefined) {
await app.listen({ port, host }); await app.listen({ port, host });
@@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { agentSessionDirEnvKeys } from "../../config.js";
import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, filterSessionsForCwd, SessionDirResolver } from "./piSessionManagerGateway.js"; import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, filterSessionsForCwd, SessionDirResolver } from "./piSessionManagerGateway.js";
import type { PiSessionListEntry } from "./piSessionService.js"; import type { PiSessionListEntry } from "./piSessionService.js";
import type { PiSessionManager } from "./piSessionService.js"; import type { PiSessionManager } from "./piSessionService.js";
@@ -24,7 +25,7 @@ afterEach(async () => {
describe("SessionDirResolver", () => { describe("SessionDirResolver", () => {
it("uses Pi default session storage when no Pi override is configured", () => { it("uses Pi default session storage when no Pi override is configured", () => {
const resolver = new SessionDirResolver({ agentDir, env: {} }); const resolver = new SessionDirResolver(piProfileOptions());
expect(resolver.resolve(cwd)).toMatchObject({ source: "pi-default", sessionDir: defaultPiSessionDir(cwd, agentDir), usesConfiguredSessionDir: false }); expect(resolver.resolve(cwd)).toMatchObject({ source: "pi-default", sessionDir: defaultPiSessionDir(cwd, agentDir), usesConfiguredSessionDir: false });
expect(defaultPiSessionsRoot(agentDir)).toBe(join(agentDir, "sessions")); expect(defaultPiSessionsRoot(agentDir)).toBe(join(agentDir, "sessions"));
@@ -34,7 +35,7 @@ describe("SessionDirResolver", () => {
await mkdir(agentDir, { recursive: true }); await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" }, null, 2)}\n`, "utf8"); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: {} }); const resolver = new SessionDirResolver(piProfileOptions());
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".pi", "sessions"), usesConfiguredSessionDir: true }); expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".pi", "sessions"), usesConfiguredSessionDir: true });
}); });
@@ -45,7 +46,7 @@ describe("SessionDirResolver", () => {
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "global-sessions") }, null, 2)}\n`, "utf8"); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "global-sessions") }, null, 2)}\n`, "utf8");
await writeFile(join(cwd, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".workspace-sessions" }, null, 2)}\n`, "utf8"); await writeFile(join(cwd, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".workspace-sessions" }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: {} }); const resolver = new SessionDirResolver(piProfileOptions());
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".workspace-sessions"), usesConfiguredSessionDir: true }); expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".workspace-sessions"), usesConfiguredSessionDir: true });
}); });
@@ -55,7 +56,7 @@ describe("SessionDirResolver", () => {
await mkdir(agentDir, { recursive: true }); await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8"); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envDir } }); const resolver = new SessionDirResolver(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: envDir }));
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
}); });
@@ -65,10 +66,22 @@ describe("SessionDirResolver", () => {
await mkdir(agentDir, { recursive: true }); await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8"); await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: { PI_WEB_AGENT_SESSION_DIR: envDir } }); const resolver = new SessionDirResolver(piProfileOptions({ PI_WEB_AGENT_SESSION_DIR: envDir }));
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true }); expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
}); });
it("snapshots the daemon epoch's injected session-directory environment", () => {
const firstDir = join(tempDir, "first-env-sessions");
const env = { PI_WEB_AGENT_SESSION_DIR: firstDir };
const sessionDirEnvKeys = ["PI_WEB_AGENT_SESSION_DIR"];
const resolver = new SessionDirResolver({ agentDir, env, sessionDirEnvKeys });
env.PI_WEB_AGENT_SESSION_DIR = join(tempDir, "mutated-env-sessions");
sessionDirEnvKeys[0] = "OTHER_SESSION_DIR";
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: firstDir, usesConfiguredSessionDir: true });
});
}); });
describe("Pi session manager gateway", () => { describe("Pi session manager gateway", () => {
@@ -76,7 +89,7 @@ describe("Pi session manager gateway", () => {
const otherCwd = join(tempDir, "other-workspace"); const otherCwd = join(tempDir, "other-workspace");
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-a", cwd); await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-a", cwd);
await writeSessionFile(defaultPiSessionDir(otherCwd, agentDir), "session-b", otherCwd); await writeSessionFile(defaultPiSessionDir(otherCwd, agentDir), "session-b", otherCwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} }); const gateway = createPiSessionManagerGateway(piProfileOptions());
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })])); await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })]));
@@ -86,7 +99,7 @@ describe("Pi session manager gateway", () => {
const envSessionDir = join(tempDir, "env-sessions"); const envSessionDir = join(tempDir, "env-sessions");
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd); await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd);
await writeSessionFile(envSessionDir, "env-session", cwd); await writeSessionFile(envSessionDir, "env-session", cwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } }); const gateway = createPiSessionManagerGateway(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: envSessionDir }));
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })])); await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })]));
@@ -99,6 +112,7 @@ describe("Pi session manager gateway", () => {
const gateway = createPiSessionManagerGateway({ const gateway = createPiSessionManagerGateway({
agentDir, agentDir,
env: { [envKey]: envSessionDir }, env: { [envKey]: envSessionDir },
sessionDirEnvKeys: [envKey],
}); });
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support"); if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
@@ -111,7 +125,7 @@ describe("Pi session manager gateway", () => {
const otherCwd = join(tempDir, "other-workspace"); const otherCwd = join(tempDir, "other-workspace");
await writeSessionFile(sharedSessionDir, "session-a", cwd); await writeSessionFile(sharedSessionDir, "session-a", cwd);
await writeSessionFile(sharedSessionDir, "session-b", otherCwd); await writeSessionFile(sharedSessionDir, "session-b", otherCwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: sharedSessionDir } }); const gateway = createPiSessionManagerGateway(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: sharedSessionDir }));
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-a", cwd }]); await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-a", cwd }]);
const created = gateway.create(cwd); const created = gateway.create(cwd);
@@ -125,7 +139,7 @@ describe("Pi session manager gateway", () => {
// hiding every session outside the daemon's own launch directory. // hiding every session outside the daemon's own launch directory.
expect(cwd).not.toBe(process.cwd()); expect(cwd).not.toBe(process.cwd());
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-elsewhere", cwd); await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-elsewhere", cwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} }); const gateway = createPiSessionManagerGateway(piProfileOptions());
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-elsewhere", cwd }]); await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-elsewhere", cwd }]);
}); });
@@ -153,12 +167,16 @@ describe("session listing canonicalization", () => {
// Headers are written by the Pi CLI / SDK consumers and may contain // Headers are written by the Pi CLI / SDK consumers and may contain
// unnormalized paths (trailing separators, redundant segments). // unnormalized paths (trailing separators, redundant segments).
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-messy", `${cwd}${sep}.${sep}`); await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-messy", `${cwd}${sep}.${sep}`);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} }); const gateway = createPiSessionManagerGateway(piProfileOptions());
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-messy", cwd }]); await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-messy", cwd }]);
}); });
}); });
function piProfileOptions(env: NodeJS.ProcessEnv = {}) {
return { agentDir, env, sessionDirEnvKeys: agentSessionDirEnvKeys() };
}
function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } { function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } {
return "getSessionDir" in manager && typeof manager.getSessionDir === "function"; return "getSessionDir" in manager && typeof manager.getSessionDir === "function";
} }
+26 -30
View File
@@ -3,7 +3,6 @@ import { readdir } from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path"; import { dirname, isAbsolute, join, resolve } from "node:path";
import { SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent"; import { SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import { agentSessionDirEnvKeys, effectiveAgentConfig } from "../../config.js";
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js"; import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js"; import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js";
@@ -16,20 +15,23 @@ export interface SessionDirResolution {
} }
export interface SessionDirResolverOptions { export interface SessionDirResolverOptions {
agentDir?: string; agentDir: string;
env?: NodeJS.ProcessEnv; env: Readonly<NodeJS.ProcessEnv>;
sessionDirEnvKeys?: readonly string[]; sessionDirEnvKeys: readonly string[];
} }
export class SessionDirResolver { export class SessionDirResolver {
private readonly agentDir: string; private readonly agentDir: string;
private readonly env: NodeJS.ProcessEnv; private readonly envSessionDir: string | undefined;
private readonly sessionDirEnvKeys: readonly string[]; private readonly homeDir: string;
constructor(options: SessionDirResolverOptions = {}) { constructor(options: SessionDirResolverOptions) {
this.agentDir = options.agentDir ?? effectiveAgentConfig().dir; this.agentDir = options.agentDir;
this.env = options.env ?? process.env; this.envSessionDir = options.sessionDirEnvKeys
this.sessionDirEnvKeys = options.sessionDirEnvKeys ?? agentSessionDirEnvKeys(); .map((key) => options.env[key])
.find((value) => value !== undefined && value !== "");
const configuredHome = options.env["HOME"];
this.homeDir = configuredHome !== undefined && configuredHome !== "" && isAbsolute(configuredHome) ? configuredHome : homedir();
} }
defaultSessionsRoot(): string { defaultSessionsRoot(): string {
@@ -37,34 +39,28 @@ export class SessionDirResolver {
} }
globalEnvSessionDir(): string | undefined { globalEnvSessionDir(): string | undefined {
const envSessionDir = this.envSessionDir(); if (this.envSessionDir === undefined) return undefined;
if (envSessionDir === undefined) return undefined; const expanded = expandTildePath(this.envSessionDir, this.homeDir);
const expanded = expandTildePath(envSessionDir);
return isAbsolute(expanded) ? expanded : undefined; return isAbsolute(expanded) ? expanded : undefined;
} }
resolve(cwd: string): SessionDirResolution { resolve(cwd: string): SessionDirResolution {
const envSessionDir = this.envSessionDir(); if (this.envSessionDir !== undefined) {
if (envSessionDir !== undefined) { return { source: "env", sessionDir: resolveConfiguredPath(this.envSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true };
return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true };
} }
const settingsSessionDir = SettingsManager.create(cwd, this.agentDir).getSessionDir(); const settingsSessionDir = SettingsManager.create(cwd, this.agentDir).getSessionDir();
if (settingsSessionDir !== undefined && settingsSessionDir !== "") { if (settingsSessionDir !== undefined && settingsSessionDir !== "") {
return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd), usesConfiguredSessionDir: true }; return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true };
} }
return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false }; return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false };
} }
private envSessionDir(): string | undefined {
return this.sessionDirEnvKeys.map((key) => this.env[key]).find((value) => value !== undefined && value !== "");
}
} }
export type PiSessionManagerGatewayOptions = SessionDirResolverOptions; export type PiSessionManagerGatewayOptions = SessionDirResolverOptions;
export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions = {}): PiSessionManagerGateway { export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions): PiSessionManagerGateway {
return new SettingsAwarePiSessionManagerGateway(new SessionDirResolver(options)); return new SettingsAwarePiSessionManagerGateway(new SessionDirResolver(options));
} }
@@ -105,7 +101,7 @@ export async function listSessionsInDir(sessionDir: string): Promise<PiSessionLi
return sessions.map((session) => ({ ...session, cwd: canonicalizeStoredCwd(session.cwd) })); return sessions.map((session) => ({ ...session, cwd: canonicalizeStoredCwd(session.cwd) }));
} }
export async function listSessionsInDefaultPiStore(storeRoot = defaultPiSessionsRoot()): Promise<PiSessionListEntry[]> { export async function listSessionsInDefaultPiStore(storeRoot: string): Promise<PiSessionListEntry[]> {
let entries: Dirent[]; let entries: Dirent[];
try { try {
entries = await readdir(storeRoot, { withFileTypes: true }); entries = await readdir(storeRoot, { withFileTypes: true });
@@ -130,11 +126,11 @@ function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessio
return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime()); return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime());
} }
export function defaultPiSessionsRoot(agentDir = effectiveAgentConfig().dir): string { export function defaultPiSessionsRoot(agentDir: string): string {
return join(agentDir, "sessions"); return join(agentDir, "sessions");
} }
export function defaultPiSessionDir(cwd: string, agentDir = effectiveAgentConfig().dir): string { export function defaultPiSessionDir(cwd: string, agentDir: string): string {
return sessionDirInDefaultPiStore(defaultPiSessionsRoot(agentDir), cwd); return sessionDirInDefaultPiStore(defaultPiSessionsRoot(agentDir), cwd);
} }
@@ -143,13 +139,13 @@ export function sessionDirInDefaultPiStore(storeRoot: string, cwd: string): stri
return join(storeRoot, safePath); return join(storeRoot, safePath);
} }
export function resolveConfiguredPath(path: string, cwd: string): string { export function resolveConfiguredPath(path: string, cwd: string, homeDir: string): string {
const expanded = expandTildePath(path); const expanded = expandTildePath(path, homeDir);
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded); return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
} }
function expandTildePath(path: string): string { function expandTildePath(path: string, homeDir: string): string {
if (path === "~") return homedir(); if (path === "~") return homeDir;
if (path.startsWith("~/")) return join(homedir(), path.slice(2)); if (path.startsWith("~/")) return join(homeDir, path.slice(2));
return path; return path;
} }
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js"; import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js"; import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService archive and cleanup", () => { describe("PiSessionService archive and cleanup", () => {
it("archives a session subtree within the root workspace", async () => { it("archives a session subtree within the root workspace", async () => {
const archivedInputs: string[] = []; const archivedInputs: string[] = [];
@@ -12,6 +14,7 @@ describe("PiSessionService archive and cleanup", () => {
const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path }; const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path };
const fake = fakeRuntime("root", { sessionFile: root.path }); const fake = fakeRuntime("root", { sessionFile: root.path });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: { archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]), list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
@@ -45,6 +48,7 @@ describe("PiSessionService archive and cleanup", () => {
it("permanently deletes archived sessions through the archive store", async () => { it("permanently deletes archived sessions through the archive store", async () => {
const deletedSessionIds: string[] = []; const deletedSessionIds: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: { archiveStore: {
list: () => Promise.resolve([]), list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -78,6 +82,7 @@ describe("PiSessionService archive and cleanup", () => {
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); }); const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: { archiveStore: {
list: () => Promise.resolve([]), list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined), get: () => Promise.resolve(undefined),
@@ -112,6 +117,7 @@ describe("PiSessionService archive and cleanup", () => {
let createCalls = 0; let createCalls = 0;
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" })))); const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => { createAgentRuntime: () => {
createCalls += 1; createCalls += 1;
return Promise.resolve(busy.runtime); return Promise.resolve(busy.runtime);
@@ -152,6 +158,7 @@ describe("PiSessionService archive and cleanup", () => {
const busy = fakeRuntime("busy-archived", { isStreaming: true }); const busy = fakeRuntime("busy-archived", { isStreaming: true });
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(busy.runtime), createAgentRuntime: runtimeCreator(busy.runtime),
archiveStore: { archiveStore: {
list: () => Promise.resolve([busyRecord, idleRecord]), list: () => Promise.resolve([busyRecord, idleRecord]),
@@ -188,6 +195,7 @@ describe("PiSessionService archive and cleanup", () => {
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const listCalls: string[] = []; const listCalls: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: { archiveStore: {
list: () => Promise.resolve([ list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" }, { sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
@@ -230,6 +238,7 @@ describe("PiSessionService archive and cleanup", () => {
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" }; const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" }; const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
now: () => new Date("2026-06-25T00:00:00.000Z"), now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: { archiveStore: {
list: () => Promise.resolve([archived, otherArchived]), list: () => Promise.resolve([archived, otherArchived]),
@@ -282,6 +291,7 @@ describe("PiSessionService archive and cleanup", () => {
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` })))); const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
now: () => new Date("2026-06-25T00:00:00.000Z"), now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: { archiveStore: {
list: () => Promise.resolve([ list: () => Promise.resolve([
@@ -323,6 +333,7 @@ describe("PiSessionService archive and cleanup", () => {
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" }); const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
const archivedInputs: string[] = []; const archivedInputs: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
now: () => new Date("2026-06-25T00:00:00.000Z"), now: () => new Date("2026-06-25T00:00:00.000Z"),
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: { archiveStore: {
@@ -5,6 +5,8 @@ import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js"; import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js"; import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
function deferred<T = void>() { function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void; let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void; let reject!: (reason?: unknown) => void;
@@ -20,12 +22,15 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const hub = new CapturingSessionEventHub(); const hub = new CapturingSessionEventHub();
const fake = fakeRuntime(); const fake = fakeRuntime();
let createCalls = 0; let createCalls = 0;
const createAgentRuntime: RuntimeCreator = async () => { let runtimeAgentDir: string | undefined;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
createCalls += 1; createCalls += 1;
runtimeAgentDir = options.agentDir;
await Promise.resolve(); await Promise.resolve();
return fake.runtime; return fake.runtime;
}; };
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime, createAgentRuntime,
sessionManager: sessionGateway([]), sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -34,6 +39,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const session = await service.start("/workspace"); const session = await service.start("/workspace");
expect(createCalls).toBe(1); expect(createCalls).toBe(1);
expect(runtimeAgentDir).toBe(TEST_AGENT_DIR);
expect(fake.calls.bindExtensions).toHaveLength(1); expect(fake.calls.bindExtensions).toHaveLength(1);
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 }); expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
expect(service.activeCount()).toBe(1); expect(service.activeCount()).toBe(1);
@@ -53,6 +59,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
let service: PiSessionService | undefined; let service: PiSessionService | undefined;
try { try {
service = new PiSessionService(hub, { service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]), sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -79,6 +86,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const fake = fakeRuntime("legacy-session"); const fake = fakeRuntime("legacy-session");
const open = vi.fn(() => fakeSessionManager()); const open = vi.fn(() => fakeSessionManager());
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: { sessionManager: {
create: () => fakeSessionManager(), create: () => fakeSessionManager(),
@@ -127,6 +135,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const gateway = sessionGateway([sessionRecord(sessionId)]); const gateway = sessionGateway([sessionRecord(sessionId)]);
const open = vi.spyOn(gateway, "open"); const open = vi.spyOn(gateway, "open");
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: emptyArchiveStore(), archiveStore: emptyArchiveStore(),
createAgentRuntime, createAgentRuntime,
sessionManager: gateway, sessionManager: gateway,
@@ -180,6 +189,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
: Promise.resolve(runtime); : Promise.resolve(runtime);
}; };
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: emptyArchiveStore(), archiveStore: emptyArchiveStore(),
createAgentRuntime, createAgentRuntime,
sessionManager: sessionGateway([sessionRecord(sessionId)]), sessionManager: sessionGateway([sessionRecord(sessionId)]),
@@ -219,6 +229,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const runtimeResult = deferred<PiSessionRuntime>(); const runtimeResult = deferred<PiSessionRuntime>();
const fake = fakeRuntime(sessionId); const fake = fakeRuntime(sessionId);
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: emptyArchiveStore(), archiveStore: emptyArchiveStore(),
createAgentRuntime: () => { createAgentRuntime: () => {
createStarted.resolve(); createStarted.resolve();
@@ -252,6 +263,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined; let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; }; fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]), sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -278,6 +290,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
}, },
}); });
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]), sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -312,6 +325,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
}, },
}); });
service = new PiSessionService(hub, { service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("idle-session")]), sessionManager: sessionGateway([sessionRecord("idle-session")]),
heartbeatIntervalMs: 1_000, heartbeatIntervalMs: 1_000,
@@ -347,6 +361,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
}, },
}); });
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("completion-session")]), sessionManager: sessionGateway([sessionRecord("completion-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -365,6 +380,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("uses injected archive and session-manager gateways for listing", async () => { it("uses injected archive and session-manager gateways for listing", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: { archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]), list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
get: () => Promise.resolve(undefined), get: () => Promise.resolve(undefined),
@@ -394,6 +410,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("lists archived records that have been moved out of the active session directory", async () => { it("lists archived records that have been moved out of the active session directory", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: { archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined), get: () => Promise.resolve(undefined),
@@ -424,6 +441,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const hub = new CapturingSessionEventHub(); const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("runtime-reload-session"); const fake = fakeRuntime("runtime-reload-session");
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]), sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -456,6 +474,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
return runtime; return runtime;
}; };
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime, createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("reload-session")]), sessionManager: sessionGateway([sessionRecord("reload-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -479,6 +498,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("refuses to reload a session that has active work in progress", async () => { it("refuses to reload a session that has active work in progress", async () => {
const fake = fakeRuntime("busy-session", { isStreaming: true }); const fake = fakeRuntime("busy-session", { isStreaming: true });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("busy-session")]), sessionManager: sessionGateway([sessionRecord("busy-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -493,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("refuses to reload an archived session", async () => { it("refuses to reload an archived session", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: { archiveStore: {
list: () => Promise.resolve([]), list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId) get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -514,6 +535,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("reconciles workspace activity when listing only archived sessions", async () => { it("reconciles workspace activity when listing only archived sessions", async () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = []; const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: { archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]), list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined), get: () => Promise.resolve(undefined),
@@ -5,10 +5,13 @@ import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js"; import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService prompt, queue, and auth warnings", () => { describe("PiSessionService prompt, queue, and auth warnings", () => {
it("sends prompts to an injected runtime without touching the SDK runtime", async () => { it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
const fake = fakeRuntime("prompt-session"); const fake = fakeRuntime("prompt-session");
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("prompt-session")]), sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -26,6 +29,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
}); });
const hub = new CapturingSessionEventHub(); const hub = new CapturingSessionEventHub();
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("echo-session")]), sessionManager: sessionGateway([sessionRecord("echo-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -55,6 +59,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
return fake.runtime; return fake.runtime;
}; };
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime, createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("prompt-session")]), sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -90,6 +95,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const hub = new CapturingSessionEventHub(); const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("name-session")]), sessionManager: sessionGateway([sessionRecord("name-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -111,6 +117,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
getFollowUpMessages: () => ["then do this"], getFollowUpMessages: () => ["then do this"],
}); });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("status-session")]), sessionManager: sessionGateway([sessionRecord("status-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -131,6 +138,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
getFollowUpMessages: () => ["already queued"], getFollowUpMessages: () => ["already queued"],
}); });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("dedupe-session")]), sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -146,6 +154,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const hub = new CapturingSessionEventHub(); const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("queued-session", { isStreaming: true }); const fake = fakeRuntime("queued-session", { isStreaming: true });
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("queued-session")]), sessionManager: sessionGateway([sessionRecord("queued-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -171,6 +180,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
return Promise.resolve(); return Promise.resolve();
}; };
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("compacting-session")]), sessionManager: sessionGateway([sessionRecord("compacting-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -215,6 +225,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
it("clears queued messages when aborting active work", async () => { it("clears queued messages when aborting active work", async () => {
const fake = fakeRuntime("abort-session"); const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-session")]), sessionManager: sessionGateway([sessionRecord("abort-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -231,6 +242,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
it("clears prompts queued during compaction when aborting active work", async () => { it("clears prompts queued during compaction when aborting active work", async () => {
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true }); const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]), sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -255,6 +267,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("auth-session", { model, modelRegistry }); const fake = fakeRuntime("auth-session", { model, modelRegistry });
const service = new PiSessionService(hub, { const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRegistry, modelRegistry,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("auth-session")]), sessionManager: sessionGateway([sessionRecord("auth-session")]),
@@ -285,6 +298,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
it("clears queued messages when stopping a session runtime", async () => { it("clears queued messages when stopping a session runtime", async () => {
const fake = fakeRuntime("stop-session"); const fake = fakeRuntime("stop-session");
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("stop-session")]), sessionManager: sessionGateway([sessionRecord("stop-session")]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -3,12 +3,15 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => { describe("PiSessionService", () => {
describe("spawnSession", () => { describe("spawnSession", () => {
function spawnService(decision: SpawnTargetDecision) { function spawnService(decision: SpawnTargetDecision) {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
const log: { details: Record<string, unknown>; message: string }[] = []; const log: { details: Record<string, unknown>; message: string }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]), sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
@@ -41,6 +44,7 @@ describe("PiSessionService", () => {
return fake.runtime; return fake.runtime;
}; };
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime, createAgentRuntime,
sessionManager: sessionGateway([]), sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
@@ -75,6 +79,7 @@ describe("PiSessionService", () => {
it("is disabled when no spawn target resolver is configured", async () => { it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("spawned-x"); const fake = fakeRuntime("spawned-x");
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]), sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
@@ -6,6 +6,8 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js"; import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => { describe("PiSessionService", () => {
describe("spawnSubsession", () => { describe("spawnSubsession", () => {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) { function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) {
@@ -32,6 +34,7 @@ describe("PiSessionService", () => {
isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)), isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)),
}; };
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime, createAgentRuntime,
sessionManager: sessionGateway([]), sessionManager: sessionGateway([]),
archiveStore, archiveStore,
@@ -72,6 +75,7 @@ describe("PiSessionService", () => {
return runtime; return runtime;
}; };
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime, createAgentRuntime,
sessionManager: sessionGateway([]), sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(), archiveStore: emptyArchiveStore(),
@@ -111,6 +115,7 @@ describe("PiSessionService", () => {
const runtimes = [parent.runtime, child.runtime]; const runtimes = [parent.runtime, child.runtime];
let index = 0; let index = 0;
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => { createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime; const runtime = runtimes[index] ?? child.runtime;
index += 1; index += 1;
@@ -162,6 +167,7 @@ describe("PiSessionService", () => {
let index = 0; let index = 0;
const open = vi.fn(() => childManager); const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => { createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime; const runtime = runtimes[index] ?? child.runtime;
index += 1; index += 1;
@@ -203,6 +209,7 @@ describe("PiSessionService", () => {
}), }),
}); });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime), createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(), archiveStore: emptyArchiveStore(),
@@ -227,6 +234,7 @@ describe("PiSessionService", () => {
}), }),
}); });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime), createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(), archiveStore: emptyArchiveStore(),
@@ -248,6 +256,7 @@ describe("PiSessionService", () => {
}), }),
}); });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime), createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(), archiveStore: emptyArchiveStore(),
@@ -268,6 +277,7 @@ describe("PiSessionService", () => {
sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }), sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }),
}); });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime), createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() }, sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(), archiveStore: emptyArchiveStore(),
@@ -288,6 +298,7 @@ describe("PiSessionService", () => {
}), }),
}); });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(forkedParent.runtime), createAgentRuntime: runtimeCreator(forkedParent.runtime),
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(), archiveStore: emptyArchiveStore(),
@@ -326,6 +337,7 @@ describe("PiSessionService", () => {
let index = 0; let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: (_createRuntime, options) => { createAgentRuntime: (_createRuntime, options) => {
delegationCapabilities.push(options.delegationToolsEnabled); delegationCapabilities.push(options.delegationToolsEnabled);
const runtime = runtimes[index] ?? parent.runtime; const runtime = runtimes[index] ?? parent.runtime;
@@ -388,6 +400,7 @@ describe("PiSessionService", () => {
return childManager; return childManager;
}); });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => { createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime; const runtime = runtimes[index] ?? parent.runtime;
index += 1; index += 1;
@@ -445,6 +458,7 @@ describe("PiSessionService", () => {
let index = 0; let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => { createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime; const runtime = runtimes[index] ?? parent.runtime;
index += 1; index += 1;
@@ -509,6 +523,7 @@ describe("PiSessionService", () => {
throw new Error(`unexpected open path ${path}`); throw new Error(`unexpected open path ${path}`);
}); });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime, createAgentRuntime,
sessionManager: { sessionManager: {
create: () => parentManager, create: () => parentManager,
@@ -584,6 +599,7 @@ describe("PiSessionService", () => {
throw new Error(`unexpected open path ${path}`); throw new Error(`unexpected open path ${path}`);
}); });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime, createAgentRuntime,
sessionManager: { sessionManager: {
create: () => copiedParentManager, create: () => copiedParentManager,
@@ -641,6 +657,7 @@ describe("PiSessionService", () => {
let index = 0; let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => { createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime; const runtime = runtimes[index] ?? parent.runtime;
index += 1; index += 1;
@@ -693,6 +710,7 @@ describe("PiSessionService", () => {
let index = 0; let index = 0;
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager); const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => { createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime; const runtime = runtimes[index] ?? parent.runtime;
index += 1; index += 1;
@@ -733,6 +751,7 @@ describe("PiSessionService", () => {
const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager }); const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager });
const open = vi.fn(() => childManager); const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(child.runtime), createAgentRuntime: runtimeCreator(child.runtime),
sessionManager: { sessionManager: {
create: () => childManager, create: () => childManager,
@@ -842,6 +861,7 @@ describe("PiSessionService", () => {
it("is disabled when no spawn target resolver is configured", async () => { it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("nope"); const fake = fakeRuntime("nope");
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime), createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]), sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000, heartbeatIntervalMs: 60_000,
+5 -7
View File
@@ -25,8 +25,6 @@ import type { ActiveSession } from "./sessionRuntimeStore.js";
import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js"; import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js";
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js"; import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js"; import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
import { effectiveAgentConfig } from "../../config.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js"; import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
import { parsePromptAttachments } from "../../shared/promptAttachments.js"; import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js"; import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js";
@@ -380,9 +378,9 @@ function createPiWebEditToolDefinition(cwd: string) {
} }
export interface PiSessionServiceDependencies { export interface PiSessionServiceDependencies {
agentDir: string;
sessionManager: PiSessionManagerGateway;
archiveStore?: SessionArchiveRepository; archiveStore?: SessionArchiveRepository;
agentDir?: string;
sessionManager?: PiSessionManagerGateway;
createRuntime?: PiWebCreateAgentSessionRuntimeFactory; createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
createAgentRuntime?: CreateAgentRuntime; createAgentRuntime?: CreateAgentRuntime;
modelRegistry?: ModelRegistryInstance; modelRegistry?: ModelRegistryInstance;
@@ -441,10 +439,10 @@ export class PiSessionService implements SessionRouteService {
private readonly logger: PiSessionLogger; private readonly logger: PiSessionLogger;
private readonly now: () => Date; private readonly now: () => Date;
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) { constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore(); this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
this.agentDir = deps.agentDir ?? effectiveAgentConfig().dir; this.agentDir = deps.agentDir;
this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir }); this.sessionManager = deps.sessionManager;
this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir); this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir);
this.spawnTargets = deps.spawnTargets; this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger; this.logger = deps.logger ?? noopLogger;
+3 -1
View File
@@ -9,6 +9,8 @@ import type { SessionRouteLookup, SessionRouteService } from "./sessionService.j
import { registerSessionRoutes } from "./sessionRoutes.js"; import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
let app: FastifyInstance; let app: FastifyInstance;
let service: PiSessionService; let service: PiSessionService;
let sessionManager: RejectingSessionManager; let sessionManager: RejectingSessionManager;
@@ -18,7 +20,7 @@ beforeEach(async () => {
await app.register(fastifyWebsocket); await app.register(fastifyWebsocket);
sessionManager = new RejectingSessionManager(); sessionManager = new RejectingSessionManager();
const eventHub = new SessionEventHub(); const eventHub = new SessionEventHub();
service = new PiSessionService(eventHub, { sessionManager, heartbeatIntervalMs: 60_000 }); service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 });
registerSessionRoutes(app, service, eventHub); registerSessionRoutes(app, service, eventHub);
}); });
+7 -1
View File
@@ -17,7 +17,7 @@ describe("active agent profile descriptor", () => {
expect(first.revision).toMatch(/^sha256:[0-9a-f]{64}$/u); expect(first.revision).toMatch(/^sha256:[0-9a-f]{64}$/u);
expect(createActiveAgentProfileDescriptor({ ...baseAgent, command: "other-agent" }).revision).not.toBe(first.revision); expect(createActiveAgentProfileDescriptor({ ...baseAgent, command: "other-agent" }).revision).not.toBe(first.revision);
expect(createActiveAgentProfileDescriptor({ ...baseAgent, dir: "/other/state" }).revision).not.toBe(first.revision); expect(createActiveAgentProfileDescriptor({ ...baseAgent, dir: "/other/state" }).revision).not.toBe(first.revision);
expect(createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: ["OTHER_SESSION_DIR"] }).revision).not.toBe(first.revision); expect(createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"] }).revision).not.toBe(first.revision);
}); });
it("takes an immutable snapshot for the session daemon profile epoch", () => { it("takes an immutable snapshot for the session daemon profile epoch", () => {
@@ -32,6 +32,12 @@ describe("active agent profile descriptor", () => {
expect(Reflect.set(profile.sessionDirEnvKeys, "0", "MUTATED_SESSION_DIR")).toBe(false); expect(Reflect.set(profile.sessionDirEnvKeys, "0", "MUTATED_SESSION_DIR")).toBe(false);
}); });
it("rejects profile fields outside the host and explicit environment policy", () => {
expect(() => createActiveAgentProfileDescriptor({ ...baseAgent, command: "./acme-agent" })).toThrow("must be valid for this host");
expect(() => createActiveAgentProfileDescriptor({ ...baseAgent, dir: "relative/state" })).toThrow("must be valid for this host");
expect(() => createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: ["ARBITRARY_AGENT_SESSION_DIR"] })).toThrow("explicit PI WEB policy");
});
it("copies only the secret-free descriptor fields", () => { it("copies only the secret-free descriptor fields", () => {
const input = { const input = {
...baseAgent, ...baseAgent,
+13 -1
View File
@@ -1,9 +1,15 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { EffectivePiWebAgentConfig } from "../config.js"; import { isHostAbsoluteAgentDir, isSafeAgentCommandForHost, PI_CODING_AGENT_SESSION_DIR_ENV, PI_WEB_AGENT_SESSION_DIR_ENV, type EffectivePiWebAgentConfig } from "../config.js";
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js"; import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
import { ACTIVE_AGENT_PROFILE_SCHEMA_VERSION } from "../shared/activeAgentProfile.js"; import { ACTIVE_AGENT_PROFILE_SCHEMA_VERSION } from "../shared/activeAgentProfile.js";
export function createActiveAgentProfileDescriptor(agent: EffectivePiWebAgentConfig): ActiveAgentProfileDescriptor { export function createActiveAgentProfileDescriptor(agent: EffectivePiWebAgentConfig): ActiveAgentProfileDescriptor {
if (!isSafeAgentCommandForHost(agent.command) || !isHostAbsoluteAgentDir(agent.dir)) {
throw new Error("Active agent profile command and directory must be valid for this host");
}
if (!hasValidSessionDirEnvKeys(agent.sessionDirEnvKeys)) {
throw new Error("Active agent profile session directory environment keys must use the explicit PI WEB policy");
}
const sessionDirEnvKeys = Object.freeze([...agent.sessionDirEnvKeys]); const sessionDirEnvKeys = Object.freeze([...agent.sessionDirEnvKeys]);
const revisionInput = JSON.stringify({ const revisionInput = JSON.stringify({
schemaVersion: ACTIVE_AGENT_PROFILE_SCHEMA_VERSION, schemaVersion: ACTIVE_AGENT_PROFILE_SCHEMA_VERSION,
@@ -20,3 +26,9 @@ export function createActiveAgentProfileDescriptor(agent: EffectivePiWebAgentCon
sessionDirEnvKeys, sessionDirEnvKeys,
}); });
} }
function hasValidSessionDirEnvKeys(keys: readonly string[]): boolean {
return (keys.length === 1 || keys.length === 2)
&& keys[0] === PI_WEB_AGENT_SESSION_DIR_ENV
&& (keys.length === 1 || keys[1] === PI_CODING_AGENT_SESSION_DIR_ENV);
}
+13
View File
@@ -43,6 +43,19 @@ describe("SessionDaemonClient active agent profile protocol", () => {
}); });
}); });
it.skipIf(process.platform === "win32")("rejects foreign-platform active state paths before local consumers use them", async () => {
const client = new SessionDaemonClient();
vi.spyOn(client, "request").mockResolvedValue(runtimeResponse({
...activeAgentProfile,
dir: "C:\\agent-profiles\\acme",
}));
await expect(client.getActiveAgentProfile()).resolves.toEqual({
status: "invalid",
error: "session daemon active agent profile was not valid for this host",
});
});
it("treats a legacy runtime response without a profile as invalid for profile-dependent work", async () => { it("treats a legacy runtime response without a profile as invalid for profile-dependent work", async () => {
const client = new SessionDaemonClient(); const client = new SessionDaemonClient();
vi.spyOn(client, "request").mockResolvedValue(runtimeResponse(undefined)); vi.spyOn(client, "request").mockResolvedValue(runtimeResponse(undefined));
+4
View File
@@ -1,5 +1,6 @@
import http from "node:http"; import http from "node:http";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { isHostAbsoluteAgentDir, isSafeAgentCommandForHost } from "../config.js";
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js"; import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
import { parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { sessiondHttpUrl, sessiondSocketPath } from "./config.js"; import { sessiondHttpUrl, sessiondSocketPath } from "./config.js";
@@ -108,6 +109,9 @@ export async function getSessionDaemonActiveAgentProfile(client: SessionDaemonRe
if (runtime.activeAgentProfile === undefined) { if (runtime.activeAgentProfile === undefined) {
return { status: "invalid", error: "session daemon runtime response did not include an active agent profile" }; return { status: "invalid", error: "session daemon runtime response did not include an active agent profile" };
} }
if (!isSafeAgentCommandForHost(runtime.activeAgentProfile.command) || !isHostAbsoluteAgentDir(runtime.activeAgentProfile.dir)) {
return { status: "invalid", error: "session daemon active agent profile was not valid for this host" };
}
return { status: "available", profile: runtime.activeAgentProfile }; return { status: "available", profile: runtime.activeAgentProfile };
} }
+25 -1
View File
@@ -10,6 +10,8 @@ const ACTIVE_AGENT_PROFILE_FIELDS = new Set([
"sessionDirEnvKeys", "sessionDirEnvKeys",
]); ]);
const SHA256_REVISION_PATTERN = /^sha256:[0-9a-f]{64}$/u; const SHA256_REVISION_PATTERN = /^sha256:[0-9a-f]{64}$/u;
const SAFE_BARE_AGENT_COMMAND_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/u;
const ACTIVE_SESSION_DIR_ENV_KEYS = new Set(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]);
export function parseActiveAgentProfileDescriptor(value: unknown): ActiveAgentProfileDescriptor | undefined { export function parseActiveAgentProfileDescriptor(value: unknown): ActiveAgentProfileDescriptor | undefined {
if (!isRecord(value) || Object.keys(value).some((key) => !ACTIVE_AGENT_PROFILE_FIELDS.has(key))) return undefined; if (!isRecord(value) || Object.keys(value).some((key) => !ACTIVE_AGENT_PROFILE_FIELDS.has(key))) return undefined;
@@ -21,9 +23,11 @@ export function parseActiveAgentProfileDescriptor(value: unknown): ActiveAgentPr
const sessionDirEnvKeys = value["sessionDirEnvKeys"]; const sessionDirEnvKeys = value["sessionDirEnvKeys"];
if (schemaVersion !== ACTIVE_AGENT_PROFILE_SCHEMA_VERSION) return undefined; if (schemaVersion !== ACTIVE_AGENT_PROFILE_SCHEMA_VERSION) return undefined;
if (typeof revision !== "string" || !SHA256_REVISION_PATTERN.test(revision)) return undefined; if (typeof revision !== "string" || !SHA256_REVISION_PATTERN.test(revision)) return undefined;
if (typeof command !== "string" || command === "" || typeof dir !== "string" || dir === "") return undefined; if (typeof command !== "string" || !isPortableAgentCommand(command)) return undefined;
if (typeof dir !== "string" || !isPortableAbsolutePath(dir)) return undefined;
if (!isNonEmptyStringArray(sessionDirEnvKeys)) return undefined; if (!isNonEmptyStringArray(sessionDirEnvKeys)) return undefined;
if (new Set(sessionDirEnvKeys).size !== sessionDirEnvKeys.length) return undefined; if (new Set(sessionDirEnvKeys).size !== sessionDirEnvKeys.length) return undefined;
if (sessionDirEnvKeys[0] !== "PI_WEB_AGENT_SESSION_DIR" || sessionDirEnvKeys.some((key) => !ACTIVE_SESSION_DIR_ENV_KEYS.has(key))) return undefined;
return Object.freeze({ return Object.freeze({
schemaVersion, schemaVersion,
@@ -34,6 +38,26 @@ export function parseActiveAgentProfileDescriptor(value: unknown): ActiveAgentPr
}); });
} }
function isPortableAgentCommand(value: string): boolean {
if (value !== value.trim() || /[\s;&|`$<>]/u.test(value)) return false;
if (isPortableAbsolutePath(value)) return !value.endsWith("/") && !value.endsWith("\\");
return SAFE_BARE_AGENT_COMMAND_PATTERN.test(value);
}
function isPortableAbsolutePath(value: string): boolean {
if (value === "" || value !== value.trim() || hasControlCharacter(value)) return false;
const withForwardSlashes = value.replace(/\\/g, "/");
return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes);
}
function hasControlCharacter(value: string): boolean {
for (const character of value) {
const code = character.charCodeAt(0);
if (code < 32 || code === 127) return true;
}
return false;
}
function isNonEmptyStringArray(value: unknown): value is string[] { function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry: unknown) => typeof entry === "string" && entry !== ""); return Array.isArray(value) && value.every((entry: unknown) => typeof entry === "string" && entry !== "");
} }
+3
View File
@@ -80,6 +80,9 @@ describe("PI WEB status parsing", () => {
}); });
expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, token: "secret" }))).toBeUndefined(); expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, token: "secret" }))).toBeUndefined();
expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, command: "./acme-agent" }))).toBeUndefined();
expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, dir: "relative/state" }))).toBeUndefined();
expect(parsePiWebRuntimeResponse(responseFor(undefined, { ...profile, sessionDirEnvKeys: ["ARBITRARY_AGENT_SESSION_DIR"] }))).toBeUndefined();
expect(parsePiWebRuntimeResponse(responseFor(profile, undefined))).toBeUndefined(); expect(parsePiWebRuntimeResponse(responseFor(profile, undefined))).toBeUndefined();
}); });