Merge pull request #57 from jmfederico/pr-36-generic-agent-config

feat: add Pi-compatible agent profiles
This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 08:49:07 +02:00
committed by GitHub
82 changed files with 3060 additions and 312 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add selectable Pi-compatible agent profiles and companion CLIs for isolated auth, models, settings, sessions, Pi packages, plugins, diagnostics, and safe update commands. Settings shows when a session-daemon restart is required, and mixed-version remote saves fail instead of reporting false success. The embedded runtime remains the bundled Pi SDK.
+98 -14
View File
@@ -81,7 +81,7 @@
<p>
PI WEB configuration covers the machine-local and project-local settings you usually need: bind address,
trusted development-host settings, UI preferences, PI WEB plugin enablement, file-explorer path access,
manual upload defaults, upload limits, and session-daemon tools.
manual upload defaults, upload limits, Pi-compatible agent profiles and companion CLIs, and session-daemon tools.
</p>
</div>
</section>
@@ -98,6 +98,7 @@
<a href="#keys">Config matrix</a>
<a href="#path-access">External path access</a>
<a href="#manual-uploads">Manual uploads</a>
<a href="#agent-runtime">Agent profile and companion CLI</a>
<a href="#session-tools">Session tools</a>
<a href="#completion-tools">Completion tools</a>
</aside>
@@ -112,8 +113,8 @@
</ul>
<p>
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected
machine for config that affects work running there: session daemon tools, PI WEB plugin enablement,
external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway:
machine for config that affects work running there: the Pi-compatible agent profile and companion CLI,
session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway:
keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers
that do not advertise selected-machine settings support report those settings as unavailable instead of
silently falling back to the gateway.
@@ -162,13 +163,15 @@
</p>
<p>
Environment overrides include <code>PI_WEB_HOST</code>, <code>PI_WEB_PORT</code> / <code>PORT</code>,
<code>PI_WEB_ALLOWED_HOSTS</code>, <code>PI_WEB_MAX_UPLOAD_BYTES</code>, <code>PI_WEB_SPAWN_SESSIONS</code>,
and <code>PI_WEB_SUBSESSIONS</code>.
<code>PI_WEB_ALLOWED_HOSTS</code>, <code>PI_WEB_MAX_UPLOAD_BYTES</code>, <code>PI_WEB_AGENT_COMMAND</code>,
<code>PI_WEB_AGENT_DIR</code>, <code>PI_WEB_AGENT_SESSION_DIR</code>, <code>PI_CODING_AGENT_DIR</code> /
<code>PI_CODING_AGENT_SESSION_DIR</code> for Pi compatibility, <code>PI_WEB_SPAWN_SESSIONS</code>, and
<code>PI_WEB_SUBSESSIONS</code>.
</p>
<ul>
<li><code>host</code> / <code>port</code>: restart the gateway web/API service or process.</li>
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon on that machine.</li>
<li><code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon on that machine.</li>
<li><code>agent.command</code> / <code>agent.dir</code> / <code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon on that machine.</li>
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
<li><code>uploads.defaultFolder</code>: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.</li>
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
@@ -201,6 +204,10 @@
"defaultFolder": ".pi-web/uploads"
},
"maxUploadBytes": 67108864,
"agent": {
"command": "pi",
"dir": "~/agent-profiles/research"
},
"spawnSessions": true,
"subsessions": false,
"plugins": {
@@ -260,7 +267,7 @@
it, and whether project-local config overrides or merges with global config. Rows with JSON key
<code></code> are runtime-only environment variables, not config-file keys. <code>Global</code> means
machine-global. In Settings, selected-machine-safe global keys (<code>pathAccess</code>, <code>uploads</code>,
<code>maxUploadBytes</code>, <code>spawnSessions</code>, <code>subsessions</code>, and <code>plugins</code>)
<code>maxUploadBytes</code>, <code>agent</code>, <code>spawnSessions</code>, <code>subsessions</code>, and <code>plugins</code>)
are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine
registry/tokens stay local.
</p>
@@ -326,6 +333,22 @@
<td>Not supported locally</td>
<td>Restart web/API and session daemon on that machine</td>
</tr>
<tr>
<td>Companion CLI command</td>
<td><code>agent.command</code></td>
<td><code>PI_WEB_AGENT_COMMAND</code></td>
<td>Global/session daemon</td>
<td>Not supported locally</td>
<td>Restart session daemon on that machine; affects doctor/status/update checks</td>
</tr>
<tr>
<td>Agent profile state directory</td>
<td><code>agent.dir</code></td>
<td><code>PI_WEB_AGENT_DIR</code> (<code>PI_CODING_AGENT_DIR</code> for Pi compatibility)</td>
<td>Global/session daemon</td>
<td>Not supported locally</td>
<td>Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins</td>
</tr>
<tr>
<td>Agent can spawn sessions</td>
<td><code>spawnSessions</code></td>
@@ -432,18 +455,18 @@
<td>Restart web/API; advanced state override</td>
</tr>
<tr>
<td>Pi session storage directory</td>
<td>Agent profile session storage directory</td>
<td></td>
<td><code>PI_CODING_AGENT_SESSION_DIR</code></td>
<td>Pi/session daemon env</td>
<td><code>PI_WEB_AGENT_SESSION_DIR</code> (<code>PI_CODING_AGENT_SESSION_DIR</code> for Pi compatibility)</td>
<td>Session daemon env</td>
<td>Not supported locally</td>
<td>Restart session daemon; follows Pi session priority</td>
<td>Restart session daemon; env-only session storage override</td>
</tr>
<tr>
<td>Pi agent config directory</td>
<td>Agent profile state directory</td>
<td></td>
<td><code>PI_CODING_AGENT_DIR</code></td>
<td>Pi/Web/API/session daemon env</td>
<td><code>PI_WEB_AGENT_DIR</code> (<code>PI_CODING_AGENT_DIR</code> for Pi compatibility)</td>
<td>Web/API + session daemon env</td>
<td>Not supported locally</td>
<td>Restart services</td>
</tr>
@@ -513,6 +536,67 @@
</p>
</section>
<section id="agent-runtime">
<h2>Pi-compatible agent profile and companion CLI</h2>
<p>
<code>agent.command</code> selects the Pi-compatible companion CLI used by <code>pi-web doctor</code> and,
when it can be generated safely, package-managed update commands. It defaults to <code>pi</code>. This
setting does <strong>not</strong> replace the embedded runtime: every session continues to use PI WEB's
bundled Pi SDK.
</p>
<p>
<code>agent.dir</code> selects the Pi-compatible state profile used for auth providers, models, settings,
sessions, Pi packages, and Pi-package-backed PI WEB plugin discovery. It defaults to
<code>~/.pi/agent</code> only for a canonical Pi companion command. The directory must use the data layout
supported by the bundled Pi SDK; PI WEB does not load or convert incompatible fork formats, migrate
profile data, or repartition PI WEB-managed archives when the profile changes.
</p>
<div class="code-card">
<pre><code>{
"agent": {
"command": "pi-lab",
"dir": "/opt/pi-profiles/lab"
}
}</code></pre>
</div>
<p>
An alternate command always requires an explicit state directory. The command must be a safe bare
executable name such as <code>pi-lab</code> or a host-absolute executable path such as
<code>/opt/pi/bin/pi</code>; relative paths, shell expressions, and launcher strings are rejected. The
state directory must be host-absolute or start with <code>~</code>. In a federated save, the gateway
transports Unix and Windows absolute paths without reinterpreting them, and the target machine validates
and returns the persisted profile.
</p>
<p>
Environment variables take precedence over the config file. <code>PI_WEB_AGENT_COMMAND</code> selects the
companion CLI, <code>PI_WEB_AGENT_DIR</code> sets the profile state directory, and
<code>PI_WEB_AGENT_SESSION_DIR</code> overrides session storage separately from <code>agent.dir</code>. The
legacy <code>PI_CODING_AGENT_DIR</code> and <code>PI_CODING_AGENT_SESSION_DIR</code> names apply only to a
canonical Pi companion command; PI WEB never derives ambient environment-variable names from an arbitrary
command. Use the explicit <code>PI_WEB_AGENT_*</code> names for alternate commands.
<code>PI_WEB_AGENT_DIR</code> is an unconditional override, while a legacy
<code>PI_CODING_AGENT_DIR</code> override stops applying when Settings selects an alternate command so the
command and directory can transition together.
</p>
<p>
The session daemon resolves the persisted desired values plus its environment once at startup. That
secret-free active profile stays fixed for the daemon lifetime. <strong>Settings → Session daemon</strong>
saves command and directory together as desired configuration and shows whether the profile is active,
needs a restart, or cannot be compared. Until the daemon restarts, sessions, Pi package operations,
package-backed plugin discovery, status/install detection, and update planning continue to use the
daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the
newly saved values.
</p>
<div class="callout warning">
If the session daemon cannot report a valid active profile, profile-dependent package and plugin
operations report unavailable instead of falling back to independently resolved config. A package-managed
update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi
companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised
support, and the gateway rejects a remote save if the target does not return the requested profile.
Restart the session daemon on the selected machine to establish the next active profile.
</div>
</section>
<section id="session-tools">
<h2>Session daemon tools</h2>
<h3><code>spawnSessions</code></h3>
+36 -7
View File
@@ -1,6 +1,6 @@
# PI WEB configuration reference
PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, and session-daemon tools.
PI WEB configuration covers the machine-local and project-local settings you usually need: the web/API bind address, trusted development-host settings, UI preferences, plugin enablement, file-explorer path access, manual upload defaults, upload limits, Pi-compatible agent profiles and companion CLIs, and session-daemon tools.
This file is the markdown reference for agents and package consumers. The website page is <https://pi-web.dev/config>.
@@ -11,7 +11,7 @@ PI WEB uses two config files:
- **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`.
- **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings.
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected machine for config that affects work running there: session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway: keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers that do not advertise selected-machine settings support report those settings as unavailable instead of silently falling back to the gateway.
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected machine for config that affects work running there: the Pi-compatible agent profile and companion CLI, session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway: keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers that do not advertise selected-machine settings support report those settings as unavailable instead of silently falling back to the gateway.
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. In a federated setup, **Settings → Pi packages** targets the currently selected machine. The PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not install, remove, or update Pi packages.
@@ -33,13 +33,13 @@ defaults → global config file → environment overrides
Supported project-local settings are then applied for that project's workspaces. For upload defaults, `<project>/.pi-web/config.json` overrides the global value.
Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`.
Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR` / `PI_CODING_AGENT_SESSION_DIR` for Pi compatibility, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`.
Process restarts depend on the key:
- `host` / `port`: restart the gateway web/API service or process.
- `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
- `spawnSessions` / `subsessions`: restart the session daemon on that machine.
- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions`: restart the session daemon on that machine.
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
- `plugins`: reload the browser tab after changing PI WEB plugin enablement.
@@ -59,6 +59,10 @@ Process restarts depend on the key:
"defaultFolder": ".pi-web/uploads"
},
"maxUploadBytes": 67108864,
"agent": {
"command": "pi",
"dir": "~/agent-profiles/research"
},
"spawnSessions": true,
"subsessions": false,
"plugins": {
@@ -97,7 +101,7 @@ Plugins may own separate project files, such as `.pi-web/tasks.json` for the bui
## Configuration matrix
Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.
Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `agent`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.
| Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart |
| --- | --- | --- | --- | --- | --- |
@@ -108,6 +112,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed |
| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh |
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine |
| Companion CLI command | `agent.command` | `PI_WEB_AGENT_COMMAND` | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects doctor/status/update checks |
| Agent profile state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and package-backed plugins |
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine |
| Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
@@ -122,8 +128,8 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| Web-to-daemon URL | — | `PI_WEB_SESSIOND_URL` | Web/API env | Not supported locally | Restart web/API |
| Projects storage file | — | `PI_WEB_PROJECTS_FILE` | Web/API + session daemon env | Not supported locally | Restart services; advanced state override |
| Remote machines storage file | — | `PI_WEB_MACHINES_FILE` | Web/API env | Not supported locally | Restart web/API; advanced state override |
| Pi session storage directory | — | `PI_CODING_AGENT_SESSION_DIR` | Pi/session daemon env | Not supported locally | Restart session daemon; follows Pi session priority |
| Pi agent config directory | — | `PI_CODING_AGENT_DIR` | Pi/Web/API/session daemon env | Not supported locally | Restart services |
| Agent profile session storage directory | — | `PI_WEB_AGENT_SESSION_DIR` (`PI_CODING_AGENT_SESSION_DIR` for Pi compatibility) | Session daemon env | Not supported locally | Restart session daemon; env-only session storage override |
| Agent profile state directory | — | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Web/API + session daemon env | Not supported locally | Restart services |
| Skip update checks | — | `PI_WEB_SKIP_VERSION_CHECK`, `PI_WEB_OFFLINE`, `PI_SKIP_VERSION_CHECK`, `PI_OFFLINE` | Web/API env | Not supported locally | Restart web/API after env changes |
## Key details
@@ -177,6 +183,29 @@ For machine federation, Settings saves the global upload default on the selected
The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES` on the machine serving the upload.
### Pi-compatible agent profile and companion CLI
`agent.command` selects the Pi-compatible companion CLI used by `pi-web doctor` and, when it can be generated safely, package-managed update commands. It defaults to `pi`. This setting does **not** replace the embedded runtime: every session continues to use PI WEB's bundled Pi SDK.
`agent.dir` selects the Pi-compatible state profile used for auth providers, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugin discovery. It defaults to `~/.pi/agent` only for a canonical Pi companion command. The directory must use the data layout supported by the bundled Pi SDK; PI WEB does not load or convert incompatible fork formats, migrate profile data, or repartition PI WEB-managed archives when the profile changes.
```json
{
"agent": {
"command": "pi-lab",
"dir": "/opt/pi-profiles/lab"
}
}
```
An alternate command always requires an explicit state directory. The command must be a safe bare executable name such as `pi-lab` or a host-absolute executable path such as `/opt/pi/bin/pi`; relative paths, shell expressions, and launcher strings are rejected. The state directory must be host-absolute or start with `~`. In a federated save, the gateway transports Unix and Windows absolute paths without reinterpreting them, and the target machine validates and returns the persisted profile.
Environment variables take precedence over the config file. `PI_WEB_AGENT_COMMAND` selects the companion CLI, `PI_WEB_AGENT_DIR` sets the profile state directory, and `PI_WEB_AGENT_SESSION_DIR` overrides session storage separately from `agent.dir`. The legacy `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR` names apply only to a canonical Pi companion command; PI WEB never derives ambient environment-variable names from an arbitrary command. Use the explicit `PI_WEB_AGENT_*` names for alternate commands. `PI_WEB_AGENT_DIR` is an unconditional override, while a legacy `PI_CODING_AGENT_DIR` override stops applying when Settings selects an alternate command so the command and directory can transition together.
The session daemon resolves the persisted desired values plus its environment once at startup. That secret-free active profile stays fixed for the daemon lifetime. **Settings → Session daemon** saves command and directory together as desired configuration and shows whether the profile is active, needs a restart, or cannot be compared. Until the daemon restarts, sessions, Pi package operations, package-backed plugin discovery, status/install detection, and update planning continue to use the daemon-owned active profile; a web/API restart recovers that same active profile instead of applying the newly saved values.
If the session daemon cannot report a valid active profile, profile-dependent package and plugin operations report unavailable instead of falling back to independently resolved config. A package-managed update command is shown only when PI WEB can preserve the active profile with a recognized, safe Pi companion CLI; otherwise the command is omitted. Remote profile editing likewise requires advertised support, and the gateway rejects a remote save if the target does not return the requested profile. Restart the session daemon on the selected machine to establish the next active profile.
### Session daemon tools
`spawnSessions` controls whether agents receive the `spawn_session` tool. It defaults to `true`; set it to `false` if you do not want an agent to start independent PI WEB sessions.
+42 -3
View File
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
agentCommandForChecks,
commandWithVersionCheck,
doctorExitCode,
isCliEntrypoint,
@@ -12,6 +13,8 @@ import {
} from "./cli.js";
const originalShell = process.env["SHELL"];
const originalPiWebConfig = process.env["PI_WEB_CONFIG"];
const originalPiWebAgentCommand = process.env["PI_WEB_AGENT_COMMAND"];
afterEach(() => {
if (originalShell === undefined) {
@@ -19,25 +22,61 @@ afterEach(() => {
} else {
process.env["SHELL"] = originalShell;
}
if (originalPiWebConfig === undefined) {
delete process.env["PI_WEB_CONFIG"];
} else {
process.env["PI_WEB_CONFIG"] = originalPiWebConfig;
}
if (originalPiWebAgentCommand === undefined) {
delete process.env["PI_WEB_AGENT_COMMAND"];
} else {
process.env["PI_WEB_AGENT_COMMAND"] = originalPiWebAgentCommand;
}
});
describe("commandWithVersionCheck", () => {
it("emits a POSIX subshell group for bash", () => {
process.env["SHELL"] = "/bin/bash";
expect(commandWithVersionCheck("npm")).toBe("command -v npm && (npm --version 2>&1 || true)");
expect(commandWithVersionCheck("npm")).toBe("command -v 'npm' && ('npm' --version 2>&1 || true)");
});
it("emits a POSIX subshell group for zsh", () => {
process.env["SHELL"] = "/bin/zsh";
expect(commandWithVersionCheck("pi")).toBe("command -v pi && (pi --version 2>&1 || true)");
expect(commandWithVersionCheck("pi")).toBe("command -v 'pi' && ('pi' --version 2>&1 || true)");
});
it("uses fish begin/end grouping instead of a POSIX subshell", () => {
process.env["SHELL"] = "/usr/local/bin/fish";
const command = commandWithVersionCheck("npm");
expect(command).toBe("command -v npm && begin; npm --version 2>&1 || true; end");
expect(command).toBe("command -v 'npm' && begin; 'npm' --version 2>&1 || true; end");
expect(command).not.toContain("(");
});
it("shell-quotes command words", () => {
process.env["SHELL"] = "/bin/bash";
expect(commandWithVersionCheck("/tmp/agent's/acme-agent")).toBe("command -v '/tmp/agent'\\''s/acme-agent' && ('/tmp/agent'\\''s/acme-agent' --version 2>&1 || true)");
});
});
describe("agentCommandForChecks", () => {
it("reads the configured agent command for doctor checks", () => {
const dir = mkdtempSync(join(tmpdir(), "pi-web-cli-test-"));
try {
const configPath = join(dir, "config.json");
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("acme-agent");
expect(agentCommandForChecks({
PI_WEB_CONFIG: configPath,
PI_WEB_AGENT_COMMAND: "environment-agent",
PI_WEB_AGENT_DIR: join(dir, "environment-agent-state"),
})).toBe("environment-agent");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("native-service doctor CLI contracts", () => {
+12 -6
View File
@@ -5,7 +5,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defaultPiWebConfigPath, defaultPiWebDataDir, examplePiWebConfig } from "./config.js";
import { defaultPiWebConfigPath, defaultPiWebDataDir, effectivePiWebConfig, examplePiWebConfig } from "./config.js";
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
import {
@@ -153,7 +153,7 @@ function runQuiet(command: string, args: string[]): number {
}
function hasCommand(command: string): boolean {
return capture("/usr/bin/env", ["sh", "-c", `command -v ${command}`]).status === 0;
return capture("/usr/bin/env", ["sh", "-c", `command -v ${shellSingleQuote(command)}`]).status === 0;
}
function isLingerEnabled(): boolean | undefined {
@@ -760,15 +760,16 @@ function serviceShellLabel(): string {
}
function commandCheck(command: string): string {
return `command -v ${command}`;
return `command -v ${serviceShellQuote(command)}`;
}
export function commandWithVersionCheck(command: string): string {
const found = commandCheck(command);
const commandWord = serviceShellQuote(command);
if (detectServiceShell().name === "fish") {
return `${found} && begin; ${command} --version 2>&1 || true; end`;
return `${found} && begin; ${commandWord} --version 2>&1 || true; end`;
}
return `${found} && (${command} --version 2>&1 || true)`;
return `${found} && (${commandWord} --version 2>&1 || true)`;
}
function nodeVersionCheck(): string {
@@ -778,12 +779,17 @@ function nodeVersionCheck(): string {
].join(" && ");
}
export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string {
return effectivePiWebConfig({ env }).config.agent.command;
}
function generalDoctorChecks(): Check[] {
const shell = serviceShellLabel();
const agentCommand = agentCommandForChecks();
return [
[`Caller login ${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
[`Caller login ${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
[`Caller login ${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
[`Caller login ${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
];
}
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+7 -3
View File
@@ -79,12 +79,16 @@ describe("machine-scoped runtime API", () => {
});
it("reads machine runtime through the gateway route", async () => {
const fetchMock = stubJsonFetch({ machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] });
const response = { machineId: "remote a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] };
const fetchMock = stubSequenceFetch([jsonResponse(response), jsonResponse(response)]);
await machinesApi.runtime("remote a");
await machinesApi.runtime("remote a", true);
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime");
expect(fetchCall(fetchMock, 1)[0]).toBe("https://pi.example.test/api/machines/remote%20a/runtime?refresh=1");
expect(fetchCall(fetchMock, 1)[1]?.cache).toBe("no-store");
});
});
@@ -475,7 +479,7 @@ function piWebConfigResponse(config: PiWebConfigValues) {
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
+1 -1
View File
@@ -115,7 +115,7 @@ export const machinesApi = {
addMachine: (input: { name: string; baseUrl: string; token?: string }) => request("api/machines", parseMachine, { method: "POST", body: JSON.stringify(input) }),
deleteMachine: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}`, (value) => value, { method: "DELETE" }),
health: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/health`, parseMachineHealth),
runtime: (machineId: string) => request(`api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
runtime: (machineId: string, refresh = false) => request(`api/machines/${encodeURIComponent(machineId)}/runtime${refresh ? "?refresh=1" : ""}`, parseMachineRuntime, refresh ? { cache: "no-store" } : {}),
};
function configPath(machineId?: string): string {
+69 -10
View File
@@ -1,34 +1,93 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("parses PI WEB config responses", () => {
expect(parsePiWebConfigResponse({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
})).toEqual({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
});
});
it("parses PI WEB runtime responses", () => {
it("parses PI WEB runtime responses including the daemon-owned active profile", () => {
expect(parsePiWebRuntimeResponse({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
sessiond: {
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.0.0",
available: true,
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
activeAgentProfile: {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command: "agent-lab",
dir: "/srv/agent-lab",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
},
},
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
})).toMatchObject({ capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
})).toMatchObject({
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage],
components: { sessiond: { activeAgentProfile: { command: "agent-lab", dir: "/srv/agent-lab" } } },
});
});
it("retains portable active profiles in machine runtime snapshots and rejects invalid ownership", () => {
const profile = {
schemaVersion: 1,
revision: `sha256:${"b".repeat(64)}`,
command: "C:\\tools\\pi.exe",
dir: "C:\\agent-profiles\\work",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
const components = {
web: { component: "web", label: "Web/UI", available: true, capabilities: [] },
sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [], activeAgentProfile: profile },
};
const parsed = parseMachineRuntime({ machineId: "remote-a", ok: true, checkedAt: "now", components, capabilities: [] });
expect(parsed.components?.sessiond.activeAgentProfile).toMatchObject({ command: profile.command, dir: profile.dir });
expect(Object.isFrozen(parsed.components?.sessiond.activeAgentProfile)).toBe(true);
expect(() => parseMachineRuntime({
machineId: "remote-a",
ok: true,
checkedAt: "now",
components: { ...components, web: { ...components.web, activeAgentProfile: profile } },
capabilities: [],
})).toThrow("Invalid active agent profile descriptor");
expect(() => parseMachineRuntime({
machineId: "remote-a",
ok: true,
checkedAt: "now",
components: { ...components, sessiond: { ...components.sessiond, activeAgentProfile: { ...profile, token: "secret" } } },
capabilities: [],
})).toThrow("Invalid active agent profile descriptor");
});
it("rejects malformed agent directory override metadata", () => {
expect(() => parsePiWebConfigResponse({
path: "/tmp/config.json",
exists: true,
config: {},
effectiveConfig: {},
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentDirSource: "future" },
})).toThrow("Invalid PI WEB agentDirSource field");
});
it("parses Pi package list and mutation responses", () => {
+36 -3
View File
@@ -1,5 +1,6 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileResponse, OAuthFlowState, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevelsResponse, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -562,11 +563,21 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
...optionalField("pathAccess", optionalPathAccess(record["pathAccess"])),
...optionalField("uploads", optionalUploads(record["uploads"])),
...optionalField("maxUploadBytes", optionalNumber(record, "maxUploadBytes")),
...optionalField("agent", optionalAgent(record["agent"])),
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
};
}
function optionalAgent(value: unknown): PiWebConfigValues["agent"] | undefined {
if (value === undefined) return undefined;
if (!isRecord(value) || Array.isArray(value)) throw new Error("Invalid PI WEB agent field");
return {
...optionalField("command", optionalString(value, "command")),
...optionalField("dir", optionalString(value, "dir")),
};
}
function optionalAllowedHosts(value: unknown): PiWebConfigValues["allowedHosts"] | undefined {
if (value === undefined) return undefined;
if (value === true) return true;
@@ -629,7 +640,24 @@ function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined {
function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
const record = requireRecord(value);
return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions"), subsessions: requireBoolean(record, "subsessions") };
return {
host: requireBoolean(record, "host"),
port: requireBoolean(record, "port"),
allowedHosts: requireBoolean(record, "allowedHosts"),
spawnSessions: requireBoolean(record, "spawnSessions"),
subsessions: requireBoolean(record, "subsessions"),
agentCommand: optionalBoolean(record, "agentCommand") ?? false,
agentDir: optionalBoolean(record, "agentDir") ?? false,
...optionalAgentDirSource(record),
agentSessionDir: optionalBoolean(record, "agentSessionDir") ?? false,
};
}
function optionalAgentDirSource(record: Record<string, unknown>): { agentDirSource?: PiWebAgentDirEnvSource } {
const value = record["agentDirSource"];
if (value === undefined) return {};
if (value !== "pi-web" && value !== "pi-compatibility") throw new Error("Invalid PI WEB agentDirSource field");
return { agentDirSource: value };
}
export function parsePiPackagesResponse(value: unknown): PiPackagesResponse {
@@ -733,12 +761,17 @@ function parsePiWebRuntimeComponents(value: unknown): PiWebRuntimeResponse["comp
function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponent {
const record = requireRecord(value);
const component = parsePiWebServiceComponent(record["component"]);
const activeAgentProfileValue = record["activeAgentProfile"];
const activeAgentProfile = activeAgentProfileValue === undefined ? undefined : parseActiveAgentProfileDescriptor(activeAgentProfileValue);
if (activeAgentProfileValue !== undefined && (component !== "sessiond" || activeAgentProfile === undefined)) throw new Error("Invalid active agent profile descriptor");
return {
component: parsePiWebServiceComponent(record["component"]),
component,
label: requireString(record, "label"),
...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
available: requireBoolean(record, "available"),
capabilities: parsePiWebCapabilities(record["capabilities"]),
...optionalField("activeAgentProfile", activeAgentProfile),
...optionalField("error", optionalString(record, "error")),
};
}
+2 -2
View File
@@ -54,13 +54,13 @@ export class AuthDialog extends LitElement {
case "method": return html`
<div class="options">
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in pi auth.json</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in the active Pi-compatible profile's auth.json</small></button>
</div>
`;
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
case "apiKey": return html`
<div class="form">
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored by pi in <code>auth.json</code>.</p>
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored in the active Pi-compatible profile's <code>auth.json</code>.</p>
<input type="password" autocomplete="off" placeholder="API key" .value=${state.value} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}>
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
<div class="actions"><button @click=${() => { this.cancel(); }}>Cancel</button><button class="primary" ?disabled=${state.saving === true} @click=${() => { this.onSaveApiKey?.(); }}>${state.saving === true ? "Saving…" : "Save API key"}</button></div>
@@ -67,4 +67,3 @@ export class FormattedText extends LitElement {
static override styles = formattedTextStyles;
}
+1 -1
View File
@@ -1962,7 +1962,7 @@ export class PiWebApp extends LitElement {
${state.machineDialogOpen ? html`<machine-dialog .error=${state.error} .onSubmit=${(input: MachineDialogSubmit) => this.submitMachineDialog(input)} .onCancel=${() => { this.setState({ machineDialogOpen: false }); }}></machine-dialog>` : null}
${this.sessionCleanupDialog !== undefined ? html`<session-cleanup-dialog .canCleanup=${this.canCleanupSessions()} .unavailableMessage=${this.sessionCleanupUnavailableMessage()} .preview=${this.sessionCleanupDialog.preview} .previewRequest=${this.sessionCleanupDialog.previewRequest} .result=${this.sessionCleanupDialog.result} .loading=${this.sessionCleanupDialog.loading === true} .running=${this.sessionCleanupDialog.running === true} .error=${this.sessionCleanupDialog.error ?? ""} .onPreview=${(request: SessionCleanupRequest) => { void this.previewSessionCleanup(request); }} .onRun=${(request: SessionCleanupRequest) => { void this.runSessionCleanup(request); }} .onClose=${() => { this.closeSessionCleanupDialog(); }}></session-cleanup-dialog>` : null}
${state.themeDialog !== undefined ? html`<command-picker title=${state.themeDialog.title} .options=${state.themeDialog.options} .selectedValue=${state.themeDialog.selectedValue} .onPick=${(value: string) => { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}></command-picker>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }}></settings-dialog>` : null}
${this.settingsSection !== undefined ? html`<settings-dialog .section=${this.settingsSection} .machine=${state.selectedMachine} .machineRuntime=${this.selectedMachineRuntime()} .actions=${this.getDefaultActions()} .onNavigate=${(section: SettingsSection) => { this.navigateSettings(section); }} .onClose=${() => { this.closeSettings(); }} .onConfigSaved=${(config: PiWebConfigValues) => { this.applyClientConfig(config); }} .onRefreshMachineRuntime=${(machineId: string) => this.machines.refreshMachineRuntime(machineId)}></settings-dialog>` : null}
</div>
`;
}
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebPluginsResponse } from "../api";
import { SettingsDialog } from "./SettingsDialog";
import { callDialogPromise, callDialogUpdated, configResponse, deferred, getDialogProperty, pluginInfo, pluginsResponse, remoteMachine, runtimeWithPackageManagement as runtimeWithoutSelectedMachineSettings, secondRemoteMachine, setDialogProperty, stubWindowTimers } from "./SettingsDialog.testSupport";
@@ -40,6 +41,21 @@ describe("settings-dialog session daemon machine targeting", () => {
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
});
it("reloads desired config and the active runtime descriptor together", async () => {
const config = configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
const runtimeRefresh = vi.fn(() => Promise.resolve());
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.onRefreshMachineRuntime = runtimeRefresh;
await callDialogPromise(dialog, "reloadSessiondState");
expect(configSpy).toHaveBeenCalledWith(remoteMachine.id);
expect(runtimeRefresh).toHaveBeenCalledWith(remoteMachine.id);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
});
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
stubWindowTimers();
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
@@ -57,6 +73,43 @@ describe("settings-dialog session daemon machine targeting", () => {
expect(getDialogProperty(dialog, "saving")).toBe(false);
});
it("fails closed for a remote agent-profile save without granular support", async () => {
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }));
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = {
machineId: remoteMachine.id,
ok: true,
checkedAt: "now",
capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings],
};
await callDialogPromise(dialog, "saveSessiondConfig", { agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
expect(saveSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Pi-compatible agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("saves a remote agent profile when granular support is advertised", async () => {
stubWindowTimers();
const patch = { agent: { command: "agent-lab", dir: "/srv/agent-lab" } };
const saved = configResponse(patch);
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(saved);
const dialog = new SettingsDialog();
dialog.machine = remoteMachine;
dialog.machineRuntime = {
machineId: remoteMachine.id,
ok: true,
checkedAt: "now",
capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig],
};
await callDialogPromise(dialog, "saveSessiondConfig", patch);
expect(saveSpy).toHaveBeenCalledWith(patch, remoteMachine.id);
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(saved);
});
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
const load = deferred<PiWebConfigResponse>();
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
@@ -101,7 +101,7 @@ export function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
+24 -3
View File
@@ -11,7 +11,7 @@ import "./settings/SettingsShortcutsPanel";
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig";
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type AgentProfileSettingsSupport, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig";
import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig";
@@ -24,6 +24,7 @@ export class SettingsDialog extends LitElement {
@property({ attribute: false }) onNavigate?: (section: SettingsSection) => void;
@property({ attribute: false }) onClose?: () => void;
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
@property({ attribute: false }) onRefreshMachineRuntime?: (machineId: string) => void | Promise<void>;
@state() private configResponse: PiWebConfigResponse | undefined;
@state() private accessConfigResponse: PiWebConfigResponse | undefined;
@state() private sessiondConfigResponse: PiWebConfigResponse | undefined;
@@ -57,7 +58,7 @@ export class SettingsDialog extends LitElement {
super.connectedCallback();
void this.loadConfig();
void this.loadAccessConfigForTarget();
void this.loadSessiondConfigForTarget();
void this.reloadSessiondState();
void this.loadPluginsForTarget();
void this.loadPackagesForTarget();
}
@@ -137,7 +138,9 @@ export class SettingsDialog extends LitElement {
.error=${this.sessiondError}
.savedMessage=${this.savedMessage}
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
.onReload=${() => this.loadSessiondConfigForTarget()}
.activeAgentProfile=${this.machineRuntime?.components?.sessiond.activeAgentProfile}
.agentProfileSupport=${this.agentProfileSettingsSupport()}
.onReload=${() => this.reloadSessiondState()}
.onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)}
></settings-sessiond-panel>
`;
@@ -264,6 +267,13 @@ export class SettingsDialog extends LitElement {
}
}
private async reloadSessiondState(target = this.settingsTarget()): Promise<void> {
await Promise.all([
this.loadSessiondConfigForTarget(target),
this.onRefreshMachineRuntime?.(target.id),
]);
}
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
const requestSeq = ++this.sessiondLoadRequestSeq;
const support = this.selectedMachineSettingsSupport(target);
@@ -424,6 +434,13 @@ export class SettingsDialog extends LitElement {
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
if (config.agent !== undefined) {
const profileSupport = this.agentProfileSettingsSupport(target);
if (!isAgentProfileSettingsSupported(profileSupport)) {
this.sessiondError = profileSupport.message ?? `Pi-compatible agent profile settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
}
this.saving = true;
this.sessiondError = "";
this.savedMessage = "";
@@ -521,6 +538,10 @@ export class SettingsDialog extends LitElement {
return selectedMachineSettingsSupport(target, this.machineRuntime);
}
private agentProfileSettingsSupport(target = this.settingsTarget()): AgentProfileSettingsSupport {
return agentProfileSettingsSupport(target, this.machineRuntime);
}
private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean {
const previousSupport = selectedMachineSettingsSupport(target, previousRuntime);
const currentSupport = this.selectedMachineSettingsSupport(target);
@@ -230,6 +230,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
@@ -162,7 +162,7 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { TemplateResult } from "lit";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
import type { SettingsNotice } from "./SettingsPanelFrame";
@@ -8,24 +8,34 @@ describe("settings-sessiond-panel layout", () => {
it("names the selected machine in the scope and restart notice when config is available", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
panel.configResponse = configResponse({ spawnSessions: true, subsessions: false });
setPanelConfig(panel, configResponse({
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
spawnSessions: true,
subsessions: false,
}));
panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
const rendered = flattenTemplateContent(panel.render());
expectTextOrder(rendered, [
"Session daemon",
"These settings affect the long-lived session runtime on Lab Mac (remote machine).",
"Select the Pi-compatible agent profile and companion CLI for Lab Mac (remote machine).",
"Reload",
"Restart required on Lab Mac (remote machine)",
"run <code>pi-web restart</code> on that machine",
"Pi-compatible agent profile restart required on Lab Mac (remote machine)",
"Run <code>pi-web restart</code> on that machine",
"Config file",
"Companion CLI command",
"agent-lab",
"Profile state directory",
"/srv/agent-lab",
"Allow agents to start sessions",
]);
});
it("orders save/load notices before the restart notice and settings content", () => {
const panel = new SettingsSessiondPanel();
panel.configResponse = configResponse({ spawnSessions: false });
setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: false }));
panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
panel.error = "Failed to save session-daemon config.";
panel.savedMessage = "Session daemon settings saved.";
@@ -34,11 +44,55 @@ describe("settings-sessiond-panel layout", () => {
expectTextOrder(rendered, [
"Failed to save session-daemon config.",
"Session daemon settings saved.",
"Restart required on local (local gateway)",
"Pi-compatible agent profile restart required on local (local gateway)",
"Config file",
]);
});
it("shows the profile as active without restart guidance when desired and active match", () => {
const panel = new SettingsSessiondPanel();
setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }));
panel.activeAgentProfile = activeProfile("agent-lab", "/srv/agent-lab");
const rendered = flattenTemplateContent(panel.render());
expect(rendered).toContain("Profile status");
expect(rendered).toContain("Active");
expect(rendered).not.toContain("restart required on");
});
it("submits command and directory together as one profile save", async () => {
const panel = new SettingsSessiondPanel();
const onSave = vi.fn();
setPanelConfig(panel, configResponse({ agent: { command: "pi", dir: "/srv/pi" } }));
setPanelProperty(panel, "agentDraft", { command: " alternate-agent ", dir: " /srv/alternate " });
panel.onSave = onSave;
const event = new Event("submit", { cancelable: true });
await callPanelPromise(panel, "saveAgentProfile", event);
expect(event.defaultPrevented).toBe(true);
expect(onSave.mock.calls).toEqual([[{ agent: { command: "alternate-agent", dir: "/srv/alternate" } }]]);
});
it("preserves a dirty profile draft when an unrelated daemon setting is saved", () => {
const panel = new SettingsSessiondPanel();
const initial = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: false });
setPanelConfig(panel, initial);
callPanelMethod(panel, "updateAgentDraft", { command: "alternate-agent", dir: "/srv/alternate" });
const toggled = configResponse({ agent: { command: "pi", dir: "/srv/pi" }, spawnSessions: true });
panel.configResponse = toggled;
callPanelMethod(panel, "willUpdate", new Map([["configResponse", initial]]));
expect(Reflect.get(panel, "agentDraft")).toEqual({ command: "alternate-agent", dir: "/srv/alternate" });
const saved = configResponse({ agent: { command: "alternate-agent", dir: "/srv/alternate" }, spawnSessions: true });
panel.configResponse = saved;
callPanelMethod(panel, "willUpdate", new Map([["configResponse", toggled]]));
expect(Reflect.get(panel, "agentDraftDirty")).toBe(false);
});
it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => {
const panel = new SettingsSessiondPanel();
panel.targetLabel = "Lab Mac (remote machine)";
@@ -57,6 +111,37 @@ describe("settings-sessiond-panel layout", () => {
});
});
function activeProfile(command: string, dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
function setPanelConfig(panel: SettingsSessiondPanel, config: PiWebConfigResponse): void {
panel.configResponse = config;
callPanelMethod(panel, "willUpdate", new Map([["configResponse", undefined]]));
}
function setPanelProperty(panel: SettingsSessiondPanel, property: string, value: unknown): void {
if (!Reflect.set(panel, property, value)) throw new Error(`Failed to set SettingsSessiondPanel property ${property}`);
}
async function callPanelPromise(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): Promise<void> {
const result = callPanelMethod(panel, methodName, ...args);
if (!(result instanceof Promise)) throw new Error(`SettingsSessiondPanel.${methodName} did not return a promise`);
await result;
}
function callPanelMethod(panel: SettingsSessiondPanel, methodName: string, ...args: readonly unknown[]): unknown {
const method: unknown = Reflect.get(panel, methodName);
if (typeof method !== "function") throw new Error(`SettingsSessiondPanel.${methodName} is not callable`);
return Reflect.apply(method, panel, args);
}
function flattenTemplateContent(template: TemplateResult): string {
const chunks: string[] = [];
visitTemplate(template);
@@ -138,6 +223,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
@@ -1,9 +1,11 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import "./SettingsPanelFrame";
import type { SettingsNotice } from "./SettingsPanelFrame";
import { spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
import { agentProfileConfigPatchFromDraft, agentProfileDraftFromConfig, agentProfileDraftMatchesConfig, emptyAgentProfileConfigDraft, type AgentProfileConfigDraft } from "./settingsConfigDraft";
import type { AgentProfileSettingsSupport } from "./settingsMachineTarget";
import { agentDirFieldOverridden, agentProfileActivationState, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
@customElement("settings-sessiond-panel")
export class SettingsSessiondPanel extends LitElement {
@@ -13,8 +15,28 @@ export class SettingsSessiondPanel extends LitElement {
@property() error = "";
@property() savedMessage = "";
@property() targetLabel = "local (local gateway)";
@property({ attribute: false }) activeAgentProfile: ActiveAgentProfileDescriptor | undefined;
@property({ attribute: false }) agentProfileSupport: AgentProfileSettingsSupport = { state: "supported" };
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
@state() private agentDraft: AgentProfileConfigDraft = emptyAgentProfileConfigDraft();
@state() private agentDraftDirty = false;
@state() private agentLocalError = "";
protected override willUpdate(changed: PropertyValues<this>): void {
if (!changed.has("configResponse")) return;
if (this.configResponse === undefined) {
this.agentDraft = emptyAgentProfileConfigDraft();
this.agentDraftDirty = false;
this.agentLocalError = "";
return;
}
if (!this.agentDraftDirty || agentProfileDraftMatchesConfig(this.agentDraft, this.configResponse.config)) {
this.agentDraft = agentProfileDraftFromConfig(this.configResponse.config);
this.agentDraftDirty = false;
this.agentLocalError = "";
}
}
override render(): TemplateResult {
const config = this.configResponse;
@@ -25,6 +47,13 @@ export class SettingsSessiondPanel extends LitElement {
const subsessionsOverridden = config?.envOverrides.subsessions === true;
// Beta, off by default; also requires spawn to be enabled.
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
const agentCommandOverridden = config?.envOverrides.agentCommand === true;
const profileEditingSupported = this.agentProfileSupport.state === "supported";
const draftCommand = agentCommandOverridden ? (config.effectiveConfig.agent?.command ?? this.agentDraft.command) : this.agentDraft.command;
const agentDirLocked = agentDirFieldOverridden(config?.envOverrides, draftCommand);
const effectiveAgentDirOverridden = config?.envOverrides.agentDir === true;
const effectiveAgent = config?.effectiveConfig.agent;
const profileActivation = agentProfileActivationState(config, this.activeAgentProfile);
return html`
<settings-panel-frame
heading="Session daemon"
@@ -39,6 +68,46 @@ export class SettingsSessiondPanel extends LitElement {
<span>Config file</span>
<code>${config.path}</code>
</div>
<form class="profile-form" aria-label="Pi-compatible agent profile" @submit=${(event: Event) => { void this.saveAgentProfile(event); }}>
${profileEditingSupported ? null : html`<div class="profile-support-message">${this.agentProfileSupport.message ?? "Pi-compatible agent profile editing is unavailable for this machine."}</div>`}
<label class="field">
<span class="field-heading">
<span>Companion CLI command</span>
${agentCommandOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
class="text-input"
type="text"
autocomplete="off"
spellcheck="false"
.value=${this.agentDraft.command}
placeholder="pi"
?disabled=${this.loading || this.saving || !profileEditingSupported || agentCommandOverridden}
@input=${(event: Event) => { this.updateAgentDraft({ command: inputValue(event) }); }}
>
<small>Set the Pi-compatible companion CLI used for doctor and update checks. The embedded session runtime remains PI WEB's bundled Pi SDK.</small>
</label>
<label class="field">
<span class="field-heading">
<span>Profile state directory</span>
${effectiveAgentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
class="text-input"
type="text"
autocomplete="off"
spellcheck="false"
.value=${this.agentDraft.dir}
placeholder="~/.pi/agent or ~/agent-profiles/work"
?disabled=${this.loading || this.saving || !profileEditingSupported || agentDirLocked}
@input=${(event: Event) => { this.updateAgentDraft({ dir: inputValue(event) }); }}
>
<small>Choose the Pi-compatible auth, models, settings, and sessions PI WEB reads. An alternate command and its required state directory are saved together.</small>
</label>
<footer class="form-actions">
<button class="primary" type="submit" ?disabled=${this.loading || this.saving || !profileEditingSupported || (agentCommandOverridden && agentDirLocked)}>${this.saving ? "Saving…" : "Save agent profile"}</button>
</footer>
</form>
<div class="field">
<span class="field-heading">
<span>Allow agents to start sessions</span>
@@ -72,9 +141,14 @@ export class SettingsSessiondPanel extends LitElement {
</label>
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
</div>
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<section class="effective-card" aria-label="Desired and active session daemon configuration summary">
<h3>Desired after environment overrides</h3>
<dl>
<div><dt>Desired command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Desired state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Active command</dt><dd>${this.activeAgentProfile?.command ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Active state</dt><dd>${this.activeAgentProfile?.dir ?? html`<span class="muted">Unavailable</span>`}</dd></div>
<div><dt>Profile status</dt><dd>${profileActivationLabel(profileActivation)}</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>
@@ -86,13 +160,21 @@ export class SettingsSessiondPanel extends LitElement {
private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] {
const notices: SettingsNotice[] = [];
if (this.error !== "") notices.push({ type: "error", content: this.error });
const error = this.agentLocalError || this.error;
if (error !== "") notices.push({ type: "error", content: error });
if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
if (config !== undefined) {
const activation = agentProfileActivationState(config, this.activeAgentProfile);
if (activation === "restart-required") {
notices.push({
type: "warning",
title: `Restart required on ${this.targetLabel}`,
content: html`run <code>pi-web restart</code> on that machine (or restart its session daemon service) after changing these settings.`,
title: `Pi-compatible agent profile restart required on ${this.targetLabel}`,
content: html`The desired profile differs from the active session-daemon profile. Run <code>pi-web restart</code> on that machine (or restart its session daemon service) to apply the command and state directory together.`,
});
} else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") {
notices.push({
type: "info",
title: `Active Pi-compatible agent profile unavailable on ${this.targetLabel}`,
content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.",
});
}
return notices;
@@ -102,6 +184,22 @@ export class SettingsSessiondPanel extends LitElement {
return html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>`;
}
private async saveAgentProfile(event: Event): Promise<void> {
event.preventDefault();
this.agentLocalError = "";
try {
await this.onSave?.(agentProfileConfigPatchFromDraft(this.agentDraft));
} catch (error) {
this.agentLocalError = errorMessage(error);
}
}
private updateAgentDraft(patch: Partial<AgentProfileConfigDraft>): void {
this.agentDraft = { ...this.agentDraft, ...patch };
this.agentDraftDirty = true;
this.agentLocalError = "";
}
private async toggleSpawnSessions(event: Event): Promise<void> {
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
await this.onSave?.(spawnSessionsConfigPatch(enabled));
@@ -118,9 +216,13 @@ export class SettingsSessiondPanel extends LitElement {
button, input { font: inherit; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; }
.loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card, .config-path-card, .effective-card, .profile-support-message { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
.loading-card { color: var(--pi-muted); }
.config-path-card { display: grid; gap: 5px; }
.profile-form { display: grid; gap: 14px; }
.profile-support-message { color: var(--pi-muted); line-height: 1.45; }
.form-actions { display: flex; justify-content: flex-end; }
.primary { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-accent-contrast); }
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
.field { display: grid; gap: 7px; }
@@ -128,6 +230,20 @@ export class SettingsSessiondPanel extends LitElement {
.field-heading { display: flex; align-items: center; gap: 8px; }
.toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; }
.toggle input { width: 16px; height: 16px; }
.text-input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 1px solid var(--pi-border);
border-radius: 8px;
background: var(--pi-bg);
color: var(--pi-text);
padding: 8px 9px;
outline: none;
font: var(--pi-control-font-size, 16px) var(--pi-control-monospace-font-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
.text-input:focus { border-color: var(--pi-accent); box-shadow: 0 0 0 1px var(--pi-accent-border); }
.text-input:disabled { opacity: .55; cursor: not-allowed; }
.toggle input:disabled { cursor: not-allowed; }
.override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; }
.beta-badge { border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); background: var(--pi-bg); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
@@ -143,6 +259,20 @@ export class SettingsSessiondPanel extends LitElement {
`;
}
function profileActivationLabel(state: ReturnType<typeof agentProfileActivationState>): string | TemplateResult {
if (state === "active") return "Active";
if (state === "restart-required") return "Restart required";
return html`<span class="muted">Unavailable</span>`;
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : "";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function sessiondDescription(targetLabel: string): string {
return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
return `Select the Pi-compatible agent profile and companion CLI for ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
}
@@ -258,6 +258,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
agentProfileConfigPatchFromDraft,
agentProfileDraftFromConfig,
agentProfileDraftMatchesConfig,
gatewayServerConfigFromDraft,
gatewayServerDraftFromConfig,
machineAccessConfigPatchFromDraft,
@@ -29,6 +32,22 @@ describe("settings config drafts", () => {
expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
});
it("builds one atomic agent profile patch from both draft fields", () => {
expect(agentProfileDraftFromConfig({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toEqual({
command: "agent-lab",
dir: "/srv/agent-lab",
});
expect(agentProfileConfigPatchFromDraft({ command: " alternate-agent ", dir: " /srv/alternate-agent " })).toEqual({
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
});
expect(agentProfileConfigPatchFromDraft({ command: " ", dir: " " })).toEqual({ agent: {} });
expect(agentProfileConfigPatchFromDraft({ command: " C:\\tools\\pi.exe ", dir: " C:\\agent-profiles\\work " })).toEqual({
agent: { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" },
});
expect(agentProfileDraftMatchesConfig({ command: " agent-lab ", dir: " /srv/agent-lab " }, { agent: { command: "agent-lab", dir: "/srv/agent-lab" } })).toBe(true);
expect(agentProfileDraftMatchesConfig({ command: "agent-lab", dir: "/draft" }, { agent: { command: "agent-lab", dir: "/saved" } })).toBe(false);
});
it("builds gateway server saves without dropping preserved config values", () => {
expect(gatewayServerConfigFromDraft({
host: " gateway.local ",
@@ -43,6 +62,7 @@ describe("settings config drafts", () => {
maxUploadBytes: 1234,
spawnSessions: true,
subsessions: false,
agent: { command: "agent-lab", dir: "~/agent-profiles/lab" },
})).toEqual({
host: "gateway.local",
port: 9000,
@@ -54,6 +74,7 @@ describe("settings config drafts", () => {
maxUploadBytes: 1234,
spawnSessions: true,
subsessions: false,
agent: { command: "agent-lab", dir: "~/agent-profiles/lab" },
});
expect(gatewayServerConfigFromDraft({
@@ -12,6 +12,11 @@ export interface MachineAccessConfigDraft {
uploadDefaultFolder: string;
}
export interface AgentProfileConfigDraft {
command: string;
dir: string;
}
export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
}
@@ -20,6 +25,10 @@ export function emptyMachineAccessConfigDraft(): MachineAccessConfigDraft {
return { allowedPathsText: "", uploadDefaultFolder: "" };
}
export function emptyAgentProfileConfigDraft(): AgentProfileConfigDraft {
return { command: "", dir: "" };
}
export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft {
return {
host: config.host ?? "",
@@ -36,6 +45,30 @@ export function machineAccessDraftFromConfig(config: PiWebConfigValues): Machine
};
}
export function agentProfileDraftFromConfig(config: PiWebConfigValues): AgentProfileConfigDraft {
return {
command: config.agent?.command ?? "",
dir: config.agent?.dir ?? "",
};
}
export function agentProfileConfigPatchFromDraft(draft: AgentProfileConfigDraft): PiWebConfigValues {
const command = draft.command.trim();
const dir = draft.dir.trim();
return {
agent: {
...(command === "" ? {} : { command }),
...(dir === "" ? {} : { dir }),
},
};
}
export function agentProfileDraftMatchesConfig(draft: AgentProfileConfigDraft, config: PiWebConfigValues): boolean {
const normalizedDraft = agentProfileConfigPatchFromDraft(draft).agent ?? {};
const configured = config.agent ?? {};
return normalizedDraft.command === configured.command && normalizedDraft.dir === configured.dir;
}
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
const config = preservedGatewayConfigRemainder(baseConfig);
const host = draft.host.trim();
@@ -68,6 +101,7 @@ function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebCo
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }),
};
}
@@ -8,7 +8,7 @@ const configResponse: PiWebConfigResponse = {
exists: true,
config: { host: "127.0.0.1" },
effectiveConfig: { host: "127.0.0.1" },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
const pluginsResponse: PiWebPluginsResponse = { plugins: [] };
@@ -81,6 +81,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Machine, MachineRuntime } from "../../api";
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
import { agentProfileSettingsSupport, friendlySelectedMachineSettingsErrorMessage, isAgentProfileSettingsSupported, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
const remoteMachine: Machine = {
id: "remote-a",
@@ -39,6 +39,27 @@ describe("selected-machine settings target helpers", () => {
expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`);
});
it("gates remote agent profile edits on their granular capability", () => {
const target = settingsMachineTarget(remoteMachine);
expect(agentProfileSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" });
expect(agentProfileSettingsSupport(target, undefined)).toEqual({
state: "unknown",
message: "Pi-compatible agent profile support could not be verified on Lab Mac. Reload machine status before changing the profile.",
});
expect(agentProfileSettingsSupport(target, {
ok: true,
capabilities: [PI_WEB_CAPABILITIES.agentProfileConfig],
})).toEqual({ state: "supported" });
const unsupported = agentProfileSettingsSupport(target, { ok: true, capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings] });
expect(isAgentProfileSettingsSupported(unsupported)).toBe(false);
expect(unsupported).toEqual({
state: "unsupported",
message: "Pi-compatible agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.",
});
});
it("turns older remote config route failures into selected-machine compatibility guidance", () => {
const target = settingsMachineTarget(remoteMachine);
@@ -14,6 +14,8 @@ export interface SelectedMachineSettingsSupport {
message?: string;
}
export type AgentProfileSettingsSupport = SelectedMachineSettingsSupport;
export function settingsMachineTarget(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): SettingsMachineTarget {
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
return { id: "local", name: "local", kind: "local" };
@@ -30,6 +32,21 @@ export function selectedMachineSettingsSupport(target: SettingsMachineTarget, ru
return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) };
}
export function agentProfileSettingsSupport(target: SettingsMachineTarget, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): AgentProfileSettingsSupport {
if (target.kind === "local") return { state: "supported" };
if (runtime?.ok !== true) {
return {
state: "unknown",
message: `Pi-compatible agent profile support could not be verified on ${target.name}. Reload machine status before changing the profile.`,
};
}
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.agentProfileConfig)) return { state: "supported" };
return {
state: "unsupported",
message: `Pi-compatible agent profile settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`,
};
}
export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string {
return `${support.state}:${support.message ?? ""}`;
}
@@ -38,6 +55,10 @@ export function isSelectedMachineSettingsUnsupported(support: SelectedMachineSet
return support?.state === "unsupported";
}
export function isAgentProfileSettingsSupported(support: AgentProfileSettingsSupport | undefined): boolean {
return support?.state === "supported";
}
export function selectedMachineSettingsUnavailableMessage(target: SettingsMachineTarget): string {
return `Selected-machine settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`;
}
@@ -64,6 +64,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { agentDirFieldOverridden, agentProfileActivationState, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
describe("session daemon settings config helpers", () => {
it("builds daemon-only save patches for the sessiond toggles", () => {
@@ -8,6 +8,39 @@ describe("session daemon settings config helpers", () => {
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
});
it("compares the desired effective profile with the daemon-owned active profile", () => {
const config = configResponse(
{ agent: { command: "configured-agent", dir: "/configured" } },
{},
{ agent: { command: "effective-agent", dir: "/effective" } },
);
expect(agentProfileActivationState(config, activeProfile("effective-agent", "/effective"))).toBe("active");
expect(agentProfileActivationState(config, activeProfile("other-agent", "/effective"))).toBe("restart-required");
expect(agentProfileActivationState(config, activeProfile("effective-agent", "/other"))).toBe("restart-required");
expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective"))).toBe("restart-required");
expect(agentProfileActivationState(configResponse({}, {}, { agent: { command: "pi", dir: "/effective" } }), activeProfile("pi", "/effective", ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]))).toBe("active");
expect(agentProfileActivationState(config, undefined)).toBe("unavailable");
expect(agentProfileActivationState(undefined, activeProfile("effective-agent", "/effective"))).toBe("unavailable");
});
it("releases only Pi's compatibility directory override when the draft selects an alternate command", () => {
const baseOverrides = configResponse({}).envOverrides;
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi")).toBe(true);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "pi.exe")).toBe(true);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-compatibility" }, "alternate-agent")).toBe(false);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true, agentDirSource: "pi-web" }, "alternate-agent")).toBe(true);
expect(agentDirFieldOverridden({ ...baseOverrides, agentDir: true }, "alternate-agent")).toBe(true);
});
it("does not leak the gateway agent directory source into a selected-machine response", () => {
const gateway = configResponse({}, { agentDir: true, agentDirSource: "pi-web" });
const selectedMachine = configResponse({}, { agentDir: false });
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine).envOverrides.agentDirSource).toBeUndefined();
});
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
const gateway = configResponse({
host: "127.0.0.1",
@@ -17,8 +50,13 @@ describe("session daemon settings config helpers", () => {
plugins: { info: { enabled: true } },
spawnSessions: false,
subsessions: false,
agent: { command: "gateway-agent", dir: "/srv/gateway-agent" },
});
const selectedMachine = configResponse({ spawnSessions: true, subsessions: true }, { spawnSessions: true, subsessions: false });
const selectedMachine = configResponse(
{ spawnSessions: true, subsessions: true, agent: { command: "machine-agent", dir: "/srv/machine-agent" } },
{ spawnSessions: true, subsessions: false, agentCommand: true, agentDir: false, agentDirSource: "pi-compatibility", agentSessionDir: true },
{ spawnSessions: true, subsessions: true, agent: { command: "env-agent", dir: "/srv/machine-agent" } },
);
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine)).toEqual({
...gateway,
@@ -30,6 +68,7 @@ describe("session daemon settings config helpers", () => {
plugins: { info: { enabled: true } },
spawnSessions: true,
subsessions: true,
agent: { command: "machine-agent", dir: "/srv/machine-agent" },
},
effectiveConfig: {
host: "127.0.0.1",
@@ -39,6 +78,7 @@ describe("session daemon settings config helpers", () => {
plugins: { info: { enabled: true } },
spawnSessions: true,
subsessions: true,
agent: { command: "env-agent", dir: "/srv/machine-agent" },
},
envOverrides: {
host: false,
@@ -46,17 +86,45 @@ describe("session daemon settings config helpers", () => {
allowedHosts: false,
spawnSessions: true,
subsessions: false,
agentCommand: true,
agentDir: false,
agentDirSource: "pi-compatibility",
agentSessionDir: true,
},
});
});
});
function configResponse(config: PiWebConfigValues, overrides: Partial<PiWebConfigResponse["envOverrides"]> = {}): PiWebConfigResponse {
function activeProfile(command: string, dir: string, sessionDirEnvKeys: readonly string[] = ["PI_WEB_AGENT_SESSION_DIR"]): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command,
dir,
sessionDirEnvKeys,
};
}
function configResponse(
config: PiWebConfigValues,
overrides: Partial<PiWebConfigResponse["envOverrides"]> = {},
effectiveConfig: PiWebConfigValues = config,
): PiWebConfigResponse {
return {
path: "/tmp/pi-web/config.json",
exists: true,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, ...overrides },
effectiveConfig,
envOverrides: {
host: false,
port: false,
allowedHosts: false,
spawnSessions: false,
subsessions: false,
agentCommand: false,
agentDir: false,
agentSessionDir: false,
...overrides,
},
};
}
@@ -1,4 +1,7 @@
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
import { usesPiCodingAgentStateCompatibility } from "../../../../shared/activeAgentProfile";
import type { ActiveAgentProfileDescriptor, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api";
export type AgentProfileActivationState = "active" | "restart-required" | "unavailable";
export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues {
return { spawnSessions: enabled };
@@ -8,15 +11,51 @@ export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues {
return { subsessions: enabled };
}
export function agentProfileActivationState(
config: PiWebConfigResponse | undefined,
activeProfile: ActiveAgentProfileDescriptor | undefined,
): AgentProfileActivationState {
const desiredProfile = config?.effectiveConfig.agent;
if (desiredProfile?.command === undefined || desiredProfile.dir === undefined || activeProfile === undefined) return "unavailable";
const desiredSessionDirEnvKeys = [
"PI_WEB_AGENT_SESSION_DIR",
...(usesPiCodingAgentStateCompatibility(desiredProfile.command) ? ["PI_CODING_AGENT_SESSION_DIR"] : []),
];
return desiredProfile.command === activeProfile.command
&& desiredProfile.dir === activeProfile.dir
&& sameStrings(activeProfile.sessionDirEnvKeys, desiredSessionDirEnvKeys)
? "active"
: "restart-required";
}
export function agentDirFieldOverridden(envOverrides: PiWebConfigEnvOverrides | undefined, draftCommand: string): boolean {
if (envOverrides?.agentDirSource === "pi-web") return true;
if (envOverrides?.agentDirSource === "pi-compatibility") return usesPiCodingAgentStateCompatibility(draftCommand.trim() || "pi");
// Older remote responses do not identify the source. Keep their override
// read-only rather than incorrectly treating a PI_WEB_AGENT_DIR as conditional.
return envOverrides?.agentDir === true;
}
export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
const envOverrides: PiWebConfigEnvOverrides = {
...base.envOverrides,
spawnSessions: selectedMachine.envOverrides.spawnSessions,
subsessions: selectedMachine.envOverrides.subsessions,
agentCommand: selectedMachine.envOverrides.agentCommand,
agentDir: selectedMachine.envOverrides.agentDir,
agentSessionDir: selectedMachine.envOverrides.agentSessionDir,
};
if (selectedMachine.envOverrides.agentDirSource === undefined) delete envOverrides.agentDirSource;
else envOverrides.agentDirSource = selectedMachine.envOverrides.agentDirSource;
return {
...base,
config: { ...base.config, ...selectedMachine.config },
effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig },
envOverrides: {
...base.envOverrides,
spawnSessions: selectedMachine.envOverrides.spawnSessions,
subsessions: selectedMachine.envOverrides.subsessions,
},
envOverrides,
};
}
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
@@ -93,7 +93,7 @@ describe("MachineController", () => {
expect(projects.loadProjects).toHaveBeenCalledOnce();
expect(updateUrl).toHaveBeenCalledOnce();
expect(health).toHaveBeenCalledWith(addedMachine.id);
expect(runtime).toHaveBeenCalledWith(addedMachine.id);
expect(runtime).toHaveBeenCalledWith(addedMachine.id, true);
});
it("preserves the current machine state when adding a machine fails", async () => {
@@ -102,7 +102,7 @@ export class MachineController {
async refreshMachineRuntime(machineId = this.getState().selectedMachine?.id ?? "local"): Promise<void> {
try {
const runtime = await api.runtime(machineId);
const runtime = await api.runtime(machineId, true);
this.setState({ machineRuntimes: { ...this.getState().machineRuntimes, [runtime.machineId]: runtime } });
} catch (error) {
this.setState({ error: String(error) });
+116 -1
View File
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
let tempDir: string;
let configPath: string;
@@ -63,6 +63,121 @@ describe("PI WEB config persistence", () => {
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
});
it("persists and reads custom agent runtime settings", () => {
savePiWebConfig({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, testOptions());
expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "acme-agent", dir: "/opt/acme-agent/state" });
});
it("defaults to the Pi agent directory only for canonical Pi companion names", () => {
for (const command of ["pi", "pi.cmd"]) {
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command } })).toMatchObject({
command,
dir: join(tempDir, ".home", ".pi", "agent"),
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"],
});
}
});
it("requires explicit state for alternate names and absolute Pi launchers", () => {
const absolutePiCommand = join(tempDir, "bin", "pi");
for (const command of ["acme-agent", absolutePiCommand]) {
expect(() => effectiveAgentConfig({}, { agent: { command } })).toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`);
expect(() => savePiWebConfig({ agent: { command } }, testOptions())).toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`);
}
});
it("accepts safe bare executable names and host-absolute executable paths", () => {
const absoluteCommand = join(tempDir, "bin", "acme-agent");
const agentDir = join(tempDir, "state", "acme");
expect(effectiveAgentConfig({}, { agent: { command: "acme-agent", dir: agentDir } })).toMatchObject({ command: "acme-agent", dir: agentDir });
expect(effectiveAgentConfig({}, { agent: { command: absoluteCommand, dir: agentDir } })).toMatchObject({ command: absoluteCommand, dir: agentDir });
});
it.each(["./acme-agent", "bin/acme-agent", "../acme-agent", "node acme-agent.js", "acme-agent;other", "-acme-agent"])("rejects unsafe or workspace-relative agent command %j", (command) => {
expect(() => savePiWebConfig({ agent: { command, dir: join(tempDir, "agent") } }, testOptions())).toThrow("safe bare executable name or host-absolute executable path");
});
it.skipIf(process.platform === "win32")("rejects foreign-platform absolute agent command and state paths", () => {
expect(() => effectiveAgentConfig({}, { agent: { command: "C:\\tools\\acme-agent.exe", dir: join(tempDir, "agent") } })).toThrow("safe bare executable name or host-absolute executable path");
expect(() => effectiveAgentConfig({}, { agent: { command: "acme-agent", dir: "C:\\profiles\\acme" } })).toThrow("agent.dir must be a host-absolute path");
});
it("rejects home expansion that would create a workspace-relative agent directory", () => {
expect(() => effectiveAgentConfig({ HOME: "relative-home" })).toThrow("agent.dir must be a host-absolute path");
});
it("resolves explicit alternate agent command and state directory settings", () => {
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "acme-agent", dir: "~/agent-profiles/acme" } })).toMatchObject({
command: "acme-agent",
dir: join(tempDir, ".home", "agent-profiles", "acme"),
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
});
});
it("ignores empty agent environment overrides", () => {
const env = {
HOME: join(tempDir, ".home"),
PI_WEB_AGENT_COMMAND: "",
PI_WEB_AGENT_DIR: "",
PI_WEB_AGENT_SESSION_DIR: "",
PI_CODING_AGENT_DIR: "",
PI_CODING_AGENT_SESSION_DIR: "",
};
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, "acme-agent")).toBe(false);
expect(hasAgentSessionDirEnvOverride(env, "acme-agent")).toBe(false);
});
it("uses explicit PI WEB agent directory env precedence", () => {
const env = {
PI_WEB_AGENT_COMMAND: "acme-agent",
PI_WEB_AGENT_DIR: join(tempDir, "web-env-agent"),
PI_CODING_AGENT_DIR: join(tempDir, "pi-env-agent"),
};
expect(effectiveAgentConfig(env, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({
command: "acme-agent",
dir: join(tempDir, "web-env-agent"),
});
expect(agentDirEnvSource(env)).toBe("pi-web");
});
it("keeps legacy Pi env directory overrides scoped to the canonical Pi command", () => {
const legacyDir = join(tempDir, "pi-env-agent");
const alternateDir = join(tempDir, "alternate-agent");
const env = { PI_CODING_AGENT_DIR: legacyDir };
expect(effectiveAgentConfig(env, { agent: { dir: join(tempDir, "config-agent") } })).toMatchObject({ dir: legacyDir });
expect(effectiveAgentConfig(env, { agent: { command: "acme-agent", dir: alternateDir } })).toMatchObject({ command: "acme-agent", dir: alternateDir });
expect(agentDirEnvSource(env)).toBe("pi-compatibility");
expect(hasAgentDirEnvOverride(env, "pi")).toBe(true);
expect(hasAgentDirEnvOverride(env, "acme-agent")).toBe(false);
for (const command of ["acme-agent", join(tempDir, "bin", "pi")]) {
expect(() => effectiveAgentConfig(env, { agent: { command } }))
.toThrow(`PI WEB config agent.dir or PI_WEB_AGENT_DIR is required when agent.command is ${JSON.stringify(command)}`);
}
});
it("uses only explicit session directory env keys", () => {
expect(agentSessionDirEnvKeys()).toEqual(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]);
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home"), PI_WEB_AGENT_COMMAND: "acme-agent", PI_WEB_AGENT_DIR: join(tempDir, "agent") }).sessionDirEnvKeys).toEqual(["PI_WEB_AGENT_SESSION_DIR"]);
expect(agentSessionDirEnvKeys(join(tempDir, "bin", "pi"))).toEqual(["PI_WEB_AGENT_SESSION_DIR"]);
});
it("rejects unknown nested agent keys instead of erasing them", async () => {
const original = { agent: { command: "acme-agent", dir: join(tempDir, "agent"), futureSetting: true } };
await writeFile(configPath, `${JSON.stringify(original, null, 2)}\n`, "utf8");
expect(() => loadPiWebConfig(testOptions())).toThrow('PI WEB config agent contains unknown key "futureSetting"');
expect(() => savePiWebConfig({ port: 9000 }, testOptions())).toThrow('PI WEB config agent contains unknown key "futureSetting"');
expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual(original);
});
it("exposes the default upload folder in the effective config", () => {
expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER });
});
+179 -5
View File
@@ -1,9 +1,12 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import type { PiWebConfigValues } from "./shared/apiTypes.js";
import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path";
import type { PiWebAgentDirEnvSource, PiWebConfigValues } from "./shared/apiTypes.js";
import { isPiCompanionCommand, usesPiCodingAgentStateCompatibility } from "./shared/activeAgentProfile.js";
import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js";
export { isPiCompanionCommand };
export type PiWebConfig = PiWebConfigValues;
export interface LoadedPiWebConfig {
@@ -12,6 +15,17 @@ export interface LoadedPiWebConfig {
config: PiWebConfig;
}
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "agent"> {
uploads: NonNullable<PiWebConfig["uploads"]>;
spawnSessions: boolean;
subsessions: boolean;
agent: Required<NonNullable<PiWebConfig["agent"]>>;
}
export interface LoadedEffectivePiWebConfig extends Omit<LoadedPiWebConfig, "config"> {
config: EffectivePiWebConfig;
}
export interface LoadOptions {
env?: NodeJS.ProcessEnv;
cwd?: string;
@@ -35,6 +49,51 @@ export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads";
export const DEFAULT_AGENT_COMMAND = "pi";
export const PI_WEB_AGENT_COMMAND_ENV = "PI_WEB_AGENT_COMMAND";
export const PI_WEB_AGENT_DIR_ENV = "PI_WEB_AGENT_DIR";
export const PI_WEB_AGENT_SESSION_DIR_ENV = "PI_WEB_AGENT_SESSION_DIR";
export const PI_CODING_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
export const PI_CODING_AGENT_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
export interface EffectivePiWebAgentConfig {
command: string;
dir: string;
sessionDirEnvKeys: string[];
}
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", "current");
const configuredDir = envValue(env, PI_WEB_AGENT_DIR_ENV) ?? (usesPiCodingAgentStateCompatibility(command) ? envValue(env, PI_CODING_AGENT_DIR_ENV) : undefined) ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env);
return {
command,
dir: resolveAgentDirPath(configuredDir, env, "agent.dir", "environment"),
sessionDirEnvKeys: agentSessionDirEnvKeys(command),
};
}
export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] {
return uniqueStrings([
PI_WEB_AGENT_SESSION_DIR_ENV,
...(usesPiCodingAgentStateCompatibility(command) ? [PI_CODING_AGENT_SESSION_DIR_ENV] : []),
]);
}
export function agentDirEnvSource(env: NodeJS.ProcessEnv): PiWebAgentDirEnvSource | undefined {
if (isEnvSet(env[PI_WEB_AGENT_DIR_ENV])) return "pi-web";
if (isEnvSet(env[PI_CODING_AGENT_DIR_ENV])) return "pi-compatibility";
return undefined;
}
export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean {
const source = agentDirEnvSource(env);
return source === "pi-web" || (source === "pi-compatibility" && usesPiCodingAgentStateCompatibility(command));
}
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"]> {
return { defaultFolder: config.uploads?.defaultFolder ?? DEFAULT_UPLOADS_FOLDER };
}
@@ -72,14 +131,17 @@ export function loadPiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig {
return { path, exists: true, config: parsePiWebConfig(parsed, path) };
}
export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig {
const loaded = loadPiWebConfig(options);
export function effectivePiWebConfig(options: LoadOptions = {}): LoadedEffectivePiWebConfig {
return resolveEffectivePiWebConfig(loadPiWebConfig(options), options);
}
export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options: LoadOptions = {}): LoadedEffectivePiWebConfig {
const env = options.env ?? process.env;
const host = env["PI_WEB_HOST"];
const port = env["PI_WEB_PORT"] ?? env["PORT"];
const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"];
const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"];
const agent = effectiveAgentConfig(env, loaded.config);
return {
...loaded,
config: {
@@ -94,6 +156,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
spawnSessions: spawnSessionsEnabled(env, loaded.config),
// Beta capability, resolved off by default.
subsessions: subsessionsEnabled(env, loaded.config),
agent: { command: agent.command, dir: agent.dir },
},
};
}
@@ -102,7 +165,9 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
const env = options.env ?? process.env;
const path = piWebConfigPath(env, options.cwd ?? process.cwd());
const normalized = parsePiWebConfig(piWebConfigRecord(config), path);
effectiveAgentConfig(env, normalized);
const existing = readExistingConfigObject(path);
if (existing["agent"] !== undefined) parseAgentConfig(existing["agent"], path);
delete existing["host"];
delete existing["port"];
delete existing["allowedHosts"];
@@ -113,6 +178,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
delete existing["maxUploadBytes"];
delete existing["spawnSessions"];
delete existing["subsessions"];
delete existing["agent"];
const merged = { ...existing, ...piWebConfigRecord(normalized) };
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
@@ -138,6 +204,7 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
...(config.agent !== undefined ? { agent: config.agent } : {}),
};
}
@@ -153,6 +220,7 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}),
};
}
@@ -203,6 +271,74 @@ function parseString(value: unknown, key: string, path: string): string {
return value;
}
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}`);
const unknownKey = Object.keys(value).find((key) => !AGENT_CONFIG_KEYS.has(key));
if (unknownKey !== undefined) throw new Error(`PI WEB config agent contains unknown key ${JSON.stringify(unknownKey)}: ${path}`);
const command = value["command"];
const dir = value["dir"];
return {
...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path, pathHost) } : {}),
...(dir !== undefined ? { dir: parseAgentDir(dir, "agent.dir", path, pathHost) } : {}),
};
}
function parseAgentCommand(value: unknown, key: string, path: string, pathHost: AgentPathHost): string {
const command = parseString(value, key, path).trim();
if (!isSafeAgentCommand(command, pathHost)) {
const absoluteLabel = pathHost === "current" ? "host-absolute" : "absolute";
throw new Error(`PI WEB config ${key} must be a safe bare executable name or ${absoluteLabel} executable path: ${path}`);
}
return command;
}
function parseAgentDir(value: unknown, key: string, path: string, pathHost: AgentPathHost): string {
const dir = parseString(value, key, path).trim();
const isAbsoluteDir = pathHost === "current" ? isHostAbsoluteAgentDir(dir) : isPortableAbsoluteAgentPath(dir);
if (!isAbsoluteDir && !isHomePath(dir, pathHost)) {
const absoluteLabel = pathHost === "current" ? "a host-absolute" : "an absolute";
throw new Error(`PI WEB config ${key} must be ${absoluteLabel} path or start with ~: ${path}`);
}
return dir;
}
function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, key: string, path: string): string {
const parsed = parseAgentDir(value, key, path, "current");
const expanded = expandHomePath(parsed, env);
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 {
const port = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN;
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`PI WEB config ${key} must be an integer from 1 to 65535: ${path}`);
@@ -252,6 +388,44 @@ function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string)
return parts.join("/");
}
function isHomePath(value: string, pathHost: AgentPathHost): boolean {
return value === "~" || value.startsWith("~/") || ((pathHost === "portable" || process.platform === "win32") && value.startsWith("~\\"));
}
function expandHomePath(value: string, env: NodeJS.ProcessEnv): string {
const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
if (value === "~") return home;
if (value.startsWith("~/") || (process.platform === "win32" && value.startsWith("~\\"))) return join(home, value.slice(2));
return value;
}
function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string {
if (usesPiCodingAgentStateCompatibility(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 envValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
const value = env[key];
return value !== undefined && value !== "" ? value : undefined;
}
function isEnvSet(value: string | undefined): boolean {
return value !== undefined && value !== "";
}
function uniqueStrings(values: readonly string[]): string[] {
return [...new Set(values)];
}
function hasControlCharacter(value: string): boolean {
for (const character of value) {
const code = character.charCodeAt(0);
if (code < 32 || code === 127) return true;
}
return false;
}
function isAbsoluteLike(value: string): boolean {
const withForwardSlashes = value.replace(/\\/g, "/");
return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes);
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
import type { SessionDaemonRequestClient } from "../sessiond/sessionDaemonClient.js";
import {
ActiveAgentProfileAccessError,
requireActiveAgentProfile,
SessionDaemonActiveAgentProfileProvider,
} from "./activeAgentProfileProvider.js";
const firstProfile = activeProfile("a", "first-agent", "/state/first");
const secondProfile = activeProfile("b", "second-agent", "/state/second");
describe("SessionDaemonActiveAgentProfileProvider", () => {
it("queries sessiond on every read and observes a new daemon profile epoch", async () => {
const request = vi.fn<SessionDaemonRequestClient["request"]>()
.mockResolvedValueOnce(runtimeResponse(firstProfile))
.mockResolvedValueOnce(runtimeResponse(secondProfile));
const provider = new SessionDaemonActiveAgentProfileProvider({ request });
await expect(provider.getActiveAgentProfile()).resolves.toEqual({ status: "available", profile: firstProfile });
await expect(provider.getActiveAgentProfile()).resolves.toEqual({ status: "available", profile: secondProfile });
expect(request).toHaveBeenCalledTimes(2);
expect(request).toHaveBeenNthCalledWith(1, "GET", "/runtime");
expect(request).toHaveBeenNthCalledWith(2, "GET", "/runtime");
});
it("preserves invalid protocol and daemon unavailability as distinct results", async () => {
const invalidRequest = vi.fn<SessionDaemonRequestClient["request"]>().mockResolvedValue({
statusCode: 200,
headers: { "content-type": "application/json" },
body: "not-json",
});
const unavailableRequest = vi.fn<SessionDaemonRequestClient["request"]>().mockRejectedValue(new Error("connect ECONNREFUSED"));
await expect(new SessionDaemonActiveAgentProfileProvider({ request: invalidRequest }).getActiveAgentProfile()).resolves.toEqual({
status: "invalid",
error: "session daemon runtime response was not valid JSON",
});
await expect(new SessionDaemonActiveAgentProfileProvider({ request: unavailableRequest }).getActiveAgentProfile()).resolves.toEqual({
status: "unavailable",
error: "connect ECONNREFUSED",
});
});
});
describe("requireActiveAgentProfile", () => {
it.each(["invalid", "unavailable"] as const)("fails closed for an %s active profile", async (status) => {
const provider = {
getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} profile` } as const),
};
const error = await requireActiveAgentProfile(provider).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(ActiveAgentProfileAccessError);
expect(error).toMatchObject({ profileStatus: status, message: `Active agent profile is ${status}: ${status} profile` });
});
});
function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${revisionCharacter.repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
function runtimeResponse(profile: ActiveAgentProfileDescriptor) {
return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
component: "sessiond",
label: "Session daemon",
available: true,
capabilities: [],
activeAgentProfile: profile,
}),
};
}
+36
View File
@@ -0,0 +1,36 @@
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
import {
getSessionDaemonActiveAgentProfile,
type SessionDaemonAgentProfileResult,
type SessionDaemonRequestClient,
} from "../sessiond/sessionDaemonClient.js";
export interface ActiveAgentProfileProvider {
getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult>;
}
/** Reads the daemon-owned profile on every call so a new sessiond epoch is observed. */
export class SessionDaemonActiveAgentProfileProvider implements ActiveAgentProfileProvider {
constructor(private readonly daemon: SessionDaemonRequestClient) {}
getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult> {
return getSessionDaemonActiveAgentProfile(this.daemon);
}
}
export class ActiveAgentProfileAccessError extends Error {
readonly profileStatus: "unavailable" | "invalid";
constructor(result: Exclude<SessionDaemonAgentProfileResult, { status: "available" }>) {
const label = result.status === "unavailable" ? "unavailable" : "invalid";
super(`Active agent profile is ${label}: ${result.error}`);
this.name = "ActiveAgentProfileAccessError";
this.profileStatus = result.status;
}
}
export async function requireActiveAgentProfile(provider: ActiveAgentProfileProvider): Promise<ActiveAgentProfileDescriptor> {
const result = await provider.getActiveAgentProfile();
if (result.status !== "available") throw new ActiveAgentProfileAccessError(result);
return result.profile;
}
+154
View File
@@ -0,0 +1,154 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebPluginInfo } from "../shared/apiTypes.js";
import type { SessionDaemonAgentProfileResult } from "../sessiond/sessionDaemonClient.js";
import type { ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js";
import { buildApp } from "./app.js";
import type { PiWebConfigService } from "./configRoutes.js";
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-active-profile-app-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("buildApp active profile composition", () => {
it("routes package and package-backed plugin reads through the same refreshable provider", async () => {
const firstAgentDir = join(tempDir, "first-agent");
const secondAgentDir = join(tempDir, "second-agent");
const firstPackageDir = join(tempDir, "first-package");
const secondPackageDir = join(tempDir, "second-package");
await Promise.all([
writePackagePlugin(firstPackageDir, "profile-first"),
writePackagePlugin(secondPackageDir, "profile-second"),
writePiPackageSettings(firstAgentDir, [firstPackageDir]),
writePiPackageSettings(secondAgentDir, [secondPackageDir]),
]);
let result: SessionDaemonAgentProfileResult = { status: "available", profile: activeProfile("a", "first-agent", firstAgentDir) };
const getActiveAgentProfile = vi.fn(() => Promise.resolve(result));
const app = await buildApp({
agentProfileProvider: { getActiveAgentProfile },
config: emptyConfigService(),
clientDist: false,
logger: false,
});
try {
const firstPackages = await app.inject({ method: "GET", url: "/api/pi-packages" });
const firstPlugins = await app.inject({ method: "GET", url: "/api/plugins" });
expect(firstPackages.statusCode).toBe(200);
expect(packageSources(firstPackages.json())).toContain(firstPackageDir);
expect(pluginIds(firstPlugins.json())).toContain("profile-first");
expect(pluginIds(firstPlugins.json())).not.toContain("profile-second");
result = { status: "available", profile: activeProfile("b", "second-agent", secondAgentDir) };
const secondPackages = await app.inject({ method: "GET", url: "/api/pi-packages" });
const secondPlugins = await app.inject({ method: "GET", url: "/api/plugins" });
expect(secondPackages.statusCode).toBe(200);
expect(packageSources(secondPackages.json())).toContain(secondPackageDir);
expect(packageSources(secondPackages.json())).not.toContain(firstPackageDir);
expect(pluginIds(secondPlugins.json())).toContain("profile-second");
expect(pluginIds(secondPlugins.json())).not.toContain("profile-first");
expect(getActiveAgentProfile).toHaveBeenCalledTimes(4);
} finally {
await app.close();
}
});
it.each(["unavailable", "invalid"] as const)("returns 503 instead of falling back when the active profile is %s", async (status) => {
const provider: ActiveAgentProfileProvider = {
getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} daemon profile` }),
};
const app = await buildApp({
agentProfileProvider: provider,
config: emptyConfigService(),
clientDist: false,
logger: false,
});
try {
const packages = await app.inject({ method: "GET", url: "/api/pi-packages" });
const plugins = await app.inject({ method: "GET", url: "/api/plugins" });
const manifest = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(packages.statusCode).toBe(503);
expect(packages.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` });
expect(plugins.statusCode).toBe(503);
expect(plugins.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` });
expect(manifest.statusCode).toBe(503);
expect(manifest.json()).toEqual({ error: `Active agent profile is ${status}: ${status} daemon profile` });
} finally {
await app.close();
}
});
});
function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${revisionCharacter.repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
async function writePiPackageSettings(agentDir: string, packages: string[]): Promise<void> {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages }, null, 2)}\n`, "utf8");
}
async function writePackagePlugin(root: string, pluginId: string): Promise<void> {
await mkdir(root, { recursive: true });
await writeFile(join(root, "package.json"), `${JSON.stringify({
name: `@test/${pluginId}`,
version: "1.0.0",
piWeb: { plugins: [{ id: pluginId, module: "pi-web-plugin.js" }] },
}, null, 2)}\n`, "utf8");
await writeFile(join(root, "pi-web-plugin.js"), "export default {};\n", "utf8");
}
function emptyConfigService(): PiWebConfigService {
const response: PiWebConfigResponse = {
path: join(tempDir, "config.json"),
exists: false,
config: {},
effectiveConfig: {},
envOverrides: {
host: false,
port: false,
allowedHosts: false,
spawnSessions: false,
subsessions: false,
agentCommand: false,
agentDir: false,
agentSessionDir: false,
},
};
return {
read: () => Promise.resolve(response),
write: () => Promise.resolve(response),
};
}
function packageSources(value: unknown): string[] {
if (!isRecord(value) || !Array.isArray(value["packages"])) return [];
return value["packages"].flatMap((entry) => isRecord(entry) && typeof entry["source"] === "string" ? [entry["source"]] : []);
}
function pluginIds(value: unknown): PiWebPluginInfo["id"][] {
if (!isRecord(value) || !Array.isArray(value["plugins"])) return [];
return value["plugins"].flatMap((entry) => isRecord(entry) && typeof entry["id"] === "string" ? [entry["id"]] : []);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+95
View File
@@ -0,0 +1,95 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { ActiveAgentProfileDescriptor, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { appTestContext, registerAppTestHooks } from "./app.testSupport.js";
registerAppTestHooks();
describe("buildApp active agent profile", () => {
it.each(["/api/config", "/api/machines/local/config"])("keeps desired writes separate from the active profile and observes a new daemon epoch through %s", async (configRoute) => {
const originalEnv = captureEnv([
"PI_WEB_SKIP_VERSION_CHECK",
"PI_WEB_DOCKER_RUNTIME",
"PI_WEB_AGENT_COMMAND",
"PI_WEB_AGENT_DIR",
"PI_CODING_AGENT_DIR",
]);
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
process.env["PI_WEB_DOCKER_RUNTIME"] = "0";
Reflect.deleteProperty(process.env, "PI_WEB_AGENT_COMMAND");
Reflect.deleteProperty(process.env, "PI_WEB_AGENT_DIR");
Reflect.deleteProperty(process.env, "PI_CODING_AGENT_DIR");
try {
const initialAgentDir = join(appTestContext.tempDir, "initial-agent");
const updatedAgentDir = join(appTestContext.tempDir, "updated-agent");
appTestContext.piWebConfig = { agent: { command: "desired-agent", dir: initialAgentDir } };
appTestContext.agentProfileResult = { status: "available", profile: activeProfile("a", "active-agent", initialAgentDir) };
await mkdir(initialAgentDir, { recursive: true });
await installConfiguredPiWebPackage(updatedAgentDir);
process.env["PI_WEB_AGENT_DIR"] = updatedAgentDir;
const initialStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" });
expect(initialStatus.statusCode).toBe(200);
expect(initialStatus.json<PiWebStatusResponse>().components.web.installation?.kind).not.toBe("pi-package");
const updateResponse = await appTestContext.app.inject({
method: "PUT",
url: configRoute,
payload: { config: { agent: { command: "next-agent", dir: updatedAgentDir } } },
});
expect(updateResponse.statusCode).toBe(200);
const desiredWriteStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status" });
const desiredWriteVersion = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/version" });
expect(desiredWriteStatus.statusCode).toBe(200);
expect(desiredWriteStatus.json<PiWebStatusResponse>().components.web.installation?.kind).not.toBe("pi-package");
expect(desiredWriteVersion.json<PiWebVersionResponse>().components.web.installation?.kind).not.toBe("pi-package");
appTestContext.agentProfileResult = { status: "available", profile: activeProfile("b", "next-agent", updatedAgentDir) };
const restartedStatus = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/status?refresh=1" });
const restartedVersion = await appTestContext.app.inject({ method: "GET", url: "/api/pi-web/version" });
expect(restartedStatus.statusCode).toBe(200);
expect(restartedStatus.json<PiWebStatusResponse>().components.web.installation).toMatchObject({
kind: "pi-package",
source: process.cwd(),
scope: "user",
});
expect(restartedVersion.json<PiWebVersionResponse>().components.web.installation).toMatchObject({
kind: "pi-package",
source: process.cwd(),
scope: "user",
});
} finally {
restoreEnv(originalEnv);
}
});
});
function activeProfile(revisionCharacter: string, command: string, dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${revisionCharacter.repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
async function installConfiguredPiWebPackage(agentDir: string): Promise<void> {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8");
}
function captureEnv(keys: readonly string[]): Map<string, string | undefined> {
return new Map(keys.map((key) => [key, process.env[key]]));
}
function restoreEnv(values: ReadonlyMap<string, string | undefined>): void {
for (const [key, value] of values) {
if (value === undefined) Reflect.deleteProperty(process.env, key);
else process.env[key] = value;
}
}
+128 -7
View File
@@ -61,18 +61,39 @@ describe("buildApp machine routes", () => {
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
sessiond: { component: "sessiond", label: "Remote Sessiond", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived] },
web: { component: "web", label: "Remote Web", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] },
sessiond: {
component: "sessiond",
label: "Remote Sessiond",
runtimeVersion: "1.0.0",
available: true,
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived],
activeAgentProfile: {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command: "remote-agent",
dir: "/srv/remote-agent",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
},
},
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"],
},
}));
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const runtime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime` });
const refreshedRuntime = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/runtime?refresh=1` });
expect(runtime.statusCode).toBe(200);
expect(runtime.json()).toMatchObject({ machineId: remote.id, ok: true, capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage] });
expect(refreshedRuntime.statusCode).toBe(200);
expect(runtime.json()).toMatchObject({
machineId: remote.id,
ok: true,
capabilities: [PI_WEB_CAPABILITIES.sessionsDeleteArchived, PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.agentProfileConfig],
components: { sessiond: { activeAgentProfile: { command: "remote-agent", dir: "/srv/remote-agent" } } },
});
expect(requestJson).toHaveBeenCalledTimes(2);
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
});
@@ -101,16 +122,18 @@ describe("buildApp machine routes", () => {
it("merges remote selected-machine config updates into the target machine config", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
let persistedConfig = fullPiWebConfig();
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) });
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) });
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) });
persistedConfig = configFromMachineConfigWriteBody(body);
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(persistedConfig) });
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true } },
payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true, agent: { command: "remote-agent", dir: "/srv/remote-agent" } } },
});
const expectedMerged: PiWebConfigValues = {
@@ -120,6 +143,7 @@ describe("buildApp machine routes", () => {
uploads: { defaultFolder: "remote/uploads" },
maxUploadBytes: 4096,
spawnSessions: true,
agent: { command: "remote-agent", dir: "/srv/remote-agent" },
};
expect(response.statusCode).toBe(200);
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
@@ -131,6 +155,103 @@ describe("buildApp machine routes", () => {
maxUploadBytes: 4096,
spawnSessions: true,
subsessions: false,
agent: { command: "remote-agent", dir: "/srv/remote-agent" },
});
});
it("rejects a false-success agent profile write from an older remote machine", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const legacyConfig = fullPiWebConfig();
delete legacyConfig.agent;
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/json" },
body: piWebConfigResponse(legacyConfig),
}));
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { agent: { command: "remote-agent", dir: "/srv/remote-agent" } } },
});
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({
error: "Remote machine did not persist the requested agent profile",
machineId: remote.id,
});
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
config: { ...legacyConfig, agent: { command: "remote-agent", dir: "/srv/remote-agent" } },
});
});
it("verifies an explicit remote profile reset instead of treating an empty profile as no patch", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const requestJson = vi.fn<MachineClient["requestJson"]>((method) => {
const config = fullPiWebConfig();
if (method === "PUT") delete config.agent;
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { agent: {} } },
});
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({ error: "Remote machine did not persist the requested agent profile" });
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
config: { ...fullPiWebConfig(), agent: {} },
});
});
it("keeps non-profile selected-machine saves compatible with older remote machines", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const legacyConfig = fullPiWebConfig();
delete legacyConfig.agent;
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : legacyConfig;
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { spawnSessions: true } },
});
expect(response.statusCode).toBe(200);
expect(response.json<PiWebConfigResponse>().config.spawnSessions).toBe(true);
});
it("preserves foreign-platform agent paths while the target verifies persistence", async () => {
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>();
const windowsAgent = { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\work" };
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
const config = method === "PUT" ? configFromMachineConfigWriteBody(body) : fullPiWebConfig();
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(config) });
});
appTestContext.remoteClient = fakeRemoteClient({ requestJson });
const response = await appTestContext.app.inject({
method: "PUT",
url: `/api/machines/${remote.id}/config`,
payload: { config: { agent: windowsAgent } },
});
expect(response.statusCode).toBe(200);
expect(response.json<PiWebConfigResponse>().config.agent).toEqual(windowsAgent);
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", {
config: { ...fullPiWebConfig(), agent: windowsAgent },
});
});
+2 -1
View File
@@ -6,7 +6,8 @@ describe("PI WEB status routes", () => {
it("forces a fresh status load when refresh is requested", async () => {
const get = vi.fn(() => Promise.resolve(status("cached")));
const refresh = vi.fn(() => Promise.resolve(status("forced")));
const app = await buildApp({ piWebStatusCache: { get, refresh }, clientDist: false, logger: false });
const invalidate = vi.fn();
const app = await buildApp({ piWebStatusCache: { get, refresh, invalidate }, clientDist: false, logger: false });
try {
const cachedResponse = await app.inject({ method: "GET", url: "/api/pi-web/status" });
+26 -2
View File
@@ -14,7 +14,8 @@ import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { PiPackageService } from "./piPackageService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
import type { PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import type { ActiveAgentProfileDescriptor, PiPackageInfo, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import type { SessionDaemonAgentProfileResult } from "../sessiond/sessionDaemonClient.js";
interface AppTestContext {
readonly app: FastifyInstance;
@@ -24,6 +25,7 @@ interface AppTestContext {
readonly sessionDaemonRequests: CapturedSessionDaemonRequest[];
readonly piPackageRequests: CapturedPiPackageRequest[];
piWebConfig: PiWebConfigValues;
agentProfileResult: SessionDaemonAgentProfileResult;
}
let app: FastifyInstance | undefined;
@@ -33,6 +35,7 @@ let remoteClient: MachineClient | undefined;
let sessionDaemonRequests: CapturedSessionDaemonRequest[] = [];
let piPackageRequests: CapturedPiPackageRequest[] = [];
let piWebConfig: PiWebConfigValues = {};
let agentProfileResult: SessionDaemonAgentProfileResult = { status: "invalid", error: "App test harness was not initialized" };
export const appTestContext: AppTestContext = {
get app() {
@@ -65,6 +68,12 @@ export const appTestContext: AppTestContext = {
set piWebConfig(config) {
piWebConfig = config;
},
get agentProfileResult() {
return agentProfileResult;
},
set agentProfileResult(result) {
agentProfileResult = result;
},
};
export function registerAppTestHooks(): void {
@@ -75,6 +84,7 @@ export function registerAppTestHooks(): void {
sessionDaemonRequests = [];
piPackageRequests = [];
piWebConfig = {};
agentProfileResult = { status: "available", profile: appTestAgentProfile(join(tempDir, "agent")) };
app = await buildApp({
projects: new ProjectService(new ProjectStore(join(tempDir, "projects.json"))),
workspaces: new WorkspaceService(),
@@ -95,6 +105,7 @@ export function registerAppTestHooks(): void {
}),
}),
sessionDaemon: fakeSessionDaemon(),
agentProfileProvider: { getActiveAgentProfile: () => Promise.resolve(agentProfileResult) },
config: fakeConfigService(),
piPackages: fakePiPackageService(),
piWebPlugins: {
@@ -117,6 +128,7 @@ export function registerAppTestHooks(): void {
sessionDaemonRequests = [];
piPackageRequests = [];
piWebConfig = {};
agentProfileResult = { status: "invalid", error: "App test harness was not initialized" };
if (appToClose !== undefined) await appToClose.close();
if (tempDirToRemove !== undefined) await rm(tempDirToRemove, { recursive: true, force: true });
@@ -152,6 +164,16 @@ function fakeConfigService() {
};
}
function appTestAgentProfile(dir: string): ActiveAgentProfileDescriptor {
return {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command: "pi",
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"],
};
}
export function fullPiWebConfig(): PiWebConfigValues {
return {
host: "127.0.0.1",
@@ -164,6 +186,7 @@ export function fullPiWebConfig(): PiWebConfigValues {
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
};
}
@@ -175,6 +198,7 @@ export function selectedMachinePiWebConfig(): PiWebConfigValues {
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
};
}
@@ -184,7 +208,7 @@ export function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigRespo
exists: false,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
+59 -11
View File
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyServerOptions } from "fastify";
import fastifyCompress from "@fastify/compress";
import fastifyStatic from "@fastify/static";
import fastifyWebsocket from "@fastify/websocket";
@@ -21,10 +21,16 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
import { createActiveProfilePiPackageService, type PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
import { createPiWebStatusCache, type PiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import {
ActiveAgentProfileAccessError,
requireActiveAgentProfile,
SessionDaemonActiveAgentProfileProvider,
type ActiveAgentProfileProvider,
} from "./activeAgentProfileProvider.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
@@ -36,6 +42,7 @@ export interface AppDependencies {
workspaces?: WorkspaceService;
machines?: MachineService;
sessionDaemon?: SessionProxyDaemon;
agentProfileProvider?: ActiveAgentProfileProvider;
piWebPlugins?: Pick<PiWebPluginService, "manifest" | "plugins" | "readAsset">;
piPackages?: PiPackageService;
piWebStatusCache?: PiWebStatusCache;
@@ -120,6 +127,30 @@ function registerLocalFileSuggestionRoutes(app: FastifyInstance, projects: Proje
});
}
async function readEffectiveConfig(config: Pick<PiWebConfigService, "read">) {
return (await config.read()).effectiveConfig;
}
function invalidatePiWebStatusOnWrite(config: PiWebConfigService, statusCache: Pick<PiWebStatusCache, "invalidate">): PiWebConfigService {
return {
read: () => config.read(),
write: async (nextConfig) => {
const response = await config.write(nextConfig);
statusCache.invalidate();
return response;
},
};
}
async function withProfileDependency<T>(reply: FastifyReply, operation: () => Promise<T>): Promise<T | FastifyReply> {
try {
return await operation();
} catch (error) {
if (!(error instanceof ActiveAgentProfileAccessError)) throw error;
return reply.code(503).send({ error: error.message });
}
}
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) });
// Vite proxies development API requests here, while production and machine-scoped
@@ -133,39 +164,56 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const projects = deps.projects ?? new ProjectService(new ProjectStore());
const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const piPackages = deps.piPackages ?? createDefaultPiPackageService();
const configService = deps.config ?? createFilePiWebConfigService();
const readConfig = () => readEffectiveConfig(configService);
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const agentProfileProvider = deps.agentProfileProvider ?? new SessionDaemonActiveAgentProfileProvider(sessionDaemon);
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService({
configProvider: readConfig,
agentDirProvider: async () => (await requireActiveAgentProfile(agentProfileProvider)).dir,
});
const piPackages = deps.piPackages ?? createActiveProfilePiPackageService(agentProfileProvider);
const piWebStatusCache = deps.piWebStatusCache ?? createPiWebStatusCache(
({ force }) => getPiWebStatus(sessionDaemon, { forceReleaseCheck: force }),
async ({ force }) => {
const activeAgentProfile = await agentProfileProvider.getActiveAgentProfile();
return getPiWebStatus(sessionDaemon, {
forceReleaseCheck: force,
...(activeAgentProfile.status === "available" ? { activeAgentProfile: activeAgentProfile.profile } : {}),
});
},
{ onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); } },
);
const machines = deps.machines ?? new MachineService(undefined, {
localRuntime: () => getPiWebRuntime(sessionDaemon),
});
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
app.get("/pi-web-plugins/manifest.json", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.manifest()));
app.get<{ Params: { pluginId: string; "*": string } }>("/pi-web-plugins/:pluginId/*", async (request, reply) => {
if (await proxyMachinePluginAsset(machines, request.params.pluginId, request.params["*"], request.url, reply)) return;
return withProfileDependency(reply, async () => {
const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]);
if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" });
return reply.type(asset.contentType).send(asset.content);
});
});
app.get<{ Querystring: { refresh?: string } }>("/api/pi-web/status", async (request) => request.query.refresh === "1"
? piWebStatusCache.refresh({ force: true })
: piWebStatusCache.get());
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/version", async () => {
const activeAgentProfile = await agentProfileProvider.getActiveAgentProfile();
return getPiWebVersionStatus(sessionDaemon, activeAgentProfile.status === "available" ? { activeAgentProfile: activeAgentProfile.profile } : {});
});
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins());
app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins());
app.get("/api/plugins", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.plugins()));
app.get("/api/machines/local/plugins", async (_request, reply) => withProfileDependency(reply, () => piWebPlugins.plugins()));
registerPiPackageRoutes(app, piPackages);
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
registerConfigRoutes(app, configService);
registerLocalMachineConfigRoutes(app, configService);
const invalidatingConfigService = invalidatePiWebStatusOnWrite(configService, piWebStatusCache);
registerConfigRoutes(app, invalidatingConfigService);
registerLocalMachineConfigRoutes(app, invalidatingConfigService);
registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines);
+66 -2
View File
@@ -1,6 +1,6 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
let app: FastifyInstance;
@@ -46,6 +46,7 @@ describe("config routes", () => {
pathAccess: { allowedPaths: ["/tmp"] },
uploads: { defaultFolder: "uploads\\manual" },
maxUploadBytes: 1234,
agent: { command: "agent-lab", dir: "~/agent-profiles/lab" },
};
const expectedConfig: PiWebConfigValues = {
...requestedConfig,
@@ -111,6 +112,21 @@ describe("config routes", () => {
expect(service.write).not.toHaveBeenCalled();
});
it.each([
{ agent: { command: "./agent", dir: "/srv/agent" }, error: "safe bare executable name or host-absolute executable path" },
{ agent: { command: "agent", dir: "/srv/agent", futureSetting: true }, error: 'agent contains unknown key "futureSetting"' },
])("rejects unsafe agent profile payloads before writing", async ({ agent, error }) => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { agent } },
});
expect(response.statusCode).toBe(400);
expect(response.json<{ error: string }>().error).toContain(error);
expect(service.write).not.toHaveBeenCalled();
});
it("filters local machine config reads to selected-machine-safe keys", async () => {
savedConfig = fullConfig();
@@ -130,6 +146,7 @@ describe("config routes", () => {
plugins: { info: { enabled: false } },
uploads: { defaultFolder: "uploads\\manual" },
spawnSessions: true,
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
};
const response = await app.inject({
@@ -143,6 +160,7 @@ describe("config routes", () => {
plugins: { info: { enabled: false } },
uploads: { defaultFolder: "uploads/manual" },
spawnSessions: true,
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
};
expect(response.statusCode).toBe(200);
expect(savedConfig).toEqual(expectedConfig);
@@ -154,9 +172,53 @@ describe("config routes", () => {
maxUploadBytes: 1024,
spawnSessions: true,
subsessions: false,
agent: { command: "alternate-agent", dir: "/srv/alternate-agent" },
});
});
it("keeps foreign-platform agent paths portable at federation transport boundaries", () => {
const agent = { command: "C:\\tools\\pi.exe", dir: "C:\\agent-profiles\\pi" };
const response = {
...responseFor({ agent }, true),
effectiveConfig: { agent },
};
expect(parsePiWebConfigResponseBody(response).config.agent).toEqual(agent);
expect(parseSelectedMachineConfigRequest({ agent }, "portable").agent).toEqual(agent);
if (process.platform !== "win32") {
expect(() => parseSelectedMachineConfigRequest({ agent })).toThrow("host-absolute executable path");
}
});
it("defaults missing agent override fields from older config responses", () => {
const parsed = parsePiWebConfigResponseBody({
path: "/tmp/pi-web/config.json",
exists: true,
config: {},
effectiveConfig: {},
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
});
expect(parsed.envOverrides).toMatchObject({ agentCommand: false, agentDir: false, agentSessionDir: false });
});
it("retains the agent directory environment source across federation responses", () => {
const parsed = parsePiWebConfigResponseBody({
...responseFor({}, false),
envOverrides: {
...responseFor({}, false).envOverrides,
agentDir: true,
agentDirSource: "pi-compatibility",
},
});
expect(parsed.envOverrides).toMatchObject({ agentDir: true, agentDirSource: "pi-compatibility" });
expect(() => parsePiWebConfigResponseBody({
...responseFor({}, false),
envOverrides: { ...responseFor({}, false).envOverrides, agentDirSource: "future-source" },
})).toThrow("valid agent directory source");
});
it("rejects unsafe local selected-machine config keys before writing", async () => {
savedConfig = fullConfig();
@@ -197,6 +259,7 @@ function fullConfig(): PiWebConfigValues {
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
};
}
@@ -208,6 +271,7 @@ function selectedMachineConfig(): PiWebConfigValues {
maxUploadBytes: 1024,
spawnSessions: false,
subsessions: false,
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
};
}
@@ -217,6 +281,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes
exists,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
+42 -10
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { effectivePiWebConfig, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import { agentDirEnvSource, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseAgentConfig, parseUploadsConfig, resolveEffectivePiWebConfig, savePiWebConfig, type AgentPathHost, type LoadOptions, type PiWebConfig } from "../config.js";
import type { PiWebAgentDirEnvSource, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
import { isPiWebPluginId } from "../shared/pluginIds.js";
export interface PiWebConfigService {
@@ -15,6 +15,7 @@ export const SELECTED_MACHINE_CONFIG_KEYS = [
"maxUploadBytes",
"spawnSessions",
"subsessions",
"agent",
] as const satisfies readonly (keyof PiWebConfigValues)[];
const SELECTED_MACHINE_CONFIG_KEY_SET = new Set<string>(SELECTED_MACHINE_CONFIG_KEYS);
@@ -31,14 +32,14 @@ export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebCo
export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConfigResponse {
const loaded = loadPiWebConfig(options);
const effective = effectivePiWebConfig(options);
const effective = resolveEffectivePiWebConfig(loaded, options);
const env = options.env ?? process.env;
return {
path: loaded.path,
exists: loaded.exists,
config: loaded.config,
effectiveConfig: effective.config,
envOverrides: piWebConfigEnvOverrides(env),
envOverrides: piWebConfigEnvOverrides(env, effective.config),
};
}
@@ -82,13 +83,13 @@ export function registerLocalMachineConfigRoutes(app: FastifyInstance, service:
});
}
export function parseSelectedMachineConfigRequest(value: unknown): PiWebConfig {
export function parseSelectedMachineConfigRequest(value: unknown, agentPathHost: AgentPathHost = "current"): PiWebConfig {
if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object");
for (const key of Object.keys(value)) {
if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`);
}
try {
return pickSelectedMachineConfig(parseConfigRequest(value));
return pickSelectedMachineConfig(parseConfigRequest(value, agentPathHost));
} catch (error) {
throw new Error(selectedMachineConfigErrorMessage(error), { cause: error });
}
@@ -111,13 +112,13 @@ export function parsePiWebConfigResponseBody(value: unknown, source = "PI WEB co
return {
path: requireResponseString(record, "path", source),
exists: requireResponseBoolean(record, "exists", source),
config: parseConfigRequest(record["config"]),
effectiveConfig: parseConfigRequest(record["effectiveConfig"]),
config: parseConfigRequest(record["config"], "portable"),
effectiveConfig: parseConfigRequest(record["effectiveConfig"], "portable"),
envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source),
};
}
function parseConfigRequest(value: unknown): PiWebConfig {
function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "current"): PiWebConfig {
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
const config: PiWebConfig = {};
const host = value["host"];
@@ -130,6 +131,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
const maxUploadBytes = value["maxUploadBytes"];
const spawnSessions = value["spawnSessions"];
const subsessions = value["subsessions"];
const agent = value["agent"];
if (host !== undefined) {
if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
config.host = host;
@@ -152,6 +154,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean");
config.subsessions = subsessions;
}
if (agent !== undefined) config.agent = parseAgentRequest(agent, agentPathHost);
return config;
}
@@ -163,6 +166,7 @@ function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig {
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
...(config.agent !== undefined ? { agent: config.agent } : {}),
};
}
@@ -212,6 +216,10 @@ function parseMaxUploadBytesRequest(value: unknown): number {
return value;
}
function parseAgentRequest(value: unknown, pathHost: AgentPathHost): NonNullable<PiWebConfig["agent"]> {
return parseAgentConfig(value, "request", pathHost);
}
function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]> {
if (!isRecord(value) || Array.isArray(value)) throw new Error("PI WEB config plugins must be an object");
return Object.fromEntries(Object.entries(value).map(([pluginId, config]) => {
@@ -233,6 +241,10 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P
allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
subsessions: requireResponseBoolean(record, "subsessions", source),
agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false,
agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false,
...optionalAgentDirSource(record, source),
agentSessionDir: optionalResponseBoolean(record, "agentSessionDir", source) ?? false,
};
}
@@ -253,13 +265,33 @@ function requireResponseBoolean(record: Record<string, unknown>, key: string, so
return value;
}
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
function optionalResponseBoolean(record: Record<string, unknown>, key: string, source: string): boolean | undefined {
const value = record[key];
if (value === undefined) return undefined;
if (typeof value !== "boolean") throw new Error(`${source} field must be a boolean: ${key}`);
return value;
}
function optionalAgentDirSource(record: Record<string, unknown>, source: string): { agentDirSource?: PiWebAgentDirEnvSource } {
const value = record["agentDirSource"];
if (value === undefined) return {};
if (value !== "pi-web" && value !== "pi-compatibility") throw new Error(`${source} field must be a valid agent directory source: agentDirSource`);
return { agentDirSource: value };
}
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {}): PiWebConfigEnvOverrides {
const command = config.agent?.command;
const dirEnvSource = agentDirEnvSource(env);
return {
host: isEnvSet(env["PI_WEB_HOST"]),
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]),
agentDir: hasAgentDirEnvOverride(env, command),
...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }),
agentSessionDir: hasAgentSessionDirEnvOverride(env, command),
};
}
+18 -4
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { WebSocket } from "ws";
import type { PiWebAgentConfig } from "../../shared/apiTypes.js";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
import { bridgeSockets } from "../webSocketBridge.js";
@@ -69,13 +70,14 @@ async function proxySelectedMachineConfigRequest(client: MachineClient, machineI
}
if (method === "PUT") {
const patch = parseSelectedMachineConfigRequest(configPayload(body));
const patch = parseSelectedMachineConfigRequest(configPayload(body), "portable");
const currentResponse = await client.requestJson("GET", remotePath);
if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId);
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
const merged = mergeSelectedMachineConfig(current.config, patch);
return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId);
const updateResponse = await client.requestJson("PUT", remotePath, { config: merged });
return sendSelectedMachineConfigResponse(reply, updateResponse, machineId, patch.agent);
}
return reply.code(405).send({ error: "Method not allowed" });
@@ -85,11 +87,23 @@ function configPayload(body: unknown): unknown {
return isRecord(body) ? body["config"] : undefined;
}
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string, expectedAgentProfile?: PiWebAgentConfig): FastifyReply {
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
const response = parsePiWebConfigResponseBody(upstream.body, "Remote machine config response");
if (expectedAgentProfile !== undefined && !sameAgentProfile(response.config.agent, expectedAgentProfile)) {
return reply.code(409).send({
error: "Remote machine did not persist the requested agent profile",
machineId,
detail: "Update and restart PI WEB on the remote machine before changing its agent profile.",
});
}
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
return reply.send(selectedMachineConfigResponse(response));
}
function sameAgentProfile(actual: PiWebAgentConfig | undefined, expected: PiWebAgentConfig): boolean {
return actual !== undefined && actual.command === expected.command && actual.dir === expected.dir;
}
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
+2 -2
View File
@@ -18,8 +18,8 @@ export function registerMachineRoutes(app: FastifyInstance, machines = new Machi
return health;
});
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
const runtime = await machines.runtime(request.params.machineId);
app.get<{ Params: { machineId: string }; Querystring: { refresh?: string } }>("/api/machines/:machineId/runtime", async (request, reply) => {
const runtime = await machines.runtime(request.params.machineId, request.query.refresh === "1");
if (runtime === undefined) return reply.code(404).send({ error: "Machine not found" });
return runtime;
});
+4 -1
View File
@@ -171,6 +171,7 @@ describe("MachineService", () => {
const first = await remoteService.runtime(machine.id);
const second = await remoteService.runtime(machine.id);
const forced = await remoteService.runtime(machine.id, true);
expect(first).toEqual({
machineId: machine.id,
@@ -182,7 +183,8 @@ describe("MachineService", () => {
capabilities: body.capabilities,
});
expect(second).toEqual(first);
expect(requestJson).toHaveBeenCalledTimes(1);
expect(forced).toEqual(first);
expect(requestJson).toHaveBeenCalledTimes(2);
expect(requestJson).toHaveBeenCalledWith("GET", "/api/pi-web/runtime", undefined, { timeoutMs: 3000 });
expect(factoryMachines).toEqual([
expect.objectContaining({
@@ -192,6 +194,7 @@ describe("MachineService", () => {
token: "secret",
headers: { "X-Pi-Web-Test": "yes" },
}),
expect.objectContaining({ id: machine.id }),
]);
});
+2 -2
View File
@@ -93,10 +93,10 @@ export class MachineService {
return health;
}
async runtime(id: string): Promise<MachineRuntime | undefined> {
async runtime(id: string, refresh = false): Promise<MachineRuntime | undefined> {
const cached = this.runtimeCache.get(id);
const now = this.now().getTime();
if (cached !== undefined && cached.expiresAt > now) return cached.runtime;
if (!refresh && cached !== undefined && cached.expiresAt > now) return cached.runtime;
const runtime = id === "local" ? await this.localRuntime() : await this.remoteRuntime(id);
if (runtime === undefined) return undefined;
+10
View File
@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js";
import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js";
import type { PiPackageService } from "./piPackageService.js";
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
@@ -95,6 +96,15 @@ describe("registerPiPackageRoutes", () => {
expect(serviceMocks.update).not.toHaveBeenCalled();
});
it("returns 503 when the daemon-owned active profile is unavailable", async () => {
serviceMocks.list.mockRejectedValueOnce(new ActiveAgentProfileAccessError({ status: "unavailable", error: "connect ECONNREFUSED" }));
const response = await app.inject({ method: "GET", url: "/api/pi-packages" });
expect(response.statusCode).toBe(503);
expect(response.json()).toEqual({ error: "Active agent profile is unavailable: connect ECONNREFUSED" });
});
it("returns stable 500 errors for package-manager failures", async () => {
serviceMocks.install.mockRejectedValueOnce(new Error("install failed"));
+8 -3
View File
@@ -1,10 +1,11 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { PiPackageScope } from "../shared/apiTypes.js";
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js";
import type { PiPackageService } from "./piPackageService.js";
class PiPackageRequestValidationError extends Error {}
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService = createDefaultPiPackageService(), prefix = "/api"): void {
export function registerPiPackageRoutes(app: FastifyInstance, service: PiPackageService, prefix = "/api"): void {
const routePrefix = normalizeRoutePrefix(prefix);
app.get(`${routePrefix}/pi-packages`, async (_request, reply) => {
@@ -79,7 +80,11 @@ function requireRequestObject(value: unknown): Record<string, unknown> {
}
function sendPiPackageError(reply: FastifyReply, error: unknown): FastifyReply {
const status = error instanceof PiPackageRequestValidationError ? 400 : 500;
const status = error instanceof PiPackageRequestValidationError
? 400
: error instanceof ActiveAgentProfileAccessError
? 503
: 500;
return reply.code(status).send({ error: errorMessage(error) });
}
+63 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { PiPackageInfo } from "../shared/apiTypes.js";
import { DefaultPiPackageService, type PiPackageManagerPort } from "./piPackageService.js";
import { type ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js";
import { ActiveProfilePiPackageService, DefaultPiPackageService, type PiPackageManagerPort, type PiPackageService } from "./piPackageService.js";
function fakeManager(packages: PiPackageInfo[] = []) {
const listConfiguredPackages = vi.fn<PiPackageManagerPort["listConfiguredPackages"]>(() => packages);
@@ -21,6 +22,44 @@ function deferred<T = void>() {
return { promise, resolve, reject };
}
describe("ActiveProfilePiPackageService", () => {
it("uses the daemon profile active when each package operation begins", async () => {
const getActiveAgentProfile = vi.fn<ActiveAgentProfileProvider["getActiveAgentProfile"]>()
.mockResolvedValueOnce(availableProfile("a", "/state/first"))
.mockResolvedValueOnce(availableProfile("b", "/state/second"));
const firstService = fakePiPackageService("first");
const secondService = fakePiPackageService("second");
const serviceForAgentDir = vi.fn((agentDir: string): PiPackageService => agentDir === "/state/first" ? firstService : secondService);
const service = new ActiveProfilePiPackageService({ getActiveAgentProfile }, serviceForAgentDir);
await expect(service.list()).resolves.toEqual({ packages: [{ source: "first", scope: "user", filtered: false }] });
await expect(service.install("npm:@acme/tools")).resolves.toMatchObject({ action: "install", source: "npm:@acme/tools", packages: [{ source: "second" }] });
expect(serviceForAgentDir).toHaveBeenNthCalledWith(1, "/state/first");
expect(serviceForAgentDir).toHaveBeenNthCalledWith(2, "/state/second");
expect(firstService.list).toHaveBeenCalledOnce();
expect(secondService.install).toHaveBeenCalledWith("npm:@acme/tools");
});
it.each(["unavailable", "invalid"] as const)("fails closed without constructing a package manager when the profile is %s", async (status) => {
const activeAgentProfile: ActiveAgentProfileProvider = {
getActiveAgentProfile: () => Promise.resolve({ status, error: `${status} profile` }),
};
const serviceForAgentDir = vi.fn<(agentDir: string) => PiPackageService>();
const service = new ActiveProfilePiPackageService(activeAgentProfile, serviceForAgentDir);
await expect(service.list()).rejects.toMatchObject({
profileStatus: status,
message: `Active agent profile is ${status}: ${status} profile`,
});
await expect(service.install("npm:@acme/tools")).rejects.toMatchObject({
profileStatus: status,
message: `Active agent profile is ${status}: ${status} profile`,
});
expect(serviceForAgentDir).not.toHaveBeenCalled();
});
});
describe("DefaultPiPackageService", () => {
it("lists configured Pi packages with source, scope, filtered status, and installed path", async () => {
const fake = fakeManager([
@@ -201,3 +240,26 @@ describe("DefaultPiPackageService", () => {
]);
});
});
function availableProfile(revisionCharacter: string, dir: string) {
return {
status: "available" as const,
profile: {
schemaVersion: 1 as const,
revision: `sha256:${revisionCharacter.repeat(64)}`,
command: `${revisionCharacter}-agent`,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
},
};
}
function fakePiPackageService(source: string) {
const packages = [{ source, scope: "user" as const, filtered: false }];
return {
list: vi.fn(() => Promise.resolve({ packages })),
install: vi.fn((installedSource: string) => Promise.resolve({ action: "install" as const, source: installedSource, packages })),
remove: vi.fn((removedSource: string, scope: "user" | "project" = "user") => Promise.resolve({ action: "remove" as const, source: removedSource, scope, removed: true, packages })),
update: vi.fn((updatedSource?: string) => Promise.resolve({ action: "update" as const, ...(updatedSource === undefined ? {} : { source: updatedSource }), packages })),
} satisfies PiPackageService;
}
+48 -2
View File
@@ -1,5 +1,6 @@
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse } from "../shared/apiTypes.js";
import { requireActiveAgentProfile, type ActiveAgentProfileProvider } from "./activeAgentProfileProvider.js";
export interface PiPackageManagerPort {
listConfiguredPackages(): PiPackageInfo[];
@@ -16,6 +17,47 @@ export interface PiPackageService {
update(source?: string): Promise<PiPackageMutationResponse>;
}
export type PiPackageServiceForAgentDir = (agentDir: string) => PiPackageService;
export class ActiveProfilePiPackageService implements PiPackageService {
private mutationQueue: Promise<void> = Promise.resolve();
constructor(
private readonly activeAgentProfile: ActiveAgentProfileProvider,
private readonly serviceForAgentDir: PiPackageServiceForAgentDir,
) {}
async list(): Promise<PiPackagesResponse> {
return await this.withActiveService((service) => service.list());
}
install(source: string): Promise<PiPackageMutationResponse> {
return this.enqueueMutation((service) => service.install(source));
}
remove(source: string, scope?: PiPackageScope): Promise<PiPackageMutationResponse> {
return this.enqueueMutation((service) => service.remove(source, scope));
}
update(source?: string): Promise<PiPackageMutationResponse> {
return this.enqueueMutation((service) => service.update(source));
}
private enqueueMutation(operation: (service: PiPackageService) => Promise<PiPackageMutationResponse>): Promise<PiPackageMutationResponse> {
const queuedMutation = this.mutationQueue.then(() => this.withActiveService(operation));
this.mutationQueue = queuedMutation.then(
() => undefined,
() => undefined,
);
return queuedMutation;
}
private async withActiveService<T>(operation: (service: PiPackageService) => Promise<T>): Promise<T> {
const profile = await requireActiveAgentProfile(this.activeAgentProfile);
return await operation(this.serviceForAgentDir(profile.dir));
}
}
export class DefaultPiPackageService implements PiPackageService {
private mutationQueue: Promise<void> = Promise.resolve();
@@ -84,7 +126,11 @@ export class DefaultPiPackageService implements PiPackageService {
}
}
export function createDefaultPiPackageService(cwd = process.cwd(), agentDir = getAgentDir()): PiPackageService {
export function createActiveProfilePiPackageService(activeAgentProfile: ActiveAgentProfileProvider, cwd = process.cwd()): PiPackageService {
return new ActiveProfilePiPackageService(activeAgentProfile, (agentDir) => createDefaultPiPackageService(cwd, agentDir));
}
export function createDefaultPiPackageService(cwd: string, agentDir: string): PiPackageService {
const settingsManager = SettingsManager.create(cwd, agentDir);
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
return new DefaultPiPackageService({
+38
View File
@@ -2,6 +2,7 @@ import { mkdtemp, rm, writeFile, mkdir, symlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ActiveAgentProfileAccessError } from "./activeAgentProfileProvider.js";
import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService.js";
let tempDir: string;
@@ -133,6 +134,43 @@ describe("PiWebPluginService", () => {
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
});
it("uses the active agent directory on every Pi package plugin discovery", async () => {
const packageDir = join(tempDir, "pkg");
const initialAgentDir = join(tempDir, "initial-agent");
const updatedAgentDir = join(tempDir, "updated-agent");
let activeAgentDir = initialAgentDir;
await writePlugin(packageDir, {
packageJson: { piWeb: { plugins: [{ id: "agent-package", module: "dist/plugin.js" }] } },
files: { "dist/plugin.js": "export default {};" },
});
await mkdir(initialAgentDir, { recursive: true });
await mkdir(updatedAgentDir, { recursive: true });
await writeFile(join(updatedAgentDir, "settings.json"), `${JSON.stringify({ packages: [packageDir] }, null, 2)}\n`, "utf8");
const service = new PiWebPluginService({ roots: [], cwd: tempDir, agentDirProvider: () => activeAgentDir });
await expect(service.manifest()).resolves.toEqual({ plugins: [] });
activeAgentDir = updatedAgentDir;
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "agent-package", source: packageDir, scope: "user" }] });
});
it("fails complete package-backed discovery closed while keeping known local assets independent", async () => {
const pluginDir = join(tempDir, "plugins", "local-only");
await writePlugin(pluginDir, {
packageJson: { piWeb: { plugins: [{ id: "local-only", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
const profileError = new ActiveAgentProfileAccessError({ status: "invalid", error: "missing descriptor" });
const service = new PiWebPluginService({
roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }],
agentDirProvider: () => { throw profileError; },
});
await expect(service.manifest()).rejects.toBe(profileError);
await expect(service.readAsset("local-only", "pi-web-plugin.js")).resolves.toMatchObject({ contentType: "application/javascript; charset=utf-8" });
});
it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
const agentDir = join(tempDir, "agent");
const firstPackageDir = join(tempDir, "first-package");
+48 -11
View File
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js";
import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js";
import { isPiWebPluginId } from "../shared/pluginIds.js";
@@ -46,8 +46,9 @@ interface PiWebPluginServiceOptions {
roots?: LocalPluginRoot[];
cwd?: string;
agentDir?: string;
agentDirProvider?: () => string | Promise<string>;
packageProvider?: PiPackageProvider | false;
configProvider?: () => PiWebConfig;
configProvider?: () => PiWebConfig | Promise<PiWebConfig>;
}
interface LocalPluginRoot {
@@ -69,7 +70,10 @@ interface PiWebPluginEntry {
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
export class DefaultPiPackageProvider implements PiPackageProvider {
constructor(private readonly cwd = process.cwd(), private readonly agentDir = getAgentDir()) {}
constructor(
private readonly cwd: string,
private readonly agentDir: string,
) {}
listPackages(): ConfiguredPiPackage[] {
return this.createPackageManager().listConfiguredPackages();
@@ -90,14 +94,22 @@ export class DefaultPiPackageProvider implements PiPackageProvider {
export class PiWebPluginService {
private readonly roots: LocalPluginRoot[];
private readonly packageProvider: PiPackageProvider | undefined;
private readonly configProvider: () => PiWebConfig;
private readonly agentDir: string | undefined;
private readonly agentDirProvider: (() => string | Promise<string>) | undefined;
private readonly staticPackageProvider: PiPackageProvider | undefined;
private readonly packageProviderForAgentDir: ((agentDir: string) => PiPackageProvider) | undefined;
private readonly configProvider: () => PiWebConfig | Promise<PiWebConfig>;
constructor(options: PiWebPluginServiceOptions = {}) {
const cwd = options.cwd ?? process.cwd();
const agentDir = options.agentDir ?? getAgentDir();
this.roots = options.roots ?? defaultPluginRoots(cwd);
this.packageProvider = options.packageProvider === false ? undefined : options.packageProvider ?? new DefaultPiPackageProvider(cwd, agentDir);
this.agentDir = options.agentDir;
this.agentDirProvider = options.agentDirProvider;
const packageProvider = options.packageProvider;
this.staticPackageProvider = packageProvider === false || packageProvider === undefined ? undefined : packageProvider;
this.packageProviderForAgentDir = packageProvider === false || packageProvider !== undefined
? undefined
: (agentDir) => new DefaultPiPackageProvider(cwd, agentDir);
this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config);
}
@@ -110,13 +122,14 @@ export class PiWebPluginService {
}
async plugins(): Promise<PiWebPluginsResponse> {
const [plugins, config] = await Promise.all([this.discoverPlugins(), Promise.resolve(this.configProvider())]);
const config = await this.configProvider();
const plugins = await this.discoverPlugins();
return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) };
}
async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> {
if (!isPiWebPluginId(pluginId)) return undefined;
const plugin = (await this.discoverPlugins()).find((candidate) => candidate.id === pluginId);
const plugin = await this.findPlugin(pluginId);
if (plugin === undefined) return undefined;
const resolved = resolve(plugin.root, assetPath);
@@ -146,12 +159,36 @@ export class PiWebPluginService {
private async discoverPlugins(): Promise<PluginRecord[]> {
const records = new Map<string, PluginRecord>();
for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin);
if (this.packageProvider !== undefined) {
for (const plugin of await this.discoverPiPackagePlugins(this.packageProvider)) addUnique(records, plugin);
const packageProvider = await this.currentPackageProvider();
if (packageProvider !== undefined) {
for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin);
}
return [...records.values()].sort((left, right) => left.id.localeCompare(right.id));
}
private async findPlugin(pluginId: string): Promise<PluginRecord | undefined> {
const localPlugin = (await this.discoverLocalPlugins()).find((candidate) => candidate.id === pluginId);
if (localPlugin !== undefined) return localPlugin;
const packageProvider = await this.currentPackageProvider();
if (packageProvider === undefined) return undefined;
const records = new Map<string, PluginRecord>();
for (const plugin of await this.discoverPiPackagePlugins(packageProvider)) addUnique(records, plugin);
return records.get(pluginId);
}
private async currentPackageProvider(): Promise<PiPackageProvider | undefined> {
if (this.staticPackageProvider !== undefined) return this.staticPackageProvider;
if (this.packageProviderForAgentDir === undefined) return undefined;
return this.packageProviderForAgentDir(await this.currentAgentDir());
}
private async currentAgentDir(): Promise<string> {
if (this.agentDirProvider !== undefined) return await this.agentDirProvider();
if (this.agentDir !== undefined) return this.agentDir;
throw new Error("Pi package plugin discovery requires an explicit active agent directory");
}
private async discoverLocalPlugins(): Promise<PluginRecord[]> {
const plugins: PluginRecord[] = [];
for (const root of this.roots) plugins.push(...await discoverLocalRoot(root));
+142 -4
View File
@@ -2,9 +2,9 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { comparePackageVersions, getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus, updateCommandFor } from "./piWebStatus.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import type { PiWebComponentStatus } from "../shared/apiTypes.js";
import type { PiWebComponentStatus, PiWebRuntimeComponent } from "../shared/apiTypes.js";
import { PI_WEB_CAPABILITIES } from "../shared/capabilities.js";
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
@@ -14,6 +14,7 @@ const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"];
const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
const originalAgentDir = process.env["PI_WEB_AGENT_DIR"];
afterEach(() => {
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
@@ -23,6 +24,7 @@ afterEach(() => {
restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
restoreEnv("PI_WEB_AGENT_DIR", originalAgentDir);
vi.restoreAllMocks();
});
@@ -51,6 +53,50 @@ describe("PI WEB status", () => {
expect(status).not.toHaveProperty("release");
});
it("detects session daemon package installs from the configured agent dir for runtime responses", async () => {
disableDockerRuntimeEnv();
const agentDir = await tempHome();
try {
await installConfiguredPiWebPackage(agentDir);
const daemon = daemonWithRuntime({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
available: true,
capabilities: [],
});
const status = await getPiWebVersionStatus(daemon, { activeAgentProfile: activeProfile("a", "alt-agent", agentDir) });
expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" });
} finally {
await rm(agentDir, { recursive: true, force: true });
}
});
it("does not fall back to the web process environment when no active profile is available", async () => {
disableDockerRuntimeEnv();
const agentDir = await tempHome();
try {
await installConfiguredPiWebPackage(agentDir);
process.env["PI_WEB_AGENT_DIR"] = agentDir;
const daemon = daemonWithRuntime({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
available: true,
capabilities: [],
});
const status = await getPiWebVersionStatus(daemon);
expect(status.components.web.installation?.kind).not.toBe("pi-package");
expect(status.components.sessiond.installation?.kind).not.toBe("pi-package");
} finally {
await rm(agentDir, { recursive: true, force: true });
}
});
it("reports web-only capabilities from the web runtime", async () => {
const daemon = daemonWithComponent({
component: "sessiond",
@@ -63,10 +109,34 @@ describe("PI WEB status", () => {
const runtime = await getPiWebRuntime(daemon);
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]));
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]));
});
it("carries the daemon-owned active agent profile through the web runtime response", async () => {
const activeAgentProfile = {
schemaVersion: 1 as const,
revision: `sha256:${"a".repeat(64)}`,
command: "acme-agent",
dir: "/opt/acme-agent/state",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
const daemon = daemonWithRuntime({
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
available: true,
capabilities: [],
activeAgentProfile,
});
const runtime = await getPiWebRuntime(daemon);
expect(runtime.components.sessiond.activeAgentProfile).toEqual(activeAgentProfile);
expect(runtime.components.web.activeAgentProfile).toBeUndefined();
});
it("bypasses cached npm release data for a forced check", async () => {
@@ -117,6 +187,50 @@ describe("PI WEB status", () => {
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
});
it("suppresses Pi package update planning without an active companion command", async () => {
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: undefined, hasCommand },
);
expect(updateCommand).toBeUndefined();
expect(hasCommand).not.toHaveBeenCalled();
});
it("preserves and shell-quotes the active state profile in Pi-package update commands", async () => {
const command = "/tmp/agent's/pi";
const dir = "/tmp/profile's/state";
const updateCommand = await updateCommandFor(
{ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
"pi-web restart",
{
activeAgentProfile: activeProfile("a", command, dir),
hasCommand: (candidate) => Promise.resolve(candidate === command),
},
);
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 () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
disableDockerRuntimeEnv();
@@ -219,6 +333,16 @@ describe("PI WEB status", () => {
});
});
function activeProfile(revisionCharacter: string, command: string, dir: string) {
return {
schemaVersion: 1 as const,
revision: `sha256:${revisionCharacter.repeat(64)}`,
command,
dir,
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
}
function npmVersionResponse(version: string): Response {
return new Response(JSON.stringify({ version }), { status: 200, headers: { "content-type": "application/json" } });
}
@@ -233,6 +357,16 @@ function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClie
return daemon;
}
function daemonWithRuntime(component: PiWebRuntimeComponent): SessionDaemonClient {
const daemon = new SessionDaemonClient();
vi.spyOn(daemon, "request").mockResolvedValue({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(component),
});
return daemon;
}
function staleLocalSessiond(): PiWebComponentStatus {
return {
component: "sessiond",
@@ -255,6 +389,10 @@ async function installSystemdServiceFiles(home: string, names: string[]): Promis
await Promise.all(names.map((name) => writeFile(join(dir, name), "")));
}
async function installConfiguredPiWebPackage(agentDir: string): Promise<void> {
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ packages: [process.cwd()] }, null, 2)}\n`, "utf8");
}
async function installExecutable(dir: string, name: string): Promise<void> {
const path = join(dir, name);
await writeFile(path, "#!/usr/bin/env sh\nexit 0\n");
+27 -21
View File
@@ -5,12 +5,13 @@ import { promisify } from "node:util";
import { homedir } from "node:os";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { ActiveAgentProfileDescriptor, PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import { isHostAbsoluteAgentDir, isPiCompanionCommand, isSafeAgentCommandForHost, PI_CODING_AGENT_DIR_ENV } from "../config.js";
import { createPiWebReleaseLookupCache, type PiWebReleaseLookup } from "./piWebReleaseLookupCache.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
@@ -76,6 +77,8 @@ interface PiWebStatusDaemon {
export interface PiWebStatusOptions {
forceReleaseCheck?: boolean;
activeAgentProfile?: ActiveAgentProfileDescriptor;
hasCommand?: (command: string) => Promise<boolean>;
}
const latestReleaseLookupCache = createPiWebReleaseLookupCache(fetchLatestNpmVersion);
@@ -102,10 +105,10 @@ export async function getPiWebRuntime(daemon: PiWebStatusDaemon = new SessionDae
};
}
export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise<PiWebComponentStatus> {
export async function getPiWebComponentStatus(component: PiWebServiceComponent, options: PiWebStatusOptions = {}): Promise<PiWebComponentStatus> {
const [installed, installation] = await Promise.all([
readInstalledPackageInfo(),
detectPiWebInstallation(),
detectPiWebInstallation(options.activeAgentProfile?.dir),
]);
const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION;
const installedVersion = installed?.version;
@@ -120,10 +123,10 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent):
};
}
export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebVersionResponse> {
const [web, sessiond] = await Promise.all([
getPiWebComponentStatus("web"),
getSessiondComponentStatus(daemon),
getPiWebComponentStatus("web", options),
getSessiondComponentStatus(daemon, options),
]);
return {
packageName: PI_WEB_PACKAGE_NAME,
@@ -133,11 +136,11 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess
}
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> {
const versionStatus = await getPiWebVersionStatus(daemon);
const versionStatus = await getPiWebVersionStatus(daemon, options);
const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION, options.forceReleaseCheck === true);
const components = { web, sessiond };
const commands = await commandsFor(components);
const commands = await commandsFor(components, { activeAgentProfile: options.activeAgentProfile, hasCommand: options.hasCommand ?? hasCommand });
const messages = buildMessages(components, release, commands);
return {
...versionStatus,
@@ -191,13 +194,15 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined
return { name, version, path };
}
async function detectPiWebInstallation(): Promise<PiWebInstallationInfo> {
async function detectPiWebInstallation(agentDir?: string): Promise<PiWebInstallationInfo> {
const docker = detectDockerInstallation();
if (docker !== undefined) return docker;
const root = packageRootPath();
const realRoot = await realPathOrSelf(root);
const piPackage = await detectPiPackageInstallation(realRoot, root);
if (agentDir !== undefined) {
const piPackage = await detectPiPackageInstallation(realRoot, root, agentDir);
if (piPackage !== undefined) return piPackage;
}
const npmGlobal = await detectNpmGlobalInstallation(realRoot, root);
if (npmGlobal !== undefined) return npmGlobal;
return { kind: "local", path: root };
@@ -243,9 +248,8 @@ function isTruthyEnv(key: string): boolean {
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
}
async function detectPiPackageInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
async function detectPiPackageInstallation(realRoot: string, displayPath: string, agentDir: string): Promise<PiWebInstallationInfo | undefined> {
try {
const agentDir = getAgentDir();
const packageManager = new DefaultPackageManager({
cwd: process.cwd(),
agentDir,
@@ -313,7 +317,7 @@ async function getSessiondRuntimeComponent(daemon: PiWebStatusDaemon): Promise<P
}
}
async function getSessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<PiWebComponentStatus> {
async function getSessiondComponentStatus(daemon: PiWebStatusDaemon, options: PiWebStatusOptions = {}): Promise<PiWebComponentStatus> {
try {
const upstream = await daemon.request("GET", "/runtime");
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
@@ -324,7 +328,7 @@ async function getSessiondComponentStatus(daemon: PiWebStatusDaemon): Promise<Pi
if (legacyVersion !== undefined) return legacyVersion;
const runtime = parsePiWebRuntimeComponent(parsed);
if (runtime?.available !== true) return await legacySessiondComponentStatus(daemon) ?? unavailableSessiond(runtime?.error ?? "runtime response did not include valid runtime information");
const status = await getPiWebComponentStatus("sessiond");
const status = await getPiWebComponentStatus("sessiond", options);
return { ...status, ...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }), available: true };
} catch (error) {
return unavailableSessiond(error instanceof Error ? error.message : String(error));
@@ -412,7 +416,7 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
return version;
}
async function commandsFor(components: PiWebStatusResponse["components"]): 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);
if (installation?.kind === "docker") return dockerCommands(installation);
@@ -424,7 +428,7 @@ async function commandsFor(components: PiWebStatusResponse["components"]): Promi
const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
const status = serviceCommands.status ?? cliCommands.status;
const update = await updateCommandFor(installation, restart);
const update = await updateCommandFor(installation, restart, options);
return {
...(update === undefined ? {} : { update }),
@@ -463,11 +467,13 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
return cliCommands.restart ?? serviceCommands.restart;
}
async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): 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 (installation?.kind === "pi-package") {
if (!(await hasCommand("pi"))) return undefined;
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`;
const profile = options.activeAgentProfile;
if (profile === undefined || !isSafeAgentCommandForHost(profile.command) || !isHostAbsoluteAgentDir(profile.dir) || !isPiCompanionCommand(profile.command)) return undefined;
if (!(await options.hasCommand(profile.command))) return undefined;
return `${PI_CODING_AGENT_DIR_ENV}=${shellQuote(profile.dir)} ${shellQuote(profile.command)} update ${shellQuote(installation.source ?? PI_WEB_NPM_SOURCE)} && ${restartCommand}`;
}
if (installation?.kind === "local" && installation.path !== undefined) {
if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined;
@@ -547,7 +553,7 @@ async function isGitCheckoutWithUpstream(path: string): Promise<boolean> {
}
function hasCommand(command: string): Promise<boolean> {
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]);
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${shellQuote(command)}`]);
}
async function commandSucceeds(command: string, args: string[]): Promise<boolean> {
+36
View File
@@ -113,6 +113,42 @@ describe("createPiWebStatusCache", () => {
expect(load).toHaveBeenCalledTimes(3);
});
it("loads again after a fresh cache is invalidated", async () => {
const load = vi.fn()
.mockResolvedValueOnce(status("first"))
.mockResolvedValueOnce(status("second"));
const cache = createPiWebStatusCache(load);
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
cache.invalidate();
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
expect(load).toHaveBeenCalledTimes(2);
});
it("abandons an in-flight load when invalidated", async () => {
const firstLoad = createDeferred<PiWebStatusResponse>();
const secondLoad = createDeferred<PiWebStatusResponse>();
const load = vi.fn()
.mockImplementationOnce(() => firstLoad.promise)
.mockImplementationOnce(() => secondLoad.promise);
const cache = createPiWebStatusCache(load);
const first = cache.get();
await waitForMicrotasks();
cache.invalidate();
const second = cache.get();
await waitForMicrotasks();
secondLoad.resolve(status("second"));
await expect(second).resolves.toMatchObject({ generatedAt: "second" });
firstLoad.resolve(status("first"));
await expect(first).resolves.toMatchObject({ generatedAt: "first" });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
expect(load).toHaveBeenCalledTimes(2);
});
it("deduplicates concurrent cold loads", async () => {
const deferred = createDeferred<PiWebStatusResponse>();
const load = vi.fn(() => deferred.promise);
+6
View File
@@ -19,6 +19,7 @@ export interface PiWebStatusCacheRefreshOptions {
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(options?: PiWebStatusCacheRefreshOptions): Promise<PiWebStatusResponse>;
invalidate(): void;
}
export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptions) => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
@@ -56,5 +57,10 @@ export function createPiWebStatusCache(load: (options: PiWebStatusCacheLoadOptio
return refresh();
},
refresh,
invalidate(): void {
cached = undefined;
loadSequence += 1;
pending = undefined;
},
};
}
+35 -20
View File
@@ -9,6 +9,7 @@ import { SessionEventHub } from "./realtime/sessionEventHub.js";
import { AuthService } from "./sessions/authService.js";
import { registerAuthRoutes } from "./sessions/authRoutes.js";
import { PiSessionService } from "./sessions/piSessionService.js";
import { createPiSessionManagerGateway } from "./sessions/piSessionManagerGateway.js";
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js";
import { ProjectService } from "./projects/projectService.js";
@@ -19,11 +20,18 @@ import { TerminalService } from "./terminals/terminalService.js";
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js";
import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes } from "../config.js";
import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js";
import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";
const { config } = effectivePiWebConfig();
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) });
const daemonEnvironment: NodeJS.ProcessEnv = Object.freeze({ ...process.env });
const { config } = effectivePiWebConfig({ env: daemonEnvironment });
const activeAgentProfile = createActiveAgentProfileDescriptor({
command: config.agent.command,
dir: config.agent.dir,
sessionDirEnvKeys: agentSessionDirEnvKeys(config.agent.command),
});
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(daemonEnvironment, config) });
await app.register(fastifyWebsocket);
await runSessionDaemonStartup({
@@ -31,44 +39,51 @@ await runSessionDaemonStartup({
createRuntime() {
const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = new AuthService();
const spawnTargets = spawnSessionsEnabled(process.env, config)
const auth = new AuthService({ agentDir: activeAgentProfile.dir });
const spawnTargets = config.spawnSessions
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
const sessions = new PiSessionService(eventHub, {
modelRegistry: auth.modelRegistry,
agentDir: activeAgentProfile.dir,
workspaceActivity,
logger: app.log,
...(spawnTargets === undefined ? {} : { spawnTargets }),
subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config),
subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
sessionManager: createPiSessionManagerGateway({
agentDir: activeAgentProfile.dir,
env: daemonEnvironment,
sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys,
}),
});
auth.subscribe((change) => { sessions.applyAuthChange(change); });
const terminals = new TerminalService(eventHub, workspaceActivity);
return { eventHub, workspaceActivity, auth, sessions, terminals };
const runtimeComponent = Object.freeze({
...getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES),
activeAgentProfile,
});
return { eventHub, workspaceActivity, auth, sessions, terminals, activeAgentProfile, runtimeComponent };
},
registerRoutes({ eventHub, workspaceActivity, auth, sessions, terminals }) {
registerRoutes({ eventHub, workspaceActivity, auth, sessions, terminals, runtimeComponent }) {
registerWorkspaceActivityRoutes(app, workspaceActivity);
registerAuthRoutes(app, auth);
registerSessionRoutes(app, sessions, eventHub);
registerTerminalRoutes(app, terminals);
app.get("/health", () => {
const runtime = getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES);
return {
app.get("/health", () => ({
ok: true,
activeSessions: sessions.activeCount(),
checkedAt: new Date().toISOString(),
version: {
component: runtime.component,
label: runtime.label,
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
component: runtimeComponent.component,
label: runtimeComponent.label,
...(runtimeComponent.runtimeVersion === undefined ? {} : { runtimeVersion: runtimeComponent.runtimeVersion }),
stale: false,
available: runtime.available,
available: runtimeComponent.available,
},
};
});
}));
app.get("/runtime", () => getPiWebRuntimeComponent("sessiond", SESSIOND_RUNTIME_CAPABILITIES));
app.get("/runtime", () => runtimeComponent);
},
async listen({ auth, sessions, terminals }) {
let shuttingDown = false;
@@ -85,9 +100,9 @@ await runSessionDaemonStartup({
process.once("SIGINT", (signal) => { void shutdown(signal); });
process.once("SIGTERM", (signal) => { void shutdown(signal); });
const portValue = process.env["PI_WEB_SESSIOND_PORT"];
const portValue = daemonEnvironment["PI_WEB_SESSIOND_PORT"];
const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined;
const host = process.env["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";
const host = daemonEnvironment["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1";
if (port !== undefined) {
await app.listen({ port, host });
+26 -1
View File
@@ -1,9 +1,18 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OAuthFlowState } from "../../shared/apiTypes.js";
import { AuthService, type AuthChange } from "./authService.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("AuthService", () => {
it("saves API keys and emits a global auth change", () => {
const { auth, authStorage, changes } = createAuthService();
@@ -33,6 +42,16 @@ describe("AuthService", () => {
auth.dispose();
});
it("stores credentials in the configured agent directory", async () => {
const agentDir = await tempAgentDir();
const auth = new AuthService({ agentDir });
auth.saveApiKey("anthropic", "sk-test");
await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-test");
auth.dispose();
});
it("refreshes auth state after OAuth login completes", () => {
const authStorage = AuthStorage.inMemory();
const modelRegistry = ModelRegistry.create(authStorage);
@@ -76,6 +95,12 @@ function createAuthService(data: Parameters<typeof AuthStorage.inMemory>[0] = {}
return { auth, authStorage, changes };
}
async function tempAgentDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "pi-web-auth-agent-"));
tempDirs.push(dir);
return dir;
}
class CapturingOAuthLoginFlowService extends OAuthLoginFlowService {
readonly startCalls: Parameters<OAuthLoginFlowService["start"]>[0][] = [];
disposed = false;
+8 -1
View File
@@ -1,3 +1,4 @@
import { join } from "node:path";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
@@ -11,17 +12,23 @@ type AuthChangeListener = (change: AuthChange) => void;
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
export interface AuthServiceDependencies {
agentDir?: string;
modelRegistry?: ModelRegistryInstance;
authFlows?: OAuthLoginFlowService;
}
export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance {
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
return ModelRegistry.create(authStorage, join(agentDir, "models.json"));
}
export class AuthService {
readonly modelRegistry: ModelRegistryInstance;
private readonly authFlows: OAuthLoginFlowService;
private readonly listeners = new Set<AuthChangeListener>();
constructor(deps: AuthServiceDependencies = {}) {
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir));
this.authFlows = deps.authFlows ?? new OAuthLoginFlowService();
}
@@ -2,6 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { agentSessionDirEnvKeys } from "../../config.js";
import { createPiSessionManagerGateway, defaultPiSessionDir, defaultPiSessionsRoot, filterSessionsForCwd, SessionDirResolver } from "./piSessionManagerGateway.js";
import type { PiSessionListEntry } from "./piSessionService.js";
import type { PiSessionManager } from "./piSessionService.js";
@@ -24,7 +25,7 @@ afterEach(async () => {
describe("SessionDirResolver", () => {
it("uses Pi default session storage when no Pi override is configured", () => {
const resolver = new SessionDirResolver({ agentDir, env: {} });
const resolver = new SessionDirResolver(piProfileOptions());
expect(resolver.resolve(cwd)).toMatchObject({ source: "pi-default", sessionDir: defaultPiSessionDir(cwd, agentDir), usesConfiguredSessionDir: false });
expect(defaultPiSessionsRoot(agentDir)).toBe(join(agentDir, "sessions"));
@@ -34,7 +35,7 @@ describe("SessionDirResolver", () => {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: ".pi/sessions" }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: {} });
const resolver = new SessionDirResolver(piProfileOptions());
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".pi", "sessions"), usesConfiguredSessionDir: true });
});
@@ -45,7 +46,7 @@ describe("SessionDirResolver", () => {
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "global-sessions") }, null, 2)}\n`, "utf8");
await writeFile(join(cwd, ".pi", "settings.json"), `${JSON.stringify({ sessionDir: ".workspace-sessions" }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: {} });
const resolver = new SessionDirResolver(piProfileOptions());
expect(resolver.resolve(cwd)).toMatchObject({ source: "settings", sessionDir: join(cwd, ".workspace-sessions"), usesConfiguredSessionDir: true });
});
@@ -55,10 +56,32 @@ describe("SessionDirResolver", () => {
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envDir } });
const resolver = new SessionDirResolver(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: envDir }));
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
});
it("uses PI WEB sessionDir environment overrides before settings", async () => {
const envDir = join(tempDir, "pi-web-env-sessions");
await mkdir(agentDir, { recursive: true });
await writeFile(join(agentDir, "settings.json"), `${JSON.stringify({ sessionDir: join(tempDir, "settings-sessions") }, null, 2)}\n`, "utf8");
const resolver = new SessionDirResolver(piProfileOptions({ PI_WEB_AGENT_SESSION_DIR: envDir }));
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
});
it("snapshots the daemon epoch's injected session-directory environment", () => {
const firstDir = join(tempDir, "first-env-sessions");
const env = { PI_WEB_AGENT_SESSION_DIR: firstDir };
const sessionDirEnvKeys = ["PI_WEB_AGENT_SESSION_DIR"];
const resolver = new SessionDirResolver({ agentDir, env, sessionDirEnvKeys });
env.PI_WEB_AGENT_SESSION_DIR = join(tempDir, "mutated-env-sessions");
sessionDirEnvKeys[0] = "OTHER_SESSION_DIR";
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: firstDir, usesConfiguredSessionDir: true });
});
});
describe("Pi session manager gateway", () => {
@@ -66,7 +89,7 @@ describe("Pi session manager gateway", () => {
const otherCwd = join(tempDir, "other-workspace");
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-a", cwd);
await writeSessionFile(defaultPiSessionDir(otherCwd, agentDir), "session-b", otherCwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
const gateway = createPiSessionManagerGateway(piProfileOptions());
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "session-a", cwd }), expect.objectContaining({ id: "session-b", cwd: otherCwd })]));
@@ -76,18 +99,33 @@ describe("Pi session manager gateway", () => {
const envSessionDir = join(tempDir, "env-sessions");
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "default-session", cwd);
await writeSessionFile(envSessionDir, "env-session", cwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: envSessionDir } });
const gateway = createPiSessionManagerGateway(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: envSessionDir }));
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })]));
});
it("includes generic env session directories in global listing", async () => {
for (const envKey of ["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]) {
const envSessionDir = join(tempDir, `${envKey.toLowerCase()}-sessions`);
await writeSessionFile(envSessionDir, `${envKey.toLowerCase()}-session`, cwd);
const gateway = createPiSessionManagerGateway({
agentDir,
env: { [envKey]: envSessionDir },
sessionDirEnvKeys: [envKey],
});
if (gateway.listAll === undefined) throw new Error("Expected legacy listing support");
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: `${envKey.toLowerCase()}-session`, cwd })]));
}
});
it("lists only sessions for the requested cwd when a custom Pi sessionDir is shared", async () => {
const sharedSessionDir = join(tempDir, "shared-sessions");
const otherCwd = join(tempDir, "other-workspace");
await writeSessionFile(sharedSessionDir, "session-a", cwd);
await writeSessionFile(sharedSessionDir, "session-b", otherCwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: { PI_CODING_AGENT_SESSION_DIR: sharedSessionDir } });
const gateway = createPiSessionManagerGateway(piProfileOptions({ PI_CODING_AGENT_SESSION_DIR: sharedSessionDir }));
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-a", cwd }]);
const created = gateway.create(cwd);
@@ -101,7 +139,7 @@ describe("Pi session manager gateway", () => {
// hiding every session outside the daemon's own launch directory.
expect(cwd).not.toBe(process.cwd());
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-elsewhere", cwd);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
const gateway = createPiSessionManagerGateway(piProfileOptions());
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-elsewhere", cwd }]);
});
@@ -129,12 +167,16 @@ describe("session listing canonicalization", () => {
// Headers are written by the Pi CLI / SDK consumers and may contain
// unnormalized paths (trailing separators, redundant segments).
await writeSessionFile(defaultPiSessionDir(cwd, agentDir), "session-messy", `${cwd}${sep}.${sep}`);
const gateway = createPiSessionManagerGateway({ agentDir, env: {} });
const gateway = createPiSessionManagerGateway(piProfileOptions());
await expect(gateway.list(cwd)).resolves.toMatchObject([{ id: "session-messy", cwd }]);
});
});
function piProfileOptions(env: NodeJS.ProcessEnv = {}) {
return { agentDir, env, sessionDirEnvKeys: agentSessionDirEnvKeys() };
}
function hasSessionDir(manager: PiSessionManager): manager is PiSessionManager & { getSessionDir(): string } {
return "getSessionDir" in manager && typeof manager.getSessionDir === "function";
}
+27 -25
View File
@@ -2,12 +2,10 @@ import type { Dirent } from "node:fs";
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { getAgentDir, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import { SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
import type { PiSessionListEntry, PiSessionManager, PiSessionManagerGateway } from "./piSessionService.js";
export const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
type SessionDirSource = "env" | "settings" | "pi-default";
export interface SessionDirResolution {
@@ -17,17 +15,23 @@ export interface SessionDirResolution {
}
export interface SessionDirResolverOptions {
agentDir?: string;
env?: NodeJS.ProcessEnv;
agentDir: string;
env: Readonly<NodeJS.ProcessEnv>;
sessionDirEnvKeys: readonly string[];
}
export class SessionDirResolver {
private readonly agentDir: string;
private readonly env: NodeJS.ProcessEnv;
private readonly envSessionDir: string | undefined;
private readonly homeDir: string;
constructor(options: SessionDirResolverOptions = {}) {
this.agentDir = options.agentDir ?? getAgentDir();
this.env = options.env ?? process.env;
constructor(options: SessionDirResolverOptions) {
this.agentDir = options.agentDir;
this.envSessionDir = options.sessionDirEnvKeys
.map((key) => options.env[key])
.find((value) => value !== undefined && value !== "");
const configuredHome = options.env["HOME"];
this.homeDir = configuredHome !== undefined && configuredHome !== "" && isAbsolute(configuredHome) ? configuredHome : homedir();
}
defaultSessionsRoot(): string {
@@ -35,21 +39,19 @@ export class SessionDirResolver {
}
globalEnvSessionDir(): string | undefined {
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
if (envSessionDir === undefined || envSessionDir === "") return undefined;
const expanded = expandTildePath(envSessionDir);
if (this.envSessionDir === undefined) return undefined;
const expanded = expandTildePath(this.envSessionDir, this.homeDir);
return isAbsolute(expanded) ? expanded : undefined;
}
resolve(cwd: string): SessionDirResolution {
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
if (envSessionDir !== undefined && envSessionDir !== "") {
return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true };
if (this.envSessionDir !== undefined) {
return { source: "env", sessionDir: resolveConfiguredPath(this.envSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true };
}
const settingsSessionDir = SettingsManager.create(cwd, this.agentDir).getSessionDir();
if (settingsSessionDir !== undefined && settingsSessionDir !== "") {
return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd), usesConfiguredSessionDir: true };
return { source: "settings", sessionDir: resolveConfiguredPath(settingsSessionDir, cwd, this.homeDir), usesConfiguredSessionDir: true };
}
return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false };
@@ -58,7 +60,7 @@ export class SessionDirResolver {
export type PiSessionManagerGatewayOptions = SessionDirResolverOptions;
export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions = {}): PiSessionManagerGateway {
export function createPiSessionManagerGateway(options: PiSessionManagerGatewayOptions): PiSessionManagerGateway {
return new SettingsAwarePiSessionManagerGateway(new SessionDirResolver(options));
}
@@ -99,7 +101,7 @@ export async function listSessionsInDir(sessionDir: string): Promise<PiSessionLi
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[];
try {
entries = await readdir(storeRoot, { withFileTypes: true });
@@ -124,11 +126,11 @@ function uniqueSessionsByPath(sessions: readonly PiSessionListEntry[]): PiSessio
return [...byPath.values()].sort((a, b) => b.modified.getTime() - a.modified.getTime());
}
export function defaultPiSessionsRoot(agentDir = getAgentDir()): string {
export function defaultPiSessionsRoot(agentDir: string): string {
return join(agentDir, "sessions");
}
export function defaultPiSessionDir(cwd: string, agentDir = getAgentDir()): string {
export function defaultPiSessionDir(cwd: string, agentDir: string): string {
return sessionDirInDefaultPiStore(defaultPiSessionsRoot(agentDir), cwd);
}
@@ -137,13 +139,13 @@ export function sessionDirInDefaultPiStore(storeRoot: string, cwd: string): stri
return join(storeRoot, safePath);
}
export function resolveConfiguredPath(path: string, cwd: string): string {
const expanded = expandTildePath(path);
export function resolveConfiguredPath(path: string, cwd: string, homeDir: string): string {
const expanded = expandTildePath(path, homeDir);
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
}
function expandTildePath(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
function expandTildePath(path: string, homeDir: string): string {
if (path === "~") return homeDir;
if (path.startsWith("~/")) return join(homeDir, path.slice(2));
return path;
}
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService archive and cleanup", () => {
it("archives a session subtree within the root workspace", async () => {
const archivedInputs: string[] = [];
@@ -12,6 +14,7 @@ describe("PiSessionService archive and cleanup", () => {
const otherWorkspaceChild = { ...sessionRecord("other-child", "/other"), path: "/sessions/other-child.jsonl", parentSessionPath: root.path };
const fake = fakeRuntime("root", { sessionFile: root.path });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived-child", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: archivedChild.path, archivePath: "/archive/archived-child.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 1, firstMessage: "archived", parentSessionPath: root.path }]),
@@ -45,6 +48,7 @@ describe("PiSessionService archive and cleanup", () => {
it("permanently deletes archived sessions through the archive store", async () => {
const deletedSessionIds: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -78,6 +82,7 @@ describe("PiSessionService archive and cleanup", () => {
const open = vi.fn(() => { throw new Error("bulk archive should not open inactive runtimes"); });
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([]),
get: () => Promise.resolve(undefined),
@@ -112,6 +117,7 @@ describe("PiSessionService archive and cleanup", () => {
let createCalls = 0;
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-01-03T00:00:00.000Z" }))));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
createCalls += 1;
return Promise.resolve(busy.runtime);
@@ -152,6 +158,7 @@ describe("PiSessionService archive and cleanup", () => {
const busy = fakeRuntime("busy-archived", { isStreaming: true });
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(busy.runtime),
archiveStore: {
list: () => Promise.resolve([busyRecord, idleRecord]),
@@ -188,6 +195,7 @@ describe("PiSessionService archive and cleanup", () => {
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const listCalls: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([
{ sessionId: "legacy-a", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z" },
@@ -230,6 +238,7 @@ describe("PiSessionService archive and cleanup", () => {
const archived = { sessionId: "archived-old", cwd: "/old-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-old.jsonl" };
const otherArchived = { sessionId: "archived-other", cwd: "/other-project", archivedAt: "2026-04-01T00:00:00.000Z", archivePath: "/archive/archived-other.jsonl" };
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([archived, otherArchived]),
@@ -282,6 +291,7 @@ describe("PiSessionService archive and cleanup", () => {
const archiveMany = vi.fn((inputs: readonly { sessionId: string; cwd: string }[]) => Promise.resolve(inputs.map((input) => ({ sessionId: input.sessionId, cwd: input.cwd, archivedAt: "2026-06-25T00:00:00.000Z", archivePath: `/archive/${input.sessionId}.jsonl` }))));
const deleteArchivedMany = vi.fn((sessionIds: readonly string[]) => Promise.resolve([...sessionIds]));
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
now: () => new Date("2026-06-25T00:00:00.000Z"),
archiveStore: {
list: () => Promise.resolve([
@@ -323,6 +333,7 @@ describe("PiSessionService archive and cleanup", () => {
const fake = fakeRuntime("busy-open", { isStreaming: true, sessionManager: fakeSessionManager("/old-project"), sessionFile: "/sessions/busy-open.jsonl" });
const archivedInputs: string[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
now: () => new Date("2026-06-25T00:00:00.000Z"),
createAgentRuntime: runtimeCreator(fake.runtime),
archiveStore: {
@@ -5,6 +5,8 @@ import { describe, expect, it, vi } from "vitest";
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
@@ -20,12 +22,15 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime();
let createCalls = 0;
const createAgentRuntime: RuntimeCreator = async () => {
let runtimeAgentDir: string | undefined;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
createCalls += 1;
runtimeAgentDir = options.agentDir;
await Promise.resolve();
return fake.runtime;
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -34,6 +39,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const session = await service.start("/workspace");
expect(createCalls).toBe(1);
expect(runtimeAgentDir).toBe(TEST_AGENT_DIR);
expect(fake.calls.bindExtensions).toHaveLength(1);
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
expect(service.activeCount()).toBe(1);
@@ -53,6 +59,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
let service: PiSessionService | undefined;
try {
service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -79,6 +86,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const fake = fakeRuntime("legacy-session");
const open = vi.fn(() => fakeSessionManager());
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: {
create: () => fakeSessionManager(),
@@ -127,6 +135,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const gateway = sessionGateway([sessionRecord(sessionId)]);
const open = vi.spyOn(gateway, "open");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: emptyArchiveStore(),
createAgentRuntime,
sessionManager: gateway,
@@ -180,6 +189,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
: Promise.resolve(runtime);
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: emptyArchiveStore(),
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord(sessionId)]),
@@ -219,6 +229,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const runtimeResult = deferred<PiSessionRuntime>();
const fake = fakeRuntime(sessionId);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: emptyArchiveStore(),
createAgentRuntime: () => {
createStarted.resolve();
@@ -252,6 +263,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
let rebindSession: ((session: PiAgentSession) => Promise<void>) | undefined;
fake.runtime.setRebindSession = (callback) => { rebindSession = callback; };
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -278,6 +290,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
},
});
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -312,6 +325,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
},
});
service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("idle-session")]),
heartbeatIntervalMs: 1_000,
@@ -347,6 +361,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
},
});
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("completion-session")]),
heartbeatIntervalMs: 60_000,
@@ -365,6 +380,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("uses injected archive and session-manager gateways for listing", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-01T00:00:00.000Z" }]),
get: () => Promise.resolve(undefined),
@@ -394,6 +410,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("lists archived records that have been moved out of the active session directory", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
@@ -424,6 +441,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("runtime-reload-session");
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
heartbeatIntervalMs: 60_000,
@@ -456,6 +474,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("reload-session")]),
heartbeatIntervalMs: 60_000,
@@ -479,6 +498,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("refuses to reload a session that has active work in progress", async () => {
const fake = fakeRuntime("busy-session", { isStreaming: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("busy-session")]),
heartbeatIntervalMs: 60_000,
@@ -493,6 +513,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("refuses to reload an archived session", async () => {
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([]),
get: (sessionId) => Promise.resolve(sessionId === "archived" || "archived".startsWith(sessionId)
@@ -514,6 +535,7 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
it("reconciles workspace activity when listing only archived sessions", async () => {
const reconciliations: { cwd: string; sessionIds: string[] }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
archiveStore: {
list: () => Promise.resolve([{ sessionId: "archived", cwd: "/workspace", archivedAt: "2026-01-02T00:00:00.000Z", originalPath: "/sessions/archived.jsonl", archivePath: "/archive/archived.jsonl", created: "2026-01-01T00:00:00.000Z", modified: "2026-01-01T00:01:00.000Z", messageCount: 2, firstMessage: "bye" }]),
get: () => Promise.resolve(undefined),
@@ -5,10 +5,13 @@ import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService prompt, queue, and auth warnings", () => {
it("sends prompts to an injected runtime without touching the SDK runtime", async () => {
const fake = fakeRuntime("prompt-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
@@ -26,6 +29,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
});
const hub = new CapturingSessionEventHub();
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("echo-session")]),
heartbeatIntervalMs: 60_000,
@@ -55,6 +59,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
return fake.runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
heartbeatIntervalMs: 60_000,
@@ -90,6 +95,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("name-session", { model, agent: { streamFn } });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("name-session")]),
heartbeatIntervalMs: 60_000,
@@ -111,6 +117,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
getFollowUpMessages: () => ["then do this"],
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("status-session")]),
heartbeatIntervalMs: 60_000,
@@ -131,6 +138,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
getFollowUpMessages: () => ["already queued"],
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("dedupe-session")]),
heartbeatIntervalMs: 60_000,
@@ -146,6 +154,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("queued-session", { isStreaming: true });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("queued-session")]),
heartbeatIntervalMs: 60_000,
@@ -171,6 +180,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
return Promise.resolve();
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
heartbeatIntervalMs: 60_000,
@@ -234,6 +244,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
});
fake.session.clearQueue = clearRuntimeQueue;
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
heartbeatIntervalMs: 60_000,
@@ -273,6 +284,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
it("clears an already-empty queue idempotently", async () => {
const fake = fakeRuntime("clear-empty-queue-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
heartbeatIntervalMs: 60_000,
@@ -291,6 +303,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
it("clears queued messages when aborting active work", async () => {
const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-session")]),
heartbeatIntervalMs: 60_000,
@@ -307,6 +320,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
it("clears prompts queued during compaction when aborting active work", async () => {
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
heartbeatIntervalMs: 60_000,
@@ -331,6 +345,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
const fake = fakeRuntime("auth-session", { model, modelRegistry });
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRegistry,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("auth-session")]),
@@ -361,6 +376,7 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
it("clears queued messages when stopping a session runtime", async () => {
const fake = fakeRuntime("stop-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("stop-session")]),
heartbeatIntervalMs: 60_000,
@@ -3,12 +3,15 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => {
describe("spawnSession", () => {
function spawnService(decision: SpawnTargetDecision) {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
const log: { details: Record<string, unknown>; message: string }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
@@ -41,6 +44,7 @@ describe("PiSessionService", () => {
return fake.runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
@@ -75,6 +79,7 @@ describe("PiSessionService", () => {
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("spawned-x");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
@@ -6,6 +6,8 @@ import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModel, type RuntimeCreator } from "./piSessionService.testSupport.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
describe("PiSessionService", () => {
describe("spawnSubsession", () => {
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000, childIds = ["child-1"]) {
@@ -37,6 +39,7 @@ describe("PiSessionService", () => {
isArchived: (sessionId: string) => Promise.resolve(archived.has(sessionId)),
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore,
@@ -77,6 +80,7 @@ describe("PiSessionService", () => {
return runtime;
};
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: sessionGateway([]),
archiveStore: emptyArchiveStore(),
@@ -116,6 +120,7 @@ describe("PiSessionService", () => {
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -167,6 +172,7 @@ describe("PiSessionService", () => {
let index = 0;
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -208,6 +214,7 @@ describe("PiSessionService", () => {
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -232,6 +239,7 @@ describe("PiSessionService", () => {
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -253,6 +261,7 @@ describe("PiSessionService", () => {
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -273,6 +282,7 @@ describe("PiSessionService", () => {
sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(parent.runtime),
sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -293,6 +303,7 @@ describe("PiSessionService", () => {
}),
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(forkedParent.runtime),
sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() },
archiveStore: emptyArchiveStore(),
@@ -331,6 +342,7 @@ describe("PiSessionService", () => {
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: (_createRuntime, options) => {
delegationCapabilities.push(options.delegationToolsEnabled);
const runtime = runtimes[index] ?? parent.runtime;
@@ -393,6 +405,7 @@ describe("PiSessionService", () => {
return childManager;
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -450,6 +463,7 @@ describe("PiSessionService", () => {
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -514,6 +528,7 @@ describe("PiSessionService", () => {
throw new Error(`unexpected open path ${path}`);
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: {
create: () => parentManager,
@@ -589,6 +604,7 @@ describe("PiSessionService", () => {
throw new Error(`unexpected open path ${path}`);
});
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime,
sessionManager: {
create: () => copiedParentManager,
@@ -646,6 +662,7 @@ describe("PiSessionService", () => {
let index = 0;
const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -698,6 +715,7 @@ describe("PiSessionService", () => {
let index = 0;
const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: () => {
const runtime = runtimes[index] ?? parent.runtime;
index += 1;
@@ -738,6 +756,7 @@ describe("PiSessionService", () => {
const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager });
const open = vi.fn(() => childManager);
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(child.runtime),
sessionManager: {
create: () => childManager,
@@ -882,6 +901,7 @@ describe("PiSessionService", () => {
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("nope");
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir: TEST_AGENT_DIR,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
+12 -14
View File
@@ -9,13 +9,12 @@ import {
createAgentSessionServices,
createEditToolDefinition,
defineTool,
getAgentDir,
ModelRegistry,
SessionManager,
type CreateAgentSessionRuntimeFactory,
type EditToolDetails,
} from "@earendil-works/pi-coding-agent";
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionRef, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
import type { ClientArchiveSessionsResponse, ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionCleanupExecuteResponse, ClientSessionCleanupPreviewResponse, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
@@ -23,13 +22,13 @@ import { SessionCommandService } from "./sessionCommandService.js";
import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInput } from "./sessionArchiveStore.js";
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, type SessionArchiveTreeCandidate } from "./sessionArchiveTree.js";
import type { ActiveSession } from "./sessionRuntimeStore.js";
import type { AuthChange } from "./authService.js";
import { createModelRegistryForAgentDir, type AuthChange } from "./authService.js";
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js";
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
@@ -132,9 +131,8 @@ type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "arch
deleteArchivedMany?: (sessionIds: readonly string[]) => Promise<string[]>;
};
export type PiSessionRef = ClientSessionRef;
type PiSessionLookup = string | PiSessionRef;
export type PiSessionRef = SessionRouteRef;
type PiSessionLookup = SessionRouteLookup;
export interface PiSessionListEntry {
id: string;
@@ -380,9 +378,9 @@ function createPiWebEditToolDefinition(cwd: string) {
}
export interface PiSessionServiceDependencies {
agentDir: string;
sessionManager: PiSessionManagerGateway;
archiveStore?: SessionArchiveRepository;
agentDir?: string;
sessionManager?: PiSessionManagerGateway;
createRuntime?: PiWebCreateAgentSessionRuntimeFactory;
createAgentRuntime?: CreateAgentRuntime;
modelRegistry?: ModelRegistryInstance;
@@ -407,7 +405,7 @@ export interface PiSessionServiceDependencies {
now?: () => Date;
}
export class PiSessionService {
export class PiSessionService implements SessionRouteService {
private readonly active = new Map<string, ActiveSession<PiSessionRuntime>>();
private readonly pendingSessionOpens = new Map<string, PendingSessionOpen>();
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
@@ -441,11 +439,11 @@ export class PiSessionService {
private readonly logger: PiSessionLogger;
private readonly now: () => Date;
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
this.agentDir = deps.agentDir ?? getAgentDir();
this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
this.agentDir = deps.agentDir;
this.sessionManager = deps.sessionManager;
this.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir);
this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger;
this.now = deps.now ?? (() => new Date());
+63 -32
View File
@@ -4,10 +4,13 @@ import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js";
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
let app: FastifyInstance;
let service: PiSessionService;
let sessionManager: RejectingSessionManager;
@@ -17,7 +20,7 @@ beforeEach(async () => {
await app.register(fastifyWebsocket);
sessionManager = new RejectingSessionManager();
const eventHub = new SessionEventHub();
service = new PiSessionService(eventHub, { sessionManager, heartbeatIntervalMs: 60_000 });
service = new PiSessionService(eventHub, { agentDir: TEST_AGENT_DIR, sessionManager, heartbeatIntervalMs: 60_000 });
registerSessionRoutes(app, service, eventHub);
});
@@ -39,7 +42,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
@@ -59,7 +62,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
const thinkingBlock = { type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true };
const message = { role: "assistant", content: [thinkingBlock, { type: "text", text: "visible answer" }] };
routeService.messagesResponse = { messages: [message], start: 0, total: 1 };
@@ -85,7 +88,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
const attachments = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
@@ -107,7 +110,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
@@ -130,7 +133,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
@@ -150,7 +153,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
routeService.reloadError = new Error("Stop current session activity before reloading");
registerSessionRoutes(routeApp, routeService, eventHub);
@@ -169,7 +172,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
@@ -198,7 +201,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
routeService.clearQueueError = new Error("Archived sessions are read-only. Restore the session to continue.");
registerSessionRoutes(routeApp, routeService, eventHub);
@@ -218,7 +221,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
@@ -239,7 +242,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
@@ -258,7 +261,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
@@ -282,7 +285,7 @@ describe("session routes", () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService(eventHub);
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
@@ -298,10 +301,10 @@ describe("session routes", () => {
});
});
class CapturingRouteSessionService extends PiSessionService {
class CapturingRouteSessionService implements SessionRouteService {
readonly calls: unknown[] = [];
readonly reloadCalls: (string | PiSessionRef)[] = [];
readonly clearQueueCalls: (string | PiSessionRef)[] = [];
readonly reloadCalls: SessionRouteLookup[] = [];
readonly clearQueueCalls: SessionRouteLookup[] = [];
messagesResponse: unknown[] | MessagePage = [];
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
@@ -310,37 +313,40 @@ class CapturingRouteSessionService extends PiSessionService {
reloadError: Error | undefined;
clearQueueError: Error | undefined;
constructor(eventHub: SessionEventHub) {
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
}
override cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
this.cleanupPreviewCalls.push(request);
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
}
override cleanup(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupExecuteResponse> {
cleanup(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupExecuteResponse> {
this.cleanupCalls.push(request);
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 }, archivedSessionIds: [], deletedSessionIds: [] });
}
override archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse> {
this.bulkArchiveCalls.push([...refs]);
return Promise.resolve({ archived: true, archivedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
}
override deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse> {
this.bulkDeleteCalls.push([...refs]);
return Promise.resolve({ deleted: true, deletedSessionIds: refs.map((ref) => ref.id), failures: [], generatedAt: "2026-06-25T00:00:00.000Z" });
}
override reload(lookup: string | PiSessionRef): Promise<void> {
reload(lookup: SessionRouteLookup): Promise<void> {
this.reloadCalls.push(lookup);
if (this.reloadError !== undefined) return Promise.reject(this.reloadError);
return Promise.resolve();
}
override clearQueue(lookup: string | PiSessionRef): Promise<SessionStatus> {
dispose(): Promise<void> {
return Promise.resolve();
}
list(): never { throw unusedRouteMethod("list"); }
start(): never { throw unusedRouteMethod("start"); }
clearQueue(lookup: SessionRouteLookup): Promise<SessionStatus> {
this.clearQueueCalls.push(lookup);
if (this.clearQueueError !== undefined) return Promise.reject(this.clearQueueError);
return Promise.resolve({
@@ -355,11 +361,11 @@ class CapturingRouteSessionService extends PiSessionService {
});
}
override messages(): Promise<unknown[] | MessagePage> {
messages(): Promise<unknown[] | MessagePage> {
return Promise.resolve(this.messagesResponse);
}
override status(lookup: string | PiSessionRef) {
status(lookup: SessionRouteLookup) {
this.calls.push(lookup);
return Promise.resolve({
sessionId: sessionIdFromLookup(lookup),
@@ -373,12 +379,20 @@ class CapturingRouteSessionService extends PiSessionService {
});
}
override prompt(lookup: string | PiSessionRef, text: unknown, _streamingBehavior?: unknown, attachments?: unknown): Promise<void> {
availableModels(): Promise<[]> { return Promise.resolve([]); }
setModel(): never { throw unusedRouteMethod("setModel"); }
cycleModel(): never { throw unusedRouteMethod("cycleModel"); }
availableThinkingLevels(): Promise<[]> { return Promise.resolve([]); }
setThinkingLevel(): never { throw unusedRouteMethod("setThinkingLevel"); }
cycleThinkingLevel(): never { throw unusedRouteMethod("cycleThinkingLevel"); }
commands(): Promise<[]> { return Promise.resolve([]); }
prompt(lookup: SessionRouteLookup, text: unknown, _streamingBehavior?: unknown, attachments?: unknown): Promise<void> {
this.calls.push(attachments === undefined ? { lookup, text } : { lookup, text, attachments });
return Promise.resolve();
}
override saveAttachments(_lookup: string | PiSessionRef, attachments: unknown, folder?: string) {
saveAttachments(_lookup: SessionRouteLookup, attachments: unknown, folder?: string) {
const list = Array.isArray(attachments) ? attachments : [];
return Promise.resolve(list.map((attachment: { mimeType: string; data: string; name?: string }) => ({
path: `${folder ?? ".pi-web/attachments"}/${attachment.name ?? "file.png"}`,
@@ -386,6 +400,19 @@ class CapturingRouteSessionService extends PiSessionService {
size: Buffer.from(attachment.data, "base64").byteLength,
})));
}
shell(): never { throw unusedRouteMethod("shell"); }
runCommand(): never { throw unusedRouteMethod("runCommand"); }
respondToCommand(): never { throw unusedRouteMethod("respondToCommand"); }
abort(): never { throw unusedRouteMethod("abort"); }
stop(): never { throw unusedRouteMethod("stop"); }
archive(): never { throw unusedRouteMethod("archive"); }
archiveTree(): never { throw unusedRouteMethod("archiveTree"); }
restore(): never { throw unusedRouteMethod("restore"); }
deleteArchived(): never { throw unusedRouteMethod("deleteArchived"); }
detachParent(): never { throw unusedRouteMethod("detachParent"); }
}
class RejectingSessionManager implements PiSessionManagerGateway {
@@ -412,6 +439,10 @@ class RejectingSessionManager implements PiSessionManagerGateway {
}
}
function sessionIdFromLookup(lookup: string | PiSessionRef): string {
function sessionIdFromLookup(lookup: SessionRouteLookup): string {
return typeof lookup === "string" ? lookup : lookup.id;
}
function unusedRouteMethod(name: string): Error {
return new Error(`Route test did not expect ${name} to be called`);
}
+5 -5
View File
@@ -3,10 +3,10 @@ import type { SessionBulkMutationRequest, SessionBulkMutationRef, SessionCleanup
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
import { normalizeRequestCwd } from "../workingDirectory.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
import { normalizeSessionCleanupRequest } from "./sessionCleanup.js";
type SessionLookup = string | PiSessionRef;
type SessionLookup = SessionRouteLookup;
interface SessionQuery {
cwd?: string;
@@ -30,7 +30,7 @@ interface AttachmentsRequestBody {
folder?: unknown;
}
export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void {
export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRouteService, eventHub: SessionEventHub, prefix = ""): void {
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try {
@@ -230,9 +230,9 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/stop`, (request, reply) => {
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/stop`, async (request, reply) => {
try {
sessions.stop(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
await sessions.stop(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
return { stopped: true };
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
+62
View File
@@ -0,0 +1,62 @@
import type {
SavedPromptAttachment,
SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse,
SessionBulkMutationRef,
} from "../../shared/apiTypes.js";
import type {
ClientArchiveSessionsResponse,
ClientCommand,
ClientCommandResult,
ClientMessagePage,
ClientSession,
ClientSessionCleanupExecuteResponse,
ClientSessionCleanupPreviewResponse,
ClientSessionModel,
ClientSessionRef,
ClientSessionStatus,
ClientThinkingLevel,
} from "../types.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
export type SessionRouteRef = ClientSessionRef;
export type SessionRouteLookup = string | SessionRouteRef;
/**
* Route-facing session contract for PI WEB's HTTP/WebSocket API.
*
* Keep transport concerns separate from the bundled Pi SDK implementation so
* routes remain testable. Pi-specific lifecycle hooks such as auth-change
* handling and daemon shutdown stay on the concrete service.
*/
export interface SessionRouteService {
list(cwd: string): Promise<ClientSession[]>;
start(cwd: string): Promise<ClientSession>;
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
cycleModel(ref: SessionRouteLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus>;
availableThinkingLevels(ref: SessionRouteLookup): Promise<ClientThinkingLevel[]>;
setThinkingLevel(ref: SessionRouteLookup, level: string): Promise<ClientSessionStatus>;
cycleThinkingLevel(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
commands(ref: SessionRouteLookup): Promise<ClientCommand[]>;
prompt(ref: SessionRouteLookup, text: unknown, streamingBehavior?: unknown, attachments?: unknown): Promise<void>;
saveAttachments(ref: SessionRouteLookup, attachments: unknown, folder?: string): Promise<SavedPromptAttachment[]>;
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupPreviewResponse>;
cleanup(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupExecuteResponse>;
archiveMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkArchiveResponse>;
deleteArchivedMany(refs: readonly SessionBulkMutationRef[]): Promise<SessionBulkDeleteArchivedResponse>;
shell(ref: SessionRouteLookup, text: string): Promise<void>;
runCommand(ref: SessionRouteLookup, text: string): Promise<ClientCommandResult>;
respondToCommand(ref: SessionRouteLookup, requestId: string, value: string): Promise<ClientCommandResult>;
abort(ref: SessionRouteLookup): Promise<void>;
stop(ref: SessionRouteLookup): void | Promise<void>;
archive(ref: SessionRouteLookup): Promise<void>;
archiveTree(ref: SessionRouteLookup): Promise<ClientArchiveSessionsResponse>;
restore(ref: SessionRouteLookup): Promise<void>;
deleteArchived(ref: SessionRouteLookup): Promise<void>;
reload(ref: SessionRouteLookup): Promise<void>;
detachParent(ref: SessionRouteLookup): Promise<void>;
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { EffectivePiWebAgentConfig } from "../config.js";
import { createActiveAgentProfileDescriptor } from "./activeAgentProfile.js";
const baseAgent: EffectivePiWebAgentConfig = {
command: "acme-agent",
dir: "/opt/acme-agent/state",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
describe("active agent profile descriptor", () => {
it("builds a stable revision from every effective profile field", () => {
const first = createActiveAgentProfileDescriptor(baseAgent);
const second = createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: [...baseAgent.sessionDirEnvKeys] });
expect(second).toEqual(first);
expect(first.revision).toMatch(/^sha256:[0-9a-f]{64}$/u);
expect(createActiveAgentProfileDescriptor({ ...baseAgent, command: "other-agent" }).revision).not.toBe(first.revision);
expect(createActiveAgentProfileDescriptor({ ...baseAgent, dir: "/other/state" }).revision).not.toBe(first.revision);
expect(createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys: ["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", () => {
const sessionDirEnvKeys = ["PI_WEB_AGENT_SESSION_DIR"];
const profile = createActiveAgentProfileDescriptor({ ...baseAgent, sessionDirEnvKeys });
sessionDirEnvKeys.push("LATE_MUTATION");
expect(Object.isFrozen(profile)).toBe(true);
expect(Object.isFrozen(profile.sessionDirEnvKeys)).toBe(true);
expect(profile.sessionDirEnvKeys).toEqual(["PI_WEB_AGENT_SESSION_DIR"]);
expect(Reflect.set(profile, "command", "mutated-agent")).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", () => {
const input = {
...baseAgent,
token: "must-not-cross-the-protocol",
auth: { apiKey: "also-secret" },
};
const profile = createActiveAgentProfileDescriptor(input);
expect(profile).toEqual({
schemaVersion: 1,
revision: profile.revision,
command: baseAgent.command,
dir: baseAgent.dir,
sessionDirEnvKeys: baseAgent.sessionDirEnvKeys,
});
expect(profile.revision).toMatch(/^sha256:[0-9a-f]{64}$/u);
expect(JSON.stringify(profile)).not.toContain("secret");
});
});
+34
View File
@@ -0,0 +1,34 @@
import { createHash } from "node:crypto";
import { isHostAbsoluteAgentDir, isSafeAgentCommandForHost, PI_CODING_AGENT_SESSION_DIR_ENV, PI_WEB_AGENT_SESSION_DIR_ENV, type EffectivePiWebAgentConfig } from "../config.js";
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
import { ACTIVE_AGENT_PROFILE_SCHEMA_VERSION } from "../shared/activeAgentProfile.js";
export function createActiveAgentProfileDescriptor(agent: EffectivePiWebAgentConfig): ActiveAgentProfileDescriptor {
if (!isSafeAgentCommandForHost(agent.command) || !isHostAbsoluteAgentDir(agent.dir)) {
throw new Error("Active agent profile command and directory must be valid for this host");
}
if (!hasValidSessionDirEnvKeys(agent.sessionDirEnvKeys)) {
throw new Error("Active agent profile session directory environment keys must use the explicit PI WEB policy");
}
const sessionDirEnvKeys = Object.freeze([...agent.sessionDirEnvKeys]);
const revisionInput = JSON.stringify({
schemaVersion: ACTIVE_AGENT_PROFILE_SCHEMA_VERSION,
command: agent.command,
dir: agent.dir,
sessionDirEnvKeys,
});
return Object.freeze({
schemaVersion: ACTIVE_AGENT_PROFILE_SCHEMA_VERSION,
revision: `sha256:${createHash("sha256").update(revisionInput).digest("hex")}`,
command: agent.command,
dir: agent.dir,
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);
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import { SessionDaemonClient } from "./sessionDaemonClient.js";
const activeAgentProfile = {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command: "acme-agent",
dir: "/opt/acme-agent/state",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
describe("SessionDaemonClient active agent profile protocol", () => {
it("returns the validated immutable profile from the daemon runtime endpoint", async () => {
const client = new SessionDaemonClient();
const request = vi.spyOn(client, "request").mockResolvedValue(runtimeResponse(activeAgentProfile));
const result = await client.getActiveAgentProfile();
expect(request).toHaveBeenCalledWith("GET", "/runtime");
expect(result).toEqual({ status: "available", profile: activeAgentProfile });
if (result.status === "available") {
expect(Object.isFrozen(result.profile)).toBe(true);
expect(Object.isFrozen(result.profile.sessionDirEnvKeys)).toBe(true);
}
});
it("distinguishes invalid protocol responses from daemon unavailability", async () => {
const invalidClient = new SessionDaemonClient();
vi.spyOn(invalidClient, "request").mockResolvedValue(runtimeResponse({
...activeAgentProfile,
token: "must-not-cross-the-protocol",
}));
const unavailableClient = new SessionDaemonClient();
vi.spyOn(unavailableClient, "request").mockRejectedValue(new Error("connect ECONNREFUSED"));
await expect(invalidClient.getActiveAgentProfile()).resolves.toEqual({
status: "invalid",
error: "session daemon runtime response was invalid",
});
await expect(unavailableClient.getActiveAgentProfile()).resolves.toEqual({
status: "unavailable",
error: "connect ECONNREFUSED",
});
});
it.skipIf(process.platform === "win32")("rejects foreign-platform active state paths before local consumers use them", async () => {
const client = new SessionDaemonClient();
vi.spyOn(client, "request").mockResolvedValue(runtimeResponse({
...activeAgentProfile,
dir: "C:\\agent-profiles\\acme",
}));
await expect(client.getActiveAgentProfile()).resolves.toEqual({
status: "invalid",
error: "session daemon active agent profile was not valid for this host",
});
});
it("treats a legacy runtime response without a profile as invalid for profile-dependent work", async () => {
const client = new SessionDaemonClient();
vi.spyOn(client, "request").mockResolvedValue(runtimeResponse(undefined));
await expect(client.getActiveAgentProfile()).resolves.toEqual({
status: "invalid",
error: "session daemon runtime response did not include an active agent profile",
});
});
});
function runtimeResponse(profile: unknown) {
return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
component: "sessiond",
label: "Session daemon",
available: true,
capabilities: [],
...(profile === undefined ? {} : { activeAgentProfile: profile }),
}),
};
}
+52
View File
@@ -1,7 +1,19 @@
import http from "node:http";
import { WebSocket } from "ws";
import { isHostAbsoluteAgentDir, isSafeAgentCommandForHost } from "../config.js";
import type { ActiveAgentProfileDescriptor } from "../shared/apiTypes.js";
import { parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
import { sessiondHttpUrl, sessiondSocketPath } from "./config.js";
export type SessionDaemonAgentProfileResult =
| { status: "available"; profile: ActiveAgentProfileDescriptor }
| { status: "unavailable"; error: string }
| { status: "invalid"; error: string };
export interface SessionDaemonRequestClient {
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
}
export class SessionDaemonClient {
private readonly baseUrl = sessiondHttpUrl();
private readonly socketPath = sessiondSocketPath();
@@ -12,6 +24,10 @@ export class SessionDaemonClient {
return this.requestSocket(method, path, payload);
}
getActiveAgentProfile(): Promise<SessionDaemonAgentProfileResult> {
return getSessionDaemonActiveAgentProfile(this);
}
connectWebSocket(path: string): WebSocket {
if (this.baseUrl !== undefined && this.baseUrl !== "") {
const url = new URL(path, this.baseUrl);
@@ -66,3 +82,39 @@ export class SessionDaemonClient {
});
}
}
export async function getSessionDaemonActiveAgentProfile(client: SessionDaemonRequestClient): Promise<SessionDaemonAgentProfileResult> {
let response: Awaited<ReturnType<SessionDaemonRequestClient["request"]>>;
try {
response = await client.request("GET", "/runtime");
} catch (error) {
return { status: "unavailable", error: errorMessage(error) };
}
if (response.statusCode < 200 || response.statusCode >= 300) {
return { status: "unavailable", error: `session daemon runtime request returned HTTP ${String(response.statusCode)}` };
}
let value: unknown;
try {
value = response.body === "" ? undefined : JSON.parse(response.body);
} catch {
return { status: "invalid", error: "session daemon runtime response was not valid JSON" };
}
const runtime = parsePiWebRuntimeComponent(value);
if (runtime?.component !== "sessiond") {
return { status: "invalid", error: "session daemon runtime response was invalid" };
}
if (runtime.activeAgentProfile === undefined) {
return { status: "invalid", error: "session daemon runtime response did not include an active agent profile" };
}
if (!isSafeAgentCommandForHost(runtime.activeAgentProfile.command) || !isHostAbsoluteAgentDir(runtime.activeAgentProfile.dir)) {
return { status: "invalid", error: "session daemon active agent profile was not valid for this host" };
}
return { status: "available", profile: runtime.activeAgentProfile };
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+76
View File
@@ -0,0 +1,76 @@
import type { ActiveAgentProfileDescriptor } from "./apiTypes.js";
export const ACTIVE_AGENT_PROFILE_SCHEMA_VERSION = 1 as const;
export function isPiCompanionCommand(command: string): boolean {
const name = command.split(/[\\/]/u).at(-1)?.toLowerCase() ?? command.toLowerCase();
return name.replace(/(?:\.[cm]?js|\.exe|\.cmd)$/iu, "") === "pi";
}
export function usesPiCodingAgentStateCompatibility(command: string): boolean {
return !command.includes("/") && !command.includes("\\") && isPiCompanionCommand(command);
}
const ACTIVE_AGENT_PROFILE_FIELDS = new Set([
"schemaVersion",
"revision",
"command",
"dir",
"sessionDirEnvKeys",
]);
const SHA256_REVISION_PATTERN = /^sha256:[0-9a-f]{64}$/u;
const SAFE_BARE_AGENT_COMMAND_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/u;
const ACTIVE_SESSION_DIR_ENV_KEYS = new Set(["PI_WEB_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"]);
export function parseActiveAgentProfileDescriptor(value: unknown): ActiveAgentProfileDescriptor | undefined {
if (!isRecord(value) || Object.keys(value).some((key) => !ACTIVE_AGENT_PROFILE_FIELDS.has(key))) return undefined;
const schemaVersion = value["schemaVersion"];
const revision = value["revision"];
const command = value["command"];
const dir = value["dir"];
const sessionDirEnvKeys = value["sessionDirEnvKeys"];
if (schemaVersion !== ACTIVE_AGENT_PROFILE_SCHEMA_VERSION) return undefined;
if (typeof revision !== "string" || !SHA256_REVISION_PATTERN.test(revision)) return undefined;
if (typeof command !== "string" || !isPortableAgentCommand(command)) return undefined;
if (typeof dir !== "string" || !isPortableAbsolutePath(dir)) return undefined;
if (!isNonEmptyStringArray(sessionDirEnvKeys)) return undefined;
if (new Set(sessionDirEnvKeys).size !== sessionDirEnvKeys.length) return undefined;
if (sessionDirEnvKeys[0] !== "PI_WEB_AGENT_SESSION_DIR" || sessionDirEnvKeys.some((key) => !ACTIVE_SESSION_DIR_ENV_KEYS.has(key))) return undefined;
return Object.freeze({
schemaVersion,
revision,
command,
dir,
sessionDirEnvKeys: Object.freeze([...sessionDirEnvKeys]),
});
}
function isPortableAgentCommand(value: string): boolean {
if (value !== value.trim() || /[\s;&|`$<>]/u.test(value)) return false;
if (isPortableAbsolutePath(value)) return !value.endsWith("/") && !value.endsWith("\\");
return SAFE_BARE_AGENT_COMMAND_PATTERN.test(value);
}
function isPortableAbsolutePath(value: string): boolean {
if (value === "" || value !== value.trim() || hasControlCharacter(value)) return false;
const withForwardSlashes = value.replace(/\\/g, "/");
return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes);
}
function hasControlCharacter(value: string): boolean {
for (const character of value) {
const code = character.charCodeAt(0);
if (code < 32 || code === 127) return true;
}
return false;
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry: unknown) => typeof entry === "string" && entry !== "");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+28
View File
@@ -12,6 +12,7 @@ export const PI_WEB_CAPABILITIES = {
workspaceFileSuggestions: "workspace.fileSuggestions",
piPackagesManage: "piPackages.manage",
selectedMachineSettings: "settings.selectedMachine",
agentProfileConfig: "settings.agentProfile",
} as const;
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
@@ -66,6 +67,13 @@ export interface PiWebUploadsConfig {
defaultFolder?: string;
}
export interface PiWebAgentConfig {
/** Pi-compatible companion CLI used for diagnostics and safe package-managed updates. */
command?: string;
/** Pi-compatible profile directory containing auth.json, models.json, settings.json, and sessions/. */
dir?: string;
}
export interface PiWebConfigValues {
host?: string;
port?: number;
@@ -87,6 +95,8 @@ export interface PiWebConfigValues {
* while the capability stabilizes. Requires spawnSessions to be enabled.
*/
subsessions?: boolean;
/** Desired Pi-compatible agent profile and companion CLI (Pi by default). */
agent?: PiWebAgentConfig;
}
export type PiWebPluginScope = "bundled" | "local" | "user" | "project";
@@ -141,12 +151,19 @@ export interface PiPackageMutationResponse extends PiPackagesResponse {
removed?: boolean;
}
export type PiWebAgentDirEnvSource = "pi-web" | "pi-compatibility";
export interface PiWebConfigEnvOverrides {
host: boolean;
port: boolean;
allowedHosts: boolean;
spawnSessions: boolean;
subsessions: boolean;
agentCommand: boolean;
agentDir: boolean;
/** The configured directory environment source, even when Pi compatibility is inactive for the desired command. */
agentDirSource?: PiWebAgentDirEnvSource;
agentSessionDir: boolean;
}
export interface PiWebConfigResponse {
@@ -582,12 +599,23 @@ export interface PiWebComponentStatus {
error?: string;
}
/** Secret-free identity of the Pi-compatible CLI/state profile fixed for one sessiond lifetime. */
export interface ActiveAgentProfileDescriptor {
readonly schemaVersion: 1;
readonly revision: string;
readonly command: string;
readonly dir: string;
readonly sessionDirEnvKeys: readonly string[];
}
export interface PiWebRuntimeComponent {
component: PiWebServiceComponent;
label: string;
runtimeVersion?: string;
available: boolean;
capabilities: PiWebCapability[];
/** Present only for a session daemon that supports active-profile reporting. */
activeAgentProfile?: ActiveAgentProfileDescriptor;
error?: string;
}
+4 -2
View File
@@ -5,13 +5,15 @@ describe("PI WEB capabilities", () => {
it("advertises web-only capabilities without requiring session daemon support", () => {
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.agentProfileConfig);
expect(effectivePiWebCapabilities({
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig] },
sessiond: { available: false, capabilities: [] },
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig]);
});
it("requires web and session daemon support for authoritative session persistence", () => {
+2
View File
@@ -17,6 +17,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
PI_WEB_CAPABILITIES.piPackagesManage,
PI_WEB_CAPABILITIES.selectedMachineSettings,
PI_WEB_CAPABILITIES.agentProfileConfig,
] as const satisfies readonly PiWebCapability[];
export const SESSIOND_RUNTIME_CAPABILITIES = [
@@ -40,6 +41,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
[PI_WEB_CAPABILITIES.selectedMachineSettings]: ["web"],
[PI_WEB_CAPABILITIES.agentProfileConfig]: ["web"],
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
export function isPiWebCapability(value: unknown): value is PiWebCapability {
+57 -4
View File
@@ -8,16 +8,16 @@ describe("PI WEB status parsing", () => {
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"] },
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"] },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] },
},
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"],
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig, "future.capability"],
})).toMatchObject({
components: {
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig] },
sessiond: { capabilities: [] },
},
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings],
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, PI_WEB_CAPABILITIES.agentProfileConfig],
});
});
@@ -33,6 +33,59 @@ describe("PI WEB status parsing", () => {
})).toBeUndefined();
});
it("parses and freezes a session daemon active agent profile", () => {
const parsed = parsePiWebRuntimeResponse({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", available: true, capabilities: [] },
sessiond: {
component: "sessiond",
label: "Session daemon",
available: true,
capabilities: [],
activeAgentProfile: {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command: "acme-agent",
dir: "/opt/acme-agent/state",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
},
},
},
capabilities: [],
});
expect(parsed?.components.sessiond.activeAgentProfile).toMatchObject({ command: "acme-agent", dir: "/opt/acme-agent/state" });
expect(Object.isFrozen(parsed?.components.sessiond.activeAgentProfile)).toBe(true);
expect(Object.isFrozen(parsed?.components.sessiond.activeAgentProfile?.sessionDirEnvKeys)).toBe(true);
});
it("rejects malformed, secret-bearing, or web-owned active profile descriptors", () => {
const profile = {
schemaVersion: 1,
revision: `sha256:${"a".repeat(64)}`,
command: "acme-agent",
dir: "/opt/acme-agent/state",
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR"],
};
const responseFor = (webProfile: unknown, sessiondProfile: unknown) => ({
packageName: "@jmfederico/pi-web",
generatedAt: "now",
components: {
web: { component: "web", label: "Web/UI", available: true, capabilities: [], ...(webProfile === undefined ? {} : { activeAgentProfile: webProfile }) },
sessiond: { component: "sessiond", label: "Session daemon", available: true, capabilities: [], ...(sessiondProfile === undefined ? {} : { activeAgentProfile: sessiondProfile }) },
},
capabilities: [],
});
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();
});
it("parses Docker installation metadata", () => {
expect(parsePiWebInstallationInfo({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" })).toEqual({
kind: "docker",
+5
View File
@@ -1,4 +1,5 @@
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebVersionResponse } from "./apiTypes.js";
import { parseActiveAgentProfileDescriptor } from "./activeAgentProfile.js";
import { parseKnownPiWebCapabilities } from "./capabilities.js";
export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
@@ -33,15 +34,19 @@ export function parsePiWebRuntimeComponent(value: unknown): PiWebRuntimeComponen
const runtimeVersion = value["runtimeVersion"];
const available = value["available"];
const capabilities = parseKnownPiWebCapabilities(value["capabilities"]);
const activeAgentProfileValue = value["activeAgentProfile"];
const activeAgentProfile = activeAgentProfileValue === undefined ? undefined : parseActiveAgentProfileDescriptor(activeAgentProfileValue);
const error = value["error"];
if (component !== "web" && component !== "sessiond") return undefined;
if (typeof label !== "string" || label === "" || typeof available !== "boolean" || capabilities === undefined) return undefined;
if (activeAgentProfileValue !== undefined && (component !== "sessiond" || activeAgentProfile === undefined)) return undefined;
return {
component,
label,
...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
available,
capabilities,
...(activeAgentProfile === undefined ? {} : { activeAgentProfile }),
...(typeof error === "string" ? { error } : {}),
};
}