mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can run on a remote SSH environment. After the run, Paperclip
restores the workspace and imports the remote git history.
> - The import moves the checked-out branch with `git update-ref`.
`update-ref` does not change the index.
> - The index then still matches the old tip. `git status` in the
workspace shows the new files of the run as staged deletions.
> - An operator who commits in that workspace can remove the work of the
agent by mistake.
> - The sandbox restore already resets the index after the import
(#8595). The SSH restore does not.
> - This pull request calls the same `resetLocalGitIndexToHead` after
the SSH restore.
> - The benefit is a clean `git status` after each SSH run, the same as
after a sandbox run.
## Linked Issues or Issue Description
Refs #8595 (the same fix for the sandbox restore). Refs #15662, Refs
#16440 (other open changes to the same SSH restore code).
**What happened?**
After a remote SSH run imported a commit, `git status` in the local
workspace showed every new file of that commit as a staged deletion, and
every changed file as a staged modification. The files on disk were
correct.
**Expected behavior**
`git status` is clean after the import, because the working tree and
HEAD are the commit of the run.
**Steps to reproduce**
1. Use an opencode_local agent on an SSH environment with a git_worktree
project workspace.
2. Let the agent add a file and commit on the remote copy.
3. After the run, run `git status` in the local worktree. The new file
shows as `D` in the index.
**Paperclip version or commit**
paperclipai 2026.916.1 (also present on master 272316808)
## What Changed
- `packages/adapter-utils/src/ssh.ts`: the SSH restore merge gets an
`afterApply` that calls `resetLocalGitIndexToHead`, when remote git
history was imported. The sandbox restore does the same since #8595.
- `packages/adapter-utils/src/ssh-fixture.test.ts`: "round-trips remote
git commits through the managed runtime restore path" now also checks
that nothing is staged after the restore.
## Verification
- I ran the SSH fixture tests in a container with `sshd` (they skip on a
host without it): `ssh-fixture.test.ts` and
`git-workspace-sync.test.ts`, 87 tests pass.
- Without the change, the managed runtime restore test leaves `M
tracked.txt` staged (`git diff --cached`) and `git status` shows `MM
tracked.txt`. With the change, nothing is staged and `git status` shows
` M tracked.txt`, which is the uncommitted remote edit.
- `npx tsc --noEmit -p packages/adapter-utils` passes.
- I found the bug on a real SSH environment (opencode_local in a runner
container, paperclipai 2026.916.1).
## Risks
Low risk. It reuses the index reset that the sandbox restore already
uses. It runs only when remote git history was imported. Work staged on
the host before the run is reset to HEAD, the same as in the sandbox
path.
## Model Used
Claude Opus 5.5 (claude-opus-5-5), Anthropic, used through Claude Code
with tool use (shell, file edits). Human-reviewed.
## 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
- [ ] 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
1281 lines
53 KiB
TypeScript
1281 lines
53 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;
|
|
const UNREACHABLE_SSH_SPEC = {
|
|
host: "127.0.0.1",
|
|
port: 1,
|
|
username: "nobody",
|
|
remoteWorkspacePath: "/nonexistent",
|
|
remoteCwd: "/nonexistent",
|
|
privateKey: null,
|
|
knownHosts: null,
|
|
strictHostKeyChecking: false,
|
|
} as const;
|
|
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("clears stale files when plain SSH preparation is retried", async () => {
|
|
const rootDir = await createFixtureRootDir();
|
|
const localDir = path.join(rootDir, "plain-local");
|
|
await mkdir(path.join(localDir, "node_modules"), { recursive: true });
|
|
await git(localDir, ["init"]);
|
|
await writeFile(path.join(localDir, ".gitignore"), "node_modules/\n");
|
|
await writeFile(path.join(localDir, "removed.txt"), "remove on retry");
|
|
const binary = Buffer.from([0, 255, 1]);
|
|
await writeFile(path.join(localDir, "node_modules", "personal.bin"), binary);
|
|
const started = await startSshEnvLabFixtureOrSkip(path.join(rootDir, "state.json"), "SSH plain retry");
|
|
if (!started) return;
|
|
const config = await buildSshEnvLabFixtureConfig(started);
|
|
const input = { spec: { ...config, remoteCwd: started.workspaceDir }, localDir,
|
|
remoteDir: started.workspaceDir, workspaceFileMode: "all" as const };
|
|
expect(await prepareWorkspaceForSshExecution(input)).toEqual({ gitBacked: false });
|
|
expect(await readFile(path.join(started.workspaceDir, "node_modules", "personal.bin"))).toEqual(binary);
|
|
await rm(path.join(localDir, "removed.txt"));
|
|
await prepareWorkspaceForSshExecution(input);
|
|
await expect(stat(path.join(started.workspaceDir, "removed.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(await readFile(path.join(started.workspaceDir, "node_modules", "personal.bin"))).toEqual(binary);
|
|
}, 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("round-trips project repositories nested in .paperclip-repositories across consecutive SSH runs", async () => {
|
|
const rootDir = await createFixtureRootDir();
|
|
const statePath = path.join(rootDir, "state.json");
|
|
const localRepo = path.join(rootDir, "local-workspace");
|
|
const nestedRelative = ".paperclip-repositories/frontend-0123456789ab";
|
|
const nestedRepo = path.join(localRepo, nestedRelative);
|
|
|
|
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, "backend.txt"), "backend base\n", "utf8");
|
|
await git(localRepo, ["add", "backend.txt"]);
|
|
await git(localRepo, ["commit", "-m", "backend initial"]);
|
|
await writeFile(path.join(localRepo, ".git", "info", "exclude"), "\n/.paperclip-repositories/\n", { flag: "a" });
|
|
|
|
await mkdir(nestedRepo, { recursive: true });
|
|
await git(nestedRepo, ["init"]);
|
|
await git(nestedRepo, ["checkout", "-b", "main"]);
|
|
await git(nestedRepo, ["config", "user.name", "Paperclip Test"]);
|
|
await git(nestedRepo, ["config", "user.email", "test@paperclip.dev"]);
|
|
await writeFile(path.join(nestedRepo, "frontend.txt"), "frontend base\n", "utf8");
|
|
await writeFile(path.join(nestedRepo, "obsolete.txt"), "tracked\n", "utf8");
|
|
await git(nestedRepo, ["add", "frontend.txt", "obsolete.txt"]);
|
|
await git(nestedRepo, ["commit", "-m", "frontend initial"]);
|
|
await writeFile(path.join(nestedRepo, "frontend.txt"), "frontend dirty local\n", "utf8");
|
|
await rm(path.join(nestedRepo, "obsolete.txt"));
|
|
|
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH nested project repositories test");
|
|
if (!started) return;
|
|
const config = await buildSshEnvLabFixtureConfig(started);
|
|
const spec = {
|
|
...config,
|
|
remoteCwd: started.workspaceDir,
|
|
} as const;
|
|
|
|
const first = await prepareRemoteManagedRuntime({
|
|
spec,
|
|
runId: "run-1",
|
|
adapterKey: "test-adapter",
|
|
workspaceLocalDir: localRepo,
|
|
});
|
|
const remoteNested = path.posix.join(first.workspaceRemoteDir, nestedRelative);
|
|
|
|
const remoteNestedStatus = await runSshCommand(
|
|
config,
|
|
`cd ${JSON.stringify(remoteNested)} && git log -1 --pretty=%s && git status --short`,
|
|
);
|
|
expect(remoteNestedStatus.stdout).toContain("frontend initial");
|
|
expect(remoteNestedStatus.stdout).toContain("M frontend.txt");
|
|
expect(remoteNestedStatus.stdout).toContain("D obsolete.txt");
|
|
const remoteAnchorStatus = await runSshCommand(
|
|
config,
|
|
`cd ${JSON.stringify(first.workspaceRemoteDir)} && git status --short --untracked-files=all`,
|
|
);
|
|
expect(remoteAnchorStatus.stdout).not.toContain(".paperclip-repositories");
|
|
|
|
await runSshCommand(
|
|
config,
|
|
[
|
|
`cd ${JSON.stringify(remoteNested)}`,
|
|
`git config user.name "Paperclip SSH"`,
|
|
`git config user.email "ssh@paperclip.dev"`,
|
|
`git add frontend.txt`,
|
|
`git commit -m "remote frontend update" >/dev/null`,
|
|
`printf "frontend remote dirty\\n" > frontend.txt`,
|
|
`cd ${JSON.stringify(first.workspaceRemoteDir)}`,
|
|
`git config user.name "Paperclip SSH"`,
|
|
`git config user.email "ssh@paperclip.dev"`,
|
|
`printf "backend remote\\n" > backend.txt`,
|
|
`git add backend.txt`,
|
|
`git commit -m "remote backend update" >/dev/null`,
|
|
].join(" && "),
|
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
|
);
|
|
|
|
await first.restoreWorkspace();
|
|
|
|
await expect(stat(path.join(nestedRepo, ".git"))).resolves.toBeDefined();
|
|
expect(await git(nestedRepo, ["log", "-1", "--pretty=%s"])).toBe("remote frontend update");
|
|
await expect(readFile(path.join(nestedRepo, "frontend.txt"), "utf8")).resolves.toBe("frontend remote dirty\n");
|
|
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote backend update");
|
|
expect(await git(localRepo, ["status", "--short", "--untracked-files=all"])).not.toContain(".paperclip-repositories");
|
|
|
|
const second = await prepareRemoteManagedRuntime({
|
|
spec,
|
|
runId: "run-2",
|
|
adapterKey: "test-adapter",
|
|
workspaceLocalDir: localRepo,
|
|
});
|
|
const secondNestedLog = await runSshCommand(
|
|
config,
|
|
`cd ${JSON.stringify(path.posix.join(second.workspaceRemoteDir, nestedRelative))} && git log -1 --pretty=%s`,
|
|
);
|
|
expect(secondNestedLog.stdout.trim()).toBe("remote frontend update");
|
|
await second.restoreWorkspace();
|
|
await expect(stat(path.join(nestedRepo, ".git"))).resolves.toBeDefined();
|
|
await expect(readFile(path.join(nestedRepo, "frontend.txt"), "utf8")).resolves.toBe("frontend remote dirty\n");
|
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS * 2);
|
|
|
|
it("restores project repositories through the direct SSH restore path", async () => {
|
|
const rootDir = await createFixtureRootDir();
|
|
const statePath = path.join(rootDir, "state.json");
|
|
const localRepo = path.join(rootDir, "local-workspace");
|
|
const nestedRelative = ".paperclip-repositories/frontend-0123456789ab";
|
|
const nestedRepo = path.join(localRepo, nestedRelative);
|
|
|
|
for (const repo of [localRepo, nestedRepo]) {
|
|
await mkdir(repo, { recursive: true });
|
|
await git(repo, ["init"]);
|
|
await git(repo, ["checkout", "-b", "main"]);
|
|
await git(repo, ["config", "user.name", "Paperclip Test"]);
|
|
await git(repo, ["config", "user.email", "test@paperclip.dev"]);
|
|
await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8");
|
|
await git(repo, ["add", "tracked.txt"]);
|
|
await git(repo, ["commit", "-m", "initial"]);
|
|
}
|
|
await writeFile(path.join(localRepo, ".git", "info", "exclude"), "\n/.paperclip-repositories/\n", { flag: "a" });
|
|
|
|
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH direct restore with project repositories test");
|
|
if (!started) return;
|
|
const config = await buildSshEnvLabFixtureConfig(started);
|
|
const spec = {
|
|
...config,
|
|
remoteCwd: started.workspaceDir,
|
|
} as const;
|
|
|
|
const prepared = await prepareWorkspaceForSshExecution({
|
|
spec,
|
|
localDir: localRepo,
|
|
remoteDir: started.workspaceDir,
|
|
});
|
|
expect(prepared).toEqual({ gitBacked: true, repositories: [nestedRelative] });
|
|
|
|
await runSshCommand(
|
|
config,
|
|
[
|
|
`cd ${JSON.stringify(path.posix.join(started.workspaceDir, nestedRelative))}`,
|
|
`git config user.name "Paperclip SSH"`,
|
|
`git config user.email "ssh@paperclip.dev"`,
|
|
`printf "remote\\n" > tracked.txt`,
|
|
`git commit -am "remote nested update" >/dev/null`,
|
|
].join(" && "),
|
|
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
|
|
);
|
|
|
|
await restoreWorkspaceFromSshExecution({
|
|
spec,
|
|
localDir: localRepo,
|
|
remoteDir: started.workspaceDir,
|
|
repositories: (prepared.repositories ?? []).map((repository) => ({ path: repository })),
|
|
});
|
|
|
|
await expect(stat(path.join(nestedRepo, ".git"))).resolves.toBeDefined();
|
|
expect(await git(nestedRepo, ["log", "-1", "--pretty=%s"])).toBe("remote nested update");
|
|
await expect(readFile(path.join(nestedRepo, "tracked.txt"), "utf8")).resolves.toBe("remote\n");
|
|
expect(await git(localRepo, ["status", "--short", "--untracked-files=all"])).toBe("");
|
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS * 2);
|
|
|
|
it("fails closed when .paperclip-repositories holds something other than a git checkout", async () => {
|
|
const rootDir = await createFixtureRootDir();
|
|
const localRepo = path.join(rootDir, "local-workspace");
|
|
await mkdir(path.join(localRepo, ".paperclip-repositories", "not-a-repo"), { 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"]);
|
|
|
|
await expect(prepareWorkspaceForSshExecution({ spec: UNREACHABLE_SSH_SPEC, localDir: localRepo, remoteDir: "/nonexistent" }))
|
|
.rejects.toThrow("Project repository is not a Git checkout: .paperclip-repositories/not-a-repo");
|
|
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
|
|
|
it("rejects inconsistent project repository restores before any SSH connection", async () => {
|
|
const localDir = await createFixtureRootDir();
|
|
const repositoryBaseline = { exclude: [".git", ".git/*", ".paperclip-runtime"], entries: new Map() };
|
|
const restore = (input: Partial<Parameters<typeof restoreWorkspaceFromSshExecution>[0]>) =>
|
|
restoreWorkspaceFromSshExecution({
|
|
spec: UNREACHABLE_SSH_SPEC,
|
|
localDir,
|
|
remoteDir: "/nonexistent",
|
|
baselineSnapshot: { exclude: [".git", ".git/*", ".paperclip-runtime", ".paperclip-repositories"], entries: new Map() },
|
|
restoreGitHistory: true,
|
|
...input,
|
|
});
|
|
|
|
await expect(restore({ repositories: [{ path: "../outside", baselineSnapshot: repositoryBaseline }] }))
|
|
.rejects.toThrow("Invalid project repository path: ../outside");
|
|
await expect(restore({ repositories: [{ path: ".paperclip-repositories/frontend-0123456789ab" }] }))
|
|
.rejects.toThrow("Project repository has no workspace baseline: .paperclip-repositories/frontend-0123456789ab");
|
|
await expect(restore({
|
|
baselineSnapshot: { exclude: [".git", ".git/*", ".paperclip-runtime"], entries: new Map() },
|
|
repositories: [{ path: ".paperclip-repositories/frontend-0123456789ab", baselineSnapshot: repositoryBaseline }],
|
|
})).rejects.toThrow("Workspace baseline must exclude .paperclip-repositories");
|
|
}, 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");
|
|
// The index follows the imported HEAD: only the remote's uncommitted edit shows, unstaged.
|
|
expect(await git(localRepo, ["diff", "--cached", "--name-status"])).toBe("");
|
|
}, 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);
|
|
});
|