diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index 5c4e2356..09d29db4 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 "~/.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. `; diff --git a/packages/adapters/cursor-local/src/server/execute.remote.test.ts b/packages/adapters/cursor-local/src/server/execute.remote.test.ts index 66da89d4..0617aae3 100644 --- a/packages/adapters/cursor-local/src/server/execute.remote.test.ts +++ b/packages/adapters/cursor-local/src/server/execute.remote.test.ts @@ -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; 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", diff --git a/packages/adapters/cursor-local/src/server/execute.test.ts b/packages/adapters/cursor-local/src/server/execute.test.ts new file mode 100644 index 00000000..d83101ab --- /dev/null +++ b/packages/adapters/cursor-local/src/server/execute.test.ts @@ -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; + remoteSystemHomeDirHint?: string | null; + timeoutSec: number; + graceSec: number; +}; + +type PrepareCursorSandboxCommandResult = { + command: string; + env: Record; + remoteSystemHomeDir: string | null; + addedPathEntry: string | null; + preferredCommandPath: string | null; +}; + +const { + setPrepareCursorSandboxCommand, +} = vi.hoisted(() => { + const setPrepareCursorSandboxCommand = vi.fn< + (input: PrepareCursorSandboxCommandInput) => Promise + >(); + return { setPrepareCursorSandboxCommand }; +}); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge: async () => null, + }; +}); + +vi.mock("./remote-command.js", async () => { + const actual = await vi.importActual("./remote-command.js"); + return { + ...actual, + prepareCursorSandboxCommand: async (input: Parameters[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; + 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, 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("./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 }) => { + 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 }); + } + }); +}); diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 27180aa8..d04cc73b 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -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 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 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 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 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 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 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 0) { @@ -589,7 +620,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise) diff --git a/packages/adapters/cursor-local/src/server/remote-command.test.ts b/packages/adapters/cursor-local/src/server/remote-command.test.ts new file mode 100644 index 00000000..82b0bff1 --- /dev/null +++ b/packages/adapters/cursor-local/src/server/remote-command.test.ts @@ -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; + stdin?: string; + timeoutMs?: number; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + }) => { + 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 { + 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 }); + } + }); +}); diff --git a/packages/adapters/cursor-local/src/server/remote-command.ts b/packages/adapters/cursor-local/src/server/remote-command.ts index 1edd6df0..b99d161f 100644 --- a/packages/adapters/cursor-local/src/server/remote-command.ts +++ b/packages/adapters/cursor-local/src/server/remote-command.ts @@ -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; + remoteSystemHomeDirHint?: string | null; timeoutSec: number; graceSec: number; }): Promise { - 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; + remoteSystemHomeDirHint?: string | null; timeoutSec: number; graceSec: number; }): Promise { @@ -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 {