chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -0,0 +1,397 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
/** Frame types (docs/protocol.md). */
const REQUEST = 1;
const RESULT = 2;
const ERROR = 3;
const EVENT = 4;
const CANCEL = 5;
const PING = 6;
const MAX_FRAME = 16 * 1024 * 1024;
const PING_INTERVAL_MS = 5000;
/** The daemon pings every five seconds; this much silence means the connection is gone. */
const SILENCE_LIMIT_MS = 30_000;
/** How long starting the daemon and its `hello` may take. */
const START_TIMEOUT_MS = 60_000;
export type Json = Record<string, unknown>;
/** A failure reported by the daemon, with its Node-style code. */
export class RemoteError extends Error {
readonly code: string;
readonly path?: string;
readonly fields: Json;
constructor(fields: Json) {
super(typeof fields.message === "string" ? fields.message : "Remote operation failed");
this.name = "RemoteError";
this.code = typeof fields.code === "string" ? fields.code : "unknown";
if (typeof fields.path === "string") this.path = fields.path;
this.fields = fields;
}
}
/** What `hello` reports about the remote machine. */
export interface RemoteInfo {
protocol: number;
version: string;
os: string;
arch: string;
home: string;
tmpdir: string;
/** Path separator: `/`, or `\\` on Windows. */
separator: string;
/** The daemon's working directory, where Node's `path.resolve` would fall back to `process.cwd()`. */
cwd: string;
/** Windows: per-drive working directories (`C:` → `C:\\work`) of the `=C:` variables. */
driveCwds: Record<string, string>;
pid: number;
/** Operations added to protocol 1 that this daemon supports, such as `listFiles`; absent from older daemons. */
features?: string[];
}
/**
* Node's names for the daemon's system and architecture, which `hello` reports in Rust's spelling: `windows` is
* `win32`, `macos` is `darwin`, `x86_64` is `x64`, `aarch64` is `arm64`, so they compare with `process.platform` and
* `process.arch`. Other names are kept.
*/
export function nodePlatform(info: Pick<RemoteInfo, "os" | "arch">): { platform: string; arch: string } {
const platforms: Record<string, string> = { windows: "win32", macos: "darwin" };
const archs: Record<string, string> = { x86_64: "x64", aarch64: "arm64", x86: "ia32" };
return { platform: platforms[info.os] ?? info.os, arch: archs[info.arch] ?? info.arch };
}
export interface ConnectionOptions {
/**
* Command that starts the daemon, before its `serve --token <hex>` arguments, e.g. `["ssh", "-T", "--", "host",
* "~/.pi/mobile/tools/pi-env"]`. A function computes it at each start (detecting and deploying first, for example);
* its failure fails that start, and the next request tries again.
*/
command: readonly string[] | (() => Promise<readonly string[]>);
/** Receives the daemon's and the transport's diagnostic output. */
onLog?: (text: string) => void;
}
export interface Reply {
json: Json;
payload: Uint8Array;
/** The daemon session that answered; handles from it are only valid while it lives. */
session: number;
}
type Pending = {
resolve: (value: Reply) => void;
reject: (error: Error) => void;
onEvent?: (json: Json, payload: Uint8Array) => void;
};
export interface RequestOptions {
payload?: Uint8Array;
/** Aborting sends `cancel`; the request still settles with the daemon's result or error. */
signal?: AbortSignal;
/** Progress events of the request, such as `exec` output. */
onEvent?: (json: Json, payload: Uint8Array) => void;
/** The request's id and session, for `kill`. */
onStart?: (id: number, session: number) => void;
/**
* Only run in this daemon session, for requests on handles it opened. After the connection was lost and started
* again, such requests fail instead of reaching a daemon that never opened the handle.
*/
session?: number;
}
/** Lone surrogates become U+FFFD, as Node encodes strings for system calls. */
function wellFormed(_key: string, value: unknown): unknown {
return typeof value === "string" ? value.toWellFormed() : value;
}
function frame(type: number, id: number, json: Json, payload: Uint8Array = new Uint8Array(0)): Buffer {
const body = Buffer.from(JSON.stringify(json, wellFormed), "utf8");
const header = Buffer.alloc(13);
header.writeUInt32BE(9 + body.length + payload.length, 0);
header.writeUInt8(type, 4);
header.writeUInt32BE(id, 5);
header.writeUInt32BE(body.length, 9);
return Buffer.concat([header, body, payload]);
}
/** The connection failed or ended; a request may succeed once it is started again. */
function lost(message: string): RemoteError {
return new RemoteError({ code: "unknown", message, lost: true });
}
/** Whether an error means the connection was lost, not that the daemon refused the request. */
export function isConnectionLost(error: unknown): boolean {
return error instanceof RemoteError && error.fields.lost === true;
}
function closed(): RemoteError {
return new RemoteError({ code: "unknown", message: "Connection closed" });
}
/** One running daemon. */
class Session {
readonly id: number;
readonly child: ChildProcessWithoutNullStreams;
readonly token: string;
readonly pending = new Map<number, Pending>();
/** Received bytes not yet parsed, without concatenating on every chunk. */
chunks: Buffer[] = [];
buffered = 0;
synced = false;
lastSeen = Date.now();
timer: ReturnType<typeof setInterval> | undefined;
live = true;
constructor(id: number, child: ChildProcessWithoutNullStreams, token: string) {
this.id = id;
this.child = child;
this.token = token;
}
/** The first `length` buffered bytes as one buffer, without consuming them. */
peek(length: number): Buffer {
if (this.chunks[0]!.length < length) {
this.chunks = [Buffer.concat(this.chunks)];
}
return this.chunks[0]!.subarray(0, length);
}
consume(length: number): void {
this.buffered -= length;
while (length > 0) {
const first = this.chunks[0]!;
if (first.length <= length) {
this.chunks.shift();
length -= first.length;
} else {
this.chunks[0] = first.subarray(length);
length = 0;
}
}
}
}
/**
* One connection to a pi-env daemon: started lazily on the first request and started again after it is lost. Requests
* in flight when it is lost fail with code `unknown`; for mutations their outcome is then unknown.
*/
export class Connection {
readonly #options: ConnectionOptions;
#session: Session | undefined;
#ready: Promise<{ info: RemoteInfo; session: Session }> | undefined;
#sessions = 0;
#nextId = 1;
#closed = false;
constructor(options: ConnectionOptions) {
this.#options = options;
}
/** Connect if needed and return what the daemon reported about its machine. */
async info(): Promise<RemoteInfo> {
return (await this.#connect()).info;
}
/** Connect if needed and return the live session's id. */
async session(): Promise<number> {
return (await this.#connect()).session.id;
}
async request(op: string, json: Json, options: RequestOptions = {}): Promise<Reply> {
if (options.session !== undefined) {
const current = this.#session;
if (current === undefined || current.id !== options.session || !current.live) {
throw lost("pi-env connection lost");
}
return this.#send(current, op, json, options);
}
const { session } = await this.#connect();
return this.#send(session, op, json, options);
}
/** Stop the daemon; it kills everything it started. */
close(): void {
this.#closed = true;
if (this.#session) this.#teardown(this.#session, closed());
}
/** Kill one `exec` without aborting it. */
kill(id: number, session: number): void {
const current = this.#session;
if (current?.id === session && current.live) this.#write(current, frame(CANCEL, id, { mode: "kill" }));
}
#connect(): Promise<{ info: RemoteInfo; session: Session }> {
if (this.#closed) return Promise.reject(closed());
if (this.#ready === undefined) {
const ready = this.#start();
this.#ready = ready;
// A failed start is not remembered: the next request tries again.
ready.catch(() => {
if (this.#ready === ready) this.#ready = undefined;
});
}
return this.#ready;
}
#send(session: Session, op: string, json: Json, options: RequestOptions): Promise<Reply> {
if (!session.live) return Promise.reject(lost("pi-env connection lost"));
const id = this.#nextId++;
return new Promise((resolve, reject) => {
const onAbort = () => this.#write(session, frame(CANCEL, id, {}));
const done = () => options.signal?.removeEventListener("abort", onAbort);
session.pending.set(id, {
resolve: (value) => {
done();
resolve(value);
},
reject: (error) => {
done();
reject(error);
},
...(options.onEvent ? { onEvent: options.onEvent } : {}),
});
options.signal?.addEventListener("abort", onAbort, { once: true });
options.onStart?.(id, session.id);
this.#write(session, frame(REQUEST, id, { ...json, op }, options.payload));
if (options.signal?.aborted) onAbort();
});
}
#write(session: Session, data: Buffer): void {
if (session.live) session.child.stdin.write(data);
}
async #start(): Promise<{ info: RemoteInfo; session: Session }> {
let command: readonly string[];
try {
const option = this.#options.command;
command = typeof option === "function" ? await option() : option;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new RemoteError({ code: "spawn_error", message, lost: true });
}
if (this.#closed) throw closed();
const [program, ...args] = command;
if (program === undefined) throw new RemoteError({ code: "spawn_error", message: "No daemon command" });
const token = randomBytes(16).toString("hex");
const child = spawn(program, [...args, "serve", "--token", token], { stdio: ["pipe", "pipe", "pipe"] });
const session = new Session(++this.#sessions, child, token);
this.#session = session;
let startTimer: ReturnType<typeof setTimeout> | undefined;
const failed = new Promise<never>((_resolve, reject) => {
startTimer = setTimeout(
() => reject(lost(`pi-env did not answer within ${START_TIMEOUT_MS / 1000} s`)),
START_TIMEOUT_MS,
);
child.once("error", (error) =>
reject(new RemoteError({ code: "spawn_error", message: error.message, lost: true })),
);
child.once("exit", (code) => reject(lost(`pi-env exited with code ${code} before it was ready`)));
});
failed.catch(() => {});
child.stdout.on("data", (chunk: Buffer) => this.#onData(session, chunk));
child.stderr.on("data", (chunk: Buffer) => this.#options.onLog?.(chunk.toString("utf8")));
child.on("error", () => this.#teardown(session, lost("pi-env connection lost")));
child.on("exit", () => this.#teardown(session, lost("pi-env connection lost")));
child.stdin.on("error", () => {});
session.timer = setInterval(() => {
this.#write(session, frame(PING, 0, {}));
if (Date.now() - session.lastSeen > SILENCE_LIMIT_MS) {
this.#teardown(session, lost("pi-env connection timed out"));
}
}, PING_INTERVAL_MS);
session.timer.unref();
try {
const { json } = await Promise.race([this.#send(session, "hello", { protocol: 1 }, {}), failed]);
if (json.protocol !== 1) throw lost(`Unsupported protocol ${json.protocol}`);
return { info: json as unknown as RemoteInfo, session };
} catch (error) {
this.#teardown(session, error instanceof Error ? error : lost(String(error)));
throw error;
} finally {
clearTimeout(startTimer);
}
}
#teardown(session: Session, error: Error): void {
if (!session.live) return;
session.live = false;
clearInterval(session.timer);
if (this.#session === session) {
this.#session = undefined;
this.#ready = undefined;
}
session.child.stdin.end();
session.child.kill();
const pending = [...session.pending.values()];
session.pending.clear();
for (const request of pending) request.reject(error);
}
#onData(session: Session, chunk: Buffer): void {
if (!session.live) return;
session.lastSeen = Date.now();
session.chunks.push(chunk);
session.buffered += chunk.length;
if (!session.synced) {
// Shell startup files may print before the daemon runs; skip everything before its sync line.
const marker = Buffer.from(`PI-ENV ${session.token}\n`, "utf8");
const all = session.peek(session.buffered);
const index = all.indexOf(marker);
if (index === -1) {
if (session.buffered > 1024 * 1024) {
session.consume(session.buffered - marker.length);
}
return;
}
const noise = all.subarray(0, index).toString("utf8").trim();
if (noise !== "") this.#options.onLog?.(noise);
session.consume(index + marker.length);
session.synced = true;
}
while (session.live && session.buffered >= 4) {
const length = session.peek(4).readUInt32BE(0);
if (length < 9 || length > MAX_FRAME) {
this.#teardown(session, lost("Corrupt frame from pi-env"));
return;
}
if (session.buffered < 4 + length) return;
const body = session.peek(4 + length).subarray(4);
const type = body.readUInt8(0);
const id = body.readUInt32BE(1);
const jsonLength = body.readUInt32BE(5);
let json: Json;
try {
if (9 + jsonLength > length) throw new Error("JSON length out of range");
const parsed: unknown = JSON.parse(body.subarray(9, 9 + jsonLength).toString("utf8"));
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
throw new Error("not an object");
json = parsed as Json;
} catch {
this.#teardown(session, lost("Corrupt frame from pi-env"));
return;
}
// A plain Uint8Array copy, like `NodeExecutionEnv`'s reader results, not a view of the receive buffer.
const payload = new Uint8Array(body.subarray(9 + jsonLength));
session.consume(4 + length);
this.#dispatch(session, type, id, json, payload);
}
}
#dispatch(session: Session, type: number, id: number, json: Json, payload: Uint8Array): void {
const pending = session.pending.get(id);
if (type === EVENT) {
pending?.onEvent?.(json, payload);
return;
}
if (type !== RESULT && type !== ERROR) return;
if (pending === undefined) return;
session.pending.delete(id);
if (type === RESULT) pending.resolve({ json, payload, session: session.id });
else pending.reject(new RemoteError(json));
}
}

View File

@@ -0,0 +1,34 @@
import { err, FileError, type Result } from "@earendil-works/pi-durable/env";
import { RemoteError } from "./connection.ts";
export function abortResult<T>(signal: AbortSignal | undefined, path?: string): Result<T, FileError> | undefined {
return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined;
}
/** `NodeExecutionEnv`'s mapping of Node error codes to `FileError` codes. */
export function toFileError(error: unknown, fallbackPath?: string): FileError {
if (error instanceof FileError) return error;
if (!(error instanceof RemoteError)) {
const cause = error instanceof Error ? error : new Error(String(error));
return new FileError("unknown", cause.message, fallbackPath, cause);
}
const path = error.path ?? fallbackPath;
switch (error.code) {
case "aborted":
return new FileError("aborted", error.message, path, error);
case "ENOENT":
return new FileError("not_found", error.message, path, error);
case "EACCES":
case "EPERM":
return new FileError("permission_denied", error.message, path, error);
case "ENOTDIR":
return new FileError("not_directory", error.message, path, error);
case "EISDIR":
return new FileError("is_directory", error.message, path, error);
case "EINVAL":
case "SYMLINK":
case "NOT_REGULAR":
return new FileError("invalid", error.message, path, error);
}
return new FileError("unknown", error.message, path, error);
}

View File

@@ -0,0 +1,32 @@
export {
Connection,
type ConnectionOptions,
isConnectionLost,
nodePlatform,
RemoteError,
type RemoteInfo,
type Reply,
type RequestOptions,
} from "./connection.ts";
export { LocalExecutionEnv, type LocalExecutionEnvOptions } from "./list-files.ts";
export { RemoteExecutionEnv, type RemoteExecutionEnvOptions } from "./remote-env.ts";
export {
acceptHostKey,
connectSsh,
deployDaemon,
detectPlatform,
forgetHostKey,
HostKeyChangedError,
HostKeyUnknownError,
localDaemon,
packagedDaemon,
type RemotePlatform,
type SshConnectOptions,
SshError,
type SshTarget,
scanHostKey,
sshArguments,
sshConnection,
} from "./ssh.ts";
export { openTerminal, RemoteTerminal, type TerminalForeground, type TerminalOptions } from "./terminal.ts";
export { RemoteWatcher, type RemoteWatchOptions } from "./watch.ts";

View File

@@ -0,0 +1,102 @@
import type { Context } from "@earendil-works/chord";
import {
type ExecutionEnv,
err,
FileError,
type FileKind,
type FileListEntry,
type FileListing,
type ListFilesOptions,
ok,
type Result,
} from "@earendil-works/pi-durable/env";
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
import type { Connection } from "./connection.ts";
import { abortResult, toFileError } from "./errors.ts";
/** Kind bytes of `listFiles` records: `f`, `d`, `l`. */
const KINDS: Record<number, FileKind> = { 102: "file", 100: "directory", 108: "symlink" };
/**
* `listFiles` of an absolute path through the daemon: fails with `not_supported` if the daemon does not list files
* (a release before `listFiles`), so callers fall back to their own walk.
*/
export async function listFilesOver(
connection: Connection,
path: string,
options: ListFilesOptions | undefined,
context: Context,
): Promise<Result<FileListing, FileError>> {
for (const key of ["maxEntries", "maxBytes", "timeoutMs"] as const) {
const value = options?.[key];
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
return err(new FileError("invalid", `${key} must be a non-negative safe integer`, path));
}
}
const aborted = abortResult<FileListing>(context.abortSignal, path);
if (aborted) return aborted;
try {
if (!(await connection.info()).features?.includes("listFiles")) {
return err(new FileError("not_supported", "The pi-env daemon does not list files; update it", path));
}
const { json, payload } = await connection.request(
"listFiles",
// Fields left undefined are not sent; the daemon applies the defaults.
{
path,
hidden: options?.hidden,
gitignore: options?.gitignore,
exclude: options?.exclude,
maxEntries: options?.maxEntries,
maxBytes: options?.maxBytes,
timeoutMs: options?.timeoutMs,
},
context.abortSignal ? { signal: context.abortSignal } : {},
);
const afterAbort = abortResult<FileListing>(context.abortSignal, path);
if (afterAbort) return afterAbort;
// Records of a kind byte, the UTF-8 path, and a NUL.
const entries: FileListEntry[] = [];
const bytes = Buffer.from(payload.buffer, payload.byteOffset, payload.length);
for (let start = 0; start < bytes.length; ) {
const end = bytes.indexOf(0, start);
if (end === -1) break;
const kind = KINDS[bytes[start]!];
if (kind !== undefined) entries.push({ path: bytes.toString("utf8", start + 1, end), kind });
start = end + 1;
}
return ok({ entries, truncated: json.truncated === true });
} catch (error) {
return err(toFileError(error, path));
}
}
export type LocalExecutionEnvOptions = ConstructorParameters<typeof NodeExecutionEnv>[0] & {
/** A connection to this machine's daemon, `new Connection({ command: [localDaemon()] })`, for `listFiles`. */
connection: Connection;
};
/**
* `NodeExecutionEnv` with `listFiles` through a pi-env daemon on the same machine, so local and remote environments
* list files with the same code. Everything else is `NodeExecutionEnv`'s. The connection belongs to the caller, who
* closes it; several environments may share it.
*/
export class LocalExecutionEnv extends NodeExecutionEnv implements ExecutionEnv {
readonly #connection: Connection;
constructor(options: LocalExecutionEnvOptions) {
const { connection, ...node } = options;
super(node);
this.#connection = connection;
}
async listFiles(
path: string,
options: ListFilesOptions | undefined,
context: Context,
): Promise<Result<FileListing, FileError>> {
const resolved = await this.absolutePath(path, context);
if (!resolved.ok) return resolved;
return listFilesOver(this.#connection, resolved.value, options, context);
}
}

View File

@@ -0,0 +1,957 @@
import { randomUUID } from "node:crypto";
import { type PlatformPath, posix, win32 } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { fileURLToPath } from "node:url";
import type { Context } from "@earendil-works/chord";
import {
type BinaryReader,
type DirReader,
type ExecutionEnv,
ExecutionError,
err,
FileError,
type FileInfo,
type FileKind,
type FileListing,
type FileWatcher,
type LineScan,
type ListFilesOptions,
ok,
type Result,
type ShellExecOptions,
type ShellExecResult,
type ShellOutputInfo,
type ShellOutputSkip,
StreamDecoder,
type TextLine,
type TextLineReader,
type WatchChange,
type WatchTarget,
} from "@earendil-works/pi-durable/env";
import { type Connection, type Json, nodePlatform, RemoteError, type RemoteInfo } from "./connection.ts";
import { abortResult, toFileError } from "./errors.ts";
import { listFilesOver } from "./list-files.ts";
import { RemoteWatcher, type RemoteWatchOptions } from "./watch.ts";
const MAX_TIMEOUT_MS = 2_147_483_647;
const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
/** Bytes per `pread` request of a binary reader; several are in flight at once. */
const READ_CHUNK = 256 * 1024;
const READ_DEPTH = 8;
/** Node's `writeFile` writes at most this much per call and checks for an abort before each. */
const WRITE_CHUNK = 512 * 1024;
const WRITE_DEPTH = 8;
/** Node's `readFile`: chunk of known-size reads, chunk of unknown-size reads, and the largest file it reads. */
const READ_FILE_CHUNK = 512 * 1024;
const READ_FILE_UNKNOWN_CHUNK = 64 * 1024;
const READ_FILE_MAX = 2 ** 31 - 1;
/** `NodeTextLineReader`'s read size. */
const LINE_CHUNK = 64 * 1024;
export interface RemoteExecutionEnvOptions {
connection: Connection;
/** The file namespace: equal ids see the same files (`FileSystem.id`), e.g. `pi-env:<env name>`. */
id: string;
cwd: string;
shellPath?: string;
/** Added to the remote environment of every command that inherits it, like `NodeExecutionEnv`'s `shellEnv`. */
shellEnv?: Record<string, string>;
/** How the daemon watches, like `NodeExecutionEnv`'s `watch` option. */
watch?: RemoteWatchOptions;
}
/** An info record from the daemon. */
type RemoteFileInfo = { name: string; kind: FileKind | "other"; size: number; mtimeSec: number; mtimeNsec: number };
type RemoteDirEntry = { name: string; raw?: number[]; info?: RemoteFileInfo; error?: Json };
/** Node's path rules of the remote system. */
export async function remotePath(connection: Connection): Promise<PlatformPath> {
return nodePlatform(await connection.info()).platform === "win32" ? win32 : posix;
}
function toInfo(path: string, remote: RemoteFileInfo, paths: PlatformPath): Result<FileInfo, FileError> {
if (remote.kind === "other") return err(new FileError("invalid", "Unsupported file type", path));
return ok({
name: paths.basename(path),
path,
kind: remote.kind,
size: remote.size,
mtimeMs: remote.mtimeSec * 1000 + remote.mtimeNsec / 1e6,
});
}
function concat(chunks: readonly Uint8Array[], total: number): Uint8Array {
if (chunks.length === 1) return chunks[0]!;
const bytes = new Uint8Array(total);
let position = 0;
for (const chunk of chunks) {
bytes.set(chunk, position);
position += chunk.length;
}
return bytes;
}
/**
* Read `length` bytes from `offset` as consecutive reads of at most `chunk` bytes, several in flight. Ends early at
* the end of the file. A short read continues from where it ended, as sequential reads would. `aborted` is checked
* after each read; `undefined` means aborted.
*/
async function readRange(
read: (offset: number, length: number) => Promise<Uint8Array>,
offset: number,
length: number,
chunk: number,
aborted: () => boolean,
): Promise<{ chunks: Uint8Array[]; total: number } | undefined> {
const chunks: Uint8Array[] = [];
let total = 0;
let next = offset;
const inFlight: { length: number; bytes: Promise<Uint8Array> }[] = [];
while (total < length) {
while (inFlight.length < READ_DEPTH && next < offset + length) {
const size = Math.min(offset + length - next, chunk);
const bytes = read(next, size);
bytes.catch(() => {});
inFlight.push({ length: size, bytes });
next += size;
}
const head = inFlight.shift()!;
const bytes = await head.bytes;
if (aborted()) return undefined;
if (bytes.length === 0) break;
chunks.push(bytes);
total += bytes.length;
if (bytes.length < head.length) {
// Reads already in flight assumed a full read; continue right after this one.
inFlight.length = 0;
next = offset + total;
}
}
return { chunks, total };
}
/** A daemon handle: valid only in the session that opened it. */
class Handle {
readonly env: RemoteExecutionEnv;
readonly id: number;
readonly session: number;
readonly path: string;
constructor(env: RemoteExecutionEnv, id: number, session: number, path: string) {
this.env = env;
this.id = id;
this.session = session;
this.path = path;
}
request(op: string, json: Json = {}, options: { signal?: AbortSignal; payload?: Uint8Array } = {}) {
return this.env.connection.request(op, { ...json, handle: this.id }, { ...options, session: this.session });
}
async pread(offset: number | undefined, length: number): Promise<Uint8Array> {
return (await this.request("pread", { ...(offset === undefined ? {} : { offset }), length })).payload;
}
async close(): Promise<void> {
await this.request("close").catch(() => undefined);
}
}
class RemoteBinaryReader implements BinaryReader {
readonly #handle: Handle;
#closed = false;
constructor(handle: Handle) {
this.#handle = handle;
}
get #path(): string {
return this.#handle.path;
}
#closedResult<T>(): Result<T, FileError> | undefined {
return this.#closed ? err(new FileError("invalid", "Binary reader is closed", this.#path)) : undefined;
}
async info(context: Context): Promise<Result<FileInfo, FileError>> {
const early = abortResult<FileInfo>(context.abortSignal, this.#path) ?? this.#closedResult<FileInfo>();
if (early) return early;
try {
const { json } = await this.#handle.request("fstat");
return toInfo(this.#path, json as unknown as RemoteFileInfo, await remotePath(this.#handle.env.connection));
} catch (error) {
return err(toFileError(error, this.#path));
}
}
async read(offset: number, length: number, context: Context): Promise<Result<Uint8Array, FileError>> {
const early = abortResult<Uint8Array>(context.abortSignal, this.#path) ?? this.#closedResult<Uint8Array>();
if (early) return early;
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 0) {
return err(new FileError("invalid", "Offset and length must be non-negative safe integers", this.#path));
}
try {
const read = await readRange(
(at, size) => this.#handle.pread(at, size),
offset,
length,
READ_CHUNK,
() => context.abortSignal?.aborted === true,
);
if (read === undefined) return err(new FileError("aborted", "aborted", this.#path));
return ok(concat(read.chunks, read.total));
} catch (error) {
return err(toFileError(error, this.#path));
}
}
async scanLines(
options: { startLine: number; endLine?: number },
context: Context,
): Promise<Result<LineScan, FileError>> {
const early = abortResult<LineScan>(context.abortSignal, this.#path) ?? this.#closedResult<LineScan>();
if (early) return early;
const { startLine, endLine } = options;
if (
!Number.isSafeInteger(startLine) ||
startLine < 0 ||
(endLine !== undefined && (!Number.isSafeInteger(endLine) || endLine <= startLine))
) {
return err(new FileError("invalid", "Invalid line range", this.#path));
}
try {
const { json } = await this.#handle.request(
"scanLines",
{ startLine, ...(endLine === undefined ? {} : { endLine }) },
context.abortSignal ? { signal: context.abortSignal } : {},
);
return ok(json as unknown as LineScan);
} catch (error) {
return err(toFileError(error, this.#path));
}
}
async close(_context: Context): Promise<void> {
if (this.#closed) return;
this.#closed = true;
await this.#handle.close();
}
}
class RemoteDirReader implements DirReader {
readonly #handle: Handle;
#done = false;
#closed = false;
constructor(handle: Handle) {
this.#handle = handle;
}
async next(
maxEntries: number,
context: Context,
): Promise<Result<{ entries: FileInfo[]; done: boolean }, FileError>> {
const path = this.#handle.path;
const aborted = abortResult<{ entries: FileInfo[]; done: boolean }>(context.abortSignal, path);
if (aborted) return aborted;
if (this.#closed) return err(new FileError("invalid", "Directory reader is closed", path));
if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
return err(new FileError("invalid", "maxEntries must be a positive safe integer", path));
}
const entries: FileInfo[] = [];
try {
const paths = await remotePath(this.#handle.env.connection);
// Like `NodeDirReader`, count only entries that are part of the listing.
while (!this.#done && entries.length < maxEntries) {
const { json } = await this.#handle.request("readdir", { max: maxEntries - entries.length });
const loopAbort = abortResult<{ entries: FileInfo[]; done: boolean }>(context.abortSignal, path);
if (loopAbort) return loopAbort;
for (const entry of json.entries as RemoteDirEntry[]) {
const entryPath = paths.resolve(path, entry.name);
if (entry.error !== undefined) {
// Removed between enumeration and lstat: not part of the listing any more.
if (entry.error.code === "ENOENT") continue;
return err(toFileError(new RemoteError(entry.error), entryPath));
}
const info = toInfo(entryPath, entry.info!, paths);
if (info.ok) entries.push(info.value);
}
this.#done = json.done === true;
}
return ok({ entries, done: this.#done });
} catch (error) {
return err(toFileError(error, path));
}
}
async close(_context: Context): Promise<void> {
if (this.#closed) return;
this.#closed = true;
await this.#handle.close();
}
}
/** `NodeTextLineReader`: positional reads of a file opened like `open(path, "r")`. */
class RemoteTextLineReader implements TextLineReader {
readonly #handle: Handle;
readonly #decoder = new StreamDecoder();
#offset = 0;
#buffered = "";
#ended = false;
#closed = false;
constructor(handle: Handle) {
this.#handle = handle;
}
async readLine(context: Context): Promise<Result<TextLine | undefined, FileError>> {
const path = this.#handle.path;
const aborted = abortResult<TextLine | undefined>(context.abortSignal, path);
if (aborted) return aborted;
if (this.#closed) return err(new FileError("invalid", "Text line reader is closed", path));
try {
while (true) {
const newline = this.#buffered.indexOf("\n");
if (newline !== -1) {
const text = this.#buffered.slice(0, newline);
this.#buffered = this.#buffered.slice(newline + 1);
return ok({ text, terminated: true });
}
if (this.#ended) {
if (this.#buffered.length === 0) return ok(undefined);
const text = this.#buffered;
this.#buffered = "";
return ok({ text, terminated: false });
}
const bytes = await this.#handle.pread(this.#offset, LINE_CHUNK);
const afterReadAbort = abortResult<TextLine | undefined>(context.abortSignal, path);
if (afterReadAbort) return afterReadAbort;
this.#offset += bytes.length;
if (bytes.length === 0) {
this.#buffered += this.#decoder.decode();
this.#ended = true;
} else {
this.#buffered += this.#decoder.decode(bytes);
}
}
} catch (error) {
return err(toFileError(error, path));
}
}
async close(_context: Context): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#buffered = "";
await this.#handle.close();
}
}
/**
* An `ExecutionEnv` on another machine, reached through a pi-env daemon. Results match `NodeExecutionEnv` running on
* that machine: the daemon performs the system calls, and this class applies Node's path, error, and result rules.
*/
export class RemoteExecutionEnv implements ExecutionEnv {
readonly id: string;
cwd: string;
readonly connection: Connection;
readonly #shellPath: string | undefined;
readonly #shellEnv: Record<string, string> | undefined;
readonly #watchOptions: RemoteWatchOptions;
/** Running commands this environment started, for `cleanup()`. */
readonly #running = new Map<number, number>();
constructor(options: RemoteExecutionEnvOptions) {
this.id = options.id;
this.cwd = options.cwd;
this.connection = options.connection;
this.#shellPath = options.shellPath;
this.#shellEnv = options.shellEnv;
this.#watchOptions = options.watch ?? {};
}
/** Node's `resolvePath` on the remote system, with its home directory and working directories. */
async #resolve(path: string): Promise<string> {
const info = await this.connection.info();
const windows = nodePlatform(info).platform === "win32";
const paths = windows ? win32 : posix;
let normalized = path;
if (normalized === "~") {
normalized = info.home;
} else if (normalized.startsWith("~/") || (windows && normalized.startsWith("~\\"))) {
normalized = paths.join(info.home, normalized.slice(2));
} else if (normalized.startsWith("file://")) {
try {
normalized = fileURLToPath(normalized, { windows });
} catch {
// Keep malformed URLs as ordinary paths, as Node does.
}
}
if (paths.isAbsolute(normalized)) return paths.resolve(normalized);
if (windows) {
// A drive-relative path on another drive: Node on Windows resolves it against that drive's working directory
// (`=D:`), else its own working directory if on that drive, else the drive's root.
const drive = /^([a-zA-Z]:)(?![\\/])/.exec(normalized)?.[1];
if (drive !== undefined && drive.toLowerCase() !== this.cwd.slice(0, 2).toLowerCase()) {
let base = info.driveCwds[drive.toUpperCase()] ?? info.cwd;
if (base.slice(0, 2).toLowerCase() !== drive.toLowerCase() && base[2] === "\\") base = `${drive}\\`;
return win32.resolve(base, normalized);
}
}
return paths.resolve(this.cwd, normalized);
}
async #fileOp<T>(
path: string,
context: Context,
run: (resolved: string) => Promise<T>,
check: "before" | "both" = "before",
): Promise<Result<T, FileError>> {
let resolved: string;
try {
resolved = await this.#resolve(path);
} catch (error) {
return err(toFileError(error, path));
}
const aborted = abortResult<T>(context.abortSignal, resolved);
if (aborted) return aborted;
try {
const value = await run(resolved);
if (check === "both") {
const after = abortResult<T>(context.abortSignal, resolved);
if (after) return after;
}
return ok(value);
} catch (error) {
return err(toFileError(error, resolved));
}
}
async #open(resolved: string, json: Json): Promise<{ handle: Handle; json: Json }> {
const reply = await this.connection.request("open", { path: resolved, ...json });
return { handle: new Handle(this, reply.json.handle as number, reply.session, resolved), json: reply.json };
}
async absolutePath(path: string, _context: Context): Promise<Result<string, FileError>> {
try {
return ok(await this.#resolve(path));
} catch (error) {
return err(toFileError(error, path));
}
}
async joinPath(parts: string[], _context: Context): Promise<Result<string, FileError>> {
try {
return ok((await remotePath(this.connection)).join(...parts));
} catch (error) {
return err(toFileError(error));
}
}
/** Node's `readFile`, with or without UTF-8 decoding, over a file opened like `open(path, "r")`. */
async #readFile(path: string, encoding: boolean, context: Context): Promise<Result<Uint8Array | string, FileError>> {
const signal = context.abortSignal;
return this.#fileOp(path, context, async (resolved) => {
const { handle, json } = await this.#open(resolved, { mode: "read" });
const aborted = () => signal?.aborted === true;
const abort = () => new FileError("aborted", "The operation was aborted", resolved);
try {
if (json.statError !== undefined) throw new RemoteError(json.statError as Json);
if (aborted()) throw abort();
const stat = json.stat as RemoteFileInfo;
const size = stat.kind === "file" ? stat.size : 0;
if (size > READ_FILE_MAX) {
throw new FileError("unknown", `File size (${size}) is greater than 2 GiB`, resolved);
}
if (size === 0) {
// Unknown size: sequential reads until the end, as for FIFOs and devices.
const decoder = new StringDecoder("utf8");
const chunks: Uint8Array[] = [];
let total = 0;
let text = "";
while (true) {
if (aborted()) throw abort();
const bytes = await handle.pread(undefined, READ_FILE_UNKNOWN_CHUNK);
if (bytes.length === 0) break;
total += bytes.length;
chunks.push(bytes);
if (encoding) text += decoder.write(bytes);
}
if (!encoding) return Buffer.concat(chunks, total);
return total === 0 ? "" : text + decoder.end();
}
if (!encoding) {
// A single read of the whole size, or reads of 512 KiB until the size or the end of the file.
if (size <= READ_FILE_CHUNK) return Buffer.from(await handle.pread(0, size));
const read = await readRange(
(at, length) => handle.pread(at, length),
0,
size,
READ_FILE_CHUNK,
aborted,
);
if (read === undefined) throw abort();
return Buffer.concat(read.chunks, read.total);
}
// Decoding: reads of min(size, 512 KiB) until the size, a short read, or the end of the file.
const length = Math.min(size, READ_FILE_CHUNK);
const decoder = new StringDecoder("utf8");
let text = "";
let total = 0;
let first = true;
const inFlight: Promise<Uint8Array>[] = [];
let next = 0;
while (true) {
while (inFlight.length < READ_DEPTH) {
const bytes = handle.pread(next, length);
bytes.catch(() => {});
inFlight.push(bytes);
next += length;
}
const bytes = await inFlight.shift()!;
if (aborted()) throw abort();
total += bytes.length;
if (bytes.length === 0 || total === size || bytes.length !== length) {
return first ? Buffer.from(bytes).toString("utf8") : text + decoder.end(Buffer.from(bytes));
}
text += decoder.write(Buffer.from(bytes));
first = false;
}
} finally {
await handle.close();
}
});
}
async readTextFile(path: string, context: Context): Promise<Result<string, FileError>> {
return (await this.#readFile(path, true, context)) as Result<string, FileError>;
}
async readBinaryFile(path: string, context: Context): Promise<Result<Uint8Array, FileError>> {
return (await this.#readFile(path, false, context)) as Result<Uint8Array, FileError>;
}
async openBinaryReader(
path: string,
options: { noFollow?: boolean } | undefined,
context: Context,
): Promise<Result<BinaryReader, FileError>> {
const opened = await this.#fileOp(path, context, async (resolved) => {
return (await this.#open(resolved, { noFollow: options?.noFollow === true })).handle;
});
if (!opened.ok) return opened;
const aborted = abortResult<BinaryReader>(context.abortSignal, opened.value.path);
if (aborted) {
await opened.value.close();
return aborted;
}
return ok(new RemoteBinaryReader(opened.value));
}
async openTextLineReader(path: string, context: Context): Promise<Result<TextLineReader, FileError>> {
const opened = await this.#fileOp(path, context, async (resolved) => {
return (await this.#open(resolved, { mode: "read" })).handle;
});
if (!opened.ok) return opened;
const aborted = abortResult<TextLineReader>(context.abortSignal, opened.value.path);
if (aborted) {
await opened.value.close();
return aborted;
}
return ok(new RemoteTextLineReader(opened.value));
}
async readTextLines(
path: string,
options: { maxLines?: number } | undefined,
context: Context,
): Promise<Result<string[], FileError>> {
if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]);
const opened = await this.openTextLineReader(path, context);
if (!opened.ok) return opened;
const lines: string[] = [];
try {
while (options?.maxLines === undefined || lines.length < options.maxLines) {
const line = await opened.value.readLine(context);
if (!line.ok) return line;
if (line.value === undefined) break;
lines.push(line.value.text);
}
return ok(lines);
} finally {
await opened.value.close(context);
}
}
/**
* Node's `writeFile` (abort checked before each 512 KiB write) or `appendFile` (no checks, one after): the first
* request creates parents, opens, and writes the first chunk; later chunks go to the same open file.
*/
async #write(path: string, content: string | Uint8Array, append: boolean, context: Context) {
const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : content;
const signal = append ? undefined : context.abortSignal;
return this.#fileOp(
path,
context,
async (resolved) => {
const first = bytes.subarray(0, WRITE_CHUNK);
const keep = bytes.length > WRITE_CHUNK;
const reply = await this.connection.request("write", { path: resolved, append, keep }, { payload: first });
if (!keep) return;
const handle = new Handle(this, reply.json.handle as number, reply.session, resolved);
const inFlight: Promise<unknown>[] = [];
try {
for (let offset = WRITE_CHUNK; offset < bytes.length; offset += WRITE_CHUNK) {
if (signal?.aborted) {
// A chunk that failed before this check fails the write, as it would have in sequence.
const failed = (await Promise.allSettled(inFlight)).find((result) => result.status === "rejected");
if (failed !== undefined) throw failed.reason;
throw new FileError("aborted", "The operation was aborted", resolved);
}
const written = handle.request(
"writeChunk",
{},
{ payload: bytes.subarray(offset, offset + WRITE_CHUNK) },
);
written.catch(() => {});
inFlight.push(written);
if (inFlight.length >= WRITE_DEPTH) await inFlight.shift();
}
// The first failure; later chunks are refused by the daemon, so the file has no gaps.
for (const written of inFlight) await written;
} finally {
await Promise.allSettled(inFlight);
await handle.close();
}
},
append ? "both" : "before",
);
}
writeFile(path: string, content: string | Uint8Array, context: Context): Promise<Result<void, FileError>> {
return this.#write(path, content, false, context);
}
appendFile(path: string, content: string | Uint8Array, context: Context): Promise<Result<void, FileError>> {
return this.#write(path, content, true, context);
}
async truncateFile(path: string, size: number, context: Context): Promise<Result<void, FileError>> {
return this.#fileOp(
path,
context,
async (resolved) => {
if (!Number.isSafeInteger(size) || size < 0) {
throw new FileError("invalid", "File size must be a non-negative safe integer", resolved);
}
await this.connection.request("truncate", { path: resolved, size });
},
"both",
);
}
flushFile(path: string, context: Context): Promise<Result<void, FileError>> {
return this.#fileOp(
path,
context,
async (resolved) => {
await this.connection.request("fsync", { path: resolved });
},
"both",
);
}
async renameFile(sourcePath: string, destinationPath: string, context: Context): Promise<Result<void, FileError>> {
let source: string;
let destination: string;
try {
source = await this.#resolve(sourcePath);
destination = await this.#resolve(destinationPath);
} catch (error) {
return err(toFileError(error, sourcePath));
}
const aborted = abortResult<void>(context.abortSignal, destination);
if (aborted) return aborted;
try {
await this.connection.request("rename", { path: source, to: destination });
return ok(undefined);
} catch (error) {
return err(toFileError(error, source));
}
}
async fileInfo(path: string, context: Context): Promise<Result<FileInfo, FileError>> {
const result = await this.#fileOp(path, context, async (resolved) => {
const { json } = await this.connection.request("lstat", { path: resolved });
return toInfo(resolved, json as unknown as RemoteFileInfo, await remotePath(this.connection));
});
return result.ok ? result.value : result;
}
listDir(path: string, context: Context): Promise<Result<FileInfo[], FileError>> {
return this.#fileOp(path, context, async (resolved) => {
const { handle } = await this.#openDir(resolved);
const entries: RemoteDirEntry[] = [];
try {
for (let done = false; !done; ) {
const { json } = await handle.request("readdir", { max: 1000 });
entries.push(...(json.entries as RemoteDirEntry[]));
done = json.done === true;
}
} finally {
await handle.close();
}
// Node's `readdir` fails on any entry it cannot lstat. libuv sorts names by their bytes on POSIX; on Windows it
// keeps the file system's order, which the daemon reports.
const paths = await remotePath(this.connection);
if (paths === posix) {
const key = (entry: RemoteDirEntry) => Buffer.from(entry.raw ?? Buffer.from(entry.name, "utf8"));
entries.sort((a, b) => Buffer.compare(key(a), key(b)));
}
const infos: FileInfo[] = [];
for (const entry of entries) {
if (context.abortSignal?.aborted) throw new FileError("aborted", "aborted", resolved);
const entryPath = paths.resolve(resolved, entry.name);
if (entry.error !== undefined) throw toFileError(new RemoteError(entry.error), entryPath);
const info = toInfo(entryPath, entry.info!, paths);
if (info.ok) infos.push(info.value);
}
return infos;
});
}
/** Through the daemon; `not_supported` with a daemon released before `listFiles`. */
async listFiles(
path: string,
options: ListFilesOptions | undefined,
context: Context,
): Promise<Result<FileListing, FileError>> {
let resolved: string;
try {
resolved = await this.#resolve(path);
} catch (error) {
return err(toFileError(error, path));
}
return listFilesOver(this.connection, resolved, options, context);
}
async #openDir(resolved: string): Promise<{ handle: Handle }> {
const reply = await this.connection.request("opendir", { path: resolved });
return { handle: new Handle(this, reply.json.handle as number, reply.session, resolved) };
}
async openDirReader(path: string, context: Context): Promise<Result<DirReader, FileError>> {
const opened = await this.#fileOp(path, context, async (resolved) => (await this.#openDir(resolved)).handle);
if (!opened.ok) return opened;
const aborted = abortResult<DirReader>(context.abortSignal, opened.value.path);
if (aborted) {
await opened.value.close();
return aborted;
}
return ok(new RemoteDirReader(opened.value));
}
canonicalPath(path: string, context: Context): Promise<Result<string, FileError>> {
return this.#fileOp(path, context, async (resolved) => {
const { json } = await this.connection.request("realpath", { path: resolved });
return json.path as string;
});
}
async exists(path: string, context: Context): Promise<Result<boolean, FileError>> {
const result = await this.fileInfo(path, context);
if (result.ok) return ok(true);
if (result.error.code === "not_found") return ok(false);
return err(result.error);
}
createDir(
path: string,
options: { recursive?: boolean } | undefined,
context: Context,
): Promise<Result<void, FileError>> {
return this.#fileOp(path, context, async (resolved) => {
await this.connection.request("mkdir", { path: resolved, recursive: options?.recursive ?? true });
});
}
remove(
path: string,
options: { recursive?: boolean; force?: boolean } | undefined,
context: Context,
): Promise<Result<void, FileError>> {
return this.#fileOp(path, context, async (resolved) => {
await this.connection.request("rm", {
path: resolved,
recursive: options?.recursive ?? false,
force: options?.force ?? false,
});
});
}
async createTempDir(prefix: string | undefined, context: Context): Promise<Result<string, FileError>> {
const aborted = abortResult<string>(context.abortSignal);
if (aborted) return aborted;
try {
const { tmpdir } = await this.connection.info();
const paths = await remotePath(this.connection);
const { json } = await this.connection.request("mkdtemp", { path: paths.join(tmpdir, prefix ?? "tmp-") });
return ok(json.path as string);
} catch (error) {
return err(toFileError(error));
}
}
async createTempFile(
options: { prefix?: string; suffix?: string } | undefined,
context: Context,
): Promise<Result<string, FileError>> {
const dir = await this.createTempDir("tmp-", context);
if (!dir.ok) return dir;
let filePath = "";
try {
const paths = await remotePath(this.connection);
filePath = paths.join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
// Node's `writeFile(filePath, "")`, which does not create parents.
await this.connection.request(
"write",
{ path: filePath, append: false, parents: false },
{ payload: new Uint8Array(0) },
);
return ok(filePath);
} catch (error) {
return err(toFileError(error, filePath));
}
}
async watch(
targets: readonly WatchTarget[],
onChange: (change: WatchChange) => void,
context: Context,
): Promise<Result<FileWatcher, FileError>> {
const aborted = abortResult<FileWatcher>(context.abortSignal);
if (aborted) return aborted;
try {
const resolved = await Promise.all(
targets.map(async (target) => ({ ...target, path: await this.#resolve(target.path) })),
);
const afterResolve = abortResult<FileWatcher>(context.abortSignal);
if (afterResolve) return afterResolve;
const watcher = await RemoteWatcher.open(this.connection, resolved, onChange, this.#watchOptions);
const afterOpen = abortResult<FileWatcher>(context.abortSignal);
if (afterOpen) {
await watcher.close(context);
return afterOpen;
}
return ok(watcher);
} catch (error) {
return err(toFileError(error));
}
}
async exec(
command: string | readonly string[],
options: ShellExecOptions | undefined,
context: Context,
): Promise<Result<ShellExecResult, ExecutionError>> {
const signal = context.abortSignal;
if (signal?.aborted) return err(new ExecutionError("aborted", "aborted"));
const timeout = options?.timeout;
if (timeout !== undefined) {
if (!Number.isFinite(timeout) || timeout <= 0) {
return err(new ExecutionError("timeout", "Invalid timeout: must be a finite number of seconds"));
}
if (timeout * 1000 > MAX_TIMEOUT_MS) {
return err(new ExecutionError("timeout", `Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`));
}
}
const inheritEnv = options?.inheritEnv ?? true;
let cwd: string;
try {
cwd = options?.cwd ? await this.#resolve(options.cwd) : this.cwd;
} catch (error) {
return err(new ExecutionError("unknown", error instanceof Error ? error.message : String(error)));
}
if (signal?.aborted) return err(new ExecutionError("aborted", "aborted"));
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(
inheritEnv ? { ...this.#shellEnv, ...options?.env } : { ...options?.env },
)) {
if (value !== undefined) env[key.toWellFormed()] = value;
}
let callbackError: ExecutionError | undefined;
let settled = false;
let running: number | undefined;
const controller = new AbortController();
const onAbort = () => controller.abort();
signal?.addEventListener("abort", onAbort, { once: true });
try {
const { json } = await this.connection.request(
"exec",
{
...(typeof command === "string" ? { command } : { argv: [...command] }),
cwd,
env,
inheritEnv,
...(this.#shellPath === undefined ? {} : { shellPath: this.#shellPath }),
...(timeout === undefined ? {} : { timeoutMs: timeout * 1000 }),
...(options?.spill === undefined ? {} : { spill: options.spill }),
...(options?.window === undefined ? {} : { window: options.window }),
},
{
signal: controller.signal,
onStart: (requestId, session) => {
running = requestId;
this.#running.set(requestId, session);
},
onEvent: (event, payload) => {
if (settled || callbackError !== undefined || event.kind !== "output") return;
const text = Buffer.from(payload.buffer, payload.byteOffset, payload.length).toString("utf8");
if (text === "" || options?.onOutput === undefined) return;
const info: ShellOutputInfo = { stream: event.stream === "stderr" ? "stderr" : "stdout" };
if (event.skipped !== undefined) info.skipped = event.skipped as ShellOutputSkip;
try {
options.onOutput(text, context, info);
} catch (error) {
const cause = error instanceof Error ? error : new Error(String(error));
callbackError = new ExecutionError("callback_error", cause.message, cause);
controller.abort();
}
},
},
);
if (callbackError) return err(callbackError);
return ok({
exitCode: json.exitCode as number,
...(typeof json.spillPath === "string" ? { spillPath: json.spillPath } : {}),
});
} catch (error) {
if (callbackError) return err(callbackError);
if (!(error instanceof RemoteError)) {
return err(new ExecutionError("unknown", error instanceof Error ? error.message : String(error)));
}
const failure =
error.code === "timeout"
? new ExecutionError("timeout", `timeout:${timeout}`)
: error.code === "aborted"
? new ExecutionError("aborted", "aborted")
: error.code === "shell_unavailable" || error.code === "spawn_error"
? new ExecutionError(error.code, error.message)
: new ExecutionError("unknown", error.message);
if (typeof error.fields.spillPath === "string") failure.spillPath = error.fields.spillPath;
return err(failure);
} finally {
settled = true;
signal?.removeEventListener("abort", onAbort);
if (running !== undefined) this.#running.delete(running);
}
}
async cleanup(_context: Context): Promise<void> {
// Kill without aborting, so the commands settle with their killed status, as `NodeExecutionEnv` does.
for (const [id, session] of this.#running) this.connection.kill(id, session);
this.#running.clear();
}
}
export type { RemoteInfo };

539
third_party/pi/packages/env/src/ssh.ts vendored Normal file
View File

@@ -0,0 +1,539 @@
import { spawn } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Connection, type ConnectionOptions } from "./connection.ts";
/** A remote system the package ships a daemon for. */
export type RemotePlatform = {
platform: "linux" | "android" | "darwin" | "windows";
arch: "x64" | "arm64";
/** The remote home directory, in the remote system's own spelling. */
home: string;
/** Windows: the shell `sshd` runs commands with (`DefaultShell`). */
shell?: "cmd" | "powershell";
/** Problems worth telling the owner about, such as Termux without `termux-exec`. */
warnings: string[];
};
/** How to reach a machine with the system `ssh`. */
export interface SshTarget {
/** Host name or `~/.ssh/config` alias. */
host: string;
user?: string;
port?: number;
identityFile?: string;
/** Host keys this application trusts; `acceptHostKey` adds to it. */
knownHostsFile: string;
/** The name keys are stored under, independent of aliases, ports and jump hosts, e.g. `pi-env-<env name>`. */
hostKeyAlias: string;
/** The `ssh` program; default `ssh`. */
ssh?: string;
/** An `ssh` configuration file instead of `~/.ssh/config` (`-F`). */
configFile?: string;
}
/** The remote host's key is not in `knownHostsFile`; `scanHostKey` shows it so the owner can accept it. */
export class HostKeyUnknownError extends Error {}
/** The remote host's key differs from the one in `knownHostsFile`; it is never accepted automatically. */
export class HostKeyChangedError extends Error {}
/** An `ssh` invocation that failed for another reason, with its exit code and diagnostics. */
export class SshError extends Error {
readonly exitCode: number | null;
readonly stderr: string;
constructor(message: string, exitCode: number | null, stderr: string) {
super(message);
this.exitCode = exitCode;
this.stderr = stderr;
}
}
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
/** No leading `-` (it would be read as an `ssh` option) and no whitespace or control characters. */
function checkField(name: string, value: string): void {
if (value === "" || value.startsWith("-") || /[\s\x00-\x1f\x7f]/.test(value)) {
throw new Error(`Invalid ${name}: ${JSON.stringify(value)}`);
}
}
/** A path for an `ssh` option that expands `%` tokens: quoted, with `%` literal. */
function configPath(name: string, path: string): string {
if (path === "" || /["\x00-\x1f\x7f]/.test(path)) throw new Error(`Invalid ${name}: ${JSON.stringify(path)}`);
return `"${path.replaceAll("%", "%%")}"`;
}
/**
* Arguments for `ssh` up to the host: no prompts, no forwarding of any kind, no shared connections, no commands from
* the configuration, no locale forwarding (the remote uses its own), and host keys checked strictly against the
* application's own file under a fixed alias.
*/
export function sshArguments(
target: SshTarget,
strictHostKeys = true,
knownHostsFile = target.knownHostsFile,
): string[] {
checkField("host", target.host);
checkField("host key alias", target.hostKeyAlias);
if (target.user !== undefined) checkField("user", target.user);
if (target.port !== undefined && (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535)) {
throw new Error(`Invalid port: ${target.port}`);
}
const option = (setting: string) => ["-o", setting];
return [
...(target.configFile === undefined ? [] : ["-F", target.configFile]),
"-T",
"-a",
"-x",
...option("BatchMode=yes"),
...option("ClearAllForwardings=yes"),
...option("ForwardAgent=no"),
...option("ForwardX11=no"),
...option("ControlMaster=no"),
...option("ControlPath=none"),
...option("RemoteCommand=none"),
...option("PermitLocalCommand=no"),
...option("SendEnv=-*"),
...option("ServerAliveInterval=15"),
...option(`StrictHostKeyChecking=${strictHostKeys ? "yes" : "accept-new"}`),
...option(`UserKnownHostsFile=${configPath("known hosts file", knownHostsFile)}`),
...option("GlobalKnownHostsFile=none"),
...option("HashKnownHosts=no"),
...option(`HostKeyAlias=${target.hostKeyAlias}`),
...(target.user === undefined ? [] : ["-l", target.user]),
...(target.port === undefined ? [] : ["-p", String(target.port)]),
...(target.identityFile === undefined
? []
: [
...option(`IdentityFile=${configPath("identity file", target.identityFile)}`),
...option("IdentitiesOnly=yes"),
]),
"--",
target.host,
];
}
/** How long a detection, check or upload over `ssh` may take before it is abandoned. */
const SSH_TIMEOUT_MS = 60_000;
const UPLOAD_TIMEOUT_MS = 300_000;
/** Run one remote command over `ssh`, optionally feeding stdin; resolves with stdout, rejects on failure. */
function runSsh(
target: SshTarget,
command: string,
options: { stdin?: Uint8Array; args?: string[]; timeoutMs?: number } = {},
): Promise<string> {
return new Promise((resolve, reject) => {
const args = options.args ?? sshArguments(target);
const child = spawn(target.ssh ?? "ssh", [...args, command], { stdio: ["pipe", "pipe", "pipe"] });
const timeoutMs = options.timeoutMs ?? SSH_TIMEOUT_MS;
const timer = setTimeout(() => {
child.kill();
const diagnostics = Buffer.concat(stderr).toString("utf8");
reject(
new SshError(
`ssh ${target.host} did not finish within ${timeoutMs / 1000} s: ${diagnostics.trim()}`,
null,
diagnostics,
),
);
}, timeoutMs);
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
child.stdin.on("error", () => {});
child.on("error", (error) => {
clearTimeout(timer);
reject(new SshError(error.message, null, ""));
});
child.on("close", (code) => {
clearTimeout(timer);
const output = Buffer.concat(stdout).toString("utf8");
const diagnostics = Buffer.concat(stderr).toString("utf8");
if (code === 0) return resolve(output);
if (/REMOTE HOST IDENTIFICATION HAS CHANGED/.test(diagnostics)) {
return reject(
new HostKeyChangedError(`The host key of ${target.host} changed; remove the old key to continue`),
);
}
if (/Host key verification failed|No .* host key is known/.test(diagnostics)) {
return reject(new HostKeyUnknownError(`The host key of ${target.host} is not trusted yet`));
}
reject(
new SshError(`ssh ${target.host} failed with exit code ${code}: ${diagnostics.trim()}`, code, diagnostics),
);
});
child.stdin.end(options.stdin ?? new Uint8Array(0));
});
}
/**
* Connect once with `accept-new` against a temporary known-hosts file to capture the key the host presents, through
* the same route (`~/.ssh/config`, jump hosts) as real connections. Returns its known-hosts lines and fingerprints.
* Showing a fingerprint is not authentication: compare it with one obtained out of band before accepting it.
*/
export async function scanHostKey(target: SshTarget): Promise<{ lines: string[]; fingerprints: string[] }> {
const directory = await mkdtemp(join(tmpdir(), "pi-env-hostkey-"));
try {
const scanned = join(directory, "known_hosts");
// Authentication may fail without the key; the key is recorded before authentication.
await runSsh(target, "exit 0", { args: sshArguments(target, false, scanned) }).catch((error: unknown) => {
if (!(error instanceof SshError) && !(error instanceof HostKeyUnknownError)) throw error;
});
const lines = existsSync(scanned)
? (await readFile(scanned, "utf8")).split("\n").filter((line) => line.trim() !== "")
: [];
if (lines.length === 0) throw new Error(`No host key received from ${target.host}`);
const fingerprints = await new Promise<string[]>((resolve, reject) => {
const child = spawn("ssh-keygen", ["-lf", scanned], { stdio: ["ignore", "pipe", "pipe"] });
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
});
child.on("error", reject);
child.on("close", () => resolve(output.split("\n").filter((line) => line.trim() !== "")));
});
return { lines, fingerprints };
} finally {
await rm(directory, { recursive: true, force: true });
}
}
/** A known-hosts line for `alias`: `alias type key [comment]`, no markers, patterns or hashed names. */
function parseHostKeyLine(alias: string, line: string): { type: string; key: string } {
const [host, type, key] = line.trim().split(/\s+/);
if (
host !== alias ||
type === undefined ||
key === undefined ||
!/^(ssh-[a-z0-9-]+|ecdsa-sha2-[a-z0-9-]+|sk-[a-z0-9@.-]+)$/.test(type) ||
!/^[A-Za-z0-9+/]+={0,2}$/.test(key)
) {
throw new Error(`Not a host key line for ${alias}: ${JSON.stringify(line)}`);
}
return { type, key };
}
/** Known-hosts files being changed, so changes to one file happen one at a time. */
const knownHostsChanges = new Map<string, Promise<unknown>>();
/** Rewrite the known-hosts file one change at a time, atomically. */
async function changeKnownHosts(file: string, change: (lines: string[]) => string[]): Promise<void> {
const previous = knownHostsChanges.get(file) ?? Promise.resolve();
const next = previous
.catch(() => {})
.then(async () => {
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
const existing = existsSync(file)
? (await readFile(file, "utf8")).split("\n").filter((line) => line !== "")
: [];
const lines = change(existing);
const temporary = `${file}.${randomBytes(6).toString("hex")}.tmp`;
await writeFile(temporary, lines.length === 0 ? "" : `${lines.join("\n")}\n`, { mode: 0o600, flag: "wx" });
await rename(temporary, file).catch(async (error: unknown) => {
await rm(temporary, { force: true });
throw error;
});
});
knownHostsChanges.set(file, next);
try {
await next;
} finally {
if (knownHostsChanges.get(file) === next) knownHostsChanges.delete(file);
}
}
/**
* Trust host-key lines from `scanHostKey` by adding them to the target's known-hosts file. Only plain lines for the
* target's alias are accepted. A key that differs from a trusted key of the same type is refused with
* `HostKeyChangedError`; `forgetHostKey` must remove the old keys first.
*/
export async function acceptHostKey(target: SshTarget, lines: readonly string[]): Promise<void> {
const alias = target.hostKeyAlias;
const accepted = lines.map((line) => ({ line: line.trim(), ...parseHostKeyLine(alias, line) }));
await changeKnownHosts(target.knownHostsFile, (existing) => {
const trusted = new Map<string, string>();
for (const line of existing) {
const [host, type, key] = line.trim().split(/\s+/);
if (host === alias && type !== undefined && key !== undefined) trusted.set(type, key);
}
const added: string[] = [];
for (const { line, type, key } of accepted) {
const known = trusted.get(type);
if (known === key) continue;
if (known !== undefined) {
throw new HostKeyChangedError(`The ${type} host key of ${target.host} changed; forget the old key first`);
}
trusted.set(type, key);
added.push(line);
}
return [...existing, ...added];
});
}
/** Stop trusting every key stored for the target's alias, e.g. after the owner confirmed a changed host key. */
export async function forgetHostKey(target: SshTarget): Promise<void> {
await changeKnownHosts(target.knownHostsFile, (existing) =>
existing.filter((line) => line.trim().split(/\s+/)[0] !== target.hostKeyAlias),
);
}
/** POSIX detection, run by the remote login shell; a Windows host answers through PowerShell instead. */
const POSIX_PROBE = `sh -c 'echo PI-ENV-PROBE; uname -s; uname -m; uname -o 2>/dev/null || echo -; printf "%s\\n" "$HOME" "\${TMPDIR:--}" "\${LD_PRELOAD:--}"'`;
function powershell(script: string): string {
return `powershell -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, "utf16le").toString("base64")}`;
}
const WINDOWS_PROBE = powershell(
"'PI-ENV-PROBE'; 'Windows'; [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString(); '-'; $HOME; '-'; '-'",
);
function normalizeArch(machine: string): RemotePlatform["arch"] {
const lower = machine.toLowerCase();
if (lower === "x86_64" || lower === "amd64" || lower === "x64") return "x64";
if (lower === "aarch64" || lower === "arm64") return "arm64";
throw new Error(`Unsupported remote architecture: ${machine}`);
}
type Probe = { system: string; machine: string; os: string; home: string; tmpdir: string; preload: string };
function parseProbe(target: SshTarget, output: string): Probe {
// Login shells may print a banner first.
const lines = output.split(/\r?\n/);
const start = lines.indexOf("PI-ENV-PROBE");
if (start === -1) throw new Error(`Unexpected answer from ${target.host}: ${output.trim()}`);
const [system = "", machine = "", os = "", home = "", tmpdir = "-", preload = "-"] = lines.slice(start + 1);
return { system, machine, os, home, tmpdir, preload };
}
/** Termux works only with its own `TMPDIR` and with `termux-exec`, which makes `#!/usr/bin/env` shebangs work. */
function termuxWarnings(probe: Probe): string[] {
const warnings: string[] = [];
if (probe.tmpdir === "-") warnings.push("TMPDIR is not set; Termux's sshd normally sets it to $PREFIX/tmp.");
if (!probe.preload.includes("termux-exec")) {
warnings.push("termux-exec is not loaded (LD_PRELOAD); scripts with #!/usr/bin/env shebangs will fail.");
}
warnings.push("Android may suspend Termux; run termux-wake-lock on the device to keep the connection alive.");
return warnings;
}
/** Which system the target runs: `uname` through the login shell, or PowerShell on Windows. */
export async function detectPlatform(target: SshTarget): Promise<RemotePlatform> {
let probe: Probe;
try {
probe = parseProbe(target, await runSsh(target, POSIX_PROBE));
} catch (error) {
// cmd.exe or PowerShell as the remote shell: no `sh`, or one that gets the probe's quotes wrong.
if (error instanceof HostKeyUnknownError || error instanceof HostKeyChangedError) throw error;
probe = parseProbe(target, await runSsh(target, WINDOWS_PROBE));
}
// Git Bash as Windows' default SSH shell: ask PowerShell for Windows' own architecture and home spelling.
if (/^(MINGW|MSYS|CYGWIN)/.test(probe.system)) probe = parseProbe(target, await runSsh(target, WINDOWS_PROBE));
const arch = normalizeArch(probe.machine);
const { system, os, home } = probe;
if (system === "Windows") {
// cmd.exe expands %OS%; PowerShell prints it as is.
const shell = (await runSsh(target, "echo %OS%")).includes("Windows_NT") ? "cmd" : "powershell";
return { platform: "windows", arch, home, shell, warnings: [] };
}
if (system === "Darwin") return { platform: "darwin", arch, home, warnings: [] };
if (system === "Linux") {
const android = os === "Android";
return { platform: android ? "android" : "linux", arch, home, warnings: android ? termuxWarnings(probe) : [] };
}
throw new Error(`Unsupported remote system: ${system}`);
}
/** The daemon binary this package ships for a remote system. */
export function packagedDaemon(remote: Pick<RemotePlatform, "platform" | "arch">): string {
const name = `pi-env-${remote.platform}-${remote.arch}`;
return join(packageRoot, "bin", name, remote.platform === "windows" ? "pi-env.exe" : "pi-env");
}
/** The daemon this package ships for the machine it runs on, e.g. for terminals of local sessions. */
export function localDaemon(): string {
const platform = process.platform === "win32" ? "windows" : process.platform;
if (platform !== "linux" && platform !== "android" && platform !== "darwin" && platform !== "windows") {
throw new Error(`No pi-env daemon for ${process.platform}`);
}
return packagedDaemon({ platform, arch: normalizeArch(process.arch) });
}
function quotePosix(text: string): string {
return `'${text.replaceAll("'", "'\\''")}'`;
}
function quotePowerShell(text: string): string {
return `'${text.replaceAll("'", "''")}'`;
}
/** Where a daemon with this content lives on the remote machine: named by its SHA-256, so versions never collide. */
function daemonPath(remote: RemotePlatform, sha256: string): string {
const name = `pi-env-${sha256.slice(0, 32)}`;
return remote.platform === "windows"
? `${remote.home}\\.pi\\mobile\\tools\\${name}.exe`
: `${remote.home}/.pi/mobile/tools/${name}`;
}
const POSIX_HASH = `hash() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1; elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | sed 's/.*= //'; else echo none; fi; }`;
/** Whether the remote file has this content: `present`, `missing`, or (POSIX without a hash tool) `nohash`. */
async function checkDaemon(target: SshTarget, remote: RemotePlatform, file: string, sha256: string): Promise<string> {
if (remote.platform === "windows") {
const check = powershell(
`$f = ${quotePowerShell(file)}; if ((Test-Path -LiteralPath $f) -and ((Get-FileHash -Algorithm SHA256 -LiteralPath $f).Hash.ToLower() -eq '${sha256}')) { 'present' } else { 'missing' }`,
);
return (await runSsh(target, check)).trim().endsWith("present") ? "present" : "missing";
}
const check = `${POSIX_HASH}; f=${quotePosix(file)}; if [ -f "$f" ] && [ "$(hash "$f")" = ${sha256} ]; then echo present; elif [ "$(hash /dev/null)" = none ]; then echo nohash; else echo missing; fi`;
return (await runSsh(target, `sh -c ${quotePosix(check)}`)).trim().split("\n").at(-1) ?? "missing";
}
/**
* Make sure the daemon is on the remote machine, verified by its SHA-256 before it ever runs, and remove daemons of
* other contents. The upload goes to a new temporary file and is renamed into place only once its hash matches.
* Returns the remote path of the binary.
*/
export async function deployDaemon(
target: SshTarget,
remote: RemotePlatform,
binary = packagedDaemon(remote),
): Promise<string> {
if (!existsSync(binary)) throw new Error(`No pi-env daemon for ${remote.platform}-${remote.arch} at ${binary}`);
const bytes = await readFile(binary);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const file = daemonPath(remote, sha256);
const state = await checkDaemon(target, remote, file, sha256);
if (state === "present") return file;
if (state === "nohash") throw new Error(`${target.host} has no sha256sum, shasum or openssl to verify pi-env`);
if (remote.platform === "windows") {
const upload = powershell(
[
"$ErrorActionPreference = 'Stop'",
`$f = ${quotePowerShell(file)}`,
"$d = Split-Path -Parent $f",
"New-Item -ItemType Directory -Force -Path $d | Out-Null",
"$t = Join-Path $d ('.pi-env-' + [guid]::NewGuid().ToString() + '.tmp')",
// PowerShell reads redirected stdin itself, as text lines for `$input`, so the binary comes as base64 lines
// up to an end marker: Windows' sshd may never pass on the end of stdin.
"$text = New-Object System.Text.StringBuilder",
"foreach ($line in $input) { if ($line -eq 'PI-ENV-END') { break }; [void]$text.Append($line) }",
"$bytes = [Convert]::FromBase64String($text.ToString())",
"$out = [IO.File]::Open($t, 'CreateNew', 'Write', 'None'); $out.Write($bytes, 0, $bytes.Length); $out.Close()",
`if ((Get-FileHash -Algorithm SHA256 -LiteralPath $t).Hash.ToLower() -ne '${sha256}') { Remove-Item -LiteralPath $t; throw 'pi-env upload is corrupt' }`,
// A running daemon or a virus scanner can hold the old file for a moment.
"for ($i = 0; ; $i++) { try { Move-Item -Force -LiteralPath $t -Destination $f; break } catch { if ($i -ge 20) { throw }; Start-Sleep -Milliseconds 250 } }",
// Older daemons; one that is running stays until it exits.
"Get-ChildItem -LiteralPath $d -Filter 'pi-env-*.exe' | Where-Object { $_.FullName -ne $f } | ForEach-Object { Remove-Item -LiteralPath $_.FullName -ErrorAction SilentlyContinue }",
"'deployed'",
].join("; "),
);
const lines = bytes.toString("base64").replace(/.{1,76}/g, "$&\n");
await runSsh(target, upload, { stdin: Buffer.from(`${lines}PI-ENV-END\n`), timeoutMs: UPLOAD_TIMEOUT_MS });
return file;
}
const upload = [
"set -e",
POSIX_HASH,
`f=${quotePosix(file)}`,
'd=$(dirname "$f")',
'mkdir -p "$d"',
'chmod 700 "$d"',
't=$(mktemp "$d/.pi-env.XXXXXX")',
'cat > "$t"',
`if [ "$(hash "$t")" != ${sha256} ]; then rm -f "$t"; echo "pi-env upload is corrupt" >&2; exit 1; fi`,
'chmod 700 "$t"',
'mv -f "$t" "$f"',
// Older daemons; running ones keep their file open until they exit.
'for old in "$d"/pi-env-*; do [ "$old" = "$f" ] || rm -f "$old"; done',
"echo deployed",
].join("\n");
await runSsh(target, `sh -c ${quotePosix(upload)}`, { stdin: bytes, timeoutMs: UPLOAD_TIMEOUT_MS });
return file;
}
/** Options for `sshConnection` and `connectSsh`: the target, plus the binary to deploy (default: the one this package ships). */
export interface SshConnectOptions extends SshTarget {
binary?: string;
/**
* POSIX: start the daemon through the user's login shell (`$SHELL -l`), so commands see the environment of
* `~/.profile` and similar files. Off by default: `ssh` runs commands without a login shell.
*/
loginShell?: boolean;
onLog?: ConnectionOptions["onLog"];
}
/** The remote command that starts the daemon at `file`, before the `serve` arguments. */
function launchCommand(remote: RemotePlatform, file: string, loginShell: boolean): string {
if (remote.platform === "windows") {
// PowerShell runs a quoted path only with the call operator; cmd.exe keeps one pair of quotes around a program.
if (remote.shell === "powershell") return `& ${quotePowerShell(file)}`;
return file.includes(" ") ? `"${file}"` : file;
}
// `$0` is the daemon, `"$@"` its arguments; `$SHELL` is the login shell's own name for itself.
if (loginShell) return `exec "$SHELL" -lc 'exec "$0" "$@"' ${quotePosix(file)}`;
return quotePosix(file);
}
/** What an SSH connection learned about its remote system, and the daemon it verified last. */
type SshState = { remote?: RemotePlatform; verified?: string };
/** A `Connection` whose every start detects the remote system (once), verifies or deploys the daemon, then starts it. */
function sshConnectionFor(options: SshConnectOptions, state: SshState): Connection {
return new Connection({
command: async () => {
if (state.remote === undefined) {
const remote = await detectPlatform(options);
for (const warning of remote.warnings) options.onLog?.(`${warning}\n`);
state.remote = remote;
}
// A deployment that just verified the binary counts for the first start.
const file =
state.verified ??
(await deployDaemon(options, state.remote, options.binary ?? packagedDaemon(state.remote)));
state.verified = undefined;
return [
options.ssh ?? "ssh",
...sshArguments(options),
launchCommand(state.remote, file, options.loginShell === true),
];
},
...(options.onLog === undefined ? {} : { onLog: options.onLog }),
});
}
/**
* A `Connection` to the target that does nothing until its first request. Each start detects the remote system (the
* first time), verifies the daemon and deploys it if it is missing or changed, then starts it over `ssh`. A failure
* (no network, an untrusted or changed host key) fails the requests waiting for that start with code `spawn_error`
* and the `ssh` diagnostics as message; the next request tries again. `remote()` is the detected system, once known.
*/
export function sshConnection(options: SshConnectOptions): {
connection: Connection;
remote(): RemotePlatform | undefined;
} {
const state: SshState = {};
return { connection: sshConnectionFor(options, state), remote: () => state.remote };
}
/**
* Detect the remote system and deploy the daemon now, then return a `Connection` that starts it over `ssh`, like
* `sshConnection`. Failures reject here: host keys must already be trusted (`scanHostKey`, `acceptHostKey`), otherwise
* this rejects with `HostKeyUnknownError`.
*/
export async function connectSsh(
options: SshConnectOptions,
): Promise<{ connection: Connection; remote: RemotePlatform }> {
const remote = await detectPlatform(options);
const verified = await deployDaemon(options, remote, options.binary ?? packagedDaemon(remote));
for (const warning of remote.warnings) options.onLog?.(`${warning}\n`);
return { connection: sshConnectionFor(options, { remote, verified }), remote };
}

View File

@@ -0,0 +1,127 @@
import type { Connection, Json } from "./connection.ts";
/** Input larger than this goes to the daemon in several writes; frames are at most 16 MiB. */
const WRITE_CHUNK = 1024 * 1024;
export interface TerminalOptions {
/** Working directory of the program; it must exist. */
cwd: string;
/** Program and arguments. Default: the user's shell (`SHELL` in `env` or the daemon's environment) as a login shell. */
argv?: readonly string[];
/** Added to the daemon's environment, or the whole environment with `inheritEnv: false`. Set `TERM` here. */
env?: Record<string, string>;
inheritEnv?: boolean;
cols: number;
rows: number;
/** The terminal's raw output, in order. */
onData: (bytes: Uint8Array) => void;
}
/** The foreground job of a terminal: its process group id, and its leader's name if the system tells. */
export interface TerminalForeground {
pid: number;
name?: string;
}
/**
* A program running in a pseudo-terminal of a pi-env daemon. It ends when the program exits, `close()` kills it, or
* the connection is lost (the daemon then kills it).
*/
export class RemoteTerminal {
readonly pid: number;
/** The exit code; rejects with the daemon's error, with code `aborted` after `close()`. */
readonly exited: Promise<number>;
readonly #connection: Connection;
readonly #id: number;
readonly #session: number;
readonly #abort: AbortController;
constructor(
connection: Connection,
id: number,
session: number,
pid: number,
exited: Promise<number>,
abort: AbortController,
) {
this.#connection = connection;
this.#id = id;
this.#session = session;
this.pid = pid;
this.exited = exited;
this.#abort = abort;
}
/** Send input; once the terminal has ended, input is dropped. */
write(data: Uint8Array | string): void {
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
for (let offset = 0; offset < bytes.length; offset += WRITE_CHUNK) {
this.#request("ptyWrite", {}, bytes.subarray(offset, offset + WRITE_CHUNK));
}
}
/** Change the size; the program receives `SIGWINCH`. */
resize(cols: number, rows: number): void {
this.#request("ptyResize", { cols, rows });
}
/** Kill the program and everything in its session. */
close(): void {
this.#abort.abort();
}
/**
* The job in the terminal's foreground when it is not the program itself: what a shell runs, such as an editor.
* Undefined while the program is in front (a shell at its prompt). Rejects once the terminal has ended.
*/
async foreground(): Promise<TerminalForeground | undefined> {
const reply = await this.#connection.request("ptyForeground", { target: this.#id }, { session: this.#session });
const foreground = reply.json.foreground as { pid: number; name: string | null } | null;
if (!foreground) return undefined;
return { pid: foreground.pid, ...(foreground.name ? { name: foreground.name } : {}) };
}
#request(op: string, json: Json, payload?: Uint8Array): void {
this.#connection
.request(op, { ...json, target: this.#id }, { session: this.#session, ...(payload ? { payload } : {}) })
// A terminal that ended or a lost connection: `exited` reports it.
.catch(() => {});
}
}
/** Start a program in a new pseudo-terminal of the daemon behind `connection`. */
export function openTerminal(connection: Connection, options: TerminalOptions): Promise<RemoteTerminal> {
const abort = new AbortController();
const started = Promise.withResolvers<RemoteTerminal>();
let request: { id: number; session: number } | undefined;
const exited = connection
.request(
"pty",
{
cwd: options.cwd,
...(options.argv === undefined ? {} : { argv: options.argv }),
...(options.env === undefined ? {} : { env: options.env }),
...(options.inheritEnv === undefined ? {} : { inheritEnv: options.inheritEnv }),
cols: options.cols,
rows: options.rows,
},
{
signal: abort.signal,
onStart: (id, session) => {
request = { id, session };
},
onEvent: (json, payload) => {
if (json.kind === "output") options.onData(payload);
else if (json.kind === "started" && request) {
started.resolve(
new RemoteTerminal(connection, request.id, request.session, Number(json.pid), exited, abort),
);
}
},
},
)
.then((reply) => Number(reply.json.exitCode));
// Failing before it started rejects the open; afterwards, `exited` carries it.
exited.catch(started.reject);
return started.promise;
}

158
third_party/pi/packages/env/src/watch.ts vendored Normal file
View File

@@ -0,0 +1,158 @@
import type { Context } from "@earendil-works/chord";
import { FileError, type FileWatcher, type WatchChange, type WatchTarget } from "@earendil-works/pi-durable/env";
import { type Connection, isConnectionLost } from "./connection.ts";
import { toFileError } from "./errors.ts";
/** How the daemon watches, like `NodeExecutionEnv`'s `NodeWatchOptions`. */
export interface RemoteWatchOptions {
/** Force a mode; by default Windows and file systems that do not report remote changes poll. */
mode?: "native" | "polling";
/** Interval between snapshots in `polling` mode; default 2000 ms. */
pollIntervalMs?: number;
/** Most directories one watcher covers; default 10,000. */
maxDirectories?: number;
}
const RECONNECT_FIRST_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
/**
* A watcher running in the daemon (Durable's `NodeFileWatcher` next to the files). When the connection is lost, it is
* opened again once the connection can be started again, and reports `overflow`: changes in between were not seen.
*/
export class RemoteWatcher implements FileWatcher {
readonly #connection: Connection;
readonly #targets: readonly WatchTarget[];
readonly #onChange: (change: WatchChange) => void;
readonly #options: RemoteWatchOptions;
#mode: "native" | "polling" = "native";
#closed = false;
#controller: AbortController | undefined;
#request: Promise<void> | undefined;
#wake: (() => void) | undefined;
private constructor(
connection: Connection,
targets: readonly WatchTarget[],
onChange: (change: WatchChange) => void,
options: RemoteWatchOptions,
) {
this.#connection = connection;
this.#targets = targets;
this.#onChange = onChange;
this.#options = options;
}
/** Watch `targets` (resolved paths); fails like `NodeFileWatcher.open` if coverage cannot be established. */
static async open(
connection: Connection,
targets: readonly WatchTarget[],
onChange: (change: WatchChange) => void,
options: RemoteWatchOptions = {},
): Promise<RemoteWatcher> {
const watcher = new RemoteWatcher(connection, targets, onChange, options);
await watcher.#start();
return watcher;
}
get mode(): "native" | "polling" {
return this.#mode;
}
async close(_context: Context): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#wake?.();
this.#controller?.abort();
await this.#request;
}
#deliver(change: WatchChange): void {
if (this.#closed) return;
try {
this.#onChange(change);
} catch {
// A throwing callback must not stop watching.
}
}
/** Open the daemon's watcher; resolves once it reports coverage. */
#start(): Promise<void> {
return new Promise((resolve, reject) => {
let ready = false;
const controller = new AbortController();
this.#controller = controller;
const request = this.#connection.request(
"watch",
{
targets: this.#targets.map((target) => ({
path: target.path,
recursive: target.recursive === true,
exclude: { hidden: target.exclude?.hidden === true, names: [...(target.exclude?.names ?? [])] },
})),
...this.#options,
},
{
signal: controller.signal,
onEvent: (event) => {
if (event.kind === "ready") {
ready = true;
this.#mode = event.mode === "polling" ? "polling" : "native";
resolve();
} else if (event.kind === "change") {
if (event.mode === "polling") this.#mode = "polling";
if (event.overflow === true) this.#deliver({ overflow: true });
else this.#deliver({ paths: event.paths as string[] });
} else if (event.kind === "error") {
const code = event.code === "permission_denied" ? "permission_denied" : "invalid";
this.#deliver({ error: new FileError(code, String(event.message)) });
this.#closed = true;
}
},
},
);
this.#request = request.then(
() => {},
(error: unknown) => {
if (!ready) {
reject(error);
return;
}
if (this.#closed) return;
if (isConnectionLost(error)) {
void this.#reconnect();
return;
}
this.#deliver({ error: toFileError(error) });
this.#closed = true;
},
);
});
}
async #reconnect(): Promise<void> {
for (let delay = RECONNECT_FIRST_MS; !this.#closed; delay = Math.min(delay * 2, RECONNECT_MAX_MS)) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, delay);
timer.unref();
this.#wake = () => {
clearTimeout(timer);
resolve();
};
});
this.#wake = undefined;
if (this.#closed) return;
try {
await this.#start();
// Changes while disconnected were not seen.
this.#deliver({ overflow: true });
return;
} catch (error) {
if (isConnectionLost(error)) continue;
this.#deliver({ error: toFileError(error) });
this.#closed = true;
return;
}
}
}
}