Files
PaperClipAI/packages/adapter-utils/src/ssh-fixture.test.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

1048 lines
42 KiB
TypeScript

import { execFile, spawn } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import {
buildSshSpawnTarget,
buildSshEnvLabFixtureConfig,
getSshEnvLabSupport,
prepareWorkspaceForSshExecution,
readSshEnvLabFixtureStatus,
restoreWorkspaceFromSshExecution,
runSshCommand,
syncDirectoryFromSsh,
syncDirectoryToSsh,
startSshEnvLabFixture,
stopSshEnvLabFixture,
type SshEnvLabFixtureState,
} from "./ssh.js";
import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js";
const SSH_FIXTURE_TEST_TIMEOUT_MS = 30_000;
let sshEnvLabUnsupportedReason: string | null = null;
// One entry per fixture root directory, registered at creation time so
// teardown survives a setup call that throws before the fixture starts, an
// assertion failure, or an early return on skip. `state` stays null until
// the fixture actually starts; a caller that stops the fixture itself still
// leaves the entry in the stack, so the drain below must be idempotent
// (stopSshEnvLabFixture is).
interface FixtureTeardownEntry {
rootDir: string;
state: SshEnvLabFixtureState | null;
}
const fixtureTeardowns: FixtureTeardownEntry[] = [];
// Creates the fixture root directory and registers its teardown entry in
// the same step, so a setup call that throws between here and the fixture
// start (mkdir, writeFile, git init) still leaves the root directory queued
// for removal.
async function createFixtureRootDir(): Promise<string> {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
fixtureTeardowns.push({ rootDir, state: null });
return rootDir;
}
async function drainFixtureTeardowns(): Promise<void> {
while (fixtureTeardowns.length > 0) {
const entry = fixtureTeardowns.pop();
if (!entry) continue;
if (entry.state) {
try {
await stopSshEnvLabFixture(entry.state);
} catch (error) {
// stopSshEnvLabFixture throws only when the listener survives
// SIGKILL, and it deliberately keeps the root directory so a later
// stop call can still find and signal it through the state file.
// Report the failure but keep the root directory; do not remove it,
// and do not rethrow, so a throw here cannot strand the entries
// still left on the stack.
console.error(
`SSH env-lab fixture teardown failed for pid ${entry.state.pid} on port ${entry.state.port}:`,
error,
);
continue;
}
}
await rm(entry.rootDir, { recursive: true, force: true }).catch(() => undefined);
}
}
async function git(cwd: string, args: string[]): Promise<string> {
return await new Promise((resolve, reject) => {
execFile("git", ["-C", cwd, ...args], (error, stdout, stderr) => {
if (error) {
reject(new Error((stderr || stdout || error.message).trim()));
return;
}
resolve(stdout.trim());
});
});
}
// Finds the pid of a running sshd process by its config file path, the same
// way isSshEnvLabFixtureProcess identifies a fixture internally. Used by the
// readiness-failure regression test, which needs the pid of a fixture that
// startSshEnvLabFixture never returns because it throws before returning it.
async function findSshdPidByConfigPath(sshdConfigPath: string): Promise<number | null> {
const stdout = await new Promise<string>((resolve) => {
execFile("ps", ["-eo", "pid=,args="], (error, out) => resolve(error ? "" : out));
});
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
const spaceIndex = trimmed.indexOf(" ");
if (spaceIndex === -1) continue;
const pid = Number.parseInt(trimmed.slice(0, spaceIndex), 10);
const args = trimmed.slice(spaceIndex + 1);
if (Number.isFinite(pid) && args.includes(sshdConfigPath)) {
return pid;
}
}
return null;
}
async function startSshEnvLabFixtureOrSkip(statePath: string, label: string) {
// The teardown entry for this root directory must already exist: callers
// create it with createFixtureRootDir() before they derive statePath, so
// this only attaches the state to that entry instead of pushing a new
// one (a root directory must never get two entries).
const rootDir = path.dirname(statePath);
const entry = fixtureTeardowns.find((candidate) => candidate.rootDir === rootDir);
if (!entry) {
throw new Error(
`No fixture teardown entry for ${rootDir}. Call createFixtureRootDir() before starting a fixture.`,
);
}
if (sshEnvLabUnsupportedReason) {
console.warn(`Skipping ${label}: ${sshEnvLabUnsupportedReason}`);
return null;
}
const support = await getSshEnvLabSupport();
if (!support.supported) {
sshEnvLabUnsupportedReason = support.reason ?? "unsupported environment";
console.warn(`Skipping ${label}: ${sshEnvLabUnsupportedReason}`);
return null;
}
try {
const state = await startSshEnvLabFixture({ statePath });
entry.state = state;
return state;
} catch (error) {
sshEnvLabUnsupportedReason = error instanceof Error ? error.message : String(error);
console.warn(`Skipping ${label}: ${sshEnvLabUnsupportedReason}`);
return null;
}
}
interface ParsedProgressLine {
raw: string;
percent: number | null;
doneMb: number | null;
totalMb: number | null;
}
function parseProgressLine(line: string): ParsedProgressLine {
const trimmed = line.trimEnd();
const percentMatch = trimmed.match(/:\s*(\d+)%\s*\(([\d.]+)\/([\d.]+) MB\)$/);
if (percentMatch) {
return {
raw: trimmed,
percent: Number.parseInt(percentMatch[1]!, 10),
doneMb: Number.parseFloat(percentMatch[2]!),
totalMb: Number.parseFloat(percentMatch[3]!),
};
}
const mbMatch = trimmed.match(/:\s*([\d.]+) MB$/);
if (mbMatch) {
return { raw: trimmed, percent: null, doneMb: Number.parseFloat(mbMatch[1]!), totalMb: null };
}
return { raw: trimmed, percent: null, doneMb: null, totalMb: null };
}
describe("ssh env-lab fixture", () => {
afterEach(drainFixtureTeardowns);
// Backstop: if a throw inside afterEach ever leaves an entry on the stack,
// this drains it too instead of stranding a listener until the process exits.
afterAll(drainFixtureTeardowns);
it("starts an isolated sshd fixture and executes commands through it", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const quotedWorkspace = JSON.stringify(started.workspaceDir);
const result = await runSshCommand(
config,
`cd ${quotedWorkspace} && pwd`,
);
expect(result.stdout.trim()).toBe(started.workspaceDir);
const status = await readSshEnvLabFixtureStatus(statePath);
expect(status.running).toBe(true);
await stopSshEnvLabFixture(started);
const stopped = await readSshEnvLabFixtureStatus(statePath);
expect(stopped.running).toBe(false);
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("resolves a relative statePath to the same absolute state across start, status, and stop", async () => {
const rootDir = await createFixtureRootDir();
const absoluteStatePath = path.join(rootDir, "state.json");
// A path relative to the test process's own working directory. This is
// the shape a caller outside this file's own resolveEnvLabSshStatePath
// helper can pass; startSshEnvLabFixture must resolve it up front so the
// persisted state and every derived path stay absolute.
const relativeStatePath = path.relative(process.cwd(), absoluteStatePath);
if (sshEnvLabUnsupportedReason) {
console.warn(`Skipping relative statePath test: ${sshEnvLabUnsupportedReason}`);
return;
}
const support = await getSshEnvLabSupport();
if (!support.supported) {
sshEnvLabUnsupportedReason = support.reason ?? "unsupported environment";
console.warn(`Skipping relative statePath test: ${sshEnvLabUnsupportedReason}`);
return;
}
const entry = fixtureTeardowns.find((candidate) => candidate.rootDir === rootDir);
if (!entry) {
throw new Error(`No fixture teardown entry for ${rootDir}.`);
}
let state: SshEnvLabFixtureState;
try {
state = await startSshEnvLabFixture({ statePath: relativeStatePath });
} catch (error) {
sshEnvLabUnsupportedReason = error instanceof Error ? error.message : String(error);
console.warn(`Skipping relative statePath test: ${sshEnvLabUnsupportedReason}`);
return;
}
entry.state = state;
expect(state.statePath).toBe(absoluteStatePath);
expect(state.rootDir).toBe(rootDir);
const running = await readSshEnvLabFixtureStatus(relativeStatePath);
expect(running.running).toBe(true);
expect(running.state?.statePath).toBe(absoluteStatePath);
const stopped = await stopSshEnvLabFixture(relativeStatePath);
expect(stopped).toBe(true);
entry.state = null;
const afterStop = await readSshEnvLabFixtureStatus(relativeStatePath);
expect(afterStop.running).toBe(false);
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("forwards stdin to remote SSH commands", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH stdin forwarding test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const remotePath = path.posix.join(started.workspaceDir, "stdin-forwarded.txt");
await runSshCommand(
config,
`cat > ${JSON.stringify(remotePath)}`,
{
stdin: "hello over ssh stdin\n",
timeoutMs: 30_000,
maxBuffer: 256 * 1024,
},
);
const result = await runSshCommand(
config,
`cat ${JSON.stringify(remotePath)}`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
expect(result.stdout).toBe("hello over ssh stdin\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("does not treat an unrelated reused pid as the running fixture", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture test");
if (!started) return;
await stopSshEnvLabFixture(started);
await mkdir(path.dirname(statePath), { recursive: true });
await writeFile(
statePath,
JSON.stringify({ ...started, pid: process.pid }, null, 2),
{ mode: 0o600 },
);
const staleStatus = await readSshEnvLabFixtureStatus(statePath);
expect(staleStatus.running).toBe(false);
const restarted = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture restart test");
if (!restarted) return;
expect(restarted.pid).not.toBe(process.pid);
await stopSshEnvLabFixture(restarted);
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("rejects a forged state file and cannot signal an unrelated local process", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
// A process this test does not own. A forged state must never be able
// to target it for SIGTERM or SIGKILL.
const bystander = spawn("sleep", ["30"], { stdio: "ignore" });
const bystanderPid = bystander.pid;
if (!bystanderPid) {
throw new Error("Failed to spawn the bystander process for this regression test.");
}
try {
const baseState = {
kind: "ssh_openbsd" as const,
bindHost: "127.0.0.1",
host: "127.0.0.1",
port: 0,
username: os.userInfo().username,
rootDir,
workspaceDir: path.join(rootDir, "workspace"),
statePath,
createdAt: new Date().toISOString(),
clientPrivateKeyPath: path.join(rootDir, "client_key"),
clientPublicKeyPath: path.join(rootDir, "client_key.pub"),
hostPrivateKeyPath: path.join(rootDir, "host_key"),
hostPublicKeyPath: path.join(rootDir, "host_key.pub"),
authorizedKeysPath: path.join(rootDir, "authorized_keys"),
knownHostsPath: path.join(rootDir, "known_hosts"),
sshdConfigPath: path.join(rootDir, "sshd_config"),
sshdLogPath: path.join(rootDir, "sshd.log"),
};
const forgedVariants = [
// An empty sshdConfigPath used to defeat the identity check: an
// empty string is a substring of every command line.
{ ...baseState, pid: bystanderPid, sshdConfigPath: "" },
// A sshdConfigPath outside the fixture root.
{ ...baseState, pid: bystanderPid, sshdConfigPath: "/etc/ssh/sshd_config" },
// A non-positive pid.
{ ...baseState, pid: 0 },
{ ...baseState, pid: -1 },
];
for (const forged of forgedVariants) {
await writeFile(statePath, JSON.stringify(forged, null, 2), { mode: 0o600 });
const status = await readSshEnvLabFixtureStatus(statePath);
expect(status.running).toBe(false);
expect(status.state).toBeNull();
const stopped = await stopSshEnvLabFixture(statePath);
expect(stopped).toBe(false);
}
// No forged state ever reached the identity check or a signal call,
// so the bystander process is still alive.
expect(() => process.kill(bystanderPid, 0)).not.toThrow();
} finally {
try {
process.kill(bystanderPid, "SIGKILL");
} catch {
// Already gone.
}
}
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("stops the fixture listener and frees its loopback port", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH teardown regression test");
if (!started) return;
const { pid, port, bindHost } = started;
await stopSshEnvLabFixture(started);
let pidStillRunning = true;
try {
process.kill(pid, 0);
} catch {
pidStillRunning = false;
}
expect(pidStillRunning).toBe(false);
// Bind the exact port to prove it is free; a stopped process is not
// proof the OS released the socket.
await new Promise<void>((resolve, reject) => {
const probe = net.createServer();
probe.once("error", reject);
probe.listen(port, bindHost, () => {
probe.close((closeError) => (closeError ? reject(closeError) : resolve()));
});
});
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("leaves no live listener and no root directory when the fixture fails readiness", async () => {
if (sshEnvLabUnsupportedReason) {
console.warn(`Skipping SSH readiness-failure cleanup test: ${sshEnvLabUnsupportedReason}`);
return;
}
const support = await getSshEnvLabSupport();
if (!support.supported) {
sshEnvLabUnsupportedReason = support.reason ?? "unsupported environment";
console.warn(`Skipping SSH readiness-failure cleanup test: ${sshEnvLabUnsupportedReason}`);
return;
}
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const sshdConfigPath = path.join(rootDir, "sshd_config");
// sshd binds through bindHost (127.0.0.1) and stays alive; the readiness
// check targets an unreachable RFC 5737 TEST-NET-3 address instead, so it
// fails on every attempt without ever reaching a real host. Poll for the
// resulting sshd process concurrently, since startSshEnvLabFixture never
// returns a state on this path (it throws before writing one).
let capturedPid: number | null = null;
const pollDeadline = Date.now() + 5_000;
const pollForPid = (async () => {
while (capturedPid === null && Date.now() < pollDeadline) {
capturedPid = await findSshdPidByConfigPath(sshdConfigPath);
if (capturedPid === null) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
})();
await expect(
startSshEnvLabFixture({
statePath,
host: "203.0.113.1",
readinessTimeoutMs: 1_000,
}),
).rejects.toThrow();
await pollForPid;
expect(capturedPid).not.toBeNull();
let pidStillRunning = true;
try {
process.kill(capturedPid!, 0);
} catch {
pidStillRunning = false;
}
expect(pidStillRunning).toBe(false);
await expect(stat(rootDir)).rejects.toThrow();
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("builds a remote script that sources login profiles but no nvm", async () => {
const target = await buildSshSpawnTarget({
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
command: "node",
args: ["--version"],
env: { FOO: "bar" },
});
// The remote script rides the last ssh argument. The SSH target is an
// operator-configured host that can expose `node` only through a login
// profile, so the wrapper sources the profiles. It no longer sources
// `nvm.sh`; a profile that adds nvm still runs.
const remoteScript = String(target.args.at(-1) ?? "");
expect(remoteScript).not.toContain("nvm.sh");
expect(remoteScript).not.toContain("NVM_DIR");
// Source /etc/profile so a host that exposes the PATH through
// /etc/profile.d scripts still resolves node and the agent CLI.
expect(remoteScript).toContain("/etc/profile");
expect(remoteScript).toContain(".profile");
expect(remoteScript).toContain(".bash_profile");
expect(remoteScript).toContain(".zprofile");
// Fall back to .bashrc when no .bash_profile exists, so a host that adds
// nvm in .bashrc still resolves node under a non-login SSH command.
expect(remoteScript).toContain(".bashrc");
// The last ssh argument wraps the script as `sh -c '...'`, so the inner
// quotes are escaped. Assert the command still runs: cd, env, and the argv.
expect(remoteScript).toContain("cd ");
expect(remoteScript).toContain("/srv/paperclip/workspace");
expect(remoteScript).toContain("exec env ");
expect(remoteScript).toContain("node");
expect(remoteScript).toContain("--version");
await target.cleanup();
});
it("rejects invalid environment variable keys when constructing SSH spawn targets", async () => {
await expect(
buildSshSpawnTarget({
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
command: "env",
args: [],
env: {
"BAD KEY": "value",
},
}),
).rejects.toThrow("Invalid SSH environment variable key: BAD KEY");
});
it("syncs a local directory into the remote fixture workspace", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localDir = path.join(rootDir, "local-overlay");
await mkdir(localDir, { recursive: true });
await writeFile(path.join(localDir, "message.txt"), "hello from paperclip\n", "utf8");
await writeFile(path.join(localDir, "._message.txt"), "should never sync\n", "utf8");
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const remoteDir = path.posix.join(started.workspaceDir, "overlay");
await syncDirectoryToSsh({
spec: {
...config,
remoteCwd: started.workspaceDir,
},
localDir,
remoteDir,
});
const result = await runSshCommand(
config,
`cat ${JSON.stringify(path.posix.join(remoteDir, "message.txt"))} && if [ -e ${JSON.stringify(path.posix.join(remoteDir, "._message.txt"))} ]; then echo appledouble-present; fi`,
);
expect(result.stdout).toContain("hello from paperclip");
expect(result.stdout).not.toContain("appledouble-present");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("reports throttled upload progress with a clamped percent and terminal 100% line", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localDir = path.join(rootDir, "local-overlay");
await mkdir(localDir, { recursive: true });
// Multiple files large enough that tar emits several pipe chunks, so the
// byte counter crosses several step boundaries before the stream closes.
for (let index = 0; index < 4; index += 1) {
await writeFile(path.join(localDir, `blob-${index}.bin`), Buffer.alloc(256 * 1024, index + 1));
}
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH upload progress test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const remoteDir = path.posix.join(started.workspaceDir, "overlay-progress");
const lines: ParsedProgressLine[] = [];
await syncDirectoryToSsh({
spec: { ...config, remoteCwd: started.workspaceDir },
localDir,
remoteDir,
onProgress: (line) => {
lines.push(parseProgressLine(line));
},
progressLabel: "workspace",
});
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) {
expect(line.raw).toContain("Syncing workspace to ssh");
}
// Monotonically increasing byte counts.
const doneSeries = lines.map((line) => line.doneMb ?? 0);
for (let index = 1; index < doneSeries.length; index += 1) {
expect(doneSeries[index]!).toBeGreaterThanOrEqual(doneSeries[index - 1]!);
}
// Percent clamped to <= 99% on every line emitted before the stream closed.
for (const line of lines.slice(0, -1)) {
if (line.percent != null) expect(line.percent).toBeLessThanOrEqual(99);
}
// Terminal completion line is 100% with matching done/total.
const last = lines.at(-1)!;
expect(last.percent).toBe(100);
expect(last.doneMb).toBe(last.totalMb);
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("reports restore progress with a terminal completion line", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localDir = path.join(rootDir, "local-overlay");
const restoreDir = path.join(rootDir, "restore-target");
await mkdir(localDir, { recursive: true });
await mkdir(restoreDir, { recursive: true });
for (let index = 0; index < 4; index += 1) {
await writeFile(path.join(localDir, `blob-${index}.bin`), Buffer.alloc(256 * 1024, index + 1));
}
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH restore progress test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = { ...config, remoteCwd: started.workspaceDir } as const;
const remoteDir = path.posix.join(started.workspaceDir, "restore-source");
await syncDirectoryToSsh({ spec, localDir, remoteDir });
const lines: ParsedProgressLine[] = [];
await syncDirectoryFromSsh({
spec,
remoteDir,
localDir: restoreDir,
onProgress: (line) => {
lines.push(parseProgressLine(line));
},
progressLabel: "workspace",
});
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) {
expect(line.raw).toContain("Restoring workspace from ssh");
}
// Terminal completion line: either an exact 100% (probe succeeded) or a
// final MB-received line (probe unavailable). Either is a valid terminal.
const last = lines.at(-1)!;
expect(last.percent === 100 || (last.percent === null && last.doneMb !== null)).toBe(true);
// The restored files round-tripped through the byte-counting transport.
await expect(readFile(path.join(restoreDir, "blob-0.bin"))).resolves.toEqual(
Buffer.alloc(256 * 1024, 1),
);
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("reports exact git-history import percentage from the known bundle size", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.bin"), Buffer.alloc(256 * 1024, 7));
await git(localRepo, ["add", "tracked.bin"]);
await git(localRepo, ["commit", "-m", "initial"]);
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH git import progress test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = { ...config, remoteCwd: started.workspaceDir } as const;
const lines: ParsedProgressLine[] = [];
await prepareWorkspaceForSshExecution({
spec,
localDir: localRepo,
remoteDir: started.workspaceDir,
onProgress: (line) => {
lines.push(parseProgressLine(line));
},
});
const importLines = lines.filter((line) => line.raw.includes("Importing git history to ssh"));
expect(importLines.length).toBeGreaterThan(0);
// Known bundle size -> exact percentage with no "workspace" label.
for (const line of importLines) {
expect(line.raw).not.toContain("workspace");
expect(line.percent).not.toBeNull();
}
const lastImport = importLines.at(-1)!;
expect(lastImport.percent).toBe(100);
expect(lastImport.doneMb).toBe(lastImport.totalMb);
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("can dereference local symlinks while syncing to the remote fixture", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const sourceDir = path.join(rootDir, "source");
const localDir = path.join(rootDir, "local-overlay");
await mkdir(sourceDir, { recursive: true });
await mkdir(localDir, { recursive: true });
await writeFile(path.join(sourceDir, "auth.json"), "{\"token\":\"secret\"}\n", "utf8");
await symlink(path.join(sourceDir, "auth.json"), path.join(localDir, "auth.json"));
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH symlink sync test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const remoteDir = path.posix.join(started.workspaceDir, "overlay-follow-links");
await syncDirectoryToSsh({
spec: {
...config,
remoteCwd: started.workspaceDir,
},
localDir,
remoteDir,
followSymlinks: true,
});
const result = await runSshCommand(
config,
`if [ -L ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))} ]; then echo symlink; else echo regular; fi && cat ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))}`,
);
expect(result.stdout).toContain("regular");
expect(result.stdout).toContain("{\"token\":\"secret\"}");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("round-trips a git workspace through the SSH fixture", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await writeFile(path.join(localRepo, "._tracked.txt"), "should stay local only\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
const originalHead = await git(localRepo, ["rev-parse", "HEAD"]);
await writeFile(path.join(localRepo, "tracked.txt"), "dirty local\n", "utf8");
await writeFile(path.join(localRepo, "untracked.txt"), "from local\n", "utf8");
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH workspace round-trip test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
await prepareWorkspaceForSshExecution({
spec,
localDir: localRepo,
remoteDir: started.workspaceDir,
});
const remoteStatus = await runSshCommand(
config,
`cd ${JSON.stringify(started.workspaceDir)} && git status --short`,
);
expect(remoteStatus.stdout).toContain("M tracked.txt");
expect(remoteStatus.stdout).toContain("?? untracked.txt");
expect(remoteStatus.stdout).not.toContain("._tracked.txt");
await runSshCommand(
config,
`cd ${JSON.stringify(started.workspaceDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && git add tracked.txt untracked.txt && git commit -m "remote update" >/dev/null && printf "remote dirty\\n" > tracked.txt && printf "remote extra\\n" > remote-only.txt`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await restoreWorkspaceFromSshExecution({
spec,
localDir: localRepo,
remoteDir: started.workspaceDir,
});
const restoredHead = await git(localRepo, ["rev-parse", "HEAD"]);
expect(restoredHead).not.toBe(originalHead);
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update");
expect(await git(localRepo, ["status", "--short"])).toContain("M tracked.txt");
expect(await git(localRepo, ["status", "--short"])).not.toContain("._tracked.txt");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("preserves both concurrent SSH restores in a shared git workspace", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
const started = await startSshEnvLabFixtureOrSkip(statePath, "concurrent SSH restore test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const preparedA = await prepareRemoteManagedRuntime({
spec,
runId: "run-a",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
const preparedB = await prepareRemoteManagedRuntime({
spec,
runId: "run-b",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
expect(preparedA.workspaceRemoteDir).not.toBe(preparedB.workspaceRemoteDir);
await runSshCommand(
config,
`printf "from run a\\n" > ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "run-a.txt"))}`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await runSshCommand(
config,
`printf "from run b\\n" > ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "run-b.txt"))}`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await Promise.all([
preparedA.restoreWorkspace(),
preparedB.restoreWorkspace(),
]);
await expect(readFile(path.join(localRepo, "run-a.txt"), "utf8")).resolves.toBe("from run a\n");
await expect(readFile(path.join(localRepo, "run-b.txt"), "utf8")).resolves.toBe("from run b\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("preserves nested per-run files across sequential SSH restores with stale baselines", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
const started = await startSshEnvLabFixtureOrSkip(statePath, "sequential nested SSH restore test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const preparedA = await prepareRemoteManagedRuntime({
spec,
runId: "run-a",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
const preparedB = await prepareRemoteManagedRuntime({
spec,
runId: "run-b",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
await runSshCommand(
config,
`mkdir -p ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "manual-qa/environment-matrix/ssh"))} && printf "from run a\\n" > ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "manual-qa/environment-matrix/ssh/claude_local.md"))}`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await runSshCommand(
config,
`mkdir -p ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "manual-qa/environment-matrix/ssh"))} && printf "from run b\\n" > ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "manual-qa/environment-matrix/ssh/codex_local.md"))}`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await preparedA.restoreWorkspace();
await preparedB.restoreWorkspace();
await expect(readFile(path.join(localRepo, "manual-qa/environment-matrix/ssh/claude_local.md"), "utf8")).resolves
.toBe("from run a\n");
await expect(readFile(path.join(localRepo, "manual-qa/environment-matrix/ssh/codex_local.md"), "utf8")).resolves
.toBe("from run b\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("round-trips remote git commits through the managed runtime restore path", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
const started = await startSshEnvLabFixtureOrSkip(statePath, "managed-runtime SSH git round-trip test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const prepared = await prepareRemoteManagedRuntime({
spec,
runId: "run-commit",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
await runSshCommand(
config,
`cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "committed\\n" > tracked.txt && git add tracked.txt && git commit -m "remote update" >/dev/null && printf "dirty remote\\n" > tracked.txt`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await prepared.restoreWorkspace();
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update");
await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("propagates remote commits to the local worktree with no git remote configured (no-remote-git contract)", async () => {
// Locks in the architectural contract documented in
// packages/adapter-utils/README.md and packages/adapters/AUTHORING.md:
// the local execution-workspace cwd is the only persistence boundary
// across runs. No adapter may depend on a git remote for cross-run state.
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
// Assert there is no git remote configured before we begin, and verify
// that no point in the round-trip introduces one. `git remote` returns an
// empty string when no remotes exist (and exit code 0).
expect(await git(localRepo, ["remote"])).toBe("");
const started = await startSshEnvLabFixtureOrSkip(
statePath,
"no-remote-git contract test",
);
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const prepared = await prepareRemoteManagedRuntime({
spec,
runId: "run-no-remote",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
// Remote commit lands a deliverable that must show up locally via
// sync-back alone — no `git push`, no fetch from any origin.
await runSshCommand(
config,
`cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "deliverable\\n" > tracked.txt && git add tracked.txt && git commit -m "remote-only commit" >/dev/null`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await prepared.restoreWorkspace();
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe(
"remote-only commit",
);
expect(await readFile(path.join(localRepo, "tracked.txt"), "utf8")).toBe(
"deliverable\n",
);
// Final assertion: still no git remote — restore did not silently add one.
expect(await git(localRepo, ["remote"])).toBe("");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("merges concurrent remote commits through the managed runtime restore path", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
const started = await startSshEnvLabFixtureOrSkip(statePath, "concurrent managed-runtime SSH git merge test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const preparedA = await prepareRemoteManagedRuntime({
spec,
runId: "run-commit-a",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
const preparedB = await prepareRemoteManagedRuntime({
spec,
runId: "run-commit-b",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
await runSshCommand(
config,
`cd ${JSON.stringify(preparedA.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "from run a\\n" > run-a.txt && git add run-a.txt && git commit -m "remote update a" >/dev/null`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await runSshCommand(
config,
`cd ${JSON.stringify(preparedB.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "from run b\\n" > run-b.txt && git add run-b.txt && git commit -m "remote update b" >/dev/null`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await Promise.all([
preparedA.restoreWorkspace(),
preparedB.restoreWorkspace(),
]);
await expect(readFile(path.join(localRepo, "run-a.txt"), "utf8")).resolves.toBe("from run a\n");
await expect(readFile(path.join(localRepo, "run-b.txt"), "utf8")).resolves.toBe("from run b\n");
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toContain("Paperclip SSH sync merge");
const recentSubjects = await git(localRepo, ["log", "--pretty=%s", "-3"]);
expect(recentSubjects).toContain("remote update a");
expect(recentSubjects).toContain("remote update b");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
});