Migrate SSH environment callback to bridge (#5116)

> **Stacked PR (part 3 of 7).** Depends on:
  - PR #5114
  - PR #5115
> Diff against `master` includes commits from earlier PRs in the stack —
the new commit in this PR is the topmost one.

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents executing on a remote SSH-backed environment need a way to
call back into
>   the Paperclip control plane (run events, log streaming, signals)
> - When the SSH host can't reach the Paperclip host (NAT, firewalls, or
simply not
> on the same network), the run silently fails or hangs — a recurring
class of
>   failure during SSH testing
> - In sandboxed environments we already solved this with a callback
bridge that
> tunnels back through the existing connection; SSH was the odd one out
> - This PR migrates SSH execution to use the same callback bridge, so
every
> adapter's remote run uses one consistent reverse-channel. Per-adapter
SSH glue
> is deleted in favour of a shared `CommandManagedRuntimeRunner` built
from the
>   SSH spec
> - The benefit is fewer SSH-specific failure modes, a smaller code
surface, and
>   one place to evolve the callback contract going forward

## What Changed

- Added `createSshCommandManagedRuntimeRunner` in
`packages/adapter-utils/src/ssh.ts` that adapts an SSH spec into a
generic
  command-managed-runtime runner (with cwd, env, and timeout handling)
- Removed `paperclipApiUrl` from `SshRemoteExecutionSpec`; the bridge
URL now flows
  through the shared runner
- Reworked `execution-target.ts` to use the SSH runner alongside sandbox
runners
  via a unified `CommandManagedRuntimeRunner` interface
- Simplified `remote-managed-runtime.ts` and
`sandbox-managed-runtime.ts` to consume
  the shared runner abstraction
- Deleted per-adapter SSH callback wiring from claude-local,
codex-local,
  cursor-local, gemini-local, opencode-local, pi-local execute.ts files
- Removed `environment-runtime-driver-contract.test.ts` (the contract is
now
  enforced by `environment-execution-target.test.ts`)
- Added/updated `execute.remote.test.ts` cases for each adapter to cover
the SSH
  runner path

## Verification

- `pnpm --filter @paperclipai/adapter-utils test`
- `pnpm test -- execute.remote` (covers all six local adapters' SSH
paths)
- Manual QA: ran a claude-local agent against an SSH-backed environment,
confirmed
the agent successfully called back to `/api/agent-callback/*` endpoints
during
  the run

## Risks

- Refactor touches all six local adapters. If any adapter had subtle
SSH-specific
behaviour that wasn't captured in tests, it could regress. Mitigation:
each
  adapter's `execute.remote.test.ts` was extended.
- `paperclipApiUrl` removal from `SshRemoteExecutionSpec` is a breaking
type change
for any internal consumer. Verified no external plugins consume this
type.
- The new `CommandManagedRuntimeRunner` shape is a public surface in
`@paperclipai/adapter-utils`; downstream plugins implementing custom
runners may
  need updates, but no such plugins exist in this repo.

## Model Used

- OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI
- Provider: OpenAI
- Used to author the code changes in this PR

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A
- [ ] I have updated relevant documentation to reflect my changes — N/A
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-05-03 12:43:52 -07:00 committed by GitHub
parent a7b45938b7
commit 076067865f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 331 additions and 259 deletions

View file

@ -3,6 +3,8 @@ import { constants as fsConstants, createReadStream, createWriteStream, promises
import net from "node:net";
import os from "node:os";
import path from "node:path";
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
import type { RunProcessResult } from "./server-utils.js";
export interface SshConnectionConfig {
host: string;
@ -21,7 +23,86 @@ export interface SshCommandResult {
export interface SshRemoteExecutionSpec extends SshConnectionConfig {
remoteCwd: string;
paperclipApiUrl?: string | null;
}
export function createSshCommandManagedRuntimeRunner(input: {
spec: SshRemoteExecutionSpec;
defaultCwd?: string | null;
maxBufferBytes?: number | null;
}): CommandManagedRuntimeRunner {
const defaultCwd = input.defaultCwd?.trim() || input.spec.remoteCwd;
const maxBufferBytes =
typeof input.maxBufferBytes === "number" && Number.isFinite(input.maxBufferBytes) && input.maxBufferBytes > 0
? Math.trunc(input.maxBufferBytes)
: 1024 * 1024;
return {
execute: async (commandInput): Promise<RunProcessResult> => {
const startedAt = new Date().toISOString();
const command = commandInput.command.trim();
const args = commandInput.args ?? [];
const cwd = commandInput.cwd?.trim() || defaultCwd;
const envEntries = Object.entries(commandInput.env ?? {})
.filter((entry): entry is [string, string] => typeof entry[1] === "string");
const envPrefix = envEntries.length > 0
? `env ${envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} `
: "";
const exportPrefix = envEntries.length > 0
? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " "
: "";
const commandScript = command === "sh" || command === "bash"
? args[0] === "-lc" && typeof args[1] === "string"
? `${exportPrefix}${args[1]}`
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`;
const remoteCommand = `${command === "bash" ? "bash" : "sh"} -lc ${
shellQuote(`cd ${shellQuote(cwd)} && ${commandScript}`)
}`;
try {
const result = await runSshCommand(input.spec, remoteCommand, {
timeoutMs: commandInput.timeoutMs,
maxBuffer: maxBufferBytes,
});
if (result.stdout) await commandInput.onLog?.("stdout", result.stdout);
if (result.stderr) await commandInput.onLog?.("stderr", result.stderr);
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: result.stdout,
stderr: result.stderr,
pid: null,
startedAt,
};
} catch (error) {
const failure = error as {
stdout?: unknown;
stderr?: unknown;
code?: unknown;
signal?: unknown;
killed?: unknown;
};
const stdout = typeof failure.stdout === "string" ? failure.stdout : "";
const stderr = typeof failure.stderr === "string"
? failure.stderr
: error instanceof Error
? error.message
: String(error);
if (stdout) await commandInput.onLog?.("stdout", stdout);
if (stderr) await commandInput.onLog?.("stderr", stderr);
return {
exitCode: typeof failure.code === "number" ? failure.code : null,
signal: typeof failure.signal === "string" ? failure.signal : null,
timedOut: failure.killed === true,
stdout,
stderr,
pid: null,
startedAt,
};
}
},
};
}
export interface SshEnvLabSupport {
@ -83,10 +164,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS
port: portValue,
username,
remoteCwd,
paperclipApiUrl:
typeof parsed.paperclipApiUrl === "string" && parsed.paperclipApiUrl.trim().length > 0
? parsed.paperclipApiUrl.trim()
: null,
remoteWorkspacePath:
typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0
? parsed.remoteWorkspacePath.trim()
@ -98,50 +175,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS
};
}
function normalizeHttpUrlCandidate(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return null;
}
return parsed.origin;
} catch {
return null;
}
}
export async function findReachablePaperclipApiUrlOverSsh(input: {
config: SshConnectionConfig;
candidates: string[];
timeoutMs?: number;
}): Promise<string | null> {
const uniqueCandidates = Array.from(
new Set(
input.candidates
.map((candidate) => normalizeHttpUrlCandidate(candidate))
.filter((candidate): candidate is string => candidate !== null),
),
);
for (const candidate of uniqueCandidates) {
const healthUrl = new URL("/api/health", candidate).toString();
try {
await runSshCommand(
input.config,
`sh -lc ${shellQuote(`curl -fsS -m ${Math.max(1, Math.ceil((input.timeoutMs ?? 5_000) / 1000))} ${shellQuote(healthUrl)} >/dev/null`)}`,
{ timeoutMs: input.timeoutMs ?? 5_000 },
);
return candidate;
} catch {
continue;
}
}
return null;
}
async function execFileText(
file: string,
args: string[],