From f9cf1d2f6a28593c977b5151774a747718689b26 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 29 Apr 2026 16:12:06 -0700 Subject: [PATCH] Add cursor sandbox support and fix SSH workspace sync (#4803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- packages/adapter-utils/src/ssh.ts | 4 +- .../adapters/claude-local/src/server/test.ts | 2 +- .../adapters/codex-local/src/server/test.ts | 2 +- packages/adapters/cursor-local/src/index.ts | 1 + .../cursor-local/src/server/execute.ts | 29 ++- .../cursor-local/src/server/remote-command.ts | 160 +++++++++++++++ .../adapters/cursor-local/src/server/test.ts | 29 +-- .../adapters/gemini-local/src/server/test.ts | 2 +- .../opencode-local/src/server/test.ts | 2 +- packages/adapters/pi-local/src/server/test.ts | 6 +- .../cursor-local-adapter-environment.test.ts | 107 ++++++++++ .../__tests__/cursor-local-execute.test.ts | 186 ++++++++++++++++++ 12 files changed, 507 insertions(+), 23 deletions(-) create mode 100644 packages/adapters/cursor-local/src/server/remote-command.ts diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 461d36c1..2e41829c 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -476,8 +476,8 @@ async function importGitWorkspaceToSsh(input: { `if [ ! -d ${shellQuote(path.posix.join(input.remoteDir, ".git"))} ]; then git init ${shellQuote(input.remoteDir)} >/dev/null; fi`, `git -C ${shellQuote(input.remoteDir)} fetch --force "$tmp_bundle" '${tempRef}:${tempRef}' >/dev/null`, input.snapshot.branchName - ? `git -C ${shellQuote(input.remoteDir)} checkout -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null` - : `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`, + ? `git -C ${shellQuote(input.remoteDir)} checkout --force -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null` + : `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --force --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`, `git -C ${shellQuote(input.remoteDir)} reset --hard ${shellQuote(input.snapshot.headCommit)} >/dev/null`, `git -C ${shellQuote(input.remoteDir)} clean -fdx -e .paperclip-runtime >/dev/null`, ].join("\n"); diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index 17481965..ba736c8a 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -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)}`; diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 50f1a149..50ff56d5 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -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)}`; diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index 5845fba8..ec26ff66 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -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. `; diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 5056b222..f3cf7a39 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -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 0; - const env: Record = { ...buildPaperclipEnv(agent) }; + let env: Record = { ...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 typeof entry[1] === "string", @@ -314,8 +331,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; @@ -422,6 +437,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { notes.push( diff --git a/packages/adapters/cursor-local/src/server/remote-command.ts b/packages/adapters/cursor-local/src/server/remote-command.ts new file mode 100644 index 00000000..1edd6df0 --- /dev/null +++ b/packages/adapters/cursor-local/src/server/remote-command.ts @@ -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; + timeoutSec: number; + graceSec: number; +}): Promise { + 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; + 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; + timeoutSec: number; + graceSec: number; +}): Promise { + 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, + }; +} diff --git a/packages/adapters/cursor-local/src/server/test.ts b/packages/adapters/cursor-local/src/server/test.ts index bdddf34c..0ba5ee67 100644 --- a/packages/adapters/cursor-local/src/server/test.ts +++ b/packages/adapters/cursor-local/src/server/test.ts @@ -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 { 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 = {}; + let env: Record = {}; 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(); diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts index 8d87a8a8..ec9ef49a 100644 --- a/packages/adapters/gemini-local/src/server/test.ts +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -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)}`; diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 24c4981e..f72054dd 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -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)}`; diff --git a/packages/adapters/pi-local/src/server/test.ts b/packages/adapters/pi-local/src/server/test.ts index 6baf99bf..25ef6d3e 100644 --- a/packages/adapters/pi-local/src/server/test.ts +++ b/packages/adapters/pi-local/src/server/test.ts @@ -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)}`; diff --git a/server/src/__tests__/cursor-local-adapter-environment.test.ts b/server/src/__tests__/cursor-local-adapter-environment.test.ts index c873d34e..306d5237 100644 --- a/server/src/__tests__/cursor-local-adapter-environment.test.ts +++ b/server/src/__tests__/cursor-local-adapter-environment.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; import { testEnvironment } from "@paperclipai/adapter-cursor-local/server"; async function writeFakeAgentCommand(binDir: string, argsCapturePath: string): Promise { @@ -27,6 +28,61 @@ console.log(JSON.stringify({ return commandPath; } +async function writeFakeCursorAgentCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +const outPath = process.env.PAPERCLIP_TEST_ARGS_PATH; +if (outPath) { + fs.writeFileSync(outPath, JSON.stringify({ + command: process.argv[1], + argv: process.argv.slice(2), + path: process.env.PATH || "", + }), "utf8"); +} +console.log(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "output_text", text: "hello" }] }, +})); +console.log(JSON.stringify({ + type: "result", + subtype: "success", + result: "hello", +})); +`; + await fs.mkdir(path.dirname(commandPath), { recursive: true }); + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +function createLocalSandboxRunner() { + let counter = 0; + return { + execute: async (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + stdin?: string; + timeoutMs?: number; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + }) => { + counter += 1; + return await runChildProcess(`cursor-sandbox-env-${counter}`, input.command, input.args ?? [], { + cwd: input.cwd ?? process.cwd(), + env: input.env ?? {}, + stdin: input.stdin, + timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)), + graceSec: 5, + onLog: input.onLog ?? (async () => {}), + onSpawn: input.onSpawn + ? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt }) + : undefined, + }); + }, + }; +} + describe("cursor environment diagnostics", () => { beforeEach(() => { vi.stubEnv("CURSOR_API_KEY", ""); @@ -124,6 +180,57 @@ describe("cursor environment diagnostics", () => { await fs.rm(root, { recursive: true, force: true }); }); + it("prefers ~/.local/bin/cursor-agent for remote sandbox probes when using the default command", async () => { + const root = path.join( + os.tmpdir(), + `paperclip-cursor-sandbox-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const homeDir = path.join(root, "home"); + const remoteCwd = path.join(root, "workspace"); + const argsCapturePath = path.join(root, "args.json"); + const cursorAgentPath = path.join(homeDir, ".local", "bin", "cursor-agent"); + await fs.mkdir(remoteCwd, { recursive: true }); + await writeFakeCursorAgentCommand(cursorAgentPath); + + const previousHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "cursor", + executionTarget: { + kind: "remote", + transport: "sandbox", + remoteCwd, + runner: createLocalSandboxRunner(), + timeoutMs: 30_000, + }, + config: { + command: "agent", + cwd: remoteCwd, + env: { + CURSOR_API_KEY: "test-key", + PAPERCLIP_TEST_ARGS_PATH: argsCapturePath, + }, + }, + }); + + expect(result.status).toBe("pass"); + const capture = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as { + command: string; + argv: string[]; + path: string; + }; + expect(capture.command).toBe(cursorAgentPath); + expect(capture.path.split(":")[0]).toBe(path.join(homeDir, ".local", "bin")); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("emits cursor_native_auth_present when cli-config.json has authInfo and CURSOR_API_KEY is unset", async () => { const root = path.join( os.tmpdir(), diff --git a/server/src/__tests__/cursor-local-execute.test.ts b/server/src/__tests__/cursor-local-execute.test.ts index 97839897..9f8b49ca 100644 --- a/server/src/__tests__/cursor-local-execute.test.ts +++ b/server/src/__tests__/cursor-local-execute.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; import { execute } from "@paperclipai/adapter-cursor-local/server"; async function writeFakeCursorCommand(commandPath: string): Promise { @@ -40,6 +41,68 @@ console.log(JSON.stringify({ await fs.chmod(commandPath, 0o755); } +async function writeFakeSandboxCursorAgent(commandPath: string, capturePath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); + +const payload = { + command: process.argv[1], + argv: process.argv.slice(2), + prompt: fs.readFileSync(0, "utf8"), + path: process.env.PATH || "", +}; +fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(payload), "utf8"); +console.log(JSON.stringify({ + type: "system", + subtype: "init", + session_id: "cursor-session-remote-1", + model: "auto", +})); +console.log(JSON.stringify({ + type: "assistant", + message: { content: [{ type: "output_text", text: "hello" }] }, +})); +console.log(JSON.stringify({ + type: "result", + subtype: "success", + session_id: "cursor-session-remote-1", + result: "ok", +})); +`; + await fs.mkdir(path.dirname(commandPath), { recursive: true }); + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +function createLocalSandboxRunner() { + let counter = 0; + return { + execute: async (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + stdin?: string; + timeoutMs?: number; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + }) => { + counter += 1; + return await runChildProcess(`cursor-sandbox-execute-${counter}`, input.command, input.args ?? [], { + cwd: input.cwd ?? process.cwd(), + env: input.env ?? {}, + stdin: input.stdin, + timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)), + graceSec: 5, + onLog: input.onLog ?? (async () => {}), + onSpawn: input.onSpawn + ? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt }) + : undefined, + }); + }, + }; +} + type CapturePayload = { argv: string[]; prompt: string; @@ -259,4 +322,127 @@ describe("cursor execute", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + it("prefers ~/.local/bin/cursor-agent for remote sandbox execution when using the default command", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-sandbox-execute-")); + const homeDir = path.join(root, "home"); + const workspace = path.join(root, "workspace"); + const remoteWorkspace = path.join(root, "remote-workspace"); + const capturePath = path.join(root, "capture.json"); + const cursorAgentPath = path.join(homeDir, ".local", "bin", "cursor-agent"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(remoteWorkspace, { recursive: true }); + await writeFakeSandboxCursorAgent(cursorAgentPath, capturePath); + + const previousHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + const result = await execute({ + runId: "run-sandbox-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Cursor Coder", + adapterType: "cursor", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + remoteCwd: remoteWorkspace, + runner: createLocalSandboxRunner(), + timeoutMs: 30_000, + }, + config: { + command: "agent", + cwd: workspace, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as { + command: string; + argv: string[]; + prompt: string; + path: string; + }; + expect(capture.command).toBe(cursorAgentPath); + expect(capture.path.split(":")[0]).toBe(path.join(homeDir, ".local", "bin")); + expect(capture.prompt).toContain("Follow the paperclip heartbeat."); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("keeps explicit command overrides for remote sandbox execution", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-sandbox-explicit-")); + const homeDir = path.join(root, "home"); + const workspace = path.join(root, "workspace"); + const remoteWorkspace = path.join(root, "remote-workspace"); + const capturePath = path.join(root, "capture.json"); + const cursorAgentPath = path.join(homeDir, ".local", "bin", "cursor-agent"); + const customCommandPath = path.join(root, "bin", "custom-cursor"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(remoteWorkspace, { recursive: true }); + await writeFakeSandboxCursorAgent(cursorAgentPath, path.join(root, "unused.json")); + await writeFakeSandboxCursorAgent(customCommandPath, capturePath); + + const previousHome = process.env.HOME; + process.env.HOME = homeDir; + + try { + const result = await execute({ + runId: "run-sandbox-2", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Cursor Coder", + adapterType: "cursor", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + remoteCwd: remoteWorkspace, + runner: createLocalSandboxRunner(), + timeoutMs: 30_000, + }, + config: { + command: customCommandPath, + cwd: workspace, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as { command: string }; + expect(capture.command).toBe(customCommandPath); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); });