Archived
fix: harden agent profile boundaries
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 !== "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user