forked from farhoodlabs/paperclip
Stabilize Cursor sandbox runtime resolution (#5446)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The Cursor adapter spawns the Cursor CLI against local, SSH, and sandbox execution targets; on a fresh sandbox lease, it has to resolve where Cursor was installed > - The previous resolver only looked for `~/.local/bin/cursor-agent` even though the official installer (and the adapter's own `SANDBOX_INSTALL_COMMAND`) sometimes lays the binary down as `~/.local/bin/agent`, so a sandbox where the install ran successfully would still fail to find the CLI > - This pull request lets the resolver accept either basename and lets the caller pass an optional `remoteSystemHomeDirHint` so a probe doesn't pay the cost of a remote `printf $HOME` round-trip when the home directory is already known > - The benefit is sandboxed Cursor runs find the binary that the install actually produced, and runtime probes are cheaper when the home dir is already resolved ## What Changed - `packages/adapters/cursor-local/src/server/remote-command.ts`: accept either `agent` or `cursor-agent` as the preferred basename; new optional `remoteSystemHomeDirHint` short-circuits the home-dir probe - `packages/adapters/cursor-local/src/server/execute.ts`: thread the home-dir hint through, prefer the resolved binary path, and shift the effective execution cwd to the per-run managed subdirectory once the runtime is prepared - New `remote-command.test.ts` and `execute.test.ts` cover both basenames, the hint short-circuit, and the cwd shift - `packages/adapters/cursor-local/src/index.ts`: update doc string to reflect the broader resolution - `execute.remote.test.ts` updated to expect the managed-subdirectory cwd shape introduced by the cwd shift ## Verification - `pnpm vitest run --no-coverage --project @paperclipai/adapter-cursor-local` — 6/6 passing - `pnpm typecheck` clean - Manual: a fresh sandbox lease with `npm install -g …`-installed Cursor (binary lands as `~/.local/bin/agent`) now runs cleanly through the adapter ## Risks Low. Resolver is strictly broader (matches a superset of paths); existing setups with `~/.local/bin/cursor-agent` continue to work. The home-dir hint is opt-in; callers that don't pass it get the existing probe behavior. Cursor's effective execution cwd now matches the rest of the adapters (per-run managed subdirectory) — sessions previously rooted at the workspace root will land in the new subdirectory. ## Model Used Claude Opus 4.7 (1M context) ## 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 — new tests cover both basenames + hint short-circuit + cwd shift - [x] If this change affects the UI, I have included before/after screenshots — N/A (no UI) - [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 --- > **Stacked PR.** Sits on top of #5445 (which sits on #5444). Cumulative diff against `master` includes both of those PRs' content; the files touched by *this* PR's commit are listed under "What Changed" above. Will rebase onto `master` and force-push once the prerequisite PRs merge.
This commit is contained in:
@@ -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 "~/.local/bin/cursor-agent" when the default Cursor entrypoint is requested, so standard E2B-style installs do not need hardcoded absolute command paths.
|
||||
- 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.
|
||||
`;
|
||||
|
||||
@@ -103,6 +103,7 @@ describe("cursor remote execution", () => {
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await mkdir(alternateWorkspaceDir, { recursive: true });
|
||||
|
||||
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
|
||||
const result = await execute({
|
||||
runId: "run-1",
|
||||
agent: {
|
||||
@@ -158,21 +159,19 @@ describe("cursor remote execution", () => {
|
||||
|
||||
expect(result.sessionParams).toMatchObject({
|
||||
sessionId: "cursor-session-1",
|
||||
cwd: "/remote/workspace",
|
||||
cwd: managedRemoteWorkspace,
|
||||
remoteExecution: {
|
||||
transport: "ssh",
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteCwd: "/remote/workspace",
|
||||
remoteCwd: managedRemoteWorkspace,
|
||||
},
|
||||
});
|
||||
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
|
||||
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
|
||||
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
|
||||
// Asset sync targets the per-run managed subdirectory even though the
|
||||
// cursor adapter still runs commands from the workspace root.
|
||||
remoteDir: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace/.paperclip-runtime/cursor/skills",
|
||||
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/cursor/skills`,
|
||||
followSymlinks: true,
|
||||
}));
|
||||
expect(runSshCommand).toHaveBeenCalledWith(
|
||||
@@ -184,12 +183,12 @@ describe("cursor remote execution", () => {
|
||||
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
||||
| undefined;
|
||||
expect(call?.[2]).toContain("--workspace");
|
||||
expect(call?.[2]).toContain("/remote/workspace");
|
||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace");
|
||||
expect(call?.[2]).toContain(managedRemoteWorkspace);
|
||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
|
||||
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace",
|
||||
cwd: managedRemoteWorkspace,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
repoRef: "main",
|
||||
},
|
||||
@@ -201,7 +200,7 @@ describe("cursor remote execution", () => {
|
||||
]);
|
||||
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
|
||||
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
||||
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
|
||||
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
|
||||
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
|
||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -212,6 +211,7 @@ describe("cursor remote execution", () => {
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace";
|
||||
await execute({
|
||||
runId: "run-ssh-resume",
|
||||
agent: {
|
||||
@@ -225,13 +225,13 @@ describe("cursor remote execution", () => {
|
||||
sessionId: "session-123",
|
||||
sessionParams: {
|
||||
sessionId: "session-123",
|
||||
cwd: "/remote/workspace",
|
||||
cwd: managedRemoteWorkspace,
|
||||
remoteExecution: {
|
||||
transport: "ssh",
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteCwd: "/remote/workspace",
|
||||
remoteCwd: managedRemoteWorkspace,
|
||||
},
|
||||
},
|
||||
sessionDisplayId: "session-123",
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
type PrepareCursorSandboxCommandInput = {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
command: string;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
remoteSystemHomeDirHint?: string | null;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
};
|
||||
|
||||
type PrepareCursorSandboxCommandResult = {
|
||||
command: string;
|
||||
env: Record<string, string>;
|
||||
remoteSystemHomeDir: string | null;
|
||||
addedPathEntry: string | null;
|
||||
preferredCommandPath: string | null;
|
||||
};
|
||||
|
||||
const {
|
||||
setPrepareCursorSandboxCommand,
|
||||
} = vi.hoisted(() => {
|
||||
const setPrepareCursorSandboxCommand = vi.fn<
|
||||
(input: PrepareCursorSandboxCommandInput) => Promise<PrepareCursorSandboxCommandResult>
|
||||
>();
|
||||
return { setPrepareCursorSandboxCommand };
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
startAdapterExecutionTargetPaperclipBridge: async () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./remote-command.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./remote-command.js")>("./remote-command.js");
|
||||
return {
|
||||
...actual,
|
||||
prepareCursorSandboxCommand: async (input: Parameters<typeof actual.prepareCursorSandboxCommand>[0]) => {
|
||||
return setPrepareCursorSandboxCommand(input);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function buildFakeAgentScript(captureDir: string): string {
|
||||
return `#!/bin/sh
|
||||
cat > ${JSON.stringify(path.join(captureDir, "prompt.txt"))}
|
||||
printf '%s' "$0" > ${JSON.stringify(path.join(captureDir, "command.txt"))}
|
||||
printf '%s' "$PATH" > ${JSON.stringify(path.join(captureDir, "path.txt"))}
|
||||
printf '%s\\n' '{"type":"system","subtype":"init","session_id":"cursor-session-fresh-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-fresh-1","result":"ok"}'
|
||||
`;
|
||||
}
|
||||
|
||||
function buildInstallSimulationCommand(commandPath: string, captureDir: string): string {
|
||||
return [
|
||||
`mkdir -p ${JSON.stringify(path.dirname(commandPath))}`,
|
||||
`mkdir -p ${JSON.stringify(captureDir)}`,
|
||||
`cat > ${JSON.stringify(commandPath)} <<'EOF'`,
|
||||
buildFakeAgentScript(captureDir),
|
||||
"EOF",
|
||||
`chmod +x ${JSON.stringify(commandPath)}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function createFreshLeaseSandboxRunner(options: {
|
||||
homeDir: string;
|
||||
installCommandPath: string;
|
||||
captureDir: string;
|
||||
}) {
|
||||
let counter = 0;
|
||||
const installCommands: string[] = [];
|
||||
const systemPath = [
|
||||
"/usr/local/bin",
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/sbin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin",
|
||||
].join(path.delimiter);
|
||||
|
||||
return {
|
||||
installCommands,
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}) => {
|
||||
counter += 1;
|
||||
const args = [...(input.args ?? [])];
|
||||
if (args[1] === SANDBOX_INSTALL_COMMAND) {
|
||||
installCommands.push(args[1]);
|
||||
args[1] = buildInstallSimulationCommand(options.installCommandPath, options.captureDir);
|
||||
}
|
||||
|
||||
const inheritedPath = input.env?.PATH ?? systemPath;
|
||||
const pathWithLocalBin = `${path.join(options.homeDir, ".local", "bin")}${path.delimiter}${inheritedPath}`;
|
||||
const env = {
|
||||
...(input.env ?? {}),
|
||||
HOME: input.env?.HOME ?? options.homeDir,
|
||||
PATH: pathWithLocalBin,
|
||||
};
|
||||
|
||||
return await runChildProcess(`cursor-fresh-lease-${counter}`, input.command, args, {
|
||||
cwd: input.cwd ?? process.cwd(),
|
||||
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 execute", () => {
|
||||
it("installs the default agent command on a fresh sandbox lease before execution", async () => {
|
||||
setPrepareCursorSandboxCommand.mockReset();
|
||||
setPrepareCursorSandboxCommand.mockImplementation(async (input) => {
|
||||
const actual = await vi.importActual<typeof import("./remote-command.js")>("./remote-command.js");
|
||||
return actual.prepareCursorSandboxCommand(input);
|
||||
});
|
||||
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-fresh-lease-"));
|
||||
const homeDir = path.join(root, "home");
|
||||
const workspace = path.join(root, "workspace");
|
||||
const remoteWorkspace = path.join(root, "remote-workspace");
|
||||
const captureDir = path.join(root, "capture");
|
||||
const agentPath = path.join(homeDir, ".local", "bin", "agent");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.mkdir(remoteWorkspace, { recursive: true });
|
||||
|
||||
const runner = createFreshLeaseSandboxRunner({
|
||||
homeDir,
|
||||
installCommandPath: agentPath,
|
||||
captureDir,
|
||||
});
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-fresh-lease-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,
|
||||
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);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(runner.installCommands).toEqual([SANDBOX_INSTALL_COMMAND]);
|
||||
|
||||
const command = await fs.readFile(path.join(captureDir, "command.txt"), "utf8");
|
||||
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(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("reruns sandbox command resolution after managed runtime setup and keeps the original sandbox home", async () => {
|
||||
setPrepareCursorSandboxCommand.mockReset();
|
||||
const prepareInputs: PrepareCursorSandboxCommandInput[] = [];
|
||||
let finalPreparedCommand: string | null = null;
|
||||
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-fresh-lease-managed-"));
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const remoteWorkspace = path.join(rootDir, "remote-workspace");
|
||||
const systemHomeDir = path.join(rootDir, "system-home");
|
||||
const managedCaptureDir = path.join(rootDir, "managed-capture");
|
||||
await fs.mkdir(managedCaptureDir, { recursive: true });
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(remoteWorkspace, { recursive: true });
|
||||
const preferredAgentScript = `#!/bin/sh
|
||||
printf '%s\\n' '{"type":"system","subtype":"init","session_id":"cursor-session-fresh-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-fresh-1","result":"ok"}'
|
||||
`;
|
||||
|
||||
setPrepareCursorSandboxCommand.mockImplementation(async (input) => {
|
||||
const call = prepareInputs.length;
|
||||
prepareInputs.push(input);
|
||||
if (call === 0) {
|
||||
return {
|
||||
command: input.command,
|
||||
env: input.env,
|
||||
remoteSystemHomeDir: systemHomeDir,
|
||||
addedPathEntry: null,
|
||||
preferredCommandPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
expect(input.remoteSystemHomeDirHint).toBe(systemHomeDir);
|
||||
const preferredCommandPath = path.join(systemHomeDir, ".local", "bin", input.command);
|
||||
finalPreparedCommand = preferredCommandPath;
|
||||
const runtimeEnv = {
|
||||
...input.env,
|
||||
PATH: `${path.join(systemHomeDir, ".local", "bin")}${path.delimiter}${input.env.PATH}`,
|
||||
};
|
||||
await fs.mkdir(path.dirname(preferredCommandPath), { recursive: true });
|
||||
await fs.writeFile(preferredCommandPath, preferredAgentScript);
|
||||
await fs.chmod(preferredCommandPath, 0o755);
|
||||
await fs.writeFile(path.join(managedCaptureDir, "agent-output.log"), preferredCommandPath);
|
||||
|
||||
return {
|
||||
command: preferredCommandPath,
|
||||
env: runtimeEnv,
|
||||
remoteSystemHomeDir: systemHomeDir,
|
||||
addedPathEntry: path.join(systemHomeDir, ".local", "bin"),
|
||||
preferredCommandPath,
|
||||
};
|
||||
});
|
||||
|
||||
const runnerState = {
|
||||
commands: [] as string[],
|
||||
};
|
||||
const runner = {
|
||||
execute: async (input: { command: string; args?: string[]; env?: Record<string, string> }) => {
|
||||
runnerState.commands.push(input.command);
|
||||
if (input.command === "sh") {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: 555,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return runChildProcess(`cursor-fresh-lease-${runnerState.commands.length}`, input.command, input.args ?? [], {
|
||||
cwd: remoteWorkspace,
|
||||
env: input.env ?? {},
|
||||
timeoutSec: 30,
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
onSpawn: async () => {},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const runMeta: Array<{ command?: string; [key: string]: unknown }> = [];
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = systemHomeDir;
|
||||
|
||||
try {
|
||||
const command = "agent";
|
||||
const result = await execute({
|
||||
runId: "run-fresh-lease-managed",
|
||||
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,
|
||||
providerKey: "fixture",
|
||||
runner: runner,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
config: {
|
||||
command,
|
||||
cwd: workspaceDir,
|
||||
promptTemplate: "Run against runtime-managed command.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
onMeta: async (meta) => {
|
||||
runMeta.push(meta as unknown as { command?: string; [key: string]: unknown });
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(prepareInputs).toHaveLength(2);
|
||||
expect(finalPreparedCommand).not.toBeNull();
|
||||
expect(finalPreparedCommand).toMatch(/\.local\/(bin|sbin)\/agent$/);
|
||||
const resolvedCommand = runMeta.find(Boolean)?.command as string | undefined;
|
||||
expect(resolvedCommand).toMatch(/\.local\/bin\/agent$/);
|
||||
expect(resolvedCommand).toContain(path.join(systemHomeDir, ".local", "bin", command));
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type Adapter
|
||||
import {
|
||||
adapterExecutionTargetIsRemote,
|
||||
adapterExecutionTargetRemoteCwd,
|
||||
overrideAdapterExecutionTargetRemoteCwd,
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
@@ -26,19 +27,18 @@ import {
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
applyPaperclipWorkspaceEnv,
|
||||
buildPaperclipEnv,
|
||||
buildInvocationEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensurePaperclipSkillSymlink,
|
||||
ensurePathInEnv,
|
||||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readPaperclipIssueWorkModeFromContext,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
@@ -224,13 +224,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceHints,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
||||
@@ -294,20 +288,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
refreshPaperclipWorkspaceEnvForExecution({
|
||||
env,
|
||||
envConfig,
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceHints,
|
||||
agentHome,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
@@ -324,10 +317,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
// 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({
|
||||
// Probe the sandbox before the managed-home override so we discover the
|
||||
// installer-managed agent symlinks from the real system HOME (for example
|
||||
// ~/.local/bin/agent). The managed HOME set later is for runtime isolation,
|
||||
// not for finding the CLI.
|
||||
const initialSandboxCommand = await prepareCursorSandboxCommand({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
command,
|
||||
@@ -336,22 +330,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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",
|
||||
),
|
||||
);
|
||||
const billingType = resolveCursorBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
let loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
const sandboxSystemHomeDir = initialSandboxCommand.remoteSystemHomeDir;
|
||||
command = initialSandboxCommand.command;
|
||||
env = initialSandboxCommand.env;
|
||||
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
@@ -385,6 +366,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}],
|
||||
});
|
||||
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
|
||||
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
|
||||
refreshPaperclipWorkspaceEnvForExecution({
|
||||
env,
|
||||
envConfig,
|
||||
workspaceCwd: effectiveWorkspaceCwd,
|
||||
workspaceSource,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
workspaceHints,
|
||||
agentHome,
|
||||
executionTargetIsRemote,
|
||||
executionCwd: effectiveExecutionCwd,
|
||||
});
|
||||
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
|
||||
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
|
||||
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
|
||||
@@ -416,10 +411,43 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
|
||||
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
const finalSandboxCommand = executionTarget?.kind === "remote" && executionTarget.transport === "sandbox"
|
||||
? await prepareCursorSandboxCommand({
|
||||
runId,
|
||||
target: executionTarget,
|
||||
command,
|
||||
cwd,
|
||||
env,
|
||||
remoteSystemHomeDirHint: sandboxSystemHomeDir,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
})
|
||||
: null;
|
||||
if (finalSandboxCommand) {
|
||||
command = finalSandboxCommand.command;
|
||||
env = finalSandboxCommand.env;
|
||||
}
|
||||
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveCursorBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
});
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
let loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
});
|
||||
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
|
||||
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId,
|
||||
target: runtimeExecutionTarget,
|
||||
runtimeRootDir: remoteRuntimeRootDir,
|
||||
adapterKey: "cursor",
|
||||
hostApiToken: env.PAPERCLIP_API_KEY,
|
||||
@@ -442,7 +470,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
||||
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
|
||||
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
@@ -482,11 +510,14 @@ 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) {
|
||||
const sandboxCommand = finalSandboxCommand ?? initialSandboxCommand;
|
||||
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 (sandboxCommand?.preferredCommandPath) {
|
||||
notes.push(
|
||||
`Remote sandbox runs prefer ${sandboxCommand.preferredCommandPath} when using the default Cursor entrypoint.`,
|
||||
);
|
||||
}
|
||||
if (!instructionsFilePath) return notes;
|
||||
if (instructionsPrefix.length > 0) {
|
||||
@@ -589,7 +620,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
};
|
||||
|
||||
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
|
||||
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
@@ -647,7 +678,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
...(executionTargetIsRemote
|
||||
? {
|
||||
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
|
||||
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
|
||||
}
|
||||
: {}),
|
||||
} as Record<string, unknown>)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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 { prepareCursorSandboxCommand } from "./remote-command.js";
|
||||
|
||||
function createLocalSandboxRunner() {
|
||||
let counter = 0;
|
||||
return {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
}) => {
|
||||
counter += 1;
|
||||
return await runChildProcess(`cursor-remote-command-${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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeFakeAgent(commandPath: string): Promise<void> {
|
||||
const script = `#!/bin/sh
|
||||
printf '%s\\n' ok
|
||||
`;
|
||||
await fs.mkdir(path.dirname(commandPath), { recursive: true });
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
describe("prepareCursorSandboxCommand", () => {
|
||||
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");
|
||||
const managedHomeDir = path.join(root, "managed-home");
|
||||
const remoteWorkspace = path.join(root, "workspace");
|
||||
const systemAgentPath = path.join(systemHomeDir, ".local", "bin", "agent");
|
||||
await fs.mkdir(remoteWorkspace, { recursive: true });
|
||||
await writeFakeAgent(systemAgentPath);
|
||||
|
||||
try {
|
||||
const result = await prepareCursorSandboxCommand({
|
||||
runId: "run-remote-command-1",
|
||||
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(systemAgentPath);
|
||||
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).not.toContain(path.join(managedHomeDir, ".local", "bin"));
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,14 @@ function prependPosixPathEntry(pathValue: string, entry: string): string {
|
||||
return cleaned.length > 0 ? `${entry}:${cleaned}` : entry;
|
||||
}
|
||||
|
||||
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"];
|
||||
}
|
||||
|
||||
type SandboxCursorRuntimeInfo = {
|
||||
remoteSystemHomeDir: string | null;
|
||||
preferredCommandPath: string | null;
|
||||
@@ -40,10 +48,15 @@ async function readSandboxCursorRuntimeInfo(input: {
|
||||
command: string;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
remoteSystemHomeDirHint?: string | null;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
}): Promise<SandboxCursorRuntimeInfo> {
|
||||
const shouldCheckPreferredCommand = isDefaultCursorCommand(input.command) && !hasPathSeparator(input.command);
|
||||
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 {
|
||||
@@ -51,9 +64,24 @@ async function readSandboxCursorRuntimeInfo(input: {
|
||||
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`
|
||||
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"
|
||||
: "",
|
||||
].filter(Boolean).join("; "),
|
||||
{
|
||||
@@ -100,6 +128,7 @@ export async function prepareCursorSandboxCommand(input: {
|
||||
command: string;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
remoteSystemHomeDirHint?: string | null;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
}): Promise<PreparedCursorSandboxCommand> {
|
||||
@@ -119,10 +148,12 @@ export async function prepareCursorSandboxCommand(input: {
|
||||
command: input.command,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
remoteSystemHomeDirHint: input.remoteSystemHomeDirHint,
|
||||
timeoutSec: input.timeoutSec,
|
||||
graceSec: input.graceSec,
|
||||
});
|
||||
const remoteSystemHomeDir = runtimeInfo.remoteSystemHomeDir;
|
||||
const remoteSystemHomeDir =
|
||||
runtimeInfo.remoteSystemHomeDir ?? input.remoteSystemHomeDirHint?.trim() ?? null;
|
||||
|
||||
if (!remoteSystemHomeDir) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user