fix: require explicit alternate agent state

This commit is contained in:
Federico Jaramillo Martinez
2026-06-29 07:56:01 +02:00
parent 75e2377756
commit 1edebb1b04
7 changed files with 88 additions and 67 deletions
+3 -3
View File
@@ -46,7 +46,7 @@ describe("commandWithVersionCheck", () => {
it("shell-quotes command words", () => {
process.env["SHELL"] = "/bin/bash";
expect(commandWithVersionCheck("/tmp/agent's/omp")).toBe("command -v '/tmp/agent'\\''s/omp' && ('/tmp/agent'\\''s/omp' --version 2>&1 || true)");
expect(commandWithVersionCheck("/tmp/agent's/acme-agent")).toBe("command -v '/tmp/agent'\\''s/acme-agent' && ('/tmp/agent'\\''s/acme-agent' --version 2>&1 || true)");
});
});
@@ -55,11 +55,11 @@ describe("agentCommandForChecks", () => {
const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-"));
try {
const configPath = join(dir, "config.json");
writeFileSync(configPath, `${JSON.stringify({ agent: { command: "omp" } })}\n`);
writeFileSync(configPath, `${JSON.stringify({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } })}\n`);
process.env["PI_WEB_CONFIG"] = configPath;
delete process.env["PI_WEB_AGENT_COMMAND"];
expect(agentCommandForChecks()).toBe("omp");
expect(agentCommandForChecks()).toBe("acme-agent");
} finally {
rmSync(dir, { recursive: true, force: true });
}
@@ -71,7 +71,7 @@ export class SettingsSessiondPanel extends LitElement {
?disabled=${this.loading || this.saving || agentDirOverridden}
@change=${(event: Event) => { void this.saveAgentField("dir", event); }}
>
<small>Choose which compatible auth, models, settings, and sessions PI WEB reads. Set a separate directory for isolated agent profiles, then restart the session daemon.</small>
<small>Choose which compatible auth, models, settings, and sessions PI WEB reads. Non-<code>pi</code> commands require an explicit state directory, then a session daemon restart.</small>
</div>
<div class="field">
<span class="field-heading">
@@ -110,7 +110,7 @@ export class SettingsSessiondPanel extends LitElement {
<h3>Effective after environment overrides</h3>
<dl>
<div><dt>Agent command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">pi default</span>`}</dd></div>
<div><dt>Agent state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">Pi default</span>`}</dd></div>
<div><dt>Agent state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">~/.pi/agent default</span>`}</dd></div>
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
</dl>
+30 -21
View File
@@ -50,23 +50,28 @@ describe("PI WEB config persistence", () => {
});
it("persists and reads custom agent runtime settings", () => {
savePiWebConfig({ agent: { command: "omp", dir: "~/.omp/agent" } }, testOptions());
savePiWebConfig({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, testOptions());
expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "omp", dir: "~/.omp/agent" });
expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "acme-agent", dir: "/opt/acme-agent/state" });
});
it("keeps the Pi agent directory default for alternate commands", () => {
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "omp" } })).toMatchObject({
command: "omp",
it("defaults to the Pi agent directory only for Pi commands and launchers", () => {
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "/tmp/pi.cmd" } })).toMatchObject({
command: "/tmp/pi.cmd",
dir: join(tempDir, ".home", ".pi", "agent"),
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"],
});
});
it("requires an explicit agent directory for non-Pi commands", () => {
expect(() => effectiveAgentConfig({}, { agent: { command: "acme-agent" } })).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"');
expect(() => savePiWebConfig({ agent: { command: "acme-agent" } }, testOptions())).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"');
});
it("resolves explicit alternate agent command and state directory settings", () => {
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "omp", dir: "~/.omp/agent" } })).toMatchObject({
command: "omp",
dir: join(tempDir, ".home", ".omp", "agent"),
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({
command: "acme-agent",
dir: join(tempDir, ".home", "agent-profiles", "acme"),
});
});
@@ -80,36 +85,40 @@ describe("PI WEB config persistence", () => {
PI_CODING_AGENT_SESSION_DIR: "",
};
expect(effectiveAgentConfig(env, { agent: { command: "omp", dir: "~/.omp/agent" } })).toMatchObject({
command: "omp",
dir: join(tempDir, ".home", ".omp", "agent"),
expect(effectiveAgentConfig(env, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({
command: "acme-agent",
dir: join(tempDir, ".home", "agent-profiles", "acme"),
});
expect(hasAgentDirEnvOverride(env)).toBe(false);
expect(hasAgentSessionDirEnvOverride(env)).toBe(false);
expect(hasAgentDirEnvOverride(env, "acme-agent")).toBe(false);
expect(hasAgentSessionDirEnvOverride(env, "acme-agent")).toBe(false);
});
it("uses generic agent directory env precedence and Pi compatibility fallback", () => {
it("uses explicit PI WEB agent directory env precedence", () => {
expect(effectiveAgentConfig({
PI_WEB_AGENT_COMMAND: "omp",
PI_WEB_AGENT_COMMAND: "acme-agent",
PI_WEB_AGENT_DIR: join(tempDir, "web-env-agent"),
PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"),
}, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({
command: "omp",
command: "acme-agent",
dir: join(tempDir, "web-env-agent"),
});
});
it("keeps legacy Pi env directory overrides scoped to Pi commands", () => {
expect(effectiveAgentConfig({
PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"),
}, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({
dir: join(tempDir, "pi-env-agent"),
});
expect(() => effectiveAgentConfig({
PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"),
}, { agent: { command: "acme-agent" } })).toThrow('PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is "acme-agent"');
});
it("does not generate command-specific session directory env keys", () => {
const keys = ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"];
expect(agentSessionDirEnvKeys()).toEqual(keys);
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), PI_WEB_AGENT_COMMAND: "omp" }).sessionDirEnvKeys).toEqual(keys);
it("uses only explicit session directory env keys", () => {
expect(agentSessionDirEnvKeys()).toEqual(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]);
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), PI_WEB_AGENT_COMMAND: "acme-agent", PI_WEB_AGENT_DIR: join(tempDir, "agent") }).sessionDirEnvKeys).toEqual(["PI_WEB_AGENT_SESSION_DIR"]);
});
it("exposes the default upload folder in the effective config", () => {
+20 -10
View File
@@ -50,24 +50,27 @@ export interface EffectivePiWebAgentConfig {
export function effectiveAgentConfig(env: NodeJS.ProcessEnv = process.env, config: Pick<PiWebConfig, "agent"> = {}, cwd = process.cwd()): EffectivePiWebAgentConfig {
const command = parseAgentCommand(envValue(env, PI_WEB_AGENT_COMMAND_ENV) ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment");
const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? envValue(env, PI_CODING_AGENT_DIR_ENV) ?? config.agent?.dir ?? defaultAgentDir(env);
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);
return {
command,
dir: resolveAgentDirPath(configuredDir, env, cwd, "agent.dir", "environment"),
sessionDirEnvKeys: agentSessionDirEnvKeys(),
sessionDirEnvKeys: agentSessionDirEnvKeys(command),
};
}
export function agentSessionDirEnvKeys(): string[] {
return uniqueStrings([PI_WEB_AGENT_SESSION_DIR_ENV, PI_CODING_AGENT_SESSION_DIR_ENV]);
export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] {
return uniqueStrings([
PI_WEB_AGENT_SESSION_DIR_ENV,
...(isPiCommand(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []),
]);
}
export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv): boolean {
return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || isEnvSet(env[PI_CODING_AGENT_DIR_ENV]);
export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean {
return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || (isPiCommand(command) && isEnvSet(env[PI_CODING_AGENT_DIR_ENV]));
}
export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv): boolean {
return agentSessionDirEnvKeys().some((key) => isEnvSet(env[key]));
export function hasAgentSessionDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean {
return agentSessionDirEnvKeys(command).some((key) => isEnvSet(env[key]));
}
export function effectiveUploadsConfig(config: Pick<PiWebConfig, "uploads"> = {}): NonNullable<PiWebConfig["uploads"]> {
@@ -141,6 +144,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
const env = options.env ?? process.env;
const path = piWebConfigPath(env, options.cwd ?? process.cwd());
const normalized = parsePiWebConfig(piWebConfigRecord(config), path);
effectiveAgentConfig(env, normalized, options.cwd ?? process.cwd());
const existing = readExistingConfigObject(path);
delete existing["host"];
delete existing["port"];
@@ -335,8 +339,14 @@ function expandHomePath(value: string, env: NodeJS.ProcessEnv): string {
return value;
}
function defaultAgentDir(env: NodeJS.ProcessEnv): string {
return expandHomePath("~/.pi/agent", env);
function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string {
if (isPiCommand(command)) return expandHomePath("~/.pi/agent", env);
throw new Error(`PI WEB config agent.dir or ${PI_WEB_AGENT_DIR_ENV} is required when agent.command is ${JSON.stringify(command)}`);
}
function isPiCommand(command: string): boolean {
const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase();
return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === DEFAULT_AGENT_COMMAND;
}
function envValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
+5 -4
View File
@@ -27,7 +27,7 @@ export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConf
exists: loaded.exists,
config: loaded.config,
effectiveConfig: effective.config,
envOverrides: piWebConfigEnvOverrides(env),
envOverrides: piWebConfigEnvOverrides(env, effective.config),
};
}
@@ -167,7 +167,8 @@ function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]
}));
}
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {}): PiWebConfigEnvOverrides {
const command = config.agent?.command;
return {
host: isEnvSet(env["PI_WEB_HOST"]),
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
@@ -175,8 +176,8 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverride
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]),
agentDir: hasAgentDirEnvOverride(env),
agentSessionDir: hasAgentSessionDirEnvOverride(env),
agentDir: hasAgentDirEnvOverride(env, command),
agentSessionDir: hasAgentSessionDirEnvOverride(env, command),
};
}