feat(env): SSH bootstrap and daemons in the npm package

- connectSsh detects the remote system, deploys the packaged daemon
  verified by SHA-256 before it runs, and connects over ssh with strict
  host keys under a fixed alias; scanHostKey and acceptHostKey
- Windows detection and deployment through PowerShell
- release builds the daemon for every supported remote system and
  ships them in @earendil-works/pi-env's bin/
- end-to-end tests against a disposable sshd
This commit is contained in:
Mario Zechner 2026-10-05 13:06:08 +02:00
parent 1cbc7a0e8e
commit b78e6a9085
7 changed files with 549 additions and 6 deletions

View file

@ -301,9 +301,17 @@ jobs:
exit 1
fi
# The pi-env daemon for every remote system @earendil-works/pi-env supports; the npm package ships them all.
env-daemons:
uses: ./.github/workflows/env-daemons.yml
permissions:
contents: read
with:
ref: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
publish-npm:
runs-on: ubuntu-latest
needs: stage-github-release
needs: [stage-github-release, env-daemons]
environment: npm-publish
permissions:
contents: read
@ -334,12 +342,33 @@ jobs:
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Download pi-env daemons
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: pi-env-*
path: packages/env/bin
- name: Check pi-env daemons
run: |
set -euo pipefail
for name in linux-x64 linux-arm64 android-arm64 android-x64 darwin-arm64 darwin-x64; do
test -s "packages/env/bin/pi-env-${name}/pi-env"
done
for name in windows-x64 windows-arm64; do
test -s "packages/env/bin/pi-env-${name}/pi-env.exe"
done
chmod +x packages/env/bin/*/pi-env*
ls -lR packages/env/bin
- name: Build
run: npm run build
- name: Check
run: npm run check
- name: Build the pi-env daemon for tests
run: npm run build:daemon --workspace packages/env
- name: Test
run: npm test

View file

@ -65,6 +65,10 @@ jobs:
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Install an SSH server for the bootstrap tests
if: runner.os == 'Linux'
run: command -v sshd || test -x /usr/sbin/sshd || (sudo apt-get update && sudo apt-get install -y openssh-server)
- name: Daemon format, lint and unit tests
working-directory: packages/env/daemon
run: |

View file

@ -5,3 +5,4 @@
### Added
- Initial release of `@earendil-works/pi-env`: `RemoteExecutionEnv`, a Durable `ExecutionEnv` on another machine, and `Connection` to the `pi-env` daemon that runs there. Results match `NodeExecutionEnv` on that machine on Linux, macOS, Android and Windows.
- SSH bootstrap: `connectSsh` detects the remote system, deploys the daemon this package ships for it (verified by SHA-256 before it runs), and connects; `scanHostKey` and `acceptHostKey` manage trusted host keys.

View file

@ -10,12 +10,23 @@ while the Durable worker, its storage and credentials stay local.
([docs/semantics.md](docs/semantics.md)).
```ts
import { Connection, RemoteExecutionEnv } from "@earendil-works/pi-env";
import { acceptHostKey, connectSsh, HostKeyUnknownError, RemoteExecutionEnv, scanHostKey } from "@earendil-works/pi-env";
const connection = new Connection({ command: ["ssh", "-T", "--", "gpu-box", "~/.pi/mobile/tools/pi-env"] });
const env = new RemoteExecutionEnv({ connection, id: "pi-env:gpu-box", cwd: "/home/me/project" });
const target = { host: "gpu-box", knownHostsFile: "/data/ssh/known_hosts", hostKeyAlias: "pi-env-gpu" };
// Once: show the host's key fingerprint to the owner, who compares it out of band and accepts it.
const { lines, fingerprints } = await scanHostKey(target);
await acceptHostKey(target, lines);
// Detects the remote system, deploys this version's daemon if missing (verified by SHA-256 before it runs), and
// returns a connection that starts it over ssh.
const { connection } = await connectSsh(target);
const env = new RemoteExecutionEnv({ connection, id: "pi-env:gpu", cwd: "/home/me/project" });
```
The package ships the daemon for every supported remote system in `bin/`. `ssh` runs with `BatchMode`, strict host-key
checking against the application's own known-hosts file under a fixed alias, no forwarding, and without forwarding the
local locale. On Windows, detection and deployment go through PowerShell.
Supported remote systems: Linux, macOS, Android (Termux) and Windows, on x86-64 and arm64. On Windows, string commands
run through Git Bash as `NodeExecutionEnv` runs them there; argv commands run directly.
@ -25,5 +36,4 @@ run through Git Bash as `NodeExecutionEnv` runs them there; argv commands run di
run Durable's env conformance suite against `RemoteExecutionEnv` and compare random operation sequences and Durable's
tools against `NodeExecutionEnv` on the same machine.
Not yet: SSH bootstrap and binary deployment, output windowing (all output is transferred), daemon-side file watching
(the client polls).
Not yet: output windowing (all output is transferred) and daemon-side file watching (the client polls).

View file

@ -1,3 +1,18 @@
export { Connection, type ConnectionOptions, RemoteError, type RemoteInfo, type RequestOptions } from "./connection.ts";
export { PollingWatcher } from "./polling-watch.ts";
export { RemoteExecutionEnv, type RemoteExecutionEnvOptions } from "./remote-env.ts";
export {
acceptHostKey,
connectSsh,
deployDaemon,
detectPlatform,
HostKeyChangedError,
HostKeyUnknownError,
packagedDaemon,
type RemotePlatform,
type SshConnectOptions,
SshError,
type SshTarget,
scanHostKey,
sshArguments,
} from "./ssh.ts";

322
packages/env/src/ssh.ts vendored Normal file
View file

@ -0,0 +1,322 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { appendFile, mkdir, mkdtemp, readFile, rm } 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;
};
/** 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));
const VERSION = (JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { version: string }).version;
/** 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)}`);
}
}
/**
* Arguments for `ssh` up to the host: no prompts, no forwarding, 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}`);
}
return [
...(target.configFile === undefined ? [] : ["-F", target.configFile]),
"-T",
"-o",
"BatchMode=yes",
"-o",
"ClearAllForwardings=yes",
"-o",
"SendEnv=-*",
"-o",
"ServerAliveInterval=15",
"-o",
`StrictHostKeyChecking=${strictHostKeys ? "yes" : "accept-new"}`,
"-o",
`UserKnownHostsFile=${knownHostsFile}`,
"-o",
`HostKeyAlias=${target.hostKeyAlias}`,
...(target.user === undefined ? [] : ["-l", target.user]),
...(target.port === undefined ? [] : ["-p", String(target.port)]),
...(target.identityFile === undefined ? [] : ["-i", target.identityFile, "-o", "IdentitiesOnly=yes"]),
"--",
target.host,
];
}
/** 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[] } = {},
): 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 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) => reject(new SshError(error.message, null, "")));
child.on("close", (code) => {
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 });
}
}
/** Trust host-key lines from `scanHostKey` by adding them to the target's known-hosts file. */
export async function acceptHostKey(target: SshTarget, lines: readonly string[]): Promise<void> {
await mkdir(dirname(target.knownHostsFile), { recursive: true, mode: 0o700 });
const existing = existsSync(target.knownHostsFile) ? await readFile(target.knownHostsFile, "utf8") : "";
const known = new Set(existing.split("\n"));
const added = lines.filter((line) => !known.has(line));
if (added.length > 0) {
const prefix = existing === "" || existing.endsWith("\n") ? "" : "\n";
await appendFile(target.knownHostsFile, `${prefix}${added.join("\n")}\n`, { mode: 0o600 });
}
}
/** 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"'`;
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}`);
}
function parseProbe(target: SshTarget, output: string): { system: string; machine: string; os: string; home: string } {
// 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 = ""] = lines.slice(start + 1);
return { system, machine, os, home };
}
/** Which system the target runs: `uname` through the login shell, or PowerShell on Windows. */
export async function detectPlatform(target: SshTarget): Promise<RemotePlatform> {
let output: string;
try {
output = await runSsh(target, POSIX_PROBE);
} catch (error) {
// cmd.exe or PowerShell as the remote shell: no `sh`.
if (!(error instanceof SshError)) throw error;
output = await runSsh(target, WINDOWS_PROBE);
}
let answer = parseProbe(target, output);
// Git Bash as Windows' default SSH shell: ask PowerShell for Windows' own architecture and home spelling.
if (/^(MINGW|MSYS|CYGWIN)/.test(answer.system)) answer = parseProbe(target, await runSsh(target, WINDOWS_PROBE));
const { system, os, home } = answer;
const arch = normalizeArch(answer.machine);
if (system === "Windows") return { platform: "windows", arch, home };
if (system === "Darwin") return { platform: "darwin", arch, home };
if (system === "Linux") return { platform: os === "Android" ? "android" : "linux", arch, home };
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");
}
function quotePosix(text: string): string {
return `'${text.replaceAll("'", "'\\''")}'`;
}
/**
* Make sure the daemon of this package's version is on the remote machine, verified by its SHA-256 before it ever
* runs: an existing file with another hash is replaced. 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");
if (remote.platform === "windows") {
const file = `${remote.home}\\.pi\\mobile\\tools\\pi-env-${VERSION}.exe`;
const literal = `'${file.replaceAll("'", "''")}'`;
const check = powershell(
`$f = ${literal}; if ((Test-Path -LiteralPath $f) -and ((Get-FileHash -Algorithm SHA256 -LiteralPath $f).Hash.ToLower() -eq '${sha256}')) { 'present' } else { 'missing' }`,
);
if ((await runSsh(target, check)).trim().endsWith("present")) return file;
const upload = powershell(
[
"$ErrorActionPreference = 'Stop'",
`$f = ${literal}`,
"$d = Split-Path -Parent $f",
"New-Item -ItemType Directory -Force -Path $d | Out-Null",
"$t = Join-Path $d ('.pi-env-' + [guid]::NewGuid().ToString() + '.tmp')",
"$in = [Console]::OpenStandardInput(); $out = [IO.File]::Create($t); $in.CopyTo($out); $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 } }",
"'deployed'",
].join("; "),
);
await runSsh(target, upload, { stdin: bytes });
return file;
}
const file = `${remote.home}/.pi/mobile/tools/pi-env-${VERSION}`;
const 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; }`;
const check = `${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`;
const state = (await runSsh(target, `sh -c ${quotePosix(check)}`)).trim().split("\n").at(-1);
if (state === "present") return file;
if (state === "nohash") throw new Error(`${target.host} has no sha256sum, shasum or openssl to verify pi-env`);
const upload = [
"set -e",
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"',
"echo deployed",
].join("\n");
await runSsh(target, `sh -c ${quotePosix(upload)}`, { stdin: bytes });
return file;
}
/** Options for `connectSsh`: the target, plus the binary to deploy (default: the one this package ships). */
export interface SshConnectOptions extends SshTarget {
binary?: string;
onLog?: ConnectionOptions["onLog"];
}
/**
* Detect the remote system, deploy the daemon if needed, and return a `Connection` that starts it over `ssh`. 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 file = await deployDaemon(options, remote, options.binary ?? packagedDaemon(remote));
// cmd.exe, Windows' default SSH shell, needs double quotes; POSIX shells get single quotes.
const program = remote.platform === "windows" ? (file.includes(" ") ? `"${file}"` : file) : quotePosix(file);
const connection = new Connection({
command: [options.ssh ?? "ssh", ...sshArguments(options), program],
...(options.onLog === undefined ? {} : { onLog: options.onLog }),
});
return { connection, remote };
}

162
packages/env/test/ssh.test.ts vendored Normal file
View file

@ -0,0 +1,162 @@
import { type ChildProcess, execFileSync, spawn } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createServer, Socket } from "node:net";
import { tmpdir, userInfo } from "node:os";
import { join, resolve } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { getOrThrow } from "@earendil-works/pi-durable/env";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { RemoteExecutionEnv } from "../src/remote-env.ts";
import {
acceptHostKey,
connectSsh,
HostKeyChangedError,
HostKeyUnknownError,
type SshTarget,
scanHostKey,
} from "../src/ssh.ts";
const daemon = resolve(import.meta.dirname, "../daemon/target/debug/pi-env");
const version = (
JSON.parse(readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8")) as { version: string }
).version;
const context = BACKGROUND_CONTEXT;
function which(program: string): string | undefined {
try {
return execFileSync("sh", ["-c", `command -v ${program}`], { encoding: "utf8" }).trim() || undefined;
} catch {
return undefined;
}
}
const sshd =
process.platform === "win32"
? undefined
: (which("sshd") ?? (existsSync("/usr/sbin/sshd") ? "/usr/sbin/sshd" : undefined));
async function freePort(): Promise<number> {
return new Promise((resolve) => {
const server = createServer().listen(0, "127.0.0.1", () => {
const { port } = server.address() as { port: number };
server.close(() => resolve(port));
});
});
}
async function waitForPort(port: number): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
const open = await new Promise<boolean>((resolve) => {
const socket = new Socket();
socket.once("connect", () => resolve(true)).once("error", () => resolve(false));
socket.connect(port, "127.0.0.1");
}).finally(() => undefined);
if (open) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("sshd did not start");
}
// A disposable sshd on localhost with its own host key, client key and home directory.
describe.skipIf(sshd === undefined)("SSH bootstrap", () => {
const root = mkdtempSync(join(tmpdir(), "pi-env-ssh-"));
const home = join(root, "home");
let server: ChildProcess | undefined;
let target: SshTarget;
beforeAll(async () => {
execFileSync("mkdir", ["-p", home]);
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(root, "host_key")]);
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(root, "client_key")]);
writeFileSync(join(root, "authorized_keys"), readFileSync(join(root, "client_key.pub")));
const port = await freePort();
writeFileSync(
join(root, "sshd_config"),
[
`Port ${port}`,
"ListenAddress 127.0.0.1",
`HostKey ${join(root, "host_key")}`,
`AuthorizedKeysFile ${join(root, "authorized_keys")}`,
`PidFile ${join(root, "sshd.pid")}`,
"PasswordAuthentication no",
"KbdInteractiveAuthentication no",
"StrictModes no",
`SetEnv HOME=${home}`,
"",
].join("\n"),
);
writeFileSync(join(root, "ssh_config"), "");
server = spawn(sshd!, ["-D", "-e", "-f", join(root, "sshd_config")], { stdio: "ignore" });
await waitForPort(port);
target = {
host: "127.0.0.1",
port,
user: userInfo().username,
identityFile: join(root, "client_key"),
knownHostsFile: join(root, "known_hosts"),
hostKeyAlias: "pi-env-test",
configFile: join(root, "ssh_config"),
};
});
afterAll(() => {
server?.kill();
rmSync(root, { recursive: true, force: true });
});
it("refuses an untrusted host, then deploys and runs the daemon once its key is accepted", async () => {
await expect(connectSsh({ ...target, binary: daemon })).rejects.toBeInstanceOf(HostKeyUnknownError);
const scanned = await scanHostKey(target);
const expected = execFileSync("ssh-keygen", ["-lf", join(root, "host_key.pub")], { encoding: "utf8" }).split(
" ",
)[1];
expect(scanned.fingerprints.join("\n")).toContain(expected);
await acceptHostKey(target, scanned.lines);
const { connection, remote } = await connectSsh({ ...target, binary: daemon });
try {
expect(remote.home).toBe(home);
const deployed = readFileSync(join(home, ".pi/mobile/tools", `pi-env-${version}`));
expect(deployed.equals(readFileSync(daemon))).toBe(true);
const env = new RemoteExecutionEnv({ connection, id: "pi-env:test", cwd: home });
getOrThrow(await env.writeFile("over-ssh.txt", "hello", context));
expect(getOrThrow(await env.readTextFile("over-ssh.txt", context))).toBe("hello");
const output: string[] = [];
const result = getOrThrow(
await env.exec(["sh", "-c", 'printf "%s" "$HOME"'], { onOutput: (text) => output.push(text) }, context),
);
expect(result.exitCode).toBe(0);
expect(output.join("")).toBe(home);
} finally {
connection.close();
}
}, 60_000);
it("reuses a verified daemon and replaces a tampered one", async () => {
const file = join(home, ".pi/mobile/tools", `pi-env-${version}`);
const before = statSync(file).mtimeMs;
(await connectSsh({ ...target, binary: daemon })).connection.close();
expect(statSync(file).mtimeMs).toBe(before);
writeFileSync(file, "tampered");
const { connection } = await connectSsh({ ...target, binary: daemon });
try {
expect(readFileSync(file).equals(readFileSync(daemon))).toBe(true);
expect((await connection.info()).home).toBe(home);
} finally {
connection.close();
}
}, 60_000);
it("refuses a changed host key", async () => {
const other = join(root, "other_key");
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", other]);
const key = readFileSync(`${other}.pub`, "utf8").split(" ").slice(0, 2).join(" ");
const knownHosts = join(root, "changed_known_hosts");
writeFileSync(knownHosts, `pi-env-test ${key}\n`);
await expect(connectSsh({ ...target, knownHostsFile: knownHosts, binary: daemon })).rejects.toBeInstanceOf(
HostKeyChangedError,
);
}, 60_000);
});