pi-mono/apps/p2/plugins/envs/manager.ts
Armin Ronacher 7dc38ff0e9 feat(p2): MCP servers on the host, on environments and from projects
A manager on the p2 server owns every MCP connection and process. Session
workers hold one view each and lease the locations their conversations
work at; the manager runs the owner's mcp.json servers there (stdio per
session and location, or shared; HTTP shared by default) and offers their
tools as deferred Durable tools found through tool_search.

Stdio servers on an environment start through its pi-env daemon, one
connection per environment shared by its instances, through cmd.exe on
Windows. A project's .pi/mcp.json, read by the worker at each location,
may add stdio servers that run there and switch the owner's servers on or
off; the manager refuses everything else. A lease gets servers only on
environments its session works on.
2026-11-03 16:48:38 +01:00

343 lines
13 KiB
TypeScript

import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import type { FileInfo } from "@earendil-works/pi-durable/env";
import {
acceptHostKey,
type Connection,
connectSsh,
forgetHostKey,
HostKeyChangedError,
HostKeyUnknownError,
RemoteExecutionEnv,
scanHostKey,
sshConnection,
} from "@earendil-works/pi-env";
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
import { remotePaths, value } from "@p2/app/v1/node";
import { ENV_NAME, type EnvConfig, envId, envRevision, loadEnvs, saveEnv } from "./config.ts";
import type { DirectoryListing, EnvSummary, EnvTest } from "./contract.ts";
import { type RemoteSession, remoteSession, sshOptions } from "./remote-env.ts";
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const context = BACKGROUND_CONTEXT;
/** Directory browsing keeps a connection this long after its last use: a new one costs several ssh handshakes. */
const IDLE_MS = 600_000;
/** Most entries one listing reads; the first 1,000 directories are shown, as for local directories. */
const LISTING_SCAN = 10_000;
const LISTING_LIMIT = 1000;
/** Largest private key accepted by upload. */
const MAX_KEY_BYTES = 16 * 1024;
const run = promisify(execFile);
type Browser = { revision: string; session: RemoteSession; timer?: ReturnType<typeof setTimeout> };
/** Owner management of `envs.json`, host keys, connection tests and remote directory browsing for new sessions. */
export class EnvManager {
readonly directory: string;
private readonly uses: (name: string) => Promise<number>;
private readonly tests = new Map<string, EnvTest>();
private readonly browsers = new Map<string, Browser>();
private readonly session: (name: string, config: EnvConfig) => RemoteSession;
/** `session` replaces SSH for tests: the environment that browses an env's directories. */
constructor(
directory: string,
uses: (name: string) => Promise<number>,
session: (name: string, config: EnvConfig) => RemoteSession = (name, config) =>
remoteSession(directory, name, config, "/"),
) {
this.directory = directory;
this.uses = uses;
this.session = session;
}
async config(name: string): Promise<EnvConfig> {
const configs = await loadEnvs(this.directory);
const config = Object.hasOwn(configs, name) ? configs[name] : undefined;
if (!config) throw new Error(`Environment "${name}" is not configured`);
return config;
}
/**
* A pi-env connection to env `name` configured as `config`, over SSH with p2's key and known hosts, deploying the
* daemon pi-env ships. It starts at its first request; the caller closes it.
*/
connection(name: string, config: EnvConfig): Connection {
return sshConnection(sshOptions(this.directory, name, config)).connection;
}
async list(): Promise<EnvSummary[]> {
const envs = await loadEnvs(this.directory);
return Promise.all(
Object.entries(envs)
.sort(([a], [b]) => a.localeCompare(b))
.map(async ([name, config]) => {
const test = this.tests.get(`${name}@${envRevision(config)}`);
return { name, config, sessions: await this.uses(name), ...(test ? { test } : {}) };
}),
);
}
async save(name: string, config: EnvConfig): Promise<void> {
await saveEnv(this.directory, name, config);
this.release(name);
}
/** Refused while any session, archived or not, references the env: sessions never fall back to local tools. */
async delete(name: string): Promise<void> {
const uses = await this.uses(name);
if (uses > 0) throw new Error(`${uses} session${uses === 1 ? "" : "s"} still use "${name}"`);
await saveEnv(this.directory, name, undefined);
this.release(name);
// A key p2 generated or stored for this env goes with it.
const key = this.keyPath(name);
await rm(key, { force: true });
await rm(`${key}.pub`, { force: true });
}
/**
* Where p2 keeps an env's private key, in the private data directory. Keyed by name, so a key can be made
* while the env is still being created.
*/
keyPath(name: string): string {
if (!ENV_NAME.test(name)) throw new Error("Invalid environment name");
return join(this.directory, "ssh", "keys", name);
}
/** The public key of the env's managed private key, if p2 keeps one. */
async publicKey(name: string): Promise<string | undefined> {
return (await readFile(`${this.keyPath(name)}.pub`, "utf8").catch(() => undefined))?.trim();
}
/** A saved env uses `path` as its identity file from now on; an env being created gets it from its form. */
private async adoptKey(name: string, path: string): Promise<void> {
const configs = await loadEnvs(this.directory);
const config = Object.hasOwn(configs, name) ? configs[name] : undefined;
if (config && config.identityFile !== path) await this.save(name, { ...config, identityFile: path });
}
/**
* A new ed25519 key for the env, kept in p2's data directory and used as its identity file; returns the
* public key to install on the host. An existing managed key is kept, so generating twice never locks you out.
*/
async generateKey(name: string): Promise<string> {
const path = this.keyPath(name);
if ((await this.publicKey(name)) === undefined) {
await mkdir(join(this.directory, "ssh", "keys"), { recursive: true, mode: 0o700 });
await rm(path, { force: true });
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", `p2-${name}`, "-f", path]);
await chmod(path, 0o600);
}
await this.adoptKey(name, path);
const publicKey = await this.publicKey(name);
if (publicKey === undefined) throw new Error("Could not generate a key");
return publicKey;
}
/**
* Keep an existing private key (OpenSSH or PEM text) for the env and use it as its identity file; returns its public
* key. Keys with a passphrase are refused: nothing could type it when a session connects.
*/
async uploadKey(name: string, key: string): Promise<string> {
this.keyPath(name);
if (Buffer.byteLength(key) > MAX_KEY_BYTES || !key.includes("PRIVATE KEY")) throw new Error("Not a private key");
const directory = join(this.directory, "ssh", "keys");
await mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = join(directory, `.upload-${randomBytes(6).toString("hex")}`);
try {
await writeFile(temporary, key.endsWith("\n") ? key : `${key}\n`, { mode: 0o600, flag: "wx" });
let publicKey: string;
try {
// An empty passphrase: a protected key fails here instead of when a session connects.
publicKey = (await run("ssh-keygen", ["-y", "-P", "", "-f", temporary])).stdout.trim();
} catch {
throw new Error("The key is protected by a passphrase or is not a valid private key");
}
const path = this.keyPath(name);
await rename(temporary, path);
await writeFile(`${path}.pub`, `${publicKey}\n`, { mode: 0o600 });
await this.adoptKey(name, path);
return publicKey;
} finally {
await rm(temporary, { force: true });
}
}
/** Connect as a session would (detect, deploy, start), then measure what the Envs panel shows. */
async test(name: string): Promise<EnvTest> {
const config = await this.config(name);
const key = `${name}@${envRevision(config)}`;
const options = sshOptions(this.directory, name, config);
const warnings: string[] = [];
let result: EnvTest;
try {
const { connection, remote } = await connectSsh({ ...options, onLog: () => {} });
try {
const info = await connection.info();
const env = new RemoteExecutionEnv({ connection, id: envId(name, config), cwd: info.home });
const started = performance.now();
value(await env.fileInfo(info.home, context));
const latencyMs = Math.round(performance.now() - started);
const watcher = value(await env.watch([{ path: info.home }], () => {}, context));
const watchMode = watcher.mode;
await watcher.stop(context);
warnings.push(...remote.warnings);
result = {
ok: true,
testedAt: Date.now(),
platform: remote.platform,
arch: remote.arch,
home: info.home,
version: info.version,
latencyMs,
watchMode,
warnings,
};
} finally {
connection.close();
}
} catch (error) {
result = { ok: false, testedAt: Date.now(), error: errorMessage(error) };
if (error instanceof HostKeyUnknownError || error instanceof HostKeyChangedError) {
// Show what the host presents now; accepting it is the owner's decision.
const scanned = await scanHostKey(options).catch(() => undefined);
result.hostKey = {
state: error instanceof HostKeyUnknownError ? "unknown" : "changed",
fingerprints: scanned?.fingerprints ?? [],
lines: scanned?.lines ?? [],
};
}
}
this.tests.set(key, result);
return result;
}
async acceptHostKey(name: string, lines: readonly string[]): Promise<void> {
await acceptHostKey(sshOptions(this.directory, name, await this.config(name)), lines);
this.release(name);
}
async forgetHostKey(name: string): Promise<void> {
await forgetHostKey(sshOptions(this.directory, name, await this.config(name)));
this.release(name);
}
/** A connection for browsing an env's directories, shared and closed after a while without use. */
private async browser(name: string): Promise<RemoteSession> {
const config = await this.config(name);
const revision = envRevision(config);
let browser = this.browsers.get(name);
if (browser?.revision !== revision) {
this.release(name);
browser = { revision, session: this.session(name, config) };
this.browsers.set(name, browser);
}
const current = browser;
clearTimeout(current.timer);
current.timer = setTimeout(() => {
if (this.browsers.get(name) !== current) return;
this.browsers.delete(name);
current.session.connection.close();
}, IDLE_MS);
current.timer.unref();
return current.session;
}
private release(name: string): void {
const browser = this.browsers.get(name);
if (!browser) return;
this.browsers.delete(name);
clearTimeout(browser.timer);
browser.session.connection.close();
}
/** An absolute path on the env: `~` is the remote home, a relative path is relative to it. */
private async resolve(session: RemoteSession, path: string): Promise<string> {
const { home } = await session.connection.info();
const paths = await remotePaths(session.connection);
const absolute = path === "~" || path.startsWith("~/") || paths.isAbsolute(path) ? path : paths.join(home, path);
return value(await session.env.absolutePath(absolute, context));
}
/** Start the connections of every env in the background, so the first listing does not wait for ssh. */
async warm(): Promise<void> {
for (const name of Object.keys(await loadEnvs(this.directory)))
void this.browser(name)
.then((session) => session.connection.info())
.catch(() => {});
}
/**
* Subdirectories of `path` on the env, following symbolic links to directories, as for local directories. Two round
* trips: opening fails for anything but a directory, one page holds most folders, and the close is not waited for.
*/
async listDirectory(name: string, path: string): Promise<DirectoryListing> {
const session = await this.browser(name);
const paths = await remotePaths(session.connection);
const resolved = await this.resolve(session, path);
const opened = await session.env.openDirReader(resolved, context);
if (!opened.ok)
throw new Error(
opened.error.code === "not_directory" ? "Working directory is not a directory" : opened.error.message,
);
const reader = opened.value;
const entries: FileInfo[] = [];
let complete = false;
try {
while (entries.length < LISTING_SCAN) {
const page = value(await reader.next(1000, context));
entries.push(...page.entries);
if (page.done) {
complete = true;
break;
}
}
} finally {
void reader.close(context);
}
const directories = (
await Promise.all(
entries.map(async (entry) => {
if (entry.kind === "directory") return entry.path;
if (entry.kind !== "symlink") return undefined;
// fileInfo does not follow links (lstat): resolve first. All links resolve in parallel.
const target = await session.env.canonicalPath(entry.path, context);
if (!target.ok) return undefined;
const followed = await session.env.fileInfo(target.value, context);
return followed.ok && followed.value.kind === "directory" ? entry.path : undefined;
}),
)
)
.filter((entry) => entry !== undefined)
.toSorted((a, b) => a.localeCompare(b));
const parent = paths.dirname(resolved);
return {
path: resolved,
parent: parent === resolved ? null : parent,
directories: directories.slice(0, LISTING_LIMIT),
truncated: !complete || directories.length > LISTING_LIMIT,
};
}
/** A session's working directory on the env: resolved, optionally created, and checked to be a directory. */
async workingDirectory(name: string, path: string, create = false): Promise<string> {
const session = await this.browser(name);
const resolved = await this.resolve(session, path);
if (create) value(await session.env.createDir(resolved, { recursive: true }, context));
const info = value(
await session.env.fileInfo(value(await session.env.canonicalPath(resolved, context)), context),
);
if (info.kind !== "directory") throw new Error("Working directory is not a directory");
return resolved;
}
close(): void {
for (const name of [...this.browsers.keys()]) this.release(name);
}
}