Files
paperclip/packages/adapter-utils/src/ssh.ts
T
Devin Foley 07e98d2b2c feat(adapter-utils): add observable sandbox sync progress (#8395)
## Thinking Path

> - Paperclip is the control plane for running AI-agent companies, so
long-running remote work needs to stay observable to human operators.
> - Cloud / sandbox agents are an active roadmap area, and their
workspace sync path is part of the runtime substrate every remote coding
run depends on.
> - In the sandbox and SSH execution-target flows, Paperclip logged that
sync had started, then often went silent for the full transfer window.
> - That made large remote syncs feel stalled and also hid a real
performance problem in the command-managed sandbox upload path.
> - The first part of this pull request threads a throttled
progress-reporting surface through the adapter execution-target stack so
sync and restore work can emit meaningful updates.
> - The second part fixes the command-managed sandbox transport itself:
it removes the old serial 32KB append bottleneck, but also falls back
away from the single-stream path when a provider-backed sandbox runner
cannot surface mid-flight stdin progress.
> - The result is that sandbox and SSH transfers are both faster and
more observable, including the live Daytona-style sandbox case that
previously only emitted `0%` and `100%`.

## Linked Issues or Issue Description

No public GitHub issue exists for this bug, so it is described inline
below following the bug report template.

### What happened

- Remote sandbox and SSH workspace syncs could spend a long time
transferring data while only logging a start line (`Syncing workspace
and runtime assets to sandbox environment`) and, at best, a terminal
line.
- In the command-managed sandbox path, the original upload
implementation also paid a large performance cost by appending base64
data in many small sequential remote writes (thousands of serial 32KB
round-trips on a large workspace).
- After the initial transport rewrite, live provider-backed sandbox runs
still only emitted `0%` and `100%` because the single-stream stdin RPC
buffered progress until completion.

### Expected behavior

- Long-running sandbox and SSH syncs should periodically report how much
of the transfer is complete (a percentage and/or MB transferred) so an
operator can tell the run is healthy and making progress rather than
stuck.
- The main sandbox upload path should not be artificially slow.
- A transfer that fails partway should leave an explicit failure marker
in the log rather than a dangling intermediate percentage.

### Steps to reproduce

1. Run an agent against a sandbox (command-managed) or SSH
(remote-managed) execution target with a non-trivial workspace.
2. Watch the run log during the workspace/runtime asset sync phase.
3. Observe that the log shows the sync start line and then stays silent
for the full transfer (live provider-backed sandbox runs only show `0%`
then `100%`).

### Paperclip version or commit

- Branch `PAPA-825-provide-status-updates-when-syncing-sandboxes` off
`master`.

### Deployment mode

- Self-hosted / local instance using sandbox (command-managed) and SSH
(remote-managed) execution targets, including provider-backed sandbox
runners.

## What Changed

- Added shared throttled runtime progress reporting and threaded
`onProgress` through the adapter execution-target surface and adapter
`execute.ts` entrypoints.
- Added sync and restore progress reporting for the command-managed
sandbox path and the SSH/remote-managed path, including git
import/export progress where totals are known.
- Reworked command-managed sandbox transfer behavior so uploads use the
faster single-stream path when appropriate, but fall back to chunked
progress-emitting writes when the runner cannot expose mid-stream stdin
progress.
- Marked provider-backed environment sandbox runners as not supporting
single-stream stdin progress so live sandbox runs emit meaningful
intermediate updates instead of only `0%` and `100%`.
- Emit an explicit terminal failure marker (`failed at NN% (x/y MB)`)
when an SSH/tar transfer rejects, so a failed sync no longer leaves a
dangling intermediate percentage in the log.
- Run the SSH sync/restore size estimate (local directory walk / remote
`du` probe) concurrently with the transfer instead of awaiting it before
opening the pipe, so progress instrumentation no longer adds startup
latency proportional to workspace file count.
- Added and extended focused regression coverage for runtime progress
throttling and the new failure marker, command-managed sandbox
transfers, sandbox orchestration, SSH transfer progress, and environment
execution-target wiring.

## Verification

- `pnpm exec vitest run
packages/adapter-utils/src/runtime-progress.test.ts
packages/adapter-utils/src/ssh-fixture.test.ts
packages/adapter-utils/src/command-managed-runtime.test.ts
packages/adapter-utils/src/sandbox-managed-runtime.test.ts`
- `pnpm exec vitest run
packages/adapter-utils/src/command-managed-runtime.test.ts
server/src/__tests__/environment-execution-target.test.ts`
- `npx tsc --noEmit` for `packages/adapter-utils`

## Risks

- The provider-backed sandbox fallback now prefers chunked
command-managed writes when progress hooks are active, so
small-to-medium uploads may trade some raw throughput for observable
intermediate progress on runtimes that cannot surface true mid-stream
stdin progress.
- Progress percentages on tar-based transfers still depend on estimates
in some cases, so operators may briefly see MB-only lines before the
estimate resolves, then near-final clamping before the terminal `100%`
line.
- This PR changes shared execution-target behavior used by multiple
adapters, so regressions would most likely appear in remote runtime
setup/teardown flows rather than in a single adapter.

## Model Used

- Initial implementation: OpenAI GPT-5.4 via Codex local agent
(`codex_local`), high reasoning mode.
- Observability follow-ups (failure marker, concurrent size estimate,
added tests): Claude Opus 4.8 via Claude Code (`claude_local`).

## 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] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-20 13:03:42 -07:00

1845 lines
57 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 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 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`,
`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";
const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch(() => 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;
}
}
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,
[
"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 login profiles first, then run
// `env KEY=VAL cmd` so user-supplied identity overrides win over anything
// a profile re-exports. Without this, a remote profile that resets HOME
// / NVM_DIR / etc. would silently undo the explicit env passed in here.
const envArgs = envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`);
const remoteScript = [
'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; 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(" ");
const remoteScript = [
'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; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
`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(),
};
}
export async function readSshEnvLabFixtureState(
statePath: string,
): Promise<SshEnvLabFixtureState | null> {
try {
const raw = JSON.parse(await fs.readFile(statePath, "utf8")) as SshEnvLabFixtureState;
if (!raw || raw.kind !== "ssh_openbsd") return null;
return raw;
} catch {
return null;
}
}
export async function stopSshEnvLabFixture(statePath: string): Promise<boolean> {
const state = await readSshEnvLabFixtureState(statePath);
if (!state) return false;
if (await isSshEnvLabFixtureProcess(state)) {
process.kill(state.pid, "SIGTERM");
await waitForCondition(async () => {
if (await isSshEnvLabFixtureProcess(state)) {
throw new Error("SSH fixture process is still running.");
}
}, { timeoutMs: 5_000, intervalMs: 100 });
}
await fs.rm(state.rootDir, { recursive: true, force: true }).catch(() => undefined);
return true;
}
export async function startSshEnvLabFixture(input: {
statePath: string;
bindHost?: string;
host?: string;
}): Promise<SshEnvLabFixtureState> {
const existing = await readSshEnvLabFixtureState(input.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(input.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: input.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: 10_000, intervalMs: 250 });
await fs.writeFile(input.statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
return state;
} catch (error) {
if (await isPidRunning(state.pid)) {
process.kill(state.pid, "SIGTERM");
}
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
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;
}
}