Files
PaperClipAI/packages/adapter-utils/src/ssh.ts
T
Nicky LeachandPaperclip 039a547962 fix(adapter-utils): make SSH env-lab fixture teardown deterministic (#12238)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent adapters use SSH environment fixtures to test process behavior
> - The SSH fixture detached its listener process from the test process
> - Teardown removed the fixture directory without stopping and awaiting
that listener
> - This pull request validates fixture state, stops the listener with
bounded escalation, and waits before directory removal
> - The benefit is deterministic test cleanup without orphan listeners
or unsafe signals

## Linked Issues or Issue Description

**What happened?**

The SSH environment fixture detached its listener process. Test teardown
removed the temporary fixture directory without stopping and awaiting
the listener. Repeated test runs left orphan listeners that held
loopback ports.

**Expected behavior**

The fixture teardown stops its listener, waits for exit, and then
removes the fixture directory. A forged state file must not signal an
unrelated process.

**Steps to reproduce**

1. Run the SSH fixture test repeatedly.
2. Inspect listener processes after each run.
3. Observe orphan listeners or ports that remain held.

**Paperclip version or commit**

Commit 324e1331f8.

**Deployment mode**

Not deployment-related.

**Installation method**

Built from source.

**Agent adapter(s) involved**

Custom / external plugin adapter.

**Database mode**

Not database-related.

**Access context**

Unclear / not applicable.

**Node.js version**

Node.js 24.

**Operating system**

Linux.

**Relevant logs or output**

A diagnostic found orphan listeners with parent process ID 1. Each
orphan held a loopback port.

**Relevant config (if applicable)**

Not applicable.

**Additional context**

The change keeps the process identifier reuse check and limits signals
to fixture-owned processes.

## What Changed

- Add one teardown owner for each SSH fixture.
- Stop the detached listener and wait for exit before removing the
fixture root.
- Add bounded SIGTERM and SIGKILL escalation with ESRCH guards.
- Validate the state file before any signal call.
- Require a positive safe-integer PID and safe absolute paths rooted at
the fixture directory.
- Require sshdConfigPath to equal the fixture root sshd_config path.
- Add regression coverage for listener cleanup and forged state files.

## Verification

- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm exec vitest run packages/adapter-utils/src/ssh-fixture.test.ts`
- Confirm the fixture listener count stays at zero before and after the
test run.

## Risks

The teardown now sends signals to a fixture-owned process. State
validation and the existing PID reuse check limit the target. The
escalation has bounded waits.

## Model Used

OpenAI GPT-5 Codex. Exact model ID: GPT-5 Codex. Context window: not
exposed in this run. Capabilities used: tool use, repository inspection,
GitHub operations, and code review workflow management.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I have addressed all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-26 14:03:47 -07:00

2053 lines
66 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { execFile, spawn } from "node:child_process";
import { constants as fsConstants, createReadStream, createWriteStream, promises as fs } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { Transform } from "node:stream";
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
import {
createUnrelatedHistoryGraftCommit,
GIT_SYNC_COMMIT_IDENTITY_ARGS,
readSanitizedOriginRemoteUrl,
} from "./git-workspace-sync.js";
import type { RunProcessResult } from "./server-utils.js";
import type { DirectorySnapshot } from "./workspace-restore-merge.js";
import { mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
import {
createRuntimeProgressReporter,
type RuntimeProgressDirection,
type RuntimeProgressPhase,
type RuntimeProgressSink,
} from "./runtime-progress.js";
export interface SshConnectionConfig {
host: string;
port: number;
username: string;
remoteWorkspacePath: string;
privateKey: string | null;
knownHosts: string | null;
strictHostKeyChecking: boolean;
}
export interface SshCommandResult {
stdout: string;
stderr: string;
}
export interface SshRemoteExecutionSpec extends SshConnectionConfig {
remoteCwd: string;
}
export function createSshCommandManagedRuntimeRunner(input: {
spec: SshRemoteExecutionSpec;
defaultCwd?: string | null;
maxBufferBytes?: number | null;
}): CommandManagedRuntimeRunner {
const defaultCwd = input.defaultCwd?.trim() || input.spec.remoteCwd;
const maxBufferBytes =
typeof input.maxBufferBytes === "number" && Number.isFinite(input.maxBufferBytes) && input.maxBufferBytes > 0
? Math.trunc(input.maxBufferBytes)
: 1024 * 1024;
return {
execute: async (commandInput): Promise<RunProcessResult> => {
const startedAt = new Date().toISOString();
const command = commandInput.command.trim();
const args = commandInput.args ?? [];
const cwd = commandInput.cwd?.trim() || defaultCwd;
const envEntries = Object.entries(commandInput.env ?? {})
.filter((entry): entry is [string, string] => typeof entry[1] === "string");
const envPrefix = envEntries.length > 0
? `env ${envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} `
: "";
const exportPrefix = envEntries.length > 0
? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " "
: "";
const commandScript = command === "sh" || command === "bash"
? (args[0] === "-c" || args[0] === "-lc") && typeof args[1] === "string"
? `${exportPrefix}${args[1]}`
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`;
const remoteCommand = `cd ${shellQuote(cwd)} && ${commandScript}`;
try {
const result = await runSshCommand(input.spec, remoteCommand, {
stdin: commandInput.stdin,
timeoutMs: commandInput.timeoutMs,
maxBuffer: maxBufferBytes,
});
if (result.stdout) await commandInput.onLog?.("stdout", result.stdout);
if (result.stderr) await commandInput.onLog?.("stderr", result.stderr);
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: result.stdout,
stderr: result.stderr,
pid: null,
startedAt,
};
} catch (error) {
const failure = error as {
stdout?: unknown;
stderr?: unknown;
code?: unknown;
signal?: unknown;
killed?: unknown;
};
const stdout = typeof failure.stdout === "string" ? failure.stdout : "";
const stderr = typeof failure.stderr === "string"
? failure.stderr
: error instanceof Error
? error.message
: String(error);
if (stdout) await commandInput.onLog?.("stdout", stdout);
if (stderr) await commandInput.onLog?.("stderr", stderr);
return {
exitCode: typeof failure.code === "number" ? failure.code : null,
signal: typeof failure.signal === "string" ? failure.signal : null,
timedOut: failure.killed === true,
stdout,
stderr,
pid: null,
startedAt,
};
}
},
};
}
export interface SshEnvLabSupport {
supported: boolean;
reason: string | null;
}
export interface SshEnvLabFixtureState {
kind: "ssh_openbsd";
bindHost: string;
host: string;
port: number;
username: string;
rootDir: string;
workspaceDir: string;
statePath: string;
pid: number;
createdAt: string;
clientPrivateKeyPath: string;
clientPublicKeyPath: string;
hostPrivateKeyPath: string;
hostPublicKeyPath: string;
authorizedKeysPath: string;
knownHostsPath: string;
sshdConfigPath: string;
sshdLogPath: string;
}
interface LocalGitWorkspaceSnapshot {
headCommit: string;
branchName: string | null;
deletedPaths: string[];
}
export function shellQuote(value: string) {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
function isValidShellEnvKey(value: string) {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionSpec | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const parsed = value as Record<string, unknown>;
const host = typeof parsed.host === "string" ? parsed.host.trim() : "";
const username = typeof parsed.username === "string" ? parsed.username.trim() : "";
const remoteCwd = typeof parsed.remoteCwd === "string" ? parsed.remoteCwd.trim() : "";
const portValue = typeof parsed.port === "number" ? parsed.port : Number(parsed.port);
if (!host || !username || !remoteCwd || !Number.isInteger(portValue) || portValue < 1 || portValue > 65535) {
return null;
}
return {
host,
port: portValue,
username,
remoteCwd,
remoteWorkspacePath:
typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0
? parsed.remoteWorkspacePath.trim()
: remoteCwd,
privateKey: typeof parsed.privateKey === "string" && parsed.privateKey.length > 0 ? parsed.privateKey : null,
knownHosts: typeof parsed.knownHosts === "string" && parsed.knownHosts.length > 0 ? parsed.knownHosts : null,
strictHostKeyChecking:
typeof parsed.strictHostKeyChecking === "boolean" ? parsed.strictHostKeyChecking : true,
};
}
async function execFileText(
file: string,
args: string[],
options: {
timeout?: number;
maxBuffer?: number;
} = {},
): Promise<SshCommandResult> {
return await new Promise<SshCommandResult>((resolve, reject) => {
execFile(
file,
args,
{
timeout: options.timeout ?? 15_000,
maxBuffer: options.maxBuffer ?? 1024 * 128,
},
(error, stdout, stderr) => {
if (error) {
reject(Object.assign(error, { stdout: stdout ?? "", stderr: stderr ?? "" }));
return;
}
resolve({
stdout: stdout ?? "",
stderr: stderr ?? "",
});
},
);
});
}
async function spawnText(
file: string,
args: string[],
options: {
stdin?: string;
timeout?: number;
maxBuffer?: number;
} = {},
): Promise<SshCommandResult> {
return await new Promise<SshCommandResult>((resolve, reject) => {
const child = spawn(file, args, {
stdio: [options.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
});
const maxBuffer = options.maxBuffer ?? 1024 * 128;
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
const finishReject = (error: Error & { stdout?: string; stderr?: string; code?: number | null; killed?: boolean }) => {
if (settled) return;
settled = true;
error.stdout = stdout;
error.stderr = stderr;
error.killed = timedOut;
reject(error);
};
const append = (
streamName: "stdout" | "stderr",
chunk: unknown,
) => {
const text = String(chunk);
if (streamName === "stdout") {
stdout += text;
} else {
stderr += text;
}
if (Buffer.byteLength(stdout, "utf8") > maxBuffer || Buffer.byteLength(stderr, "utf8") > maxBuffer) {
child.kill("SIGTERM");
finishReject(Object.assign(new Error(`Process output exceeded maxBuffer of ${maxBuffer} bytes.`), {
code: null,
}));
}
};
let killEscalation: NodeJS.Timeout | null = null;
const timeout = options.timeout && options.timeout > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
// Escalate to SIGKILL after a 5s grace window so a hung remote
// command that ignores SIGTERM cannot keep the child alive
// indefinitely.
killEscalation = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// child may have already exited between the SIGTERM and the
// escalation — that's fine.
}
}, 5_000);
killEscalation.unref?.();
}, options.timeout)
: null;
const clearTimers = () => {
if (timeout) clearTimeout(timeout);
if (killEscalation) clearTimeout(killEscalation);
};
child.stdout?.on("data", (chunk) => {
append("stdout", chunk);
});
child.stderr?.on("data", (chunk) => {
append("stderr", chunk);
});
child.on("error", (error) => {
clearTimers();
finishReject(Object.assign(error, { code: null }));
});
child.on("close", (code, signal) => {
clearTimers();
if (settled) return;
settled = true;
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(Object.assign(new Error(stderr.trim() || stdout.trim() || `Process exited with code ${code ?? -1}`), {
stdout,
stderr,
code,
signal,
killed: timedOut,
}));
});
if (options.stdin != null && child.stdin) {
child.stdin.end(options.stdin);
}
});
}
async function runLocalGit(
localDir: string,
args: string[],
options: {
timeout?: number;
maxBuffer?: number;
} = {},
): Promise<SshCommandResult> {
return await execFileText("git", ["-C", localDir, ...args], options);
}
async function commandExists(command: string): Promise<boolean> {
return (await resolveCommandPath(command)) !== null;
}
async function resolveCommandPath(command: string): Promise<string | null> {
try {
const result = await execFileText("sh", ["-c", `command -v ${shellQuote(command)}`], {
timeout: 5_000,
maxBuffer: 8 * 1024,
});
const resolved = result.stdout.trim().split("\n")[0]?.trim() ?? "";
return resolved.length > 0 ? resolved : null;
} catch {
return null;
}
}
async function withTempFile(
prefix: string,
contents: string,
mode: number,
): Promise<{ path: string; cleanup: () => Promise<void> }> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
const filePath = path.join(dir, "payload");
const normalizedContents = contents.endsWith("\n") ? contents : `${contents}\n`;
await fs.writeFile(filePath, normalizedContents, { mode, encoding: "utf8" });
return {
path: filePath,
cleanup: async () => {
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
async function createSshAuthArgs(
config: Pick<SshConnectionConfig, "privateKey" | "knownHosts" | "strictHostKeyChecking">,
): Promise<{ args: string[]; cleanup: () => Promise<void> }> {
const tempFiles: Array<() => Promise<void>> = [];
const sshArgs = [
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
"-o",
`StrictHostKeyChecking=${config.strictHostKeyChecking ? "yes" : "no"}`,
];
if (config.strictHostKeyChecking) {
if (config.knownHosts) {
const knownHosts = await withTempFile("paperclip-ssh-known-hosts-", config.knownHosts, 0o600);
tempFiles.push(knownHosts.cleanup);
sshArgs.push("-o", `UserKnownHostsFile=${knownHosts.path}`);
}
} else {
sshArgs.push("-o", "UserKnownHostsFile=/dev/null");
}
if (config.privateKey) {
const privateKey = await withTempFile("paperclip-ssh-key-", config.privateKey, 0o600);
tempFiles.push(privateKey.cleanup);
sshArgs.push("-i", privateKey.path);
}
return {
args: sshArgs,
cleanup: async () => {
await Promise.all(tempFiles.map((cleanup) => cleanup()));
},
};
}
function tarExcludeArgs(exclude: string[] | undefined): string[] {
const combined = ["._*", ...(exclude ?? [])];
return combined.flatMap((entry) => ["--exclude", entry]);
}
function tarSpawnEnv(): NodeJS.ProcessEnv {
return {
...process.env,
// Prevent macOS bsdtar from emitting AppleDouble metadata files like ._README.md.
COPYFILE_DISABLE: "1",
};
}
// Converts a tar `--exclude` pattern into a regexp for the local-size estimate.
// We only need approximate fidelity here (the estimate feeds a clamped percent),
// so we support the literal names and `*`/`?` globs used in practice.
function tarPatternToRegExp(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, "[^/]");
return new RegExp(`^${escaped}$`);
}
// Walks `localDir` summing regular-file sizes, mirroring tar's `--exclude`
// handling (plus the implicit `._*`) and `followSymlinks` so the to-ssh upload
// can report an estimated total before tar finishes producing the stream.
async function estimateLocalDirSize(input: {
localDir: string;
exclude?: string[];
followSymlinks?: boolean;
}): Promise<number> {
const regexes = ["._*", ...(input.exclude ?? [])].map(tarPatternToRegExp);
const isExcluded = (relPath: string, base: string) =>
regexes.some((regex) => regex.test(relPath) || regex.test(base));
let total = 0;
const walk = async (dir: string, relative: string): Promise<void> => {
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const entryRelative = relative ? `${relative}/${entry.name}` : entry.name;
if (isExcluded(entryRelative, entry.name)) continue;
const full = path.join(dir, entry.name);
const stats = await (input.followSymlinks ? fs.stat(full) : fs.lstat(full)).catch(() => null);
if (!stats) continue;
if (stats.isDirectory()) {
await walk(full, entryRelative);
} else if (stats.isFile()) {
total += stats.size;
}
}
};
await walk(input.localDir, "");
return total;
}
// Best-effort remote size probe for the from-ssh restore. `du -sk` is POSIX and
// available on the BSD/Linux remotes we target; it over-counts (block-rounded,
// includes excluded dirs) which keeps the reported percent safely below 100
// until the stream actually closes. Returns null when unavailable so the caller
// falls back to MB-received mode.
async function probeRemoteDirSize(input: {
spec: SshConnectionConfig;
remoteDir: string;
}): Promise<number | null> {
try {
const result = await runSshScript(
input.spec,
`du -sk ${shellQuote(input.remoteDir)} 2>/dev/null | cut -f1`,
{ timeoutMs: 15_000, maxBuffer: 16 * 1024 },
);
const kilobytes = Number.parseInt(result.stdout.trim(), 10);
return Number.isFinite(kilobytes) && kilobytes > 0 ? kilobytes * 1024 : null;
} catch {
return null;
}
}
interface TransferProgress {
// Backpressure-respecting counter to splice into a transport pipe.
counter: Transform;
// Last cumulative byte count observed by the counter.
transferred: () => number;
// Emit the terminal completion line. Idempotent.
finish: () => Promise<void>;
// Emit a terminal failure marker instead of a completion line. Idempotent.
fail: () => Promise<void>;
}
// Wraps a throttled progress reporter behind a counting Transform so transports
// can `source.pipe(progress.counter).pipe(dest)`. When `totalBytes` is a known
// exact size (e.g. a git bundle) the reporter emits an exact percentage. When it
// is an estimate (tar upload / remote probe) we clamp the reported bytes to 99%
// of the estimate so an inaccurate total never shows a premature 100%; `finish`
// then emits the terminal 100% (or, in MB-only mode, the final MB) line.
//
// `totalBytes` may be a promise so an expensive size estimate (a local dir walk
// or a remote `du` probe) runs concurrently with the transfer instead of
// blocking the pipe from opening. Until it resolves the counter reports bytes in
// MB-only mode, then adopts the percentage once the total is known; `finish`
// awaits the estimate so the terminal 100% line is still guaranteed.
function createTransferProgress(input: {
onProgress: RuntimeProgressSink;
phase: RuntimeProgressPhase;
direction: RuntimeProgressDirection;
label?: string;
totalBytes: number | null | Promise<number | null>;
estimated: boolean;
}): TransferProgress {
const reporter = createRuntimeProgressReporter({
sink: input.onProgress,
phase: input.phase,
direction: input.direction,
label: input.label,
target: "ssh",
});
let total: number | null = null;
let cap: number | null = null;
const applyTotal = (value: number | null) => {
total = value != null && value > 0 ? value : null;
cap = total != null && input.estimated ? Math.floor(total * 0.99) : null;
};
const totalReady: Promise<void> =
input.totalBytes != null && typeof (input.totalBytes as Promise<number | null>).then === "function"
? (input.totalBytes as Promise<number | null>).then(applyTotal, () => applyTotal(null))
: (applyTotal(input.totalBytes as number | null), Promise.resolve());
let transferred = 0;
let chain: Promise<void> = Promise.resolve();
const enqueue = (work: () => Promise<void>) => {
chain = chain.then(work).catch(() => undefined);
};
const counter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
transferred += chunk.length;
const reported = cap != null ? Math.min(transferred, cap) : transferred;
const totalSnapshot = total;
enqueue(() => reporter.report(reported, totalSnapshot));
callback(null, chunk);
},
});
return {
counter,
transferred: () => transferred,
finish: async () => {
await chain.catch(() => undefined);
await totalReady.catch(() => undefined);
await reporter.complete(total != null ? total : transferred, total).catch(() => undefined);
},
fail: async () => {
await chain.catch(() => undefined);
await reporter.fail(transferred, total).catch(() => undefined);
},
};
}
async function runSshScript(
config: SshConnectionConfig,
script: string,
options: {
timeoutMs?: number;
maxBuffer?: number;
} = {},
): Promise<SshCommandResult> {
return await runSshCommand(
config,
script,
options,
);
}
async function clearLocalDirectory(
localDir: string,
preserveEntries: string[] = [],
): Promise<void> {
await fs.mkdir(localDir, { recursive: true });
const preserve = new Set(preserveEntries);
const entries = await fs.readdir(localDir);
await Promise.all(
entries
.filter((entry) => !preserve.has(entry))
.map((entry) => fs.rm(path.join(localDir, entry), { recursive: true, force: true })),
);
}
async function copyDirectoryContents(sourceDir: string, targetDir: string): Promise<void> {
await fs.mkdir(targetDir, { recursive: true });
const entries = await fs.readdir(sourceDir);
await Promise.all(entries.map(async (entry) => {
await fs.cp(path.join(sourceDir, entry), path.join(targetDir, entry), {
recursive: true,
force: true,
preserveTimestamps: true,
});
}));
}
async function readLocalGitWorkspaceSnapshot(localDir: string): Promise<LocalGitWorkspaceSnapshot | null> {
try {
const insideWorkTree = await runLocalGit(localDir, ["rev-parse", "--is-inside-work-tree"], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
if (insideWorkTree.stdout.trim() !== "true") {
return null;
}
const [headCommitResult, branchResult, deletedResult] = await Promise.all([
runLocalGit(localDir, ["rev-parse", "HEAD"], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}),
runLocalGit(localDir, ["rev-parse", "--abbrev-ref", "HEAD"], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}),
runLocalGit(localDir, ["ls-files", "--deleted", "-z"], {
timeout: 10_000,
maxBuffer: 256 * 1024,
}),
]);
const branchName = branchResult.stdout.trim();
return {
headCommit: headCommitResult.stdout.trim(),
branchName: branchName && branchName !== "HEAD" ? branchName : null,
deletedPaths: deletedResult.stdout
.split("\0")
.map((entry) => entry.trim())
.filter(Boolean),
};
} catch {
return null;
}
}
async function streamLocalFileToSsh(input: {
spec: SshConnectionConfig;
localFile: string;
remoteScript: string;
progress?: TransferProgress;
}): Promise<void> {
const auth = await createSshAuthArgs(input.spec);
const sshArgs = [
...auth.args,
"-p",
String(input.spec.port),
`${input.spec.username}@${input.spec.host}`,
`sh -c ${shellQuote(input.remoteScript)}`,
];
await new Promise<void>((resolve, reject) => {
const source = createReadStream(input.localFile);
const ssh = spawn("ssh", sshArgs, {
stdio: ["pipe", "ignore", "pipe"],
});
let sshStderr = "";
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
source.destroy();
ssh.kill("SIGTERM");
reject(error);
};
ssh.stderr?.on("data", (chunk) => {
sshStderr += String(chunk);
});
source.on("error", fail);
ssh.on("error", fail);
if (input.progress) {
input.progress.counter.on("error", fail);
source.pipe(input.progress.counter).pipe(ssh.stdin ?? null);
} else {
source.pipe(ssh.stdin ?? null);
}
ssh.on("close", (code) => {
if (settled) return;
settled = true;
if ((code ?? 0) !== 0) {
reject(new Error(sshStderr.trim() || `ssh exited with code ${code ?? -1}`));
return;
}
resolve();
});
}).finally(auth.cleanup);
}
async function streamSshToLocalFile(input: {
spec: SshConnectionConfig;
remoteScript: string;
localFile: string;
progress?: TransferProgress;
}): Promise<void> {
const auth = await createSshAuthArgs(input.spec);
const sshArgs = [
...auth.args,
"-p",
String(input.spec.port),
`${input.spec.username}@${input.spec.host}`,
`sh -c ${shellQuote(input.remoteScript)}`,
];
await new Promise<void>((resolve, reject) => {
const ssh = spawn("ssh", sshArgs, {
stdio: ["ignore", "pipe", "pipe"],
});
const sink = createWriteStream(input.localFile, { mode: 0o600 });
let sshStderr = "";
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
ssh.kill("SIGTERM");
sink.destroy();
reject(error);
};
if (input.progress) {
input.progress.counter.on("error", fail);
ssh.stdout?.pipe(input.progress.counter).pipe(sink);
} else {
ssh.stdout?.pipe(sink);
}
ssh.stderr?.on("data", (chunk) => {
sshStderr += String(chunk);
});
ssh.on("error", fail);
sink.on("error", fail);
ssh.on("close", (code) => {
sink.end(() => {
if (settled) return;
settled = true;
if ((code ?? 0) !== 0) {
reject(new Error(sshStderr.trim() || `ssh exited with code ${code ?? -1}`));
return;
}
resolve();
});
});
}).finally(auth.cleanup);
}
async function importGitWorkspaceToSsh(input: {
spec: SshRemoteExecutionSpec;
localDir: string;
remoteDir: string;
snapshot: LocalGitWorkspaceSnapshot;
onProgress?: RuntimeProgressSink;
}): Promise<void> {
const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-bundle-"));
const bundlePath = path.join(bundleDir, "workspace.bundle");
// Per-import unique ref so concurrent imports against the same local repo
// can't race on `update-ref` between this run's update and bundle create.
const tempRef = `refs/paperclip/ssh-sync/import/${randomUUID()}`;
try {
await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
await runLocalGit(input.localDir, ["bundle", "create", bundlePath, tempRef], {
timeout: 60_000,
maxBuffer: 1024 * 1024,
});
const originUrl = await readSanitizedOriginRemoteUrl(input.localDir);
const remoteSetupScript = [
"set -e",
`mkdir -p ${shellQuote(path.posix.join(input.remoteDir, ".paperclip-runtime"))}`,
`tmp_bundle=$(mktemp ${shellQuote(path.posix.join(input.remoteDir, ".paperclip-runtime", "import-XXXXXX.bundle"))})`,
'trap \'rm -f "$tmp_bundle"\' EXIT',
'cat > "$tmp_bundle"',
`if [ ! -d ${shellQuote(path.posix.join(input.remoteDir, ".git"))} ]; then git init ${shellQuote(input.remoteDir)} >/dev/null; fi`,
// Carry the workspace's (credential-scrubbed) origin into the transported
// repo so branches there keep a publishable remote instead of reading as
// remote-less snapshots. set-url covers a reused workspace whose origin
// changed; add covers the fresh-init case. Best-effort under `set -e`.
...(originUrl
? [
`{ git -C ${shellQuote(input.remoteDir)} remote set-url origin ${shellQuote(originUrl)} >/dev/null 2>&1 || git -C ${shellQuote(input.remoteDir)} remote add origin ${shellQuote(originUrl)} >/dev/null 2>&1; } || true`,
]
: []),
`git -C ${shellQuote(input.remoteDir)} fetch --force "$tmp_bundle" '${tempRef}:${tempRef}' >/dev/null`,
input.snapshot.branchName
? `git -C ${shellQuote(input.remoteDir)} checkout --force -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null`
: `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --force --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
`git -C ${shellQuote(input.remoteDir)} reset --hard ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
`git -C ${shellQuote(input.remoteDir)} clean -fdx -e .paperclip-runtime >/dev/null`,
// Drop the per-import ref on the remote side too so it can't accumulate.
`git -C ${shellQuote(input.remoteDir)} update-ref -d ${shellQuote(tempRef)} >/dev/null 2>&1 || true`,
].join("\n");
// The git bundle is a real local file of known size, so report an exact
// percentage. No `workspace` label: the "Importing git history" phase is
// already self-describing in the log line.
const progress = input.onProgress
? createTransferProgress({
onProgress: input.onProgress,
phase: "Importing git history",
direction: "to",
totalBytes: (await fs.stat(bundlePath)).size,
estimated: false,
})
: null;
try {
await streamLocalFileToSsh({
spec: input.spec,
localFile: bundlePath,
remoteScript: remoteSetupScript,
progress: progress ?? undefined,
});
await progress?.finish();
} catch (error) {
await progress?.fail();
throw error;
}
} finally {
await runLocalGit(input.localDir, ["update-ref", "-d", tempRef], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch(() => undefined);
await fs.rm(bundleDir, { recursive: true, force: true }).catch(() => undefined);
}
}
async function exportGitWorkspaceFromSsh(input: {
spec: SshRemoteExecutionSpec;
remoteDir: string;
localDir: string;
importedRef?: string;
resetLocalWorkspace?: boolean;
onProgress?: RuntimeProgressSink;
}): Promise<string> {
const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-bundle-"));
const bundlePath = path.join(bundleDir, "workspace.bundle");
const importedRef = input.importedRef ?? `refs/paperclip/ssh-sync/imported/${randomUUID()}`;
try {
const exportScript = [
"set -e",
`git -C ${shellQuote(input.remoteDir)} update-ref refs/paperclip/ssh-sync/export HEAD`,
`mkdir -p ${shellQuote(path.posix.join(input.remoteDir, ".paperclip-runtime"))}`,
`tmp_bundle=$(mktemp ${shellQuote(path.posix.join(input.remoteDir, ".paperclip-runtime", "export-XXXXXX.bundle"))})`,
'cleanup() { rm -f "$tmp_bundle"; git -C ' + shellQuote(input.remoteDir) + ' update-ref -d refs/paperclip/ssh-sync/export >/dev/null 2>&1 || true; }',
'trap cleanup EXIT',
`git -C ${shellQuote(input.remoteDir)} bundle create "$tmp_bundle" refs/paperclip/ssh-sync/export >/dev/null`,
'cat "$tmp_bundle"',
].join("\n");
// The remote bundle size isn't known before streaming, so report bytes
// received (MB mode) with a terminal completion line.
const progress = input.onProgress
? createTransferProgress({
onProgress: input.onProgress,
phase: "Exporting git history",
direction: "from",
totalBytes: null,
estimated: false,
})
: null;
try {
await streamSshToLocalFile({
spec: input.spec,
remoteScript: exportScript,
localFile: bundlePath,
progress: progress ?? undefined,
});
await progress?.finish();
} catch (error) {
await progress?.fail();
throw error;
}
await runLocalGit(input.localDir, ["fetch", "--force", bundlePath, `refs/paperclip/ssh-sync/export:${importedRef}`], {
timeout: 60_000,
maxBuffer: 1024 * 1024,
});
if (input.resetLocalWorkspace !== false) {
await runLocalGit(input.localDir, ["reset", "--hard", importedRef], {
timeout: 60_000,
maxBuffer: 1024 * 1024,
});
}
const importedHead = await runLocalGit(input.localDir, ["rev-parse", importedRef], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
return importedHead.stdout.trim();
} finally {
if (input.resetLocalWorkspace !== false) {
await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch(() => undefined);
}
await fs.rm(bundleDir, { recursive: true, force: true }).catch(() => undefined);
}
}
async function integrateImportedGitHead(input: {
localDir: string;
importedHead: string;
}): Promise<void> {
const isConcurrentRefUpdateError = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
return message.includes("cannot lock ref") && message.includes("expected");
};
for (let attempt = 0; attempt < 5; attempt += 1) {
const snapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
if (!snapshot) return;
const currentHead = snapshot.headCommit;
if (!currentHead || currentHead === input.importedHead) return;
const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD";
// `git merge-base` exits 1 when the commits share no ancestor — the only
// outcome that authorizes the graft fallback below. Every other failure
// (timeout, missing object, repository error) must keep failing the
// integration instead of silently rewriting the tip.
let noCommonAncestor = false;
const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch((error: unknown) => {
noCommonAncestor = (error as { code?: unknown } | null)?.code === 1;
return null;
});
const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
if (mergeBaseHead === input.importedHead) {
return;
}
if (mergeBaseHead === currentHead) {
try {
await runLocalGit(input.localDir, ["update-ref", headRef, input.importedHead, currentHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
return;
} catch (error) {
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
throw error;
}
}
if (noCommonAncestor) {
// No common ancestor — merging is impossible and failing here would
// discard the imported work. Graft it onto the current head instead;
// see createUnrelatedHistoryGraftCommit.
const graftCommit = await createUnrelatedHistoryGraftCommit({
localDir: input.localDir,
currentHead,
importedHead: input.importedHead,
syncLabel: "Paperclip SSH sync",
});
try {
await runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
return;
} catch (error) {
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
throw error;
}
}
let mergedTree;
try {
mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
timeout: 60_000,
maxBuffer: 256 * 1024,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to merge concurrent SSH git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`,
);
}
const mergedTreeId = mergedTree.stdout.trim().split("\n")[0]?.trim() ?? "";
if (!mergedTreeId) {
throw new Error("Failed to compute a merged git tree for SSH workspace restore.");
}
const mergeCommit = await runLocalGit(
input.localDir,
[
...GIT_SYNC_COMMIT_IDENTITY_ARGS,
"commit-tree",
mergedTreeId,
"-p",
currentHead,
"-p",
input.importedHead,
"-m",
`Paperclip SSH sync merge ${input.importedHead.slice(0, 12)}`,
],
{
timeout: 60_000,
maxBuffer: 64 * 1024,
},
);
try {
await runLocalGit(input.localDir, ["update-ref", headRef, mergeCommit.stdout.trim(), currentHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
return;
} catch (error) {
if (isConcurrentRefUpdateError(error) && attempt < 4) continue;
throw error;
}
}
throw new Error(`Failed to integrate concurrent SSH git history for ${input.importedHead.slice(0, 12)} after multiple retries.`);
}
async function clearRemoteDirectory(input: {
spec: SshConnectionConfig;
remoteDir: string;
preserveEntries?: string[];
}): Promise<void> {
const preservePatterns = (input.preserveEntries ?? [])
.map((entry) => `! -name ${shellQuote(entry)}`)
.join(" ");
const script = [
"set -e",
`mkdir -p ${shellQuote(input.remoteDir)}`,
`find ${shellQuote(input.remoteDir)} -mindepth 1 -maxdepth 1 ${preservePatterns} -exec rm -rf -- {} +`,
].join("\n");
await runSshScript(input.spec, script, {
timeoutMs: 30_000,
maxBuffer: 256 * 1024,
});
}
async function removeDeletedPathsOnSsh(input: {
spec: SshConnectionConfig;
remoteDir: string;
deletedPaths: string[];
}): Promise<void> {
if (input.deletedPaths.length === 0) return;
const quotedPaths = input.deletedPaths.map((entry) => shellQuote(entry)).join(" ");
const script = `cd ${shellQuote(input.remoteDir)} && rm -rf -- ${quotedPaths}`;
await runSshScript(input.spec, script, {
timeoutMs: 30_000,
maxBuffer: 256 * 1024,
});
}
async function allocateLoopbackPort(host: string): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, host, () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate a loopback port.")));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
async function waitForCondition(
fn: () => Promise<void>,
options: {
timeoutMs?: number;
intervalMs?: number;
} = {},
): Promise<void> {
const timeoutAt = Date.now() + (options.timeoutMs ?? 10_000);
const intervalMs = options.intervalMs ?? 200;
let lastError: unknown = null;
while (Date.now() < timeoutAt) {
try {
await fn();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
throw lastError instanceof Error
? lastError
: new Error("Timed out waiting for SSH fixture readiness.");
}
async function isPidRunning(pid: number): Promise<boolean> {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function readProcessCommand(pid: number): Promise<string | null> {
for (const format of ["command=", "args="]) {
try {
const result = await execFileText("ps", ["-o", format, "-p", String(pid)], {
timeout: 5_000,
maxBuffer: 16 * 1024,
});
const command = result.stdout.trim();
if (command.length > 0) {
return command;
}
} catch {
continue;
}
}
return null;
}
async function isSshEnvLabFixtureProcess(state: Pick<SshEnvLabFixtureState, "pid" | "sshdConfigPath">): Promise<boolean> {
if (!(await isPidRunning(state.pid))) {
return false;
}
const command = await readProcessCommand(state.pid);
if (!command) {
return false;
}
return command.includes(state.sshdConfigPath);
}
export async function getSshEnvLabSupport(): Promise<SshEnvLabSupport> {
if (process.platform === "darwin" && process.env.PAPERCLIP_ENABLE_DARWIN_SSH_ENV_LAB !== "1") {
return {
supported: false,
reason: "SSH env-lab fixture is disabled on macOS; set PAPERCLIP_ENABLE_DARWIN_SSH_ENV_LAB=1 to opt in.",
};
}
for (const command of ["ssh", "sshd", "ssh-keygen"]) {
if (!(await commandExists(command))) {
return {
supported: false,
reason: `Missing required command: ${command}`,
};
}
}
return {
supported: true,
reason: null,
};
}
export function buildKnownHostsEntry(input: {
host: string;
port: number;
publicKey: string;
}): string {
return `[${input.host}]:${input.port} ${input.publicKey.trim()}`;
}
export async function runSshCommand(
config: SshConnectionConfig,
remoteCommand: string,
options: {
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
maxBuffer?: number;
} = {},
): Promise<SshCommandResult> {
let cleanup: () => Promise<void> = () => Promise.resolve();
try {
const auth = await createSshAuthArgs(config);
cleanup = auth.cleanup;
const sshArgs = [...auth.args];
const envEntries = Object.entries(options.env ?? {})
.filter((entry): entry is [string, string] => typeof entry[1] === "string");
for (const [key] of envEntries) {
if (!isValidShellEnvKey(key)) {
throw new Error(`Invalid SSH environment variable key: ${key}`);
}
}
// Mirror buildSshSpawnTarget: source the login profiles first, then run
// `env KEY=VAL cmd` so user-supplied identity overrides win over anything a
// profile re-exports. The SSH target is an operator-configured host, not a
// Paperclip sandbox image, so it can expose `node` or an agent CLI only
// through a login profile; a non-login SSH command would miss that PATH.
// Source `/etc/profile` first so a host that exposes the PATH through
// `/etc/profile.d` scripts still resolves node and the agent CLI.
// The script no longer sources `nvm.sh`; a profile that adds nvm still runs.
// .bash_profile typically sources .bashrc itself; only source .bashrc
// directly when no .bash_profile exists, so a host that adds nvm in
// .bashrc still resolves node without a double-run of the setup.
const envArgs = envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`);
const remoteScript = [
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
envArgs.length > 0
? `exec env ${envArgs.join(" ")} sh -c ${shellQuote(remoteCommand)}`
: `exec sh -c ${shellQuote(remoteCommand)}`,
].join(" && ");
sshArgs.push(
"-p",
String(config.port),
`${config.username}@${config.host}`,
`sh -c ${shellQuote(remoteScript)}`,
);
return options.stdin != null
? await spawnText("ssh", sshArgs, {
stdin: options.stdin,
timeout: options.timeoutMs ?? 15_000,
maxBuffer: options.maxBuffer ?? 1024 * 128,
})
: await execFileText("ssh", sshArgs, {
timeout: options.timeoutMs ?? 15_000,
maxBuffer: options.maxBuffer ?? 1024 * 128,
});
} finally {
await cleanup();
}
}
export async function buildSshSpawnTarget(input: {
spec: SshRemoteExecutionSpec;
command: string;
args: string[];
env: Record<string, string>;
}): Promise<{
command: string;
args: string[];
cleanup: () => Promise<void>;
}> {
for (const key of Object.keys(input.env)) {
if (!isValidShellEnvKey(key)) {
throw new Error(`Invalid SSH environment variable key: ${key}`);
}
}
const auth = await createSshAuthArgs(input.spec);
const sshArgs = [...auth.args];
const envArgs = Object.entries(input.env)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `${key}=${shellQuote(value)}`);
const remoteCommandParts = [shellQuote(input.command), ...input.args.map((arg) => shellQuote(arg))].join(" ");
// Source the login profiles first, then run `env KEY=VAL cmd` so
// user-supplied identity overrides win over anything a profile re-exports.
// The SSH target is an operator-configured host, not a Paperclip sandbox
// image, so it can expose `node` or an agent CLI only through a login
// profile; a non-login SSH command would miss that PATH. Source
// `/etc/profile` first so a host that exposes the PATH through
// `/etc/profile.d` scripts still resolves node and the agent CLI. The script
// no longer sources `nvm.sh`; a profile that adds nvm still runs.
// .bash_profile typically sources .bashrc itself; only source .bashrc
// directly when no .bash_profile exists, so a host that adds nvm in
// .bashrc still resolves node without a double-run of the setup.
const remoteScript = [
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
`cd ${shellQuote(input.spec.remoteCwd)}`,
envArgs.length > 0
? `exec env ${envArgs.join(" ")} ${remoteCommandParts}`
: `exec ${remoteCommandParts}`,
].join(" && ");
sshArgs.push(
"-p",
String(input.spec.port),
`${input.spec.username}@${input.spec.host}`,
`sh -c ${shellQuote(remoteScript)}`,
);
return {
command: "ssh",
args: sshArgs,
cleanup: auth.cleanup,
};
}
export async function syncDirectoryToSsh(input: {
spec: SshRemoteExecutionSpec;
localDir: string;
remoteDir: string;
exclude?: string[];
followSymlinks?: boolean;
onProgress?: RuntimeProgressSink;
progressLabel?: string;
}): Promise<void> {
const auth = await createSshAuthArgs(input.spec);
const sshArgs = [
...auth.args,
"-p",
String(input.spec.port),
`${input.spec.username}@${input.spec.host}`,
`sh -c ${shellQuote(`mkdir -p ${shellQuote(input.remoteDir)} && tar -xf - -C ${shellQuote(input.remoteDir)}`)}`,
];
// tar's archive size isn't known until tar finishes, so estimate it from the
// local file sizes and clamp the reported percent to 99% until the pipe closes.
// The estimate walk runs concurrently with the transfer so it never delays the
// pipe from opening on large workspaces.
const progress = input.onProgress
? createTransferProgress({
onProgress: input.onProgress,
phase: "Syncing",
direction: "to",
label: input.progressLabel,
totalBytes: estimateLocalDirSize({
localDir: input.localDir,
exclude: input.exclude,
followSymlinks: input.followSymlinks,
}),
estimated: true,
})
: null;
try {
await new Promise<void>((resolve, reject) => {
const tarArgs = [
...(input.followSymlinks ? ["-h"] : []),
"-C",
input.localDir,
...tarExcludeArgs(input.exclude),
"-cf",
"-",
".",
];
const tar = spawn("tar", tarArgs, {
stdio: ["ignore", "pipe", "pipe"],
env: tarSpawnEnv(),
});
const ssh = spawn("ssh", sshArgs, {
stdio: ["pipe", "ignore", "pipe"],
});
let tarStderr = "";
let sshStderr = "";
let settled = false;
let tarExited = false;
let sshExited = false;
let tarExitCode: number | null = null;
let sshExitCode: number | null = null;
const maybeFinish = () => {
if (settled || !tarExited || !sshExited) {
return;
}
settled = true;
if ((tarExitCode ?? 0) !== 0) {
reject(new Error(tarStderr.trim() || `tar exited with code ${tarExitCode ?? -1}`));
return;
}
if ((sshExitCode ?? 0) !== 0) {
reject(new Error(sshStderr.trim() || `ssh exited with code ${sshExitCode ?? -1}`));
return;
}
resolve();
};
const fail = (error: Error) => {
if (settled) {
return;
}
settled = true;
tar.kill("SIGTERM");
ssh.kill("SIGTERM");
reject(error);
};
if (progress) {
progress.counter.on("error", fail);
tar.stdout?.pipe(progress.counter).pipe(ssh.stdin ?? null);
} else {
tar.stdout?.pipe(ssh.stdin ?? null);
}
tar.stderr?.on("data", (chunk) => {
tarStderr += String(chunk);
});
ssh.stderr?.on("data", (chunk) => {
sshStderr += String(chunk);
});
tar.on("error", fail);
ssh.on("error", fail);
tar.on("close", (code) => {
tarExited = true;
tarExitCode = code;
maybeFinish();
});
ssh.on("close", (code) => {
sshExited = true;
sshExitCode = code;
maybeFinish();
});
}).finally(auth.cleanup);
await progress?.finish();
} catch (error) {
await progress?.fail();
throw error;
}
}
export async function syncDirectoryFromSsh(input: {
spec: SshRemoteExecutionSpec;
remoteDir: string;
localDir: string;
exclude?: string[];
preserveLocalEntries?: string[];
onProgress?: RuntimeProgressSink;
progressLabel?: string;
}): Promise<void> {
const auth = await createSshAuthArgs(input.spec);
const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-sync-back-"));
const remoteTarScript = [
`cd ${shellQuote(input.remoteDir)}`,
`tar ${[...tarExcludeArgs(input.exclude).map(shellQuote), "-cf", "-", "."].join(" ")}`,
].join(" && ");
const sshArgs = [
...auth.args,
"-p",
String(input.spec.port),
`${input.spec.username}@${input.spec.host}`,
`sh -c ${shellQuote(remoteTarScript)}`,
];
// The remote tar size isn't known locally, so probe the remote directory for
// an estimate (clamped to 99%). The probe runs concurrently with the transfer
// so its round-trip never delays the restore; when it is unavailable we report
// bytes received in MB mode with a terminal completion line.
const progress = input.onProgress
? createTransferProgress({
onProgress: input.onProgress,
phase: "Restoring",
direction: "from",
label: input.progressLabel,
totalBytes: probeRemoteDirSize({ spec: input.spec, remoteDir: input.remoteDir }),
estimated: true,
})
: null;
try {
await new Promise<void>((resolve, reject) => {
const ssh = spawn("ssh", sshArgs, {
stdio: ["ignore", "pipe", "pipe"],
});
const tar = spawn("tar", ["-xf", "-", "-C", stagingDir], {
stdio: ["pipe", "ignore", "pipe"],
env: tarSpawnEnv(),
});
let sshStderr = "";
let tarStderr = "";
let settled = false;
let sshExited = false;
let tarExited = false;
let sshExitCode: number | null = null;
let tarExitCode: number | null = null;
const maybeFinish = () => {
if (settled || !sshExited || !tarExited) return;
settled = true;
if ((sshExitCode ?? 0) !== 0) {
reject(new Error(sshStderr.trim() || `ssh exited with code ${sshExitCode ?? -1}`));
return;
}
if ((tarExitCode ?? 0) !== 0) {
reject(new Error(tarStderr.trim() || `tar exited with code ${tarExitCode ?? -1}`));
return;
}
resolve();
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
ssh.kill("SIGTERM");
tar.kill("SIGTERM");
reject(error);
};
if (progress) {
progress.counter.on("error", fail);
ssh.stdout?.pipe(progress.counter).pipe(tar.stdin ?? null);
} else {
ssh.stdout?.pipe(tar.stdin ?? null);
}
ssh.stderr?.on("data", (chunk) => {
sshStderr += String(chunk);
});
tar.stderr?.on("data", (chunk) => {
tarStderr += String(chunk);
});
ssh.on("error", fail);
tar.on("error", fail);
ssh.on("close", (code) => {
sshExited = true;
sshExitCode = code;
maybeFinish();
});
tar.on("close", (code) => {
tarExited = true;
tarExitCode = code;
maybeFinish();
});
});
await progress?.finish();
await clearLocalDirectory(input.localDir, input.preserveLocalEntries);
await copyDirectoryContents(stagingDir, input.localDir);
} catch (error) {
await progress?.fail();
throw error;
} finally {
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
await auth.cleanup();
}
}
export async function prepareWorkspaceForSshExecution(input: {
spec: SshRemoteExecutionSpec;
localDir: string;
remoteDir?: string;
onProgress?: RuntimeProgressSink;
}): Promise<{ gitBacked: boolean }> {
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
if (gitSnapshot) {
await importGitWorkspaceToSsh({
spec: input.spec,
localDir: input.localDir,
remoteDir,
snapshot: gitSnapshot,
onProgress: input.onProgress,
});
await syncDirectoryToSsh({
spec: input.spec,
localDir: input.localDir,
remoteDir,
exclude: [".git", ".paperclip-runtime"],
onProgress: input.onProgress,
progressLabel: "workspace",
});
await removeDeletedPathsOnSsh({
spec: input.spec,
remoteDir,
deletedPaths: gitSnapshot.deletedPaths,
});
return { gitBacked: true };
}
await clearRemoteDirectory({
spec: input.spec,
remoteDir,
preserveEntries: [".paperclip-runtime"],
});
await syncDirectoryToSsh({
spec: input.spec,
localDir: input.localDir,
remoteDir,
exclude: [".paperclip-runtime"],
onProgress: input.onProgress,
progressLabel: "workspace",
});
return { gitBacked: false };
}
export async function restoreWorkspaceFromSshExecution(input: {
spec: SshRemoteExecutionSpec;
localDir: string;
remoteDir?: string;
baselineSnapshot?: DirectorySnapshot;
restoreGitHistory?: boolean;
onProgress?: RuntimeProgressSink;
}): Promise<void> {
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
if (input.baselineSnapshot) {
const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-sync-back-"));
const importedRef = input.restoreGitHistory
? `refs/paperclip/ssh-sync/imported/${randomUUID()}`
: null;
try {
const importedHead = input.restoreGitHistory
? await exportGitWorkspaceFromSsh({
spec: input.spec,
remoteDir,
localDir: input.localDir,
importedRef: importedRef ?? undefined,
resetLocalWorkspace: false,
onProgress: input.onProgress,
})
: null;
await syncDirectoryFromSsh({
spec: input.spec,
remoteDir,
localDir: stagingDir,
exclude: input.baselineSnapshot.exclude,
onProgress: input.onProgress,
progressLabel: "workspace",
});
await mergeDirectoryWithBaseline({
baseline: input.baselineSnapshot,
sourceDir: stagingDir,
targetDir: input.localDir,
// Git history advances via integrateImportedGitHead; the working tree
// still comes from the remote file snapshot so dirty remote edits win.
beforeApply: importedHead
? async () => {
await integrateImportedGitHead({
localDir: input.localDir,
importedHead,
});
}
: undefined,
});
} finally {
if (importedRef) {
await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch(() => undefined);
}
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
}
return;
}
const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
if (gitSnapshot) {
await exportGitWorkspaceFromSsh({
spec: input.spec,
remoteDir,
localDir: input.localDir,
onProgress: input.onProgress,
});
await syncDirectoryFromSsh({
spec: input.spec,
remoteDir,
localDir: input.localDir,
exclude: [".git", ".paperclip-runtime"],
preserveLocalEntries: [".git"],
onProgress: input.onProgress,
progressLabel: "workspace",
});
return;
}
await syncDirectoryFromSsh({
spec: input.spec,
remoteDir,
localDir: input.localDir,
exclude: [".paperclip-runtime"],
onProgress: input.onProgress,
progressLabel: "workspace",
});
}
export async function ensureSshWorkspaceReady(
config: SshConnectionConfig,
): Promise<{ remoteCwd: string }> {
const result = await runSshCommand(
config,
`mkdir -p ${shellQuote(config.remoteWorkspacePath)} && cd ${shellQuote(config.remoteWorkspacePath)} && pwd`,
);
return {
remoteCwd: result.stdout.trim(),
};
}
const SSH_ENV_LAB_FIXTURE_PATH_FIELDS = [
"rootDir",
"workspaceDir",
"statePath",
"clientPrivateKeyPath",
"clientPublicKeyPath",
"hostPrivateKeyPath",
"hostPublicKeyPath",
"authorizedKeysPath",
"knownHostsPath",
"sshdConfigPath",
"sshdLogPath",
] as const satisfies readonly (keyof SshEnvLabFixtureState)[];
// True when candidate is an absolute path equal to rootDir or nested under
// it. Used to reject a state file whose paths point outside the fixture
// root it was read from.
function isPathRootedAt(candidate: string, rootDir: string): boolean {
if (!path.isAbsolute(candidate)) return false;
if (candidate === rootDir) return true;
const relative = path.relative(rootDir, candidate);
return (
relative.length > 0 &&
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
// The state file is untrusted input: any local process running as the same
// user can write one. A forged pid or an empty sshdConfigPath would weaken
// isSshEnvLabFixtureProcess's identity check — an empty string is a
// substring of every command line, so it would match any running process.
// Reject a state file that fails this check before any identity check or
// signal runs against it.
function isValidSshEnvLabFixtureState(
raw: SshEnvLabFixtureState,
expectedRootDir: string,
): boolean {
if (!Number.isSafeInteger(raw.pid) || raw.pid <= 0) return false;
if (raw.rootDir !== expectedRootDir) return false;
for (const field of SSH_ENV_LAB_FIXTURE_PATH_FIELDS) {
const value = raw[field];
if (typeof value !== "string" || !isPathRootedAt(value, expectedRootDir)) {
return false;
}
}
const expectedSshdConfigPath = path.join(expectedRootDir, "sshd_config");
if (raw.sshdConfigPath.length === 0 || raw.sshdConfigPath !== expectedSshdConfigPath) {
return false;
}
return true;
}
export async function readSshEnvLabFixtureState(
statePath: string,
): Promise<SshEnvLabFixtureState | null> {
try {
// Resolve a relative statePath against the current working directory
// before use. The state validator below only accepts absolute paths, and
// a relative statePath must resolve to the same absolute directory every
// time a caller reads it, no matter the process working directory.
const resolvedStatePath = path.resolve(statePath);
const raw = JSON.parse(await fs.readFile(resolvedStatePath, "utf8")) as SshEnvLabFixtureState;
if (!raw || raw.kind !== "ssh_openbsd") return null;
if (!isValidSshEnvLabFixtureState(raw, path.dirname(resolvedStatePath))) return null;
return raw;
} catch {
return null;
}
}
async function waitUntilFixtureProcessExits(
state: Pick<SshEnvLabFixtureState, "pid" | "sshdConfigPath">,
timeoutMs: number,
intervalMs = 100,
): Promise<boolean> {
const timeoutAt = Date.now() + timeoutMs;
while (true) {
if (!(await isSshEnvLabFixtureProcess(state))) return true;
if (Date.now() >= timeoutAt) return false;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
// Sends a signal to a pid and treats an already-dead process as success.
// The identity check that runs before this call is not free: it spawns
// `ps`, which opens a real gap between the check and the signal. If the
// process exits inside that gap, `process.kill` throws ESRCH even though
// the outcome the caller wants (the process is gone) already holds. Any
// other error, such as EPERM for a pid that belongs to another user, must
// still propagate.
function signalFixtureProcess(pid: number, signal: NodeJS.Signals): boolean {
try {
process.kill(pid, signal);
return true;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
return false;
}
throw error;
}
}
// Bounded shutdown escalation shared by every caller that must stop a
// fixture process: send SIGTERM, wait, re-check process identity (the pid
// can be reused in the gap between two signals), then SIGKILL, then wait
// again. Returns true only when the listener is confirmed gone.
async function escalateSshEnvLabFixtureShutdown(
state: Pick<SshEnvLabFixtureState, "pid" | "sshdConfigPath">,
): Promise<boolean> {
if (!(await isSshEnvLabFixtureProcess(state))) return true;
if (!signalFixtureProcess(state.pid, "SIGTERM")) return true;
if (await waitUntilFixtureProcessExits(state, 5_000)) return true;
if (!(await isSshEnvLabFixtureProcess(state))) return true;
if (!signalFixtureProcess(state.pid, "SIGKILL")) return true;
if (await waitUntilFixtureProcessExits(state, 2_000)) return true;
return !(await isSshEnvLabFixtureProcess(state));
}
// Accepts a state path or an already-read state so a caller that already
// holds the fixture state in memory does not have to depend on the state
// file, which a teardown step may have already removed.
export async function stopSshEnvLabFixture(
stateOrPath: string | SshEnvLabFixtureState,
): Promise<boolean> {
const state = typeof stateOrPath === "string"
? await readSshEnvLabFixtureState(stateOrPath)
: stateOrPath;
if (!state) return false;
if (!(await escalateSshEnvLabFixtureShutdown(state))) {
throw new Error(
`SSH env-lab fixture did not stop: pid ${state.pid} on port ${state.port} is still running after SIGKILL.`,
);
}
// Remove the root directory only after the listener process is confirmed
// gone. Removing it earlier would delete the state file the process needs
// for a later stop attempt to find and signal it.
await fs.rm(state.rootDir, { recursive: true, force: true }).catch(() => undefined);
return true;
}
export async function startSshEnvLabFixture(input: {
statePath: string;
bindHost?: string;
host?: string;
// Test-only. Shortens the readiness wait below its 10 second default, so
// a regression test can force the start-failure cleanup path without a
// real 10 second wait.
readinessTimeoutMs?: number;
}): Promise<SshEnvLabFixtureState> {
// Resolve a relative statePath against the current working directory once,
// up front. Every derived path (rootDir and the persisted statePath field)
// must be absolute, so the state validator in readSshEnvLabFixtureState
// accepts the file that this function writes.
const statePath = path.resolve(input.statePath);
const existing = await readSshEnvLabFixtureState(statePath);
if (existing && await isSshEnvLabFixtureProcess(existing)) {
return existing;
}
if (existing) {
await fs.rm(existing.rootDir, { recursive: true, force: true }).catch(() => undefined);
}
const support = await getSshEnvLabSupport();
if (!support.supported) {
throw new Error(`SSH env-lab fixture is unavailable: ${support.reason}`);
}
const sshdPath = await resolveCommandPath("sshd");
if (!sshdPath) {
throw new Error("SSH env-lab fixture is unavailable: missing required command: sshd");
}
const bindHost = input.bindHost ?? "127.0.0.1";
const host = input.host ?? bindHost;
const rootDir = path.dirname(statePath);
await fs.mkdir(rootDir, { recursive: true });
const username = os.userInfo().username;
const port = await allocateLoopbackPort(bindHost);
const workspaceDir = path.join(rootDir, "workspace");
const clientPrivateKeyPath = path.join(rootDir, "client_key");
const clientPublicKeyPath = `${clientPrivateKeyPath}.pub`;
const hostPrivateKeyPath = path.join(rootDir, "host_key");
const hostPublicKeyPath = `${hostPrivateKeyPath}.pub`;
const authorizedKeysPath = path.join(rootDir, "authorized_keys");
const knownHostsPath = path.join(rootDir, "known_hosts");
const sshdConfigPath = path.join(rootDir, "sshd_config");
const sshdLogPath = path.join(rootDir, "sshd.log");
const sshdPidPath = path.join(rootDir, "sshd.pid");
await fs.mkdir(workspaceDir, { recursive: true });
await execFileText("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", clientPrivateKeyPath], {
timeout: 15_000,
});
await execFileText("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", hostPrivateKeyPath], {
timeout: 15_000,
});
await fs.copyFile(clientPublicKeyPath, authorizedKeysPath);
const hostPublicKey = (await execFileText("ssh-keygen", ["-y", "-f", hostPrivateKeyPath], {
timeout: 15_000,
})).stdout.trim();
await fs.writeFile(
knownHostsPath,
`${buildKnownHostsEntry({ host, port, publicKey: hostPublicKey })}\n`,
{ mode: 0o600 },
);
await fs.writeFile(
sshdConfigPath,
[
`Port ${port}`,
`ListenAddress ${bindHost}`,
`HostKey ${hostPrivateKeyPath}`,
`PidFile ${sshdPidPath}`,
`AuthorizedKeysFile ${authorizedKeysPath}`,
"PasswordAuthentication no",
"ChallengeResponseAuthentication no",
"KbdInteractiveAuthentication no",
"PubkeyAuthentication yes",
"PermitRootLogin no",
"UsePAM no",
"StrictModes no",
`AllowUsers ${username}`,
"LogLevel VERBOSE",
"PrintMotd no",
"UseDNS no",
"Subsystem sftp internal-sftp",
"",
].join("\n"),
{ mode: 0o600 },
);
const child = spawn(sshdPath, ["-D", "-f", sshdConfigPath, "-E", sshdLogPath], {
detached: true,
stdio: "ignore",
});
child.unref();
const state: SshEnvLabFixtureState = {
kind: "ssh_openbsd",
bindHost,
host,
port,
username,
rootDir,
workspaceDir,
statePath,
pid: child.pid ?? 0,
createdAt: new Date().toISOString(),
clientPrivateKeyPath,
clientPublicKeyPath,
hostPrivateKeyPath,
hostPublicKeyPath,
authorizedKeysPath,
knownHostsPath,
sshdConfigPath,
sshdLogPath,
};
if (!state.pid) {
throw new Error("Failed to start SSH env-lab fixture.");
}
try {
await waitForCondition(async () => {
if (!(await isPidRunning(state.pid))) {
const logOutput = await fs.readFile(sshdLogPath, "utf8").catch(() => "");
throw new Error(logOutput || "SSH env-lab fixture exited before becoming ready.");
}
const config = await buildSshEnvLabFixtureConfig(state);
await ensureSshWorkspaceReady(config);
}, { timeoutMs: input.readinessTimeoutMs ?? 10_000, intervalMs: 250 });
await fs.writeFile(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
return state;
} catch (error) {
// No state file exists on this path yet, so a later stopSshEnvLabFixture
// call can never find this pid. Escalate and wait for exit here, the
// same way stopSshEnvLabFixture does, before the root directory goes
// away — otherwise a slow-to-exit sshd survives as an orphan with its
// root directory already gone.
const stopped = await escalateSshEnvLabFixtureShutdown(state);
if (stopped) {
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
} else {
const survivalNote =
`SSH env-lab fixture pid ${state.pid} on port ${state.port} is still running after SIGKILL. ` +
`Kept ${rootDir} for inspection; no state file exists to target it with a later stop call.`;
if (error instanceof Error) {
error.message = `${error.message}\n${survivalNote}`;
} else {
console.error(survivalNote);
}
}
throw error;
}
}
export async function buildSshEnvLabFixtureConfig(
state: SshEnvLabFixtureState,
): Promise<SshConnectionConfig> {
const [privateKey, knownHosts] = await Promise.all([
fs.readFile(state.clientPrivateKeyPath, "utf8"),
fs.readFile(state.knownHostsPath, "utf8"),
]);
return {
host: state.host,
port: state.port,
username: state.username,
remoteWorkspacePath: state.workspaceDir,
privateKey,
knownHosts,
strictHostKeyChecking: true,
};
}
export async function readSshEnvLabFixtureStatus(statePath: string): Promise<{
running: boolean;
state: SshEnvLabFixtureState | null;
}> {
const state = await readSshEnvLabFixtureState(statePath);
if (!state) {
return { running: false, state: null };
}
return {
running: await isSshEnvLabFixtureProcess(state),
state,
};
}
export async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}