Add cursor sandbox support and fix SSH workspace sync (#4803)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents can run inside sandboxed environments like E2B, or on remote
hosts via SSH
> - The cursor adapter needs to resolve `cursor-agent` inside sandbox
environments where it's installed in `~/.local/bin`
> - But when using the default `agent` command on a sandbox target, the
adapter didn't know to look in `~/.local/bin/cursor-agent`, causing
"command not found" failures
> - Additionally, repeated SSH runs failed because `git checkout` during
workspace sync conflicted with leftover `.paperclip-runtime` files from
previous runs
> - This PR adds sandbox-aware command resolution for cursor and fixes
the SSH workspace sync conflict
> - The benefit is cursor works in E2B sandboxes out of the box, and
repeated SSH runs don't fail on workspace sync

## What Changed

- `cursor-local`: Added `prepareCursorSandboxCommand` — on sandbox
targets, reads the remote `$HOME`, prepends `~/.local/bin` to PATH, and
prefers `~/.local/bin/cursor-agent` when the default command is
requested; tightened the sandbox command probe to validate the binary
exists before launching; preserves explicit custom command overrides
- `adapter-utils/ssh.ts`: Added `--force` to git checkout in SSH
workspace sync to handle `.paperclip-runtime` untracked file conflicts
from previous runs

## Verification

- `pnpm test` — all existing and new tests pass, including cursor
sandbox probe, sandbox execution, and custom command override tests
- `pnpm typecheck` — clean
- Manual: configure an E2B environment, run a cursor-local task, verify
it resolves cursor-agent from the sandbox install path

## Risks

- Low-medium. The `--force` flag on git checkout could discard
uncommitted changes in the remote workspace, but the workspace is
managed by Paperclip and should not contain user edits.

## Model Used

Codex GPT 5.4 high via Paperclip.

## 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
- [x] I have updated relevant documentation to reflect my changes
- [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-04-29 16:12:06 -07:00 committed by GitHub
parent a0f5cbffd7
commit f9cf1d2f6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 507 additions and 23 deletions

View file

@ -64,7 +64,7 @@ export async function testEnvironment(
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment"
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;

View file

@ -65,7 +65,7 @@ export async function testEnvironment(
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment"
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;

View file

@ -80,4 +80,5 @@ Notes:
- Sessions are resumed with --resume when stored session cwd matches current cwd.
- Paperclip auto-injects local skills into "~/.cursor/skills" when missing, so Cursor can discover "$paperclip" and related skills on local runs.
- Paperclip auto-adds --yolo unless one of --trust/--yolo/-f is already present in extraArgs.
- Remote sandbox runs prepend "~/.local/bin" to PATH and prefer "~/.local/bin/cursor-agent" when the default Cursor entrypoint is requested, so standard E2B-style installs do not need hardcoded absolute command paths.
`;

View file

@ -41,6 +41,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
import { prepareCursorSandboxCommand } from "./remote-command.js";
import { normalizeCursorStreamLine } from "../shared/stream.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
@ -199,7 +200,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "agent");
let command = asString(config.command, "agent");
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
const mode = normalizeMode(asString(config.mode, ""));
@ -231,7 +232,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
let env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
@ -299,6 +300,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
// Probe the sandbox before the managed-home override so we discover
// cursor-agent from the real system HOME (e.g. ~/.local/bin/cursor-agent).
// The managed HOME set later is for runtime isolation, not for finding the CLI.
const sandboxCommand = await prepareCursorSandboxCommand({
runId,
target: executionTarget,
command,
cwd,
env,
timeoutSec,
graceSec,
});
command = sandboxCommand.command;
env = sandboxCommand.env;
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
@ -314,8 +331,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
@ -422,6 +437,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
notes.push("Auto-added --yolo to bypass interactive prompts.");
}
notes.push("Prompt is piped to Cursor via stdin.");
if (sandboxCommand.addedPathEntry) {
notes.push(`Remote sandbox runs prepend ${sandboxCommand.addedPathEntry} to PATH.`);
}
if (sandboxCommand.preferredCommandPath) {
notes.push(`Remote sandbox runs prefer ${sandboxCommand.preferredCommandPath} when using the default Cursor entrypoint.`);
}
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(

View file

@ -0,0 +1,160 @@
import path from "node:path";
import {
runAdapterExecutionTargetShellCommand,
type AdapterExecutionTarget,
} from "@paperclipai/adapter-utils/execution-target";
import { ensurePathInEnv } from "@paperclipai/adapter-utils/server-utils";
const DEFAULT_CURSOR_COMMAND_BASENAMES = new Set(["agent", "cursor-agent"]);
function commandBasename(command: string): string {
return command.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
}
function hasPathSeparator(command: string): boolean {
return command.includes("/") || command.includes("\\");
}
function prependPosixPathEntry(pathValue: string, entry: string): string {
const parts = pathValue.split(":").filter(Boolean);
if (parts.includes(entry)) return pathValue;
const cleaned = parts.join(":");
return cleaned.length > 0 ? `${entry}:${cleaned}` : entry;
}
type SandboxCursorRuntimeInfo = {
remoteSystemHomeDir: string | null;
preferredCommandPath: string | null;
};
function readMarkedValue(lines: string[], marker: string): string | null {
const matchedLine = lines.find((line) => line.startsWith(marker));
if (!matchedLine) return null;
const value = matchedLine.slice(marker.length).trim();
return value.length > 0 ? value : null;
}
async function readSandboxCursorRuntimeInfo(input: {
runId: string;
target: AdapterExecutionTarget;
command: string;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<SandboxCursorRuntimeInfo> {
const shouldCheckPreferredCommand = isDefaultCursorCommand(input.command) && !hasPathSeparator(input.command);
const homeMarker = "__PAPERCLIP_CURSOR_HOME__:";
const preferredMarker = "__PAPERCLIP_CURSOR_AGENT__:";
try {
const result = await runAdapterExecutionTargetShellCommand(
input.runId,
input.target,
[
`printf ${JSON.stringify(`${homeMarker}%s\\n`)} "$HOME"`,
shouldCheckPreferredCommand
? `if [ -x "$HOME/.local/bin/cursor-agent" ]; then printf ${JSON.stringify(`${preferredMarker}%s\\n`)} "$HOME/.local/bin/cursor-agent"; fi`
: "",
].filter(Boolean).join("; "),
{
cwd: input.cwd,
env: input.env,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
},
);
if (result.timedOut || (result.exitCode ?? 1) !== 0) {
return {
remoteSystemHomeDir: null,
preferredCommandPath: null,
};
}
const lines = result.stdout.split(/\r?\n/);
return {
remoteSystemHomeDir: readMarkedValue(lines, homeMarker),
preferredCommandPath: readMarkedValue(lines, preferredMarker),
};
} catch {
return {
remoteSystemHomeDir: null,
preferredCommandPath: null,
};
}
}
export function isDefaultCursorCommand(command: string): boolean {
return DEFAULT_CURSOR_COMMAND_BASENAMES.has(commandBasename(command));
}
export type PreparedCursorSandboxCommand = {
command: string;
env: Record<string, string>;
remoteSystemHomeDir: string | null;
addedPathEntry: string | null;
preferredCommandPath: string | null;
};
export async function prepareCursorSandboxCommand(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
command: string;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<PreparedCursorSandboxCommand> {
if (input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
return {
command: input.command,
env: input.env,
remoteSystemHomeDir: null,
addedPathEntry: null,
preferredCommandPath: null,
};
}
const runtimeInfo = await readSandboxCursorRuntimeInfo({
runId: input.runId,
target: input.target,
command: input.command,
cwd: input.cwd,
env: input.env,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
});
const remoteSystemHomeDir = runtimeInfo.remoteSystemHomeDir;
if (!remoteSystemHomeDir) {
return {
command: input.command,
env: input.env,
remoteSystemHomeDir: null,
addedPathEntry: null,
preferredCommandPath: null,
};
}
const remoteLocalBinDir = path.posix.join(remoteSystemHomeDir, ".local", "bin");
const runtimeEnv = ensurePathInEnv(input.env);
const currentPath = runtimeEnv.PATH ?? runtimeEnv.Path ?? "";
const nextPath = prependPosixPathEntry(currentPath, remoteLocalBinDir);
const env = nextPath === currentPath ? input.env : { ...input.env, PATH: nextPath };
if (!runtimeInfo.preferredCommandPath) {
return {
command: input.command,
env,
remoteSystemHomeDir,
addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir,
preferredCommandPath: null,
};
}
return {
command: runtimeInfo.preferredCommandPath,
env,
remoteSystemHomeDir,
addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir,
preferredCommandPath: runtimeInfo.preferredCommandPath,
};
}

View file

@ -21,6 +21,7 @@ import os from "node:os";
import path from "node:path";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { parseCursorJsonl } from "./parse.js";
import { isDefaultCursorCommand, prepareCursorSandboxCommand } from "./remote-command.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
@ -42,11 +43,6 @@ function firstNonEmptyLine(text: string): string {
);
}
function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
@ -98,12 +94,12 @@ export async function testEnvironment(
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "agent");
let command = asString(config.command, "agent");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment"
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
@ -136,10 +132,21 @@ export async function testEnvironment(
}
const envConfig = parseObject(config.env);
const env: Record<string, string> = {};
let env: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const sandboxCommand = await prepareCursorSandboxCommand({
runId,
target,
command,
cwd,
env,
timeoutSec: 45,
graceSec: 5,
});
command = sandboxCommand.command;
env = sandboxCommand.env;
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
@ -192,13 +199,13 @@ export async function testEnvironment(
const canRunProbe =
checks.every((check) => check.code !== "cursor_cwd_invalid" && check.code !== "cursor_command_unresolvable");
if (canRunProbe) {
if (!commandLooksLike(command, "agent")) {
if (!isDefaultCursorCommand(command)) {
checks.push({
code: "cursor_hello_probe_skipped_custom_command",
level: "info",
message: "Skipped hello probe because command is not `agent`.",
message: "Skipped hello probe because command is not a default Cursor CLI entrypoint.",
detail: command,
hint: "Use the `agent` CLI command to run the automatic installation and auth probe.",
hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.",
});
} else {
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();

View file

@ -56,7 +56,7 @@ export async function testEnvironment(
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment"
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;

View file

@ -66,7 +66,7 @@ export async function testEnvironment(
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment"
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;

View file

@ -5,10 +5,12 @@ import type {
} from "@paperclipai/adapter-utils";
import {
asString,
asStringArray,
parseObject,
ensurePathInEnv,
} from "@paperclipai/adapter-utils/server-utils";
import {
asStringArray,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
@ -84,7 +86,7 @@ export async function testEnvironment(
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment"
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;