Archived
feat: add OMP runtime support
This commit is contained in:
+37
-4
@@ -2,9 +2,11 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { commandWithVersionCheck, isCliEntrypoint } from "./cli.js";
|
||||
import { agentCommandForChecks, commandWithVersionCheck, isCliEntrypoint } 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) {
|
||||
@@ -12,25 +14,56 @@ 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/omp")).toBe("command -v '/tmp/agent'\\''s/omp' && ('/tmp/agent'\\''s/omp' --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: "omp" } })}\n`);
|
||||
process.env["PI_WEB_CONFIG"] = configPath;
|
||||
delete process.env["PI_WEB_AGENT_COMMAND"];
|
||||
|
||||
expect(agentCommandForChecks()).toBe("omp");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCliEntrypoint", () => {
|
||||
|
||||
+18
-8
@@ -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, effectiveAgentConfig, effectivePiWebConfig, examplePiWebConfig } from "./config.js";
|
||||
import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js";
|
||||
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
|
||||
|
||||
@@ -178,7 +178,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 ${shellQuote(command)}`]).status === 0;
|
||||
}
|
||||
|
||||
function isLingerEnabled(): boolean | undefined {
|
||||
@@ -898,16 +898,21 @@ function systemdUserServiceShellCommand(command: string, cwd?: string): string[]
|
||||
];
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function commandCheck(command: string): string {
|
||||
return `command -v ${command}`;
|
||||
return `command -v ${shellQuote(command)}`;
|
||||
}
|
||||
|
||||
export function commandWithVersionCheck(command: string): string {
|
||||
const found = commandCheck(command);
|
||||
const commandWord = shellQuote(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 {
|
||||
@@ -917,14 +922,19 @@ function nodeVersionCheck(): string {
|
||||
].join(" && ");
|
||||
}
|
||||
|
||||
export function agentCommandForChecks(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return effectiveAgentConfig(env, effectivePiWebConfig({ env }).config).command;
|
||||
}
|
||||
|
||||
function doctorChecks(): Check[] {
|
||||
const shell = serviceShellLabel();
|
||||
const backend = currentServiceBackend();
|
||||
const agentCommand = agentCommandForChecks();
|
||||
if (backend === undefined) {
|
||||
return [
|
||||
[`${shell} can find node >= 22`, serviceShellCommand(nodeVersionCheck())],
|
||||
[`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
|
||||
[`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
|
||||
[`${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -932,12 +942,12 @@ function doctorChecks(): Check[] {
|
||||
...backendAvailabilityChecks(backend),
|
||||
...baseShellChecks(backend),
|
||||
[`${shell} can find npm`, serviceShellCommand(commandWithVersionCheck("npm"))],
|
||||
[`${shell} can find pi`, serviceShellCommand(commandWithVersionCheck("pi"))],
|
||||
[`${shell} can find ${agentCommand}`, serviceShellCommand(commandWithVersionCheck(agentCommand))],
|
||||
];
|
||||
const executables = resolveServiceExecutables(backend);
|
||||
checks.push(...executables.web.checks, ...executables.sessiond.checks);
|
||||
if (backend.kind === "systemd") {
|
||||
checks.push([`systemd user ${shell} can find pi`, systemdUserServiceShellCommand(commandWithVersionCheck("pi"))]);
|
||||
checks.push([`systemd user ${shell} can find ${agentCommand}`, systemdUserServiceShellCommand(commandWithVersionCheck(agentCommand))]);
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ describe("API parsers", () => {
|
||||
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: "omp", dir: "~/.omp/agent" } },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, 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: "omp", dir: "~/.omp/agent" } },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "omp", dir: "/Users/dev/.omp/agent" } },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentSessionDir: false },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -531,11 +531,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;
|
||||
@@ -598,7 +608,16 @@ 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,
|
||||
agentSessionDir: optionalBoolean(record, "agentSessionDir") ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse {
|
||||
|
||||
@@ -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 configured agent 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 configured agent <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>
|
||||
|
||||
@@ -21,6 +21,9 @@ 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 agentDirOverridden = config?.envOverrides.agentDir === true;
|
||||
const effectiveAgent = config?.effectiveConfig.agent;
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
@@ -36,6 +39,40 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
<span>Config file</span>
|
||||
<code>${config?.path ?? "Unknown"}</code>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Agent command for diagnostics</span>
|
||||
${agentCommandOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<input
|
||||
class="text-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
.value=${config?.config.agent?.command ?? ""}
|
||||
placeholder="pi"
|
||||
?disabled=${this.loading || this.saving || agentCommandOverridden}
|
||||
@change=${(event: Event) => { void this.saveAgentField("command", event); }}
|
||||
>
|
||||
<small>Use <code>omp</code> to make doctor/update checks target Oh My Pi. The embedded session runtime remains PI WEB's SDK path, so this does not dynamically load a different agent implementation.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Agent state directory</span>
|
||||
${agentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<input
|
||||
class="text-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
.value=${config?.config.agent?.dir ?? ""}
|
||||
placeholder="~/.pi/agent or ~/.omp/agent"
|
||||
?disabled=${this.loading || this.saving || agentDirOverridden}
|
||||
@change=${(event: Event) => { void this.saveAgentField("dir", event); }}
|
||||
>
|
||||
<small>Choose which compatible auth, models, settings, and sessions PI WEB reads. For OMP, set this to <code>~/.omp/agent</code>, then restart the session daemon.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allow agents to start sessions</span>
|
||||
@@ -72,6 +109,8 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
<section class="effective-card" aria-label="Effective configuration summary">
|
||||
<h3>Effective after environment overrides</h3>
|
||||
<dl>
|
||||
<div><dt>Agent command</dt><dd>${effectiveAgent?.command ?? html`<span class="muted">pi default</span>`}</dd></div>
|
||||
<div><dt>Agent state</dt><dd>${effectiveAgent?.dir ?? html`<span class="muted">Pi default</span>`}</dd></div>
|
||||
<div><dt>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,6 +125,28 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async saveAgentField(field: "command" | "dir", event: Event): Promise<void> {
|
||||
if (!(event.target instanceof HTMLInputElement)) return;
|
||||
const value = event.target.value.trim();
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
const nextConfig: PiWebConfigValues = { ...baseConfig };
|
||||
const nextAgent: NonNullable<PiWebConfigValues["agent"]> = { ...(baseConfig.agent ?? {}) };
|
||||
if (field === "command") {
|
||||
if (value === "") delete nextAgent.command;
|
||||
else nextAgent.command = value;
|
||||
} else if (value === "") {
|
||||
delete nextAgent.dir;
|
||||
} else {
|
||||
nextAgent.dir = value;
|
||||
}
|
||||
if (nextAgent.command === undefined && nextAgent.dir === undefined) {
|
||||
delete nextConfig.agent;
|
||||
} else {
|
||||
nextConfig.agent = nextAgent;
|
||||
}
|
||||
await this.onSave?.(nextConfig);
|
||||
}
|
||||
|
||||
private async toggleSpawnSessions(event: Event): Promise<void> {
|
||||
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
@@ -124,6 +185,8 @@ 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%; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 8px 9px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.text-input:disabled { opacity: .65; 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; }
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("settings config drafts", () => {
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "example.local, 192.168.1.20\n",
|
||||
allowedPathsText: "/tmp\n~/SDKs\n",
|
||||
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 })).toEqual({
|
||||
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "omp", dir: "~/.omp/agent" } })).toEqual({
|
||||
host: "127.0.0.1",
|
||||
port: 9000,
|
||||
allowedHosts: ["example.local", "192.168.1.20"],
|
||||
@@ -29,6 +29,7 @@ describe("settings config drafts", () => {
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
agent: { command: "omp", dir: "~/.omp/agent" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
|
||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||
...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }),
|
||||
};
|
||||
const host = draft.host.trim();
|
||||
const port = draft.port.trim();
|
||||
|
||||
+46
-1
@@ -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, effectiveAgentConfig, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
|
||||
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
@@ -49,6 +49,51 @@ describe("PI WEB config persistence", () => {
|
||||
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
|
||||
});
|
||||
|
||||
it("persists and reads custom agent runtime settings", () => {
|
||||
savePiWebConfig({ agent: { command: "omp", dir: "~/.omp/agent" } }, testOptions());
|
||||
|
||||
expect(loadPiWebConfig(testOptions()).config.agent).toEqual({ command: "omp", dir: "~/.omp/agent" });
|
||||
});
|
||||
|
||||
it("resolves OMP agent defaults from the configured command", () => {
|
||||
expect(effectiveAgentConfig({ HOME: join(tempDir, ".home") }, { agent: { command: "omp" } })).toMatchObject({
|
||||
command: "omp",
|
||||
dir: join(tempDir, ".home", ".omp", "agent"),
|
||||
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"],
|
||||
});
|
||||
});
|
||||
|
||||
it("lets PI WEB agent environment overrides take precedence", () => {
|
||||
expect(effectiveAgentConfig({
|
||||
PI_WEB_AGENT_COMMAND: "omp",
|
||||
PI_WEB_AGENT_DIR: join(tempDir, "env-agent"),
|
||||
}, { agent: { command: "pi", dir: join(tempDir, "config-agent") } })).toMatchObject({
|
||||
command: "omp",
|
||||
dir: join(tempDir, "env-agent"),
|
||||
});
|
||||
});
|
||||
|
||||
it("lets command-specific agent environment directories override config", () => {
|
||||
expect(effectiveAgentConfig({
|
||||
HOME: join(tempDir, ".home"),
|
||||
OMP_CODING_AGENT_DIR: join(tempDir, "omp-env-agent"),
|
||||
}, { agent: { command: "omp", dir: join(tempDir, "config-agent") } })).toMatchObject({
|
||||
command: "omp",
|
||||
dir: join(tempDir, "omp-env-agent"),
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes omp.exe to OMP environment keys", () => {
|
||||
expect(effectiveAgentConfig({
|
||||
HOME: join(tempDir, ".home"),
|
||||
OMP_CODING_AGENT_DIR: join(tempDir, "omp-exe-env-agent"),
|
||||
}, { agent: { command: "omp.exe", dir: join(tempDir, "config-agent") } })).toMatchObject({
|
||||
command: "omp.exe",
|
||||
dir: join(tempDir, "omp-exe-env-agent"),
|
||||
sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"],
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the default upload folder in the effective config", () => {
|
||||
expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER });
|
||||
});
|
||||
|
||||
+113
-1
@@ -35,6 +35,42 @@ 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"> = {}, cwd = process.cwd()): EffectivePiWebAgentConfig {
|
||||
const command = parseAgentCommand(env[PI_WEB_AGENT_COMMAND_ENV] ?? config.agent?.command ?? DEFAULT_AGENT_COMMAND, "agent.command", "environment");
|
||||
const commandDirEnv = commandAgentDirEnv(command);
|
||||
const configuredDir = env[PI_WEB_AGENT_DIR_ENV] ?? env[commandDirEnv] ?? config.agent?.dir ?? defaultAgentDirForCommand(command, env);
|
||||
return {
|
||||
command,
|
||||
dir: resolveAgentDirPath(configuredDir, env, cwd, "agent.dir", "environment"),
|
||||
sessionDirEnvKeys: agentSessionDirEnvKeys(command),
|
||||
};
|
||||
}
|
||||
|
||||
export function agentSessionDirEnvKeys(command = DEFAULT_AGENT_COMMAND): string[] {
|
||||
return uniqueStrings([PI_WEB_AGENT_SESSION_DIR_ENV, commandSessionDirEnv(command), PI_CODING_AGENT_SESSION_DIR_ENV]);
|
||||
}
|
||||
|
||||
export function hasAgentDirEnvOverride(env: NodeJS.ProcessEnv, command = DEFAULT_AGENT_COMMAND): boolean {
|
||||
return isEnvSet(env[PI_WEB_AGENT_DIR_ENV]) || isEnvSet(env[commandAgentDirEnv(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 };
|
||||
}
|
||||
@@ -79,7 +115,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf
|
||||
const port = env["PI_WEB_PORT"] ?? env["PORT"];
|
||||
const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"];
|
||||
const maxUpload = env["PI_WEB_MAX_UPLOAD_BYTES"];
|
||||
|
||||
const agent = effectiveAgentConfig(env, loaded.config, options.cwd ?? process.cwd());
|
||||
return {
|
||||
...loaded,
|
||||
config: {
|
||||
@@ -94,6 +130,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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -113,6 +150,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 +176,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 +192,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 +243,35 @@ function parseString(value: unknown, key: string, path: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseAgentConfig(value: unknown, path: string): NonNullable<PiWebConfig["agent"]> {
|
||||
if (!isRecord(value)) throw new Error(`PI WEB config agent must be an object: ${path}`);
|
||||
const command = value["command"];
|
||||
const dir = value["dir"];
|
||||
return {
|
||||
...(command !== undefined ? { command: parseAgentCommand(command, "agent.command", path) } : {}),
|
||||
...(dir !== undefined ? { dir: parseAgentDir(dir, "agent.dir", path) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAgentCommand(value: unknown, key: string, path: string): string {
|
||||
const command = parseString(value, key, path).trim();
|
||||
if (command === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`);
|
||||
if (/[\s;&|`$<>]/u.test(command)) throw new Error(`PI WEB config ${key} must be a single command name or path without shell metacharacters: ${path}`);
|
||||
return command;
|
||||
}
|
||||
|
||||
function parseAgentDir(value: unknown, key: string, path: string): string {
|
||||
const dir = parseString(value, key, path);
|
||||
if (!isAbsoluteOrHomePath(dir)) throw new Error(`PI WEB config ${key} must be an absolute path or start with ~: ${path}`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function resolveAgentDirPath(value: string, env: NodeJS.ProcessEnv, cwd: string, key: string, path: string): string {
|
||||
const parsed = parseAgentDir(value, key, path);
|
||||
const expanded = expandHomePath(parsed, env);
|
||||
return isAbsoluteLike(expanded) ? expanded : resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
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 +321,49 @@ function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string)
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
|
||||
function isAbsoluteOrHomePath(value: string): boolean {
|
||||
return value === "~" || value.startsWith("~/") || value.startsWith("~\\") || isAbsoluteLike(value);
|
||||
}
|
||||
|
||||
function expandHomePath(value: string, env: NodeJS.ProcessEnv): string {
|
||||
const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
|
||||
if (value === "~") return home;
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) return join(home, value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
function defaultAgentDirForCommand(command: string, env: NodeJS.ProcessEnv): string {
|
||||
return expandHomePath(isOmpCommand(command) ? "~/.omp/agent" : "~/.pi/agent", env);
|
||||
}
|
||||
|
||||
function commandAgentDirEnv(command: string): string {
|
||||
const prefix = agentEnvPrefix(command);
|
||||
return prefix === "PI" ? PI_CODING_AGENT_DIR_ENV : `${prefix}_CODING_AGENT_DIR`;
|
||||
}
|
||||
|
||||
function commandSessionDirEnv(command: string): string {
|
||||
return `${agentEnvPrefix(command)}_CODING_AGENT_SESSION_DIR`;
|
||||
}
|
||||
|
||||
function agentEnvPrefix(command: string): string {
|
||||
const name = command.split(/[\\/]/u).at(-1) ?? command;
|
||||
const normalized = name.replace(/(?:\.[cm]?js|\.exe)$/iu, "").replace(/[^A-Za-z0-9]+/gu, "_").replace(/^_+|_+$/gu, "").toUpperCase();
|
||||
return normalized === "" ? "PI" : normalized;
|
||||
}
|
||||
|
||||
function isOmpCommand(command: string): boolean {
|
||||
const name = command.split(/[\\/]/u).at(-1)?.toLowerCase();
|
||||
return name === "omp" || name === "omp.exe";
|
||||
}
|
||||
|
||||
function isEnvSet(value: string | undefined): boolean {
|
||||
return value !== undefined && value !== "";
|
||||
}
|
||||
|
||||
function uniqueStrings(values: readonly string[]): string[] {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
function isAbsoluteLike(value: string): boolean {
|
||||
const withForwardSlashes = value.replace(/\\/g, "/");
|
||||
return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes);
|
||||
|
||||
@@ -901,7 +901,7 @@ function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -22,6 +22,7 @@ import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigSer
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { createPiWebStatusCache } from "./piWebStatusCache.js";
|
||||
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { effectiveAgentConfig, effectivePiWebConfig } from "../config.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
|
||||
@@ -121,10 +122,11 @@ 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 agent = effectiveAgentConfig(process.env, effectivePiWebConfig().config);
|
||||
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService({ agentDir: agent.dir });
|
||||
const configService = deps.config ?? createFilePiWebConfigService();
|
||||
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
|
||||
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
|
||||
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir }), {
|
||||
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
|
||||
});
|
||||
const machines = deps.machines ?? new MachineService(undefined, {
|
||||
@@ -142,7 +144,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
});
|
||||
|
||||
app.get("/api/pi-web/status", async () => piWebStatusCache.get());
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon, { agentCommand: agent.command, agentDir: agent.dir }));
|
||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
registerConfigRoutes(app, configService);
|
||||
|
||||
@@ -37,11 +37,11 @@ describe("config routes", () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/config",
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } },
|
||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, agent: { command: "omp", dir: "~/.omp/agent" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 });
|
||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, agent: { command: "omp", dir: "~/.omp/agent" }, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 });
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
|
||||
});
|
||||
|
||||
@@ -100,6 +100,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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { effectivePiWebConfig, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||
import { effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js";
|
||||
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
import { isPiWebPluginId } from "../shared/pluginIds.js";
|
||||
|
||||
@@ -27,7 +27,7 @@ export function currentPiWebConfigResponse(options: LoadOptions = {}): PiWebConf
|
||||
exists: loaded.exists,
|
||||
config: loaded.config,
|
||||
effectiveConfig: effective.config,
|
||||
envOverrides: piWebConfigEnvOverrides(env),
|
||||
envOverrides: piWebConfigEnvOverrides(env, loaded.config),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +63,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;
|
||||
@@ -85,6 +86,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);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -128,6 +130,30 @@ function parseMaxUploadBytesRequest(value: unknown): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseAgentRequest(value: unknown): NonNullable<PiWebConfig["agent"]> {
|
||||
if (!isRecord(value)) throw new Error("PI WEB config agent must be an object");
|
||||
const command = value["command"];
|
||||
const dir = value["dir"];
|
||||
return {
|
||||
...(command === undefined ? {} : { command: parseAgentCommandRequest(command) }),
|
||||
...(dir === undefined ? {} : { dir: parseAgentDirRequest(dir) }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAgentCommandRequest(value: unknown): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new Error("PI WEB config agent.command must be a non-empty string");
|
||||
const command = value.trim();
|
||||
if (/[\s;&|`$<>]/u.test(command)) throw new Error("PI WEB config agent.command must be a single command name or path without shell metacharacters");
|
||||
return command;
|
||||
}
|
||||
|
||||
function parseAgentDirRequest(value: unknown): string {
|
||||
if (typeof value !== "string" || value.trim() === "") throw new Error("PI WEB config agent.dir must be a non-empty string");
|
||||
const dir = value.trim();
|
||||
if (!isAbsoluteOrHomePath(dir)) throw new Error("PI WEB config agent.dir must be an absolute path or start with ~");
|
||||
return dir;
|
||||
}
|
||||
|
||||
function 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]) => {
|
||||
@@ -141,13 +167,17 @@ function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]
|
||||
}));
|
||||
}
|
||||
|
||||
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
|
||||
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {}): PiWebConfigEnvOverrides {
|
||||
const agent = effectiveAgentConfig(env, config);
|
||||
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, agent.command),
|
||||
agentSessionDir: hasAgentSessionDirEnvOverride(env, agent.command),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,6 +193,10 @@ function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isAbsoluteOrHomePath(value: string): boolean {
|
||||
return value === "~" || value.startsWith("~/") || value.startsWith("~\\") || value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { 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, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { comparePackageVersions, 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";
|
||||
|
||||
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
|
||||
const originalHome = process.env["HOME"];
|
||||
@@ -40,6 +40,26 @@ describe("PI WEB status", () => {
|
||||
expect(status).not.toHaveProperty("release");
|
||||
});
|
||||
|
||||
it("detects session daemon package installs from the configured agent dir for runtime responses", async () => {
|
||||
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, { agentCommand: "omp", agentDir });
|
||||
|
||||
expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: process.cwd(), scope: "user" });
|
||||
} finally {
|
||||
await rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports stale session daemon versions as messages", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
const daemon = daemonWithComponent({
|
||||
@@ -60,6 +80,19 @@ describe("PI WEB status", () => {
|
||||
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
|
||||
});
|
||||
|
||||
it("shell-quotes pi-package agent update commands", async () => {
|
||||
const updateCommand = await updateCommandFor(
|
||||
{ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
|
||||
"pi-web restart",
|
||||
{
|
||||
agentCommand: "/tmp/agent's/omp",
|
||||
hasCommand: (command) => Promise.resolve(command === "/tmp/agent's/omp"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(updateCommand).toBe("'/tmp/agent'\\''s/omp' update 'npm:@jmfederico/pi-web' && pi-web restart");
|
||||
});
|
||||
|
||||
it("suggests native systemd commands for local development services", async () => {
|
||||
if (process.platform !== "linux") return;
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
@@ -109,6 +142,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",
|
||||
@@ -131,6 +174,11 @@ 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");
|
||||
}
|
||||
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) Reflect.deleteProperty(process.env, key);
|
||||
else process.env[key] = value;
|
||||
|
||||
+38
-21
@@ -5,11 +5,12 @@ 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 { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
||||
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
import { effectiveAgentConfig } from "../config.js";
|
||||
|
||||
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
||||
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
|
||||
@@ -73,6 +74,22 @@ interface PiWebStatusDaemon {
|
||||
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
|
||||
}
|
||||
|
||||
interface PiWebStatusOptions {
|
||||
agentCommand?: string;
|
||||
agentDir?: string;
|
||||
hasCommand?: (command: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function effectiveStatusAgentConfig(options: PiWebStatusOptions): { command: string; dir: string } {
|
||||
const agent = effectiveAgentConfig(process.env, {
|
||||
agent: {
|
||||
...(options.agentCommand === undefined ? {} : { command: options.agentCommand }),
|
||||
...(options.agentDir === undefined ? {} : { dir: options.agentDir }),
|
||||
},
|
||||
});
|
||||
return { command: agent.command, dir: agent.dir };
|
||||
}
|
||||
|
||||
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
|
||||
|
||||
const runtimePackageInfo = readPackageInfoSync();
|
||||
@@ -98,10 +115,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.agentDir),
|
||||
]);
|
||||
const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION;
|
||||
const installedVersion = installed?.version;
|
||||
@@ -116,10 +133,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,
|
||||
@@ -128,12 +145,13 @@ export async function getPiWebVersionStatus(daemon: PiWebStatusDaemon = new Sess
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
|
||||
const versionStatus = await getPiWebVersionStatus(daemon);
|
||||
export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaemonClient(), options: PiWebStatusOptions = {}): Promise<PiWebStatusResponse> {
|
||||
const agent = effectiveStatusAgentConfig(options);
|
||||
const versionStatus = await getPiWebVersionStatus(daemon, { ...options, agentDir: agent.dir });
|
||||
const { web, sessiond } = versionStatus.components;
|
||||
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
|
||||
const components = { web, sessiond };
|
||||
const commands = await commandsFor(components);
|
||||
const commands = await commandsFor(components, { agentCommand: agent.command, hasCommand: options.hasCommand ?? hasCommand });
|
||||
const messages = buildMessages(components, release, commands);
|
||||
return {
|
||||
...versionStatus,
|
||||
@@ -187,19 +205,18 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined
|
||||
return { name, version, path };
|
||||
}
|
||||
|
||||
async function detectPiWebInstallation(): Promise<PiWebInstallationInfo> {
|
||||
async function detectPiWebInstallation(agentDir = effectiveAgentConfig().dir): Promise<PiWebInstallationInfo> {
|
||||
const root = packageRootPath();
|
||||
const realRoot = await realPathOrSelf(root);
|
||||
const piPackage = await detectPiPackageInstallation(realRoot, root);
|
||||
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 };
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -267,7 +284,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) {
|
||||
@@ -278,7 +295,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));
|
||||
@@ -375,7 +392,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: { agentCommand: string; hasCommand: (command: string) => Promise<boolean> }): Promise<PiWebStatusResponse["commands"]> {
|
||||
const installation = preferredInstallation(components);
|
||||
const [serviceCommands, cliCommands] = await Promise.all([
|
||||
nativeServiceCommands(),
|
||||
@@ -385,7 +402,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 }),
|
||||
@@ -413,11 +430,11 @@ 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: { agentCommand: string; 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}`;
|
||||
if (!(await options.hasCommand(options.agentCommand))) return undefined;
|
||||
return `${shellQuote(options.agentCommand)} 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;
|
||||
@@ -497,7 +514,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> {
|
||||
|
||||
@@ -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,24 +20,27 @@ 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 { effectiveAgentConfig, effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled, subsessionsEnabled } from "../config.js";
|
||||
|
||||
const { config } = effectivePiWebConfig();
|
||||
const agent = effectiveAgentConfig(process.env, config);
|
||||
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes(process.env, config) });
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
const eventHub = new SessionEventHub();
|
||||
const workspaceActivity = new WorkspaceActivityService(eventHub);
|
||||
const auth = new AuthService();
|
||||
const auth = new AuthService({ agentDir: agent.dir });
|
||||
const spawnTargets = spawnSessionsEnabled(process.env, config)
|
||||
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
||||
: undefined;
|
||||
const sessions = new PiSessionService(eventHub, {
|
||||
modelRegistry: auth.modelRegistry,
|
||||
agentDir: agent.dir,
|
||||
workspaceActivity,
|
||||
logger: app.log,
|
||||
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
||||
subsessionsEnabled: spawnTargets !== undefined && subsessionsEnabled(process.env, config),
|
||||
sessionManager: createPiSessionManagerGateway({ agentDir: agent.dir, sessionDirEnvKeys: agent.sessionDirEnvKeys }),
|
||||
});
|
||||
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
||||
const terminals = new TerminalService(eventHub, workspaceActivity);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
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 } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AuthService, type AuthChange } from "./authService.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();
|
||||
@@ -30,6 +39,16 @@ describe("AuthService", () => {
|
||||
expect(changes).toEqual([]);
|
||||
auth.dispose();
|
||||
});
|
||||
|
||||
it("stores credentials in the configured agent directory", async () => {
|
||||
const agentDir = await tempAgentDir();
|
||||
const auth = new AuthService({ agentDir });
|
||||
|
||||
auth.saveApiKey("anthropic", "sk-omp");
|
||||
|
||||
await expect(readFile(join(agentDir, "auth.json"), "utf8")).resolves.toContain("sk-omp");
|
||||
auth.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function createAuthService(data: Parameters<typeof AuthStorage.inMemory>[0] = {}) {
|
||||
@@ -40,3 +59,9 @@ function createAuthService(data: Parameters<typeof AuthStorage.inMemory>[0] = {}
|
||||
auth.subscribe((change) => { changes.push(change); });
|
||||
return { auth, authStorage, changes };
|
||||
}
|
||||
|
||||
async function tempAgentDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "pi-web-auth-agent-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,16 @@ describe("SessionDirResolver", () => {
|
||||
|
||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
|
||||
});
|
||||
|
||||
it("uses OMP sessionDir environment overrides before settings", async () => {
|
||||
const envDir = join(tempDir, "omp-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({ agentDir, env: { OMP_CODING_AGENT_SESSION_DIR: envDir }, sessionDirEnvKeys: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"] });
|
||||
|
||||
expect(resolver.resolve(cwd)).toMatchObject({ source: "env", sessionDir: envDir, usesConfiguredSessionDir: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi session manager gateway", () => {
|
||||
@@ -82,6 +92,21 @@ describe("Pi session manager gateway", () => {
|
||||
await expect(gateway.listAll()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: "default-session", cwd }), expect.objectContaining({ id: "env-session", cwd })]));
|
||||
});
|
||||
|
||||
it("includes command-specific env session directories in global listing", async () => {
|
||||
for (const envKey of ["PI_WEB_AGENT_SESSION_DIR", "OMP_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: ["PI_WEB_AGENT_SESSION_DIR", "OMP_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"],
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -19,15 +19,18 @@ export interface SessionDirResolution {
|
||||
export interface SessionDirResolverOptions {
|
||||
agentDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
sessionDirEnvKeys?: readonly string[];
|
||||
}
|
||||
|
||||
export class SessionDirResolver {
|
||||
private readonly agentDir: string;
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
private readonly sessionDirEnvKeys: readonly string[];
|
||||
|
||||
constructor(options: SessionDirResolverOptions = {}) {
|
||||
this.agentDir = options.agentDir ?? getAgentDir();
|
||||
this.env = options.env ?? process.env;
|
||||
this.sessionDirEnvKeys = options.sessionDirEnvKeys ?? [PI_SESSION_DIR_ENV];
|
||||
}
|
||||
|
||||
defaultSessionsRoot(): string {
|
||||
@@ -35,15 +38,15 @@ export class SessionDirResolver {
|
||||
}
|
||||
|
||||
globalEnvSessionDir(): string | undefined {
|
||||
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir === undefined || envSessionDir === "") return undefined;
|
||||
const envSessionDir = this.envSessionDir();
|
||||
if (envSessionDir === undefined) return undefined;
|
||||
const expanded = expandTildePath(envSessionDir);
|
||||
return isAbsolute(expanded) ? expanded : undefined;
|
||||
}
|
||||
|
||||
resolve(cwd: string): SessionDirResolution {
|
||||
const envSessionDir = this.env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir !== undefined && envSessionDir !== "") {
|
||||
const envSessionDir = this.envSessionDir();
|
||||
if (envSessionDir !== undefined) {
|
||||
return { source: "env", sessionDir: resolveConfiguredPath(envSessionDir, cwd), usesConfiguredSessionDir: true };
|
||||
}
|
||||
|
||||
@@ -54,6 +57,10 @@ export class SessionDirResolver {
|
||||
|
||||
return { source: "pi-default", sessionDir: defaultPiSessionDir(cwd, this.agentDir), usesConfiguredSessionDir: false };
|
||||
}
|
||||
|
||||
private envSessionDir(): string | undefined {
|
||||
return this.sessionDirEnvKeys.map((key) => this.env[key]).find((value) => value !== undefined && value !== "");
|
||||
}
|
||||
}
|
||||
|
||||
export type PiSessionManagerGatewayOptions = SessionDirResolverOptions;
|
||||
|
||||
@@ -21,7 +21,7 @@ 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 { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
|
||||
@@ -340,7 +340,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
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.modelRegistry = deps.modelRegistry ?? createModelRegistryForAgentDir(this.agentDir);
|
||||
this.spawnTargets = deps.spawnTargets;
|
||||
this.logger = deps.logger ?? noopLogger;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
|
||||
@@ -61,6 +61,13 @@ export interface PiWebUploadsConfig {
|
||||
defaultFolder?: string;
|
||||
}
|
||||
|
||||
export interface PiWebAgentConfig {
|
||||
/** Agent CLI command used for diagnostics and package-managed updates. */
|
||||
command?: string;
|
||||
/** Agent config/state directory containing auth.json, models.json, settings.json, and sessions/. */
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
export interface PiWebConfigValues {
|
||||
host?: string;
|
||||
port?: number;
|
||||
@@ -82,6 +89,8 @@ export interface PiWebConfigValues {
|
||||
* while the capability stabilizes. Requires spawnSessions to be enabled.
|
||||
*/
|
||||
subsessions?: boolean;
|
||||
/** Agent runtime state used by the session daemon (Pi by default; OMP compatible). */
|
||||
agent?: PiWebAgentConfig;
|
||||
}
|
||||
|
||||
export type PiWebPluginScope = "bundled" | "local" | "user" | "project";
|
||||
@@ -105,6 +114,9 @@ export interface PiWebConfigEnvOverrides {
|
||||
allowedHosts: boolean;
|
||||
spawnSessions: boolean;
|
||||
subsessions: boolean;
|
||||
agentCommand: boolean;
|
||||
agentDir: boolean;
|
||||
agentSessionDir: boolean;
|
||||
}
|
||||
|
||||
export interface PiWebConfigResponse {
|
||||
|
||||
Reference in New Issue
Block a user