mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
parent
a7b45938b7
commit
076067865f
23 changed files with 331 additions and 259 deletions
|
|
@ -54,14 +54,11 @@ describe("resolveEnvironmentExecutionTarget", () => {
|
|||
remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD,
|
||||
leaseId: "lease-1",
|
||||
environmentId: "env-1",
|
||||
paperclipTransport: "bridge",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an explicit Paperclip API URL from lease metadata for sandbox targets", async () => {
|
||||
process.env.PAPERCLIP_API_URL = "https://paperclip.example.test";
|
||||
process.env.PAPERCLIP_RUNTIME_API_URL = "http://paperclip.example.test:3200";
|
||||
it("keeps sandbox targets on bridge mode even when lease metadata includes a Paperclip API URL", async () => {
|
||||
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
|
||||
driver: "sandbox",
|
||||
config: {
|
||||
|
|
@ -93,9 +90,11 @@ describe("resolveEnvironmentExecutionTarget", () => {
|
|||
expect(target).toMatchObject({
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
paperclipApiUrl: "https://paperclip.example.test",
|
||||
paperclipTransport: "direct",
|
||||
providerKey: "fake-plugin",
|
||||
remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD,
|
||||
});
|
||||
expect(target).not.toHaveProperty("paperclipApiUrl");
|
||||
expect(target).not.toHaveProperty("paperclipTransport");
|
||||
});
|
||||
|
||||
it("passes through a provider-declared sandbox shell command from lease metadata", async () => {
|
||||
|
|
@ -133,4 +132,50 @@ describe("resolveEnvironmentExecutionTarget", () => {
|
|||
shellCommand: "bash",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves SSH execution targets in bridge mode", async () => {
|
||||
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
|
||||
driver: "ssh",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "paperclip",
|
||||
remoteWorkspacePath: "/srv/paperclip",
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[ssh.example.test]:22 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
});
|
||||
|
||||
const target = await resolveEnvironmentExecutionTarget({
|
||||
db: {} as never,
|
||||
companyId: "company-1",
|
||||
adapterType: "codex_local",
|
||||
environment: {
|
||||
id: "env-ssh-1",
|
||||
driver: "ssh",
|
||||
config: {},
|
||||
},
|
||||
leaseId: "lease-ssh-1",
|
||||
leaseMetadata: {},
|
||||
lease: null,
|
||||
environmentRuntime: null,
|
||||
});
|
||||
|
||||
expect(target).toMatchObject({
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
remoteCwd: "/srv/paperclip",
|
||||
leaseId: "lease-ssh-1",
|
||||
environmentId: "env-ssh-1",
|
||||
spec: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "paperclip",
|
||||
remoteWorkspacePath: "/srv/paperclip",
|
||||
remoteCwd: "/srv/paperclip",
|
||||
},
|
||||
});
|
||||
expect(target).not.toHaveProperty("paperclipApiUrl");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
|
@ -55,7 +54,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
|||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const fixtureRoots: string[] = [];
|
||||
const servers: Server[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("environment-runtime-contract");
|
||||
|
|
@ -64,9 +62,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
while (fixtureRoots.length > 0) {
|
||||
const root = fixtureRoots.pop();
|
||||
if (!root) continue;
|
||||
|
|
@ -172,27 +167,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
|||
};
|
||||
}
|
||||
|
||||
async function startHealthServer() {
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/api/health") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
servers.push(server);
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected health server to listen on a TCP port.");
|
||||
}
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function runContract(testCase: RuntimeContractCase) {
|
||||
const cleanup = await testCase.setup?.();
|
||||
try {
|
||||
|
|
@ -288,9 +262,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
|||
fixtureRoots.push(fixtureRoot);
|
||||
const fixture = await startSshEnvLabFixture({ statePath: path.join(fixtureRoot, "state.json") });
|
||||
const sshConfig = await buildSshEnvLabFixtureConfig(fixture);
|
||||
const runtimeApiUrl = await startHealthServer();
|
||||
const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]);
|
||||
|
||||
await runContract({
|
||||
name: "ssh",
|
||||
|
|
@ -304,16 +275,8 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
|
|||
username: sshConfig.username,
|
||||
remoteWorkspacePath: sshConfig.remoteWorkspacePath,
|
||||
remoteCwd: sshConfig.remoteWorkspacePath,
|
||||
paperclipApiUrl: runtimeApiUrl,
|
||||
});
|
||||
},
|
||||
setup: async () => async () => {
|
||||
if (previousCandidates === undefined) {
|
||||
delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
} else {
|
||||
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
|
@ -329,26 +328,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
|||
const statePath = path.join(fixtureRoot, "state.json");
|
||||
const fixture = await startSshEnvLabFixture({ statePath });
|
||||
const sshConfig = await buildSshEnvLabFixtureConfig(fixture);
|
||||
const healthServer = createServer((req, res) => {
|
||||
if (req.url === "/api/health") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
healthServer.once("error", reject);
|
||||
healthServer.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = healthServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
|
||||
throw new Error("Expected the test health server to listen on a TCP port.");
|
||||
}
|
||||
const runtimeApiUrl = `http://127.0.0.1:${address.port}`;
|
||||
const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]);
|
||||
const { companyId, environment, runId } = await seedEnvironment({
|
||||
driver: "ssh",
|
||||
name: "Fixture SSH",
|
||||
|
|
@ -372,7 +351,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
|||
username: sshConfig.username,
|
||||
remoteWorkspacePath: sshConfig.remoteWorkspacePath,
|
||||
remoteCwd: sshConfig.remoteWorkspacePath,
|
||||
paperclipApiUrl: runtimeApiUrl,
|
||||
});
|
||||
|
||||
const released = await runtime.releaseRunLeases(runId);
|
||||
|
|
@ -381,12 +359,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
|||
expect(released[0]?.environment.driver).toBe("ssh");
|
||||
expect(released[0]?.lease.status).toBe("released");
|
||||
} finally {
|
||||
if (previousCandidates === undefined) {
|
||||
delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
} else {
|
||||
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates;
|
||||
}
|
||||
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -58,10 +58,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
|||
? input.leaseMetadata.remoteCwd.trim()
|
||||
: DEFAULT_SANDBOX_REMOTE_CWD;
|
||||
const timeoutMs = "timeoutMs" in parsed.config ? parsed.config.timeoutMs : null;
|
||||
const paperclipApiUrl =
|
||||
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
|
||||
? input.leaseMetadata.paperclipApiUrl.trim()
|
||||
: null;
|
||||
const shellCommand =
|
||||
input.leaseMetadata?.shellCommand === "bash" || input.leaseMetadata?.shellCommand === "sh"
|
||||
? input.leaseMetadata.shellCommand
|
||||
|
|
@ -75,8 +71,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
|||
remoteCwd,
|
||||
environmentId: input.environment.id ?? null,
|
||||
leaseId: input.leaseId ?? null,
|
||||
paperclipApiUrl,
|
||||
paperclipTransport: paperclipApiUrl ? "direct" : "bridge",
|
||||
timeoutMs,
|
||||
runner: input.environmentRuntime && input.lease
|
||||
? {
|
||||
|
|
@ -143,10 +137,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
|||
environmentId: input.environment.id ?? null,
|
||||
leaseId: input.leaseId ?? null,
|
||||
remoteCwd,
|
||||
paperclipApiUrl:
|
||||
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
|
||||
? input.leaseMetadata.paperclipApiUrl.trim()
|
||||
: null,
|
||||
spec: {
|
||||
host: parsed.config.host,
|
||||
port: parsed.config.port,
|
||||
|
|
@ -156,10 +146,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
|||
knownHosts: parsed.config.knownHosts,
|
||||
strictHostKeyChecking: parsed.config.strictHostKeyChecking,
|
||||
remoteCwd,
|
||||
paperclipApiUrl:
|
||||
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
|
||||
? input.leaseMetadata.paperclipApiUrl.trim()
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type {
|
|||
PluginEnvironmentLease,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { ensureSshWorkspaceReady, findReachablePaperclipApiUrlOverSsh } from "@paperclipai/adapter-utils/ssh";
|
||||
import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh";
|
||||
import { environmentService } from "./environments.js";
|
||||
import {
|
||||
parseEnvironmentDriverConfig,
|
||||
|
|
@ -227,27 +227,6 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
|||
}
|
||||
|
||||
const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config);
|
||||
const candidateUrls = (() => {
|
||||
const raw = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const paperclipApiUrl = await findReachablePaperclipApiUrlOverSsh({
|
||||
config: parsed.config,
|
||||
candidates: candidateUrls,
|
||||
});
|
||||
if (!paperclipApiUrl) {
|
||||
throw new Error(
|
||||
`SSH environment ${parsed.config.username}@${parsed.config.host} could not reach any Paperclip API candidates.`,
|
||||
);
|
||||
}
|
||||
return await environmentsSvc.acquireLease({
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environment.id,
|
||||
|
|
@ -265,7 +244,6 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
|||
username: parsed.config.username,
|
||||
remoteWorkspacePath: parsed.config.remoteWorkspacePath,
|
||||
remoteCwd,
|
||||
paperclipApiUrl,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue