Files
PaperClipAI/packages/adapters/cursor-local/src/server/remote-command.ts
T
Devin FoleyandPaperclip 0fe39a2d5c fix(cursor-local): resolve sandbox agent installs from cursor bin (#5686)
> _Stacked on top of #5685 (Harden remote sandbox runtime). Diff against
master includes commits from earlier PRs in the stack — review focuses
on the new commit only._

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The cursor-local adapter wraps the Cursor Agent CLI so a Paperclip
workflow can drive it inside a sandbox
> - When the adapter runs in a remote sandbox, the Cursor Agent CLI
installs under `$HOME/.local/bin/cursor-agent` (or wherever
`$XDG_BIN_HOME` points), not on the global PATH
> - The existing post-install resolution assumed `cursor-agent` would
resolve via the sandbox's login shell PATH after `npm install -g`, which
fails on sandboxes where the install lands in a user-prefixed directory
that isn't on PATH at probe time
> - This pull request resolves the agent CLI from the cursor binary's
own directory (`dirname "$(command -v cursor)"`) so the install probe
and execute path agree on a real binary location
> - The benefit is that cursor-local works correctly on any sandbox
provider where `npm install` lands in a user-prefixed directory

## What Changed

- `packages/adapters/cursor-local/src/server/remote-command.ts`: resolve
the cursor-agent binary from the cursor bin directory after install,
instead of relying on PATH.
- `packages/adapters/cursor-local/src/server/test.ts`: corresponding
probe tweak.
- `packages/adapters/cursor-local/src/server/test.test.ts` (new) +
`remote-command.test.ts`: focused coverage that exercises the install +
resolve path against a sandbox runner that places the binary in a
user-prefixed directory.

## Verification

- `pnpm exec vitest run --no-coverage
packages/adapters/cursor-local/src/server/test.test.ts
packages/adapters/cursor-local/src/server/remote-command.test.ts
packages/adapters/cursor-local/src/server/execute.test.ts`

All passing locally.

## Risks

- Local cursor-local runs are unaffected — the resolution change only
kicks in for the sandbox install path.
- Low risk; isolated to one adapter.

## Model Used

- Provider: Anthropic
- Model: Claude Opus 4.7 (1M context)
- Capabilities used: tool use (Read/Edit/Bash), no code execution beyond
local repo commands

## 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, no UI change
- [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

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-11 00:41:20 -07:00

239 lines
8.2 KiB
TypeScript

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"]);
// `.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() ?? "";
}
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;
}
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 [];
return basename === "cursor-agent"
? ["cursor-agent", "agent"]
: ["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;
};
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>;
remoteSystemHomeDirHint?: string | null;
timeoutSec: number;
graceSec: number;
}): Promise<SandboxCursorRuntimeInfo> {
const preferredBasenames =
!hasPathSeparator(input.command)
? preferredSandboxCommandBasenames(input.command)
: [];
const hintedRemoteSystemHomeDir = input.remoteSystemHomeDirHint?.trim() || null;
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,
[
hintedRemoteSystemHomeDir
? `printf ${JSON.stringify(`${homeMarker}%s\\n`)} ${JSON.stringify(hintedRemoteSystemHomeDir)}`
: `printf ${JSON.stringify(`${homeMarker}%s\\n`)} "$HOME"`,
preferredProbeBranches.length > 0
? preferredProbeBranches
.map((probeBranch, index) => {
const branchKeyword = index === 0 ? "if" : "elif";
return `${branchKeyword} ${probeBranch}; then :`;
})
.join("; ") + "; 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>;
remoteSystemHomeDirHint?: string | null;
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,
remoteSystemHomeDirHint: input.remoteSystemHomeDirHint,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
});
const remoteSystemHomeDir =
runtimeInfo.remoteSystemHomeDir ?? input.remoteSystemHomeDirHint?.trim() ?? null;
if (!remoteSystemHomeDir) {
return {
command: input.command,
env: input.env,
remoteSystemHomeDir: null,
addedPathEntry: null,
preferredCommandPath: null,
};
}
const sandboxPathEntries = candidateSandboxPathEntries(remoteSystemHomeDir);
const runtimeEnv = ensurePathInEnv(input.env);
const currentPath = runtimeEnv.PATH ?? runtimeEnv.Path ?? "";
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,
preferredCommandPath: null,
};
}
return {
command: runtimeInfo.preferredCommandPath,
env,
remoteSystemHomeDir,
addedPathEntry,
preferredCommandPath: runtimeInfo.preferredCommandPath,
};
}