From adc2e297a4d4efd54336bae1890629d5a02c52bd Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 13 Jul 2026 23:39:49 +0200 Subject: [PATCH] fix: harden agent profile boundaries --- src/cli.test.ts | 5 + src/cli.ts | 4 +- src/config.test.ts | 71 +++++++++--- src/config.ts | 105 +++++++++++++----- src/server/configRoutes.test.ts | 31 +++++- src/server/configRoutes.ts | 42 ++----- src/server/machines/machineProxyRoutes.ts | 2 +- src/server/piWebStatus.test.ts | 28 ++++- src/server/piWebStatus.ts | 13 ++- src/server/sessiond.ts | 16 +-- .../sessions/piSessionManagerGateway.test.ts | 38 +++++-- .../sessions/piSessionManagerGateway.ts | 56 +++++----- .../piSessionService.archiveCleanup.test.ts | 11 ++ .../piSessionService.lifecycle.test.ts | 24 +++- .../piSessionService.promptQueue.test.ts | 14 +++ .../piSessionService.spawnSession.test.ts | 5 + .../piSessionService.spawnSubsession.test.ts | 20 ++++ src/server/sessions/piSessionService.ts | 12 +- src/server/sessions/sessionRoutes.test.ts | 4 +- src/sessiond/activeAgentProfile.test.ts | 8 +- src/sessiond/activeAgentProfile.ts | 14 ++- src/sessiond/sessionDaemonClient.test.ts | 13 +++ src/sessiond/sessionDaemonClient.ts | 4 + src/shared/activeAgentProfile.ts | 26 ++++- src/shared/piWebStatusParsing.test.ts | 3 + 25 files changed, 419 insertions(+), 150 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index 732197d..80fa32e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -68,6 +68,11 @@ describe("agentCommandForChecks", () => { delete process.env["PI_WEB_AGENT_COMMAND"]; 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 { rmSync(dir, { recursive: true, force: true }); } diff --git a/src/cli.ts b/src/cli.ts index a3329e7..3b2a0e1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { homedir, userInfo } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; 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 { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; import { @@ -780,7 +780,7 @@ function nodeVersionCheck(): 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[] { diff --git a/src/config.test.ts b/src/config.test.ts index c4523c2..431ecf1 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -69,23 +69,50 @@ describe("PI WEB config persistence", () => { 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", () => { - expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "/tmp/pi.cmd" } })).toMatchObject({ - command: "/tmp/pi.cmd", - dir: join(tempDir, ".home", ".pi", "agent"), - sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"], - }); + it("defaults to the Pi agent directory only for canonical Pi companion names", () => { + for (const command of ["pi", "pi.cmd"]) { + expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command } })).toMatchObject({ + command, + 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", () => { - 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"'); - 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"'); + it("requires explicit state for alternate names and absolute Pi launchers", () => { + const absolutePiCommand = join(tempDir, "bin", "pi"); + 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", () => { expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({ command: "acme-agent", 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", () => { - expect(effectiveAgentConfig({ - PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), - }, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ - dir: join(tempDir, "pi-env-agent"), - }); + it("keeps legacy Pi env directory overrides scoped to the canonical Pi command", () => { + const legacyDir = join(tempDir, "pi-env-agent"); + expect(effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: legacyDir }); - expect(() => effectiveAgentConfig({ - PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"), - }, { agent: { command: "acme-agent" } })).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", join(tempDir, "bin", "pi")]) { + expect(() => effectiveAgentConfig({ PI_CODING_AGENT_DIR: legacyDir }, { agent: { command } })) + .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", () => { 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(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", () => { diff --git a/src/config.ts b/src/config.ts index a41eeb5..21286cd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; 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 { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; @@ -59,12 +59,12 @@ export interface EffectivePiWebAgentConfig { sessionDirEnvKeys: string[]; } -export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}, cwd = process.cwd()): EffectivePiWebAgentConfig { - const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment"); - 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); +export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick = {}): EffectivePiWebAgentConfig { + 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) ?? (usesDefaultPiStatePolicy(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env); return { command, - dir: resolveAgentDirPath(configuredDir, env, cwd, "agent.dir", "environment"), + dir: resolveAgentDirPath(configuredDir, env, "agent.dir", "environment"), sessionDirEnvKeys: agentSessionDirEnvKeys(command), }; } @@ -72,12 +72,12 @@ export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, confi export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] { return uniqueStrings([ 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 { - 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 { @@ -131,7 +131,7 @@ export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options: const port = env["PI_WEB_PORT"] ?? env["PORT"]; const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"]; 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 { ...loaded, config: { @@ -155,8 +155,9 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): const env = options.env ?? process.env; const path = piWebConfigPath(env, options.cwd ?? process.cwd()); const normalized = parsePiWebConfig(piWebConfigRecord(config), path); - effectiveAgentConfig(env, normalized, options.cwd ?? process.cwd()); + effectiveAgentConfig(env, normalized); const existing = readExistingConfigObject(path); + if (existing["agent"] !== undefined) parseAgentConfig(existing["agent"], path); delete existing["host"]; delete existing["port"]; delete existing["allowedHosts"]; @@ -260,33 +261,72 @@ function parseString(value: unknown, key: string, path: string): string { return value; } -function parseAgentConfig(value: unknown, path: string): NonNullable { +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 { 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 dir = value["dir"]; return { - ...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path) } : {}), - ...(dir !== undefined ? { dir: parseAgentDir(dir, "agent.dir", path) } : {}), + ...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path, pathHost) } : {}), + ...(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(); - if (command === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`); - if (/[\s;&|`$<>]/u.test(command)) throw new Error(`PI WEB config ${key} must be a single command name or path without shell metacharacters: ${path}`); + if (!isSafeAgentCommand(command, pathHost)) { + 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; } -function parseAgentDir(value: unknown, key: string, path: string): string { - const dir = parseString(value, key, path); - if (!isAbsoluteOrHomePath(dir)) throw new Error(`PI WEB config ${key} must be an absolute path or start with ~: ${path}`); +function parseAgentDir(value: unknown, key: string, path: string, pathHost: AgentPathHost): string { + const dir = parseString(value, key, path).trim(); + 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; } -function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, cwd: string, key: string, path: string): string { - const parsed = parseAgentDir(value, key, path); +function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, key: string, path: string): string { + const parsed = parseAgentDir(value, key, path, "current"); 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 { @@ -339,23 +379,27 @@ function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string) } -function isAbsoluteOrHomePath(value: string): boolean { - return value === "~" || value.startsWith("~/") || value.startsWith("~\\") || isAbsoluteLike(value); +function isHomePath(value: string, pathHost: AgentPathHost): boolean { + return value === "~" || value.startsWith("~/") || ((pathHost === "portable" || process.platform === "win32") && value.startsWith("~\\")); } function expandHomePath(value: string, env: NodeJS.ProcessEnv): string { const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir(); 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; } 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)}`); } -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(); 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[] { 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 { const withForwardSlashes = value.replace(/\\/g, "/"); return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes); diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 5f1593f..6225d12 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -1,6 +1,6 @@ import Fastify, { type FastifyInstance } from "fastify"; 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"; let app: FastifyInstance; @@ -112,6 +112,21 @@ describe("config routes", () => { 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 () => { 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", () => { const parsed = parsePiWebConfigResponseBody({ path: "/tmp/pi-web/config.json", diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index a99c3d1..5ab96a3 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -1,5 +1,5 @@ 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 { 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"); 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}`); } try { - return pickSelectedMachineConfig(parseConfigRequest(value)); + return pickSelectedMachineConfig(parseConfigRequest(value, agentPathHost)); } catch (error) { throw new Error(selectedMachineConfigErrorMessage(error), { cause: error }); } @@ -112,13 +112,13 @@ export function parsePiWebConfigResponseBody(value: unknown, source = "PI WEB co return { path: requireResponseString(record, "path", source), exists: requireResponseBoolean(record, "exists", source), - config: parseConfigRequest(record["config"]), - effectiveConfig: parseConfigRequest(record["effectiveConfig"]), + config: parseConfigRequest(record["config"], "portable"), + effectiveConfig: parseConfigRequest(record["effectiveConfig"], "portable"), 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"); const config: PiWebConfig = {}; 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"); config.subsessions = subsessions; } - if (agent !== undefined) config.agent = parseAgentRequest(agent); + if (agent !== undefined) config.agent = parseAgentRequest(agent, agentPathHost); return config; } @@ -216,28 +216,8 @@ function parseMaxUploadBytesRequest(value: unknown): number { return value; } -function parseAgentRequest(value: unknown): NonNullable { - if (!isRecord(value)) throw new Error("PI WEB config agent must be an object"); - 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 parseAgentRequest(value: unknown, pathHost: AgentPathHost): NonNullable { + return parseAgentConfig(value, "request", pathHost); } function parsePluginsRequest(value: unknown): NonNullable { @@ -317,10 +297,6 @@ function errorMessage(error: unknown): string { 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/server/machines/machineProxyRoutes.ts b/src/server/machines/machineProxyRoutes.ts index a5e71ff..e9d85d0 100644 --- a/src/server/machines/machineProxyRoutes.ts +++ b/src/server/machines/machineProxyRoutes.ts @@ -69,7 +69,7 @@ async function proxySelectedMachineConfigRequest(client: MachineClient, machineI } if (method === "PUT") { - const patch = parseSelectedMachineConfigRequest(configPayload(body)); + const patch = parseSelectedMachineConfigRequest(configPayload(body), "portable"); const currentResponse = await client.requestJson("GET", remotePath); if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId); diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 7b81e0a..f9626ce 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -192,24 +192,42 @@ describe("PI WEB status", () => { const updateCommand = await updateCommandFor( { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, "pi-web restart", - { agentCommand: undefined, hasCommand }, + { activeAgentProfile: undefined, hasCommand }, ); expect(updateCommand).toBeUndefined(); 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( { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" }, "pi-web restart", { - agentCommand: "/tmp/agent's/alt-agent", - hasCommand: (command) => Promise.resolve(command === "/tmp/agent's/alt-agent"), + activeAgentProfile: activeProfile("a", command, dir), + 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 () => { diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index 56c2a92..8f75d3c 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -11,6 +11,7 @@ import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/ import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js"; import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.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"; 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 release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true); 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); return { ...versionStatus, @@ -415,7 +416,7 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise { return version; } -async function commandsFor(components: PiWebStatusResponse["components"], options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise }): Promise { +async function commandsFor(components: PiWebStatusResponse["components"], options: { activeAgentProfile: ActiveAgentProfileDescriptor | undefined; hasCommand: (command: string) => Promise }): Promise { const installation = preferredInstallation(components); if (installation?.kind === "docker") return dockerCommands(installation); @@ -466,11 +467,13 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv return cliCommands.restart ?? serviceCommands.restart; } -export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { agentCommand: string | undefined; hasCommand: (command: string) => Promise }): Promise { +export async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined, options: { activeAgentProfile: ActiveAgentProfileDescriptor | undefined; hasCommand: (command: string) => Promise }): Promise { if (restartCommand === undefined) return undefined; if (installation?.kind === "pi-package") { - if (options.agentCommand === undefined || !(await options.hasCommand(options.agentCommand))) return undefined; - return `${shellQuote(options.agentCommand)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`; + const profile = options.activeAgentProfile; + 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 (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined; diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index d46973c..de7e0f6 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -20,17 +20,18 @@ import { TerminalService } from "./terminals/terminalService.js"; import { registerTerminalRoutes } from "./terminals/terminalRoutes.js"; import { getPiWebRuntimeComponent } from "./piWebStatus.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 { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js"; -const { config } = effectivePiWebConfig(); +const daemonEnvironment: NodeJS.ProcessEnv = Object.freeze({ ...process.env }); +const { config } = effectivePiWebConfig({ env: daemonEnvironment }); const activeAgentProfile = createActiveAgentProfileDescriptor({ command: config.agent.command, dir: config.agent.dir, 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 runSessionDaemonStartup({ @@ -39,7 +40,7 @@ await runSessionDaemonStartup({ const eventHub = new SessionEventHub(); const workspaceActivity = new WorkspaceActivityService(eventHub); 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() }) : undefined; const sessions = new PiSessionService(eventHub, { @@ -48,9 +49,10 @@ await runSessionDaemonStartup({ workspaceActivity, logger: app.log, ...(spawnTargets === undefined ? {} : { spawnTargets }), - subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config), + subsessionsEnabled: spawnTargets !== undefined && config.subsessions, sessionManager: createPiSessionManagerGateway({ agentDir: activeAgentProfile.dir, + env: daemonEnvironment, sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys, }), }); @@ -98,9 +100,9 @@ await runSessionDaemonStartup({ process.once("SIGINT", (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 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) { await app.listen({ port, host }); diff --git a/src/server/sessions/piSessionManagerGateway.test.ts b/src/server/sessions/piSessionManagerGateway.test.ts index 144df97..997f966 100644 --- a/src/server/sessions/piSessionManagerGateway.test.ts +++ b/src/server/sessions/piSessionManagerGateway.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { agentSessionDirEnvKeys } from "../../config.js"; import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, filterSessionsForCwd, SessionDirResolver } from "./piSessionManagerGateway.js"; import type { PiSessionListEntry } from "./piSessionService.js"; import type { PiSessionManager } from "./piSessionService.js"; @@ -24,7 +25,7 @@ afterEach(async () => { describe("SessionDirResolver", () => { 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(defaultPiSessionsRoot(agentDir)).toBe(join(agentDir, "sessions")); @@ -34,7 +35,7 @@ describe("SessionDirResolver", () => { await mkdir(agentDir, { recursive: true }); 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 }); }); @@ -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(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 }); }); @@ -55,7 +56,7 @@ describe("SessionDirResolver", () => { await mkdir(agentDir, { recursive: true }); 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 }); }); @@ -65,10 +66,22 @@ describe("SessionDirResolver", () => { await mkdir(agentDir, { recursive: true }); 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 }); }); + + 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", () => { @@ -76,7 +89,7 @@ describe("Pi session manager gateway", () => { const otherCwd = join(tempDir, "other-workspace"); await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-a", cwd); 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"); 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"); await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-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"); 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({ agentDir, env: { [envKey]: envSessionDir }, + sessionDirEnvKeys: [envKey], }); 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"); await writeSessionFile(sharedSessionDir, "session-a", cwd); 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 }]); const created = gateway.create(cwd); @@ -125,7 +139,7 @@ describe("Pi session manager gateway", () => { // hiding every session outside the daemon's own launch directory. expect(cwd).not.toBe(process.cwd()); await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-elsewhere", cwd); - const gateway = createPiSessionManagerGateway({ agentDir, env: {} }); + const gateway = createPiSessionManagerGateway(piProfileOptions()); 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 // unnormalized paths (trailing separators, redundant segments). 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 }]); }); }); +function piProfileOptions(env: NodeJS.ProcessEnv = {}) { + return { agentDir, env, sessionDirEnvKeys: agentSessionDirEnvKeys() }; +} + function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } { return "getSessionDir" in manager && typeof manager.getSessionDir === "function"; } diff --git a/src/server/sessions/piSessionManagerGateway.ts b/src/server/sessions/piSessionManagerGateway.ts index d13ea10..3593c2d 100644 --- a/src/server/sessions/piSessionManagerGateway.ts +++ b/src/server/sessions/piSessionManagerGateway.ts @@ -3,7 +3,6 @@ import { readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent"; -import { agentSessionDirEnvKeys, effectiveAgentConfig } from "../../config.js"; import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js"; import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js"; @@ -16,20 +15,23 @@ export interface SessionDirResolution { } export interface SessionDirResolverOptions { - agentDir?: string; - env?: NodeJS.ProcessEnv; - sessionDirEnvKeys?: readonly string[]; + agentDir: string; + env: Readonly; + sessionDirEnvKeys: readonly string[]; } export class SessionDirResolver { private readonly agentDir: string; - private readonly env: NodeJS.ProcessEnv; - private readonly sessionDirEnvKeys: readonly string[]; + private readonly envSessionDir: string | undefined; + private readonly homeDir: string; - constructor(options: SessionDirResolverOptions = {}) { - this.agentDir = options.agentDir ?? effectiveAgentConfig().dir; - this.env = options.env ?? process.env; - this.sessionDirEnvKeys = options.sessionDirEnvKeys ?? agentSessionDirEnvKeys(); + constructor(options: SessionDirResolverOptions) { + this.agentDir = options.agentDir; + this.envSessionDir = options.sessionDirEnvKeys + .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 { @@ -37,34 +39,28 @@ export class SessionDirResolver { } globalEnvSessionDir(): string | undefined { - const envSessionDir = this.envSessionDir(); - if (envSessionDir === undefined) return undefined; - const expanded = expandTildePath(envSessionDir); + if (this.envSessionDir === undefined) return undefined; + const expanded = expandTildePath(this.envSessionDir, this.homeDir); return isAbsolute(expanded) ? expanded : undefined; } resolve(cwd: string): SessionDirResolution { - const envSessionDir = this.envSessionDir(); - if (envSessionDir !== undefined) { - return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true }; + if (this.envSessionDir !== undefined) { + return { source: "env", sessionDir: resolveConfiguredPath(this.envSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true }; } const settingsSessionDir = SettingsManager.create(cwd, this.agentDir).getSessionDir(); 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 }; } - - private envSessionDir(): string | undefined { - return this.sessionDirEnvKeys.map((key) => this.env[key]).find((value) => value !== undefined && value !== ""); - } } export type PiSessionManagerGatewayOptions = SessionDirResolverOptions; -export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions = {}): PiSessionManagerGateway { +export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions): PiSessionManagerGateway { return new SettingsAwarePiSessionManagerGateway(new SessionDirResolver(options)); } @@ -105,7 +101,7 @@ export async function listSessionsInDir(sessionDir: string): Promise ({ ...session, cwd: canonicalizeStoredCwd(session.cwd) })); } -export async function listSessionsInDefaultPiStore(storeRoot = defaultPiSessionsRoot()): Promise { +export async function listSessionsInDefaultPiStore(storeRoot: string): Promise { let entries: Dirent[]; try { 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()); } -export function defaultPiSessionsRoot(agentDir = effectiveAgentConfig().dir): string { +export function defaultPiSessionsRoot(agentDir: string): string { 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); } @@ -143,13 +139,13 @@ export function sessionDirInDefaultPiStore(storeRoot: string, cwd: string): stri return join(storeRoot, safePath); } -export function resolveConfiguredPath(path: string, cwd: string): string { - const expanded = expandTildePath(path); +export function resolveConfiguredPath(path: string, cwd: string, homeDir: string): string { + const expanded = expandTildePath(path, homeDir); return isAbsolute(expanded) ? expanded : resolve(cwd, expanded); } -function expandTildePath(path: string): string { - if (path === "~") return homedir(); - if (path.startsWith("~/")) return join(homedir(), path.slice(2)); +function expandTildePath(path: string, homeDir: string): string { + if (path === "~") return homeDir; + if (path.startsWith("~/")) return join(homeDir, path.slice(2)); return path; } diff --git a/src/server/sessions/piSessionService.archiveCleanup.test.ts b/src/server/sessions/piSessionService.archiveCleanup.test.ts index 3116a66..969ff15 100644 --- a/src/server/sessions/piSessionService.archiveCleanup.test.ts +++ b/src/server/sessions/piSessionService.archiveCleanup.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { PiSessionService } from "./piSessionService.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", () => { it("archives a session subtree within the root workspace", async () => { 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 fake = fakeRuntime("root", { sessionFile: root.path }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), 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 }]), @@ -45,6 +48,7 @@ describe("PiSessionService archive and cleanup", () => { it("permanently deletes archived sessions through the archive store", async () => { const deletedSessionIds: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), 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 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(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), get: () => Promise.resolve(undefined), @@ -112,6 +117,7 @@ describe("PiSessionService archive and cleanup", () => { 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 service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { createCalls += 1; return Promise.resolve(busy.runtime); @@ -152,6 +158,7 @@ describe("PiSessionService archive and cleanup", () => { const busy = fakeRuntime("busy-archived", { isStreaming: true }); const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(busy.runtime), archiveStore: { 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 listCalls: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([ { 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 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(), { + agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { 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 deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds])); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), archiveStore: { 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 archivedInputs: string[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, now: () => new Date("2026-06-25T00:00:00.000Z"), createAgentRuntime: runtimeCreator(fake.runtime), archiveStore: { diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts index 67f5e6a..3fbc23a 100644 --- a/src/server/sessions/piSessionService.lifecycle.test.ts +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it, vi } from "vitest"; import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.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() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -20,12 +22,15 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime(); let createCalls = 0; - const createAgentRuntime: RuntimeCreator = async () => { + let runtimeAgentDir: string | undefined; + const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { createCalls += 1; + runtimeAgentDir = options.agentDir; await Promise.resolve(); return fake.runtime; }; const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -34,6 +39,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const session = await service.start("/workspace"); expect(createCalls).toBe(1); + expect(runtimeAgentDir).toBe(TEST_AGENT_DIR); expect(fake.calls.bindExtensions).toHaveLength(1); expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 }); expect(service.activeCount()).toBe(1); @@ -53,6 +59,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { let service: PiSessionService | undefined; try { service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -79,6 +86,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const fake = fakeRuntime("legacy-session"); const open = vi.fn(() => fakeSessionManager()); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: { create: () => fakeSessionManager(), @@ -127,6 +135,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const gateway = sessionGateway([sessionRecord(sessionId)]); const open = vi.spyOn(gateway, "open"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: emptyArchiveStore(), createAgentRuntime, sessionManager: gateway, @@ -180,6 +189,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { : Promise.resolve(runtime); }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: emptyArchiveStore(), createAgentRuntime, sessionManager: sessionGateway([sessionRecord(sessionId)]), @@ -219,6 +229,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const runtimeResult = deferred(); const fake = fakeRuntime(sessionId); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: emptyArchiveStore(), createAgentRuntime: () => { createStarted.resolve(); @@ -252,6 +263,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { let rebindSession: ((session: PiAgentSession) => Promise) | undefined; fake.runtime.setRebindSession = (callback) => { rebindSession = callback; }; const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -278,6 +290,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }, }); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, @@ -312,6 +325,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }, }); service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("idle-session")]), heartbeatIntervalMs: 1_000, @@ -347,6 +361,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { }, }); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("completion-session")]), heartbeatIntervalMs: 60_000, @@ -365,6 +380,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("uses injected archive and session-manager gateways for listing", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]), 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 () => { const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, 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" }]), get: () => Promise.resolve(undefined), @@ -424,6 +441,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime("runtime-reload-session"); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]), heartbeatIntervalMs: 60_000, @@ -456,6 +474,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { return runtime; }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([sessionRecord("reload-session")]), 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 () => { const fake = fakeRuntime("busy-session", { isStreaming: true }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("busy-session")]), heartbeatIntervalMs: 60_000, @@ -493,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => { it("refuses to reload an archived session", async () => { const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, archiveStore: { list: () => Promise.resolve([]), 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 () => { const reconciliations: { cwd: string; sessionIds: string[] }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, 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" }]), get: () => Promise.resolve(undefined), diff --git a/src/server/sessions/piSessionService.promptQueue.test.ts b/src/server/sessions/piSessionService.promptQueue.test.ts index 1e2d7a5..442f037 100644 --- a/src/server/sessions/piSessionService.promptQueue.test.ts +++ b/src/server/sessions/piSessionService.promptQueue.test.ts @@ -5,10 +5,13 @@ import { describe, expect, it, vi } from "vitest"; 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"; +const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; + describe("PiSessionService prompt, queue, and auth warnings", () => { it("sends prompts to an injected runtime without touching the SDK runtime", async () => { const fake = fakeRuntime("prompt-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, @@ -26,6 +29,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { }); const hub = new CapturingSessionEventHub(); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("echo-session")]), heartbeatIntervalMs: 60_000, @@ -55,6 +59,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { return fake.runtime; }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([sessionRecord("prompt-session")]), heartbeatIntervalMs: 60_000, @@ -90,6 +95,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime("name-session", { model, agent: { streamFn } }); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("name-session")]), heartbeatIntervalMs: 60_000, @@ -111,6 +117,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { getFollowUpMessages: () => ["then do this"], }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("status-session")]), heartbeatIntervalMs: 60_000, @@ -131,6 +138,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { getFollowUpMessages: () => ["already queued"], }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("dedupe-session")]), heartbeatIntervalMs: 60_000, @@ -146,6 +154,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const hub = new CapturingSessionEventHub(); const fake = fakeRuntime("queued-session", { isStreaming: true }); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("queued-session")]), heartbeatIntervalMs: 60_000, @@ -171,6 +180,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { return Promise.resolve(); }; const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("compacting-session")]), heartbeatIntervalMs: 60_000, @@ -215,6 +225,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { it("clears queued messages when aborting active work", async () => { const fake = fakeRuntime("abort-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("abort-session")]), 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 () => { const fake = fakeRuntime("abort-compaction-session", { isCompacting: true }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]), heartbeatIntervalMs: 60_000, @@ -255,6 +267,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => { const fake = fakeRuntime("auth-session", { model, modelRegistry }); const service = new PiSessionService(hub, { + agentDir: TEST_AGENT_DIR, modelRegistry, createAgentRuntime: runtimeCreator(fake.runtime), 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 () => { const fake = fakeRuntime("stop-session"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([sessionRecord("stop-session")]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts index 0f1244b..29346ee 100644 --- a/src/server/sessions/piSessionService.spawnSession.test.ts +++ b/src/server/sessions/piSessionService.spawnSession.test.ts @@ -3,12 +3,15 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import type { SpawnTargetDecision } from "./spawnTargetResolver.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("spawnSession", () => { function spawnService(decision: SpawnTargetDecision) { const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" }); const log: { details: Record; message: string }[] = []; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) }, @@ -41,6 +44,7 @@ describe("PiSessionService", () => { return fake.runtime; }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([]), 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 () => { const fake = fakeRuntime("spawned-x"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts index 0ba16fa..d418a27 100644 --- a/src/server/sessions/piSessionService.spawnSubsession.test.ts +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -6,6 +6,8 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js"; import type { SpawnTargetDecision } from "./spawnTargetResolver.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("spawnSubsession", () => { function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) { @@ -32,6 +34,7 @@ describe("PiSessionService", () => { isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)), }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([]), archiveStore, @@ -72,6 +75,7 @@ describe("PiSessionService", () => { return runtime; }; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: sessionGateway([]), archiveStore: emptyArchiveStore(), @@ -111,6 +115,7 @@ describe("PiSessionService", () => { const runtimes = [parent.runtime, child.runtime]; let index = 0; const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? child.runtime; index += 1; @@ -162,6 +167,7 @@ describe("PiSessionService", () => { let index = 0; const open = vi.fn(() => childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? child.runtime; index += 1; @@ -203,6 +209,7 @@ describe("PiSessionService", () => { }), }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -227,6 +234,7 @@ describe("PiSessionService", () => { }), }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -248,6 +256,7 @@ describe("PiSessionService", () => { }), }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -268,6 +277,7 @@ describe("PiSessionService", () => { sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }), }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -288,6 +298,7 @@ describe("PiSessionService", () => { }), }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(forkedParent.runtime), sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, archiveStore: emptyArchiveStore(), @@ -326,6 +337,7 @@ describe("PiSessionService", () => { let index = 0; const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: (_createRuntime, options) => { delegationCapabilities.push(options.delegationToolsEnabled); const runtime = runtimes[index] ?? parent.runtime; @@ -388,6 +400,7 @@ describe("PiSessionService", () => { return childManager; }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -445,6 +458,7 @@ describe("PiSessionService", () => { let index = 0; const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -509,6 +523,7 @@ describe("PiSessionService", () => { throw new Error(`unexpected open path ${path}`); }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: { create: () => parentManager, @@ -584,6 +599,7 @@ describe("PiSessionService", () => { throw new Error(`unexpected open path ${path}`); }); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime, sessionManager: { create: () => copiedParentManager, @@ -641,6 +657,7 @@ describe("PiSessionService", () => { let index = 0; const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -693,6 +710,7 @@ describe("PiSessionService", () => { let index = 0; const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: () => { const runtime = runtimes[index] ?? parent.runtime; index += 1; @@ -733,6 +751,7 @@ describe("PiSessionService", () => { const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager }); const open = vi.fn(() => childManager); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(child.runtime), sessionManager: { create: () => childManager, @@ -842,6 +861,7 @@ describe("PiSessionService", () => { it("is disabled when no spawn target resolver is configured", async () => { const fake = fakeRuntime("nope"); const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, createAgentRuntime: runtimeCreator(fake.runtime), sessionManager: sessionGateway([]), heartbeatIntervalMs: 60_000, diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 765ae82..a999212 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -25,8 +25,6 @@ import type { ActiveSession } from "./sessionRuntimeStore.js"; import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js"; import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.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 { parsePromptAttachments } from "../../shared/promptAttachments.js"; import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js"; @@ -380,9 +378,9 @@ function createPiWebEditToolDefinition(cwd: string) { } export interface PiSessionServiceDependencies { + agentDir: string; + sessionManager: PiSessionManagerGateway; archiveStore?: SessionArchiveRepository; - agentDir?: string; - sessionManager?: PiSessionManagerGateway; createRuntime?: PiWebCreateAgentSessionRuntimeFactory; createAgentRuntime?: CreateAgentRuntime; modelRegistry?: ModelRegistryInstance; @@ -441,10 +439,10 @@ export class PiSessionService implements SessionRouteService { private readonly logger: PiSessionLogger; 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.agentDir = deps.agentDir ?? effectiveAgentConfig().dir; - this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir }); + this.agentDir = deps.agentDir; + this.sessionManager = deps.sessionManager; this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir); this.spawnTargets = deps.spawnTargets; this.logger = deps.logger ?? noopLogger; diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts index e60f790..0b31fe7 100644 --- a/src/server/sessions/sessionRoutes.test.ts +++ b/src/server/sessions/sessionRoutes.test.ts @@ -9,6 +9,8 @@ import type { SessionRouteLookup, SessionRouteService } from "./sessionService.j import { registerSessionRoutes } from "./sessionRoutes.js"; import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; +const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; + let app: FastifyInstance; let service: PiSessionService; let sessionManager: RejectingSessionManager; @@ -18,7 +20,7 @@ beforeEach(async () => { await app.register(fastifyWebsocket); sessionManager = new RejectingSessionManager(); 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); }); diff --git a/src/sessiond/activeAgentProfile.test.ts b/src/sessiond/activeAgentProfile.test.ts index 8b7ac4b..6e8251a 100644 --- a/src/sessiond/activeAgentProfile.test.ts +++ b/src/sessiond/activeAgentProfile.test.ts @@ -17,7 +17,7 @@ describe("active agent profile descriptor", () => { expect(first.revision).toMatch(/^sha256:[0-9a-f]{64}$/u); 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, 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", () => { @@ -32,6 +32,12 @@ describe("active agent profile descriptor", () => { 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", () => { const input = { ...baseAgent, diff --git a/src/sessiond/activeAgentProfile.ts b/src/sessiond/activeAgentProfile.ts index f5db70b..604a035 100644 --- a/src/sessiond/activeAgentProfile.ts +++ b/src/sessiond/activeAgentProfile.ts @@ -1,9 +1,15 @@ 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 { ACTIVE_AGENT_PROFILE_SCHEMA_VERSION } from "../shared/activeAgentProfile.js"; 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 revisionInput = JSON.stringify({ schemaVersion: ACTIVE_AGENT_PROFILE_SCHEMA_VERSION, @@ -20,3 +26,9 @@ export function createActiveAgentProfileDescriptor(agent: EffectivePiWebAgentCon 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); +} diff --git a/src/sessiond/sessionDaemonClient.test.ts b/src/sessiond/sessionDaemonClient.test.ts index 980468c..bde974d 100644 --- a/src/sessiond/sessionDaemonClient.test.ts +++ b/src/sessiond/sessionDaemonClient.test.ts @@ -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 () => { const client = new SessionDaemonClient(); vi.spyOn(client, "request").mockResolvedValue(runtimeResponse(undefined)); diff --git a/src/sessiond/sessionDaemonClient.ts b/src/sessiond/sessionDaemonClient.ts index ca097b2..8befa77 100644 --- a/src/sessiond/sessionDaemonClient.ts +++ b/src/sessiond/sessionDaemonClient.ts @@ -1,5 +1,6 @@ import http from "node:http"; import { WebSocket } from "ws"; +import { isHostAbsoluteAgentDir, isSafeAgentCommandForHost } from "../config.js"; import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js"; import { parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js"; import { sessiondHttpUrl, sessiondSocketPath } from "./config.js"; @@ -108,6 +109,9 @@ export async function getSessionDaemonActiveAgentProfile(client: SessionDaemonRe if (runtime.activeAgentProfile === undefined) { 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 }; } diff --git a/src/shared/activeAgentProfile.ts b/src/shared/activeAgentProfile.ts index 18e0047..7bf07cc 100644 --- a/src/shared/activeAgentProfile.ts +++ b/src/shared/activeAgentProfile.ts @@ -10,6 +10,8 @@ const ACTIVE_AGENT_PROFILE_FIELDS = new Set([ "sessionDirEnvKeys", ]); 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 { 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"]; if (schemaVersion !== ACTIVE_AGENT_PROFILE_SCHEMA_VERSION) 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 (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({ 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[] { return Array.isArray(value) && value.every((entry: unknown) => typeof entry === "string" && entry !== ""); } diff --git a/src/shared/piWebStatusParsing.test.ts b/src/shared/piWebStatusParsing.test.ts index 2795430..1bbb69d 100644 --- a/src/shared/piWebStatusParsing.test.ts +++ b/src/shared/piWebStatusParsing.test.ts @@ -80,6 +80,9 @@ describe("PI WEB status parsing", () => { }); 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(); });