diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index 09d29db4..f93928d8 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -104,5 +104,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 the installed "~/.local/bin/agent" or "~/.local/bin/cursor-agent" entrypoint when the default Cursor command is requested, so standard E2B-style installs do not need hardcoded absolute command paths. +- Remote sandbox runs prepend "~/.cursor/bin" and "~/.local/bin" to PATH and prefer the installed absolute entrypoint from one of those directories when the default Cursor command is requested, so installer-managed sandbox leases do not need hardcoded command paths. `; diff --git a/packages/adapters/cursor-local/src/server/execute.test.ts b/packages/adapters/cursor-local/src/server/execute.test.ts index d83101ab..d0d8ce4b 100644 --- a/packages/adapters/cursor-local/src/server/execute.test.ts +++ b/packages/adapters/cursor-local/src/server/execute.test.ts @@ -203,7 +203,7 @@ describe("cursor execute", () => { const runtimePath = await fs.readFile(path.join(captureDir, "path.txt"), "utf8"); const prompt = await fs.readFile(path.join(captureDir, "prompt.txt"), "utf8"); expect(command).toBe(agentPath); - expect(runtimePath.split(path.delimiter)[0]).toBe(path.join(homeDir, ".local", "bin")); + expect(runtimePath.split(path.delimiter)).toContain(path.join(homeDir, ".local", "bin")); expect(prompt).toContain("Follow the paperclip heartbeat."); } finally { if (previousHome === undefined) delete process.env.HOME; diff --git a/packages/adapters/cursor-local/src/server/remote-command.test.ts b/packages/adapters/cursor-local/src/server/remote-command.test.ts index 82b0bff1..f3885fac 100644 --- a/packages/adapters/cursor-local/src/server/remote-command.test.ts +++ b/packages/adapters/cursor-local/src/server/remote-command.test.ts @@ -44,6 +44,52 @@ printf '%s\\n' ok } describe("prepareCursorSandboxCommand", () => { + it("prefers the Cursor installer bin directory when the default agent entrypoint is installed there", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-command-cursor-bin-")); + const systemHomeDir = path.join(root, "system-home"); + const managedHomeDir = path.join(root, "managed-home"); + const remoteWorkspace = path.join(root, "workspace"); + const cursorAgentPath = path.join(systemHomeDir, ".cursor", "bin", "agent"); + await fs.mkdir(remoteWorkspace, { recursive: true }); + await writeFakeAgent(cursorAgentPath); + + try { + const result = await prepareCursorSandboxCommand({ + runId: "run-remote-command-cursor-bin", + target: { + kind: "remote", + transport: "sandbox", + shellCommand: "bash", + remoteCwd: remoteWorkspace, + runner: createLocalSandboxRunner(), + timeoutMs: 30_000, + }, + command: "agent", + cwd: remoteWorkspace, + env: { + HOME: managedHomeDir, + PATH: "/usr/bin:/bin", + }, + remoteSystemHomeDirHint: systemHomeDir, + timeoutSec: 30, + graceSec: 5, + }); + + expect(result.command).toBe(cursorAgentPath); + expect(result.preferredCommandPath).toBe(cursorAgentPath); + expect(result.remoteSystemHomeDir).toBe(systemHomeDir); + expect(result.addedPathEntry).toBe(path.join(systemHomeDir, ".local", "bin")); + expect(result.env.PATH?.split(":").slice(0, 2)).toEqual([ + path.join(systemHomeDir, ".local", "bin"), + path.join(systemHomeDir, ".cursor", "bin"), + ]); + expect(result.env.PATH).not.toContain(path.join(managedHomeDir, ".cursor", "bin")); + expect(result.env.PATH).not.toContain(path.join(managedHomeDir, ".local", "bin")); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("keeps probing the original sandbox home after managed HOME overrides", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-command-")); const systemHomeDir = path.join(root, "system-home"); @@ -79,7 +125,10 @@ describe("prepareCursorSandboxCommand", () => { expect(result.preferredCommandPath).toBe(systemAgentPath); expect(result.remoteSystemHomeDir).toBe(systemHomeDir); expect(result.addedPathEntry).toBe(path.join(systemHomeDir, ".local", "bin")); - expect(result.env.PATH?.split(":")[0]).toBe(path.join(systemHomeDir, ".local", "bin")); + expect(result.env.PATH?.split(":").slice(0, 2)).toEqual([ + path.join(systemHomeDir, ".local", "bin"), + path.join(systemHomeDir, ".cursor", "bin"), + ]); expect(result.env.PATH).not.toContain(path.join(managedHomeDir, ".local", "bin")); } finally { await fs.rm(root, { recursive: true, force: true }); diff --git a/packages/adapters/cursor-local/src/server/remote-command.ts b/packages/adapters/cursor-local/src/server/remote-command.ts index b99d161f..95a958bc 100644 --- a/packages/adapters/cursor-local/src/server/remote-command.ts +++ b/packages/adapters/cursor-local/src/server/remote-command.ts @@ -6,6 +6,14 @@ import { import { ensurePathInEnv } from "@paperclipai/adapter-utils/server-utils"; const DEFAULT_CURSOR_COMMAND_BASENAMES = new Set(["agent", "cursor-agent"]); +// `.local/bin` first because the official Cursor Agent installer drops the +// binary there; `.cursor/bin` is a secondary location used by some older +// installs. The order also defines the prepended `PATH` order surfaced to the +// adapter. +const CURSOR_SANDBOX_BIN_DIRS = [ + path.posix.join(".local", "bin"), + path.posix.join(".cursor", "bin"), +]; function commandBasename(command: string): string { return command.trim().split(/[\\/]/).pop()?.toLowerCase() ?? ""; @@ -22,6 +30,10 @@ function prependPosixPathEntry(pathValue: string, entry: string): string { return cleaned.length > 0 ? `${entry}:${cleaned}` : entry; } +function prependPosixPathEntries(pathValue: string, entries: string[]): string { + return entries.reduceRight((value, entry) => prependPosixPathEntry(value, entry), pathValue); +} + function preferredSandboxCommandBasenames(command: string): string[] { const basename = commandBasename(command); if (!DEFAULT_CURSOR_COMMAND_BASENAMES.has(basename)) return []; @@ -30,6 +42,20 @@ function preferredSandboxCommandBasenames(command: string): string[] { : ["agent", "cursor-agent"]; } +function candidateSandboxCommandPaths(homeDir: string, basenames: string[]): string[] { + // Iterate dirs first, then basenames within each dir, so directory + // preference (CURSOR_SANDBOX_BIN_DIRS order) wins over basename + // preference. Both basenames inside `.local/bin` are checked before + // falling through to `.cursor/bin`. + return CURSOR_SANDBOX_BIN_DIRS.flatMap((relativeDir) => + basenames.map((basename) => path.posix.join(homeDir, relativeDir, basename)) + ); +} + +function candidateSandboxPathEntries(homeDir: string): string[] { + return CURSOR_SANDBOX_BIN_DIRS.map((relativeDir) => path.posix.join(homeDir, relativeDir)); +} + type SandboxCursorRuntimeInfo = { remoteSystemHomeDir: string | null; preferredCommandPath: string | null; @@ -60,6 +86,34 @@ async function readSandboxCursorRuntimeInfo(input: { const homeMarker = "__PAPERCLIP_CURSOR_HOME__:"; const preferredMarker = "__PAPERCLIP_CURSOR_AGENT__:"; try { + // When the caller has already resolved the remote `$HOME`, probe absolute + // paths so the shell doesn't depend on its own environment to interpret + // `$HOME`. Without a hint we still probe `$HOME/...` literally — this is + // how the sandbox finds a user-prefixed install before falling back to a + // PATH lookup. Skipping the `$HOME` probes here was the regression behind + // server tests `cursor-local-adapter-environment.test.ts` and + // `cursor-local-execute.test.ts` failing on a host whose own `agent` + // command resolves via PATH. + const fixedCandidatePaths = + preferredBasenames.length > 0 + ? hintedRemoteSystemHomeDir + ? candidateSandboxCommandPaths(hintedRemoteSystemHomeDir, preferredBasenames) + : preferredBasenames.flatMap((basename) => + CURSOR_SANDBOX_BIN_DIRS.map((relativeDir) => + `$HOME/${relativeDir}/${basename}`, + ), + ) + : []; + const preferredProbeBranches = [ + ...fixedCandidatePaths.map( + (fixedPath) => + `[ -x ${JSON.stringify(fixedPath)} ] && printf ${JSON.stringify(`${preferredMarker}%s\\n`)} ${JSON.stringify(fixedPath)}`, + ), + ...preferredBasenames.map( + (basename) => + `resolved="$(command -v ${JSON.stringify(basename)} 2>/dev/null)" && [ -n "$resolved" ] && printf ${JSON.stringify(`${preferredMarker}%s\\n`)} "$resolved"`, + ), + ]; const result = await runAdapterExecutionTargetShellCommand( input.runId, input.target, @@ -67,21 +121,13 @@ async function readSandboxCursorRuntimeInfo(input: { hintedRemoteSystemHomeDir ? `printf ${JSON.stringify(`${homeMarker}%s\\n`)} ${JSON.stringify(hintedRemoteSystemHomeDir)}` : `printf ${JSON.stringify(`${homeMarker}%s\\n`)} "$HOME"`, - preferredBasenames.length > 0 - ? [ - ...preferredBasenames.map((basename, index) => { - const branch = index === 0 ? "if" : "elif"; - const fixedPath = hintedRemoteSystemHomeDir - ? path.posix.join(hintedRemoteSystemHomeDir, ".local", "bin", basename) - : `$HOME/.local/bin/${basename}`; - return `${branch} [ -x ${JSON.stringify(fixedPath)} ]; then printf ${JSON.stringify(`${preferredMarker}%s\\n`)} ${JSON.stringify(fixedPath)}`; - }), - ...preferredBasenames.map((basename) => { - // Always `elif`: this fallback chain runs after the fixed-path - // checks above and is itself ordered by preferredBasenames. - return `elif resolved="$(command -v ${JSON.stringify(basename)} 2>/dev/null)" && [ -n "$resolved" ]; then printf ${JSON.stringify(`${preferredMarker}%s\\n`)} "$resolved"`; - }), - ].join("; ") + "; fi" + preferredProbeBranches.length > 0 + ? preferredProbeBranches + .map((probeBranch, index) => { + const branchKeyword = index === 0 ? "if" : "elif"; + return `${branchKeyword} ${probeBranch}; then :`; + }) + .join("; ") + "; fi; :" : "", ].filter(Boolean).join("; "), { @@ -165,18 +211,19 @@ export async function prepareCursorSandboxCommand(input: { }; } - const remoteLocalBinDir = path.posix.join(remoteSystemHomeDir, ".local", "bin"); + const sandboxPathEntries = candidateSandboxPathEntries(remoteSystemHomeDir); const runtimeEnv = ensurePathInEnv(input.env); const currentPath = runtimeEnv.PATH ?? runtimeEnv.Path ?? ""; - const nextPath = prependPosixPathEntry(currentPath, remoteLocalBinDir); + const nextPath = prependPosixPathEntries(currentPath, sandboxPathEntries); const env = nextPath === currentPath ? input.env : { ...input.env, PATH: nextPath }; + const addedPathEntry = nextPath === currentPath ? null : sandboxPathEntries[0]; if (!runtimeInfo.preferredCommandPath) { return { command: input.command, env, remoteSystemHomeDir, - addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir, + addedPathEntry, preferredCommandPath: null, }; } @@ -185,7 +232,7 @@ export async function prepareCursorSandboxCommand(input: { command: runtimeInfo.preferredCommandPath, env, remoteSystemHomeDir, - addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir, + addedPathEntry, preferredCommandPath: runtimeInfo.preferredCommandPath, }; } diff --git a/packages/adapters/cursor-local/src/server/test.test.ts b/packages/adapters/cursor-local/src/server/test.test.ts new file mode 100644 index 00000000..72b3f9ed --- /dev/null +++ b/packages/adapters/cursor-local/src/server/test.test.ts @@ -0,0 +1,132 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { runChildProcess } from "@paperclipai/adapter-utils/server-utils"; +import { SANDBOX_INSTALL_COMMAND } from "../index.js"; +import { testEnvironment } from "./test.js"; + +function buildFakeAgentScript(): string { + return `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '%s\\n' 'Cursor Agent 1.2.3' + exit 0 +fi +printf '%s\\n' '{"type":"system","subtype":"init","session_id":"cursor-session-envtest-1","model":"auto"}' +printf '%s\\n' '{"type":"assistant","message":{"content":[{"type":"output_text","text":"hello"}]}}' +printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-session-envtest-1","result":"ok"}' +`; +} + +function buildInstallSimulationCommand(commandPath: string): string { + return [ + `mkdir -p ${JSON.stringify(path.dirname(commandPath))}`, + `cat > ${JSON.stringify(commandPath)} <<'EOF'`, + buildFakeAgentScript(), + "EOF", + `chmod +x ${JSON.stringify(commandPath)}`, + ].join("\n"); +} + +function createSandboxRunner(options: { homeDir: string; installCommandPath: string }) { + let counter = 0; + const installCommands: string[] = []; + const systemPath = "/usr/bin:/bin"; + return { + installCommands, + 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; + const args = [...(input.args ?? [])]; + if (args[1] === SANDBOX_INSTALL_COMMAND) { + installCommands.push(args[1]); + args[1] = buildInstallSimulationCommand(options.installCommandPath); + } + return await runChildProcess(`cursor-envtest-runner-${counter}`, input.command, args, { + cwd: input.cwd ?? process.cwd(), + env: { + ...(input.env ?? {}), + HOME: input.env?.HOME ?? options.homeDir, + PATH: input.env?.PATH ?? systemPath, + }, + 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 testEnvironment", () => { + it("re-resolves the installed agent under ~/.cursor/bin and verifies --version before the hello probe", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-envtest-")); + const homeDir = path.join(root, "home"); + const workspace = path.join(root, "workspace"); + const remoteWorkspace = path.join(root, "remote-workspace"); + const agentPath = path.join(homeDir, ".cursor", "bin", "agent"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(remoteWorkspace, { recursive: true }); + + const runner = createSandboxRunner({ + homeDir, + installCommandPath: agentPath, + }); + + try { + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "cursor", + config: { + command: "agent", + cwd: workspace, + env: { + PATH: "/usr/bin:/bin", + }, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + shellCommand: "bash", + remoteCwd: remoteWorkspace, + runner, + timeoutMs: 30_000, + }, + }); + + expect(result.status).toBe("pass"); + expect(runner.installCommands).toEqual([SANDBOX_INSTALL_COMMAND]); + expect(result.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "cursor_command_resolvable", + level: "info", + message: `Command is executable: ${agentPath}`, + }), + expect.objectContaining({ + code: "cursor_version_probe_passed", + level: "info", + detail: "Cursor Agent 1.2.3", + }), + expect.objectContaining({ + code: "cursor_hello_probe_passed", + level: "info", + }), + ]), + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/adapters/cursor-local/src/server/test.ts b/packages/adapters/cursor-local/src/server/test.ts index 7155d855..cc2c2916 100644 --- a/packages/adapters/cursor-local/src/server/test.ts +++ b/packages/adapters/cursor-local/src/server/test.ts @@ -148,7 +148,6 @@ export async function testEnvironment( }); command = sandboxCommand.command; env = sandboxCommand.env; - const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); const installCheck = await maybeRunSandboxInstallCommand({ runId, target, @@ -158,6 +157,19 @@ export async function testEnvironment( env, }); if (installCheck) checks.push(installCheck); + const finalSandboxCommand = await prepareCursorSandboxCommand({ + runId, + target, + command, + cwd, + env, + remoteSystemHomeDirHint: sandboxCommand.remoteSystemHomeDir, + timeoutSec: 45, + graceSec: 5, + }); + command = finalSandboxCommand.command; + env = finalSandboxCommand.env; + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); try { await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); checks.push({ @@ -218,6 +230,58 @@ export async function testEnvironment( hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.", }); } else { + const versionProbe = await runAdapterExecutionTargetProcess( + runId, + target, + command, + ["--version"], + { + cwd, + env, + timeoutSec: 45, + graceSec: 5, + onLog: async () => {}, + }, + ); + const versionDetail = summarizeProbeDetail(versionProbe.stdout, versionProbe.stderr, null); + if (versionProbe.timedOut) { + checks.push({ + code: "cursor_version_probe_timed_out", + level: "error", + message: "Cursor version probe timed out.", + hint: "Run `agent --version` manually in this working directory to confirm the installed CLI is reachable non-interactively.", + }); + } else if ((versionProbe.exitCode ?? 1) === 0) { + checks.push({ + code: "cursor_version_probe_passed", + level: "info", + message: "Cursor version probe succeeded.", + ...(versionDetail ? { detail: versionDetail } : {}), + }); + } else { + checks.push({ + code: "cursor_version_probe_failed", + level: "error", + message: "Cursor version probe failed.", + ...(versionDetail ? { detail: versionDetail } : {}), + hint: "Run `agent --version` manually in this working directory to confirm the installed CLI is reachable non-interactively.", + }); + } + + const canRunHelloProbe = checks.every( + (check) => + check.code !== "cursor_version_probe_failed" && + check.code !== "cursor_version_probe_timed_out", + ); + if (!canRunHelloProbe) { + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; + } + const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim(); const extraArgs = (() => { const fromExtraArgs = asStringArray(config.extraArgs);