mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
parent
fe3904f434
commit
4269545b19
6 changed files with 564 additions and 62 deletions
|
|
@ -104,5 +104,5 @@ Notes:
|
||||||
- Sessions are resumed with --resume when stored session cwd matches current cwd.
|
- 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-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.
|
- 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(workspaceDir, { recursive: true });
|
||||||
await mkdir(alternateWorkspaceDir, { recursive: true });
|
await mkdir(alternateWorkspaceDir, { recursive: true });
|
||||||
|
|
||||||
|
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
|
||||||
const result = await execute({
|
const result = await execute({
|
||||||
runId: "run-1",
|
runId: "run-1",
|
||||||
agent: {
|
agent: {
|
||||||
|
|
@ -158,21 +159,19 @@ describe("cursor remote execution", () => {
|
||||||
|
|
||||||
expect(result.sessionParams).toMatchObject({
|
expect(result.sessionParams).toMatchObject({
|
||||||
sessionId: "cursor-session-1",
|
sessionId: "cursor-session-1",
|
||||||
cwd: "/remote/workspace",
|
cwd: managedRemoteWorkspace,
|
||||||
remoteExecution: {
|
remoteExecution: {
|
||||||
transport: "ssh",
|
transport: "ssh",
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 2222,
|
port: 2222,
|
||||||
username: "fixture",
|
username: "fixture",
|
||||||
remoteCwd: "/remote/workspace",
|
remoteCwd: managedRemoteWorkspace,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
|
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
|
||||||
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
|
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
|
||||||
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
|
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
// Asset sync targets the per-run managed subdirectory even though the
|
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/cursor/skills`,
|
||||||
// cursor adapter still runs commands from the workspace root.
|
|
||||||
remoteDir: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace/.paperclip-runtime/cursor/skills",
|
|
||||||
followSymlinks: true,
|
followSymlinks: true,
|
||||||
}));
|
}));
|
||||||
expect(runSshCommand).toHaveBeenCalledWith(
|
expect(runSshCommand).toHaveBeenCalledWith(
|
||||||
|
|
@ -184,12 +183,12 @@ describe("cursor remote execution", () => {
|
||||||
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(call?.[2]).toContain("--workspace");
|
expect(call?.[2]).toContain("--workspace");
|
||||||
expect(call?.[2]).toContain("/remote/workspace");
|
expect(call?.[2]).toContain(managedRemoteWorkspace);
|
||||||
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace");
|
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
|
||||||
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
||||||
{
|
{
|
||||||
workspaceId: "workspace-1",
|
workspaceId: "workspace-1",
|
||||||
cwd: "/remote/workspace",
|
cwd: managedRemoteWorkspace,
|
||||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||||
repoRef: "main",
|
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_URL).toBe("http://127.0.0.1:4310");
|
||||||
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
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(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
|
||||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
@ -212,6 +211,7 @@ describe("cursor remote execution", () => {
|
||||||
const workspaceDir = path.join(rootDir, "workspace");
|
const workspaceDir = path.join(rootDir, "workspace");
|
||||||
await mkdir(workspaceDir, { recursive: true });
|
await mkdir(workspaceDir, { recursive: true });
|
||||||
|
|
||||||
|
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace";
|
||||||
await execute({
|
await execute({
|
||||||
runId: "run-ssh-resume",
|
runId: "run-ssh-resume",
|
||||||
agent: {
|
agent: {
|
||||||
|
|
@ -225,13 +225,13 @@ describe("cursor remote execution", () => {
|
||||||
sessionId: "session-123",
|
sessionId: "session-123",
|
||||||
sessionParams: {
|
sessionParams: {
|
||||||
sessionId: "session-123",
|
sessionId: "session-123",
|
||||||
cwd: "/remote/workspace",
|
cwd: managedRemoteWorkspace,
|
||||||
remoteExecution: {
|
remoteExecution: {
|
||||||
transport: "ssh",
|
transport: "ssh",
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 2222,
|
port: 2222,
|
||||||
username: "fixture",
|
username: "fixture",
|
||||||
remoteCwd: "/remote/workspace",
|
remoteCwd: managedRemoteWorkspace,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sessionDisplayId: "session-123",
|
sessionDisplayId: "session-123",
|
||||||
|
|
|
||||||
352
packages/adapters/cursor-local/src/server/execute.test.ts
Normal file
352
packages/adapters/cursor-local/src/server/execute.test.ts
Normal file
|
|
@ -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 {
|
import {
|
||||||
adapterExecutionTargetIsRemote,
|
adapterExecutionTargetIsRemote,
|
||||||
adapterExecutionTargetRemoteCwd,
|
adapterExecutionTargetRemoteCwd,
|
||||||
|
overrideAdapterExecutionTargetRemoteCwd,
|
||||||
adapterExecutionTargetSessionIdentity,
|
adapterExecutionTargetSessionIdentity,
|
||||||
adapterExecutionTargetSessionMatches,
|
adapterExecutionTargetSessionMatches,
|
||||||
adapterExecutionTargetUsesManagedHome,
|
adapterExecutionTargetUsesManagedHome,
|
||||||
|
|
@ -26,19 +27,18 @@ import {
|
||||||
asNumber,
|
asNumber,
|
||||||
asStringArray,
|
asStringArray,
|
||||||
parseObject,
|
parseObject,
|
||||||
applyPaperclipWorkspaceEnv,
|
|
||||||
buildPaperclipEnv,
|
buildPaperclipEnv,
|
||||||
buildInvocationEnvForLogs,
|
buildInvocationEnvForLogs,
|
||||||
ensureAbsoluteDirectory,
|
ensureAbsoluteDirectory,
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
|
refreshPaperclipWorkspaceEnvForExecution,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
readPaperclipIssueWorkModeFromContext,
|
readPaperclipIssueWorkModeFromContext,
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
removeMaintainerOnlySkillSymlinks,
|
removeMaintainerOnlySkillSymlinks,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
renderPaperclipWakePrompt,
|
renderPaperclipWakePrompt,
|
||||||
shapePaperclipWorkspaceEnvForExecution,
|
|
||||||
stringifyPaperclipWakePayload,
|
stringifyPaperclipWakePayload,
|
||||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
|
|
@ -224,13 +224,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||||
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
|
||||||
workspaceCwd: effectiveWorkspaceCwd,
|
|
||||||
workspaceHints,
|
|
||||||
executionTargetIsRemote,
|
|
||||||
executionCwd: effectiveExecutionCwd,
|
|
||||||
});
|
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
||||||
|
|
@ -294,20 +288,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (wakePayloadJson) {
|
if (wakePayloadJson) {
|
||||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||||
}
|
}
|
||||||
applyPaperclipWorkspaceEnv(env, {
|
refreshPaperclipWorkspaceEnvForExecution({
|
||||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
env,
|
||||||
|
envConfig,
|
||||||
|
workspaceCwd: effectiveWorkspaceCwd,
|
||||||
workspaceSource,
|
workspaceSource,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceRepoUrl,
|
workspaceRepoUrl,
|
||||||
workspaceRepoRef,
|
workspaceRepoRef,
|
||||||
|
workspaceHints,
|
||||||
agentHome,
|
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) {
|
if (!hasExplicitApiKey && authToken) {
|
||||||
env.PAPERCLIP_API_KEY = authToken;
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
}
|
}
|
||||||
|
|
@ -324,10 +317,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
graceSec,
|
graceSec,
|
||||||
onLog,
|
onLog,
|
||||||
});
|
});
|
||||||
// Probe the sandbox before the managed-home override so we discover
|
// Probe the sandbox before the managed-home override so we discover the
|
||||||
// cursor-agent from the real system HOME (e.g. ~/.local/bin/cursor-agent).
|
// installer-managed agent symlinks from the real system HOME (for example
|
||||||
// The managed HOME set later is for runtime isolation, not for finding the CLI.
|
// ~/.local/bin/agent). The managed HOME set later is for runtime isolation,
|
||||||
const sandboxCommand = await prepareCursorSandboxCommand({
|
// not for finding the CLI.
|
||||||
|
const initialSandboxCommand = await prepareCursorSandboxCommand({
|
||||||
runId,
|
runId,
|
||||||
target: executionTarget,
|
target: executionTarget,
|
||||||
command,
|
command,
|
||||||
|
|
@ -336,22 +330,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
timeoutSec,
|
timeoutSec,
|
||||||
graceSec,
|
graceSec,
|
||||||
});
|
});
|
||||||
command = sandboxCommand.command;
|
const sandboxSystemHomeDir = initialSandboxCommand.remoteSystemHomeDir;
|
||||||
env = sandboxCommand.env;
|
command = initialSandboxCommand.command;
|
||||||
const effectiveEnv = Object.fromEntries(
|
env = initialSandboxCommand.env;
|
||||||
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 extraArgs = (() => {
|
const extraArgs = (() => {
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
|
@ -385,6 +366,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
|
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
|
||||||
|
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
|
||||||
|
refreshPaperclipWorkspaceEnvForExecution({
|
||||||
|
env,
|
||||||
|
envConfig,
|
||||||
|
workspaceCwd: effectiveWorkspaceCwd,
|
||||||
|
workspaceSource,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRepoUrl,
|
||||||
|
workspaceRepoRef,
|
||||||
|
workspaceHints,
|
||||||
|
agentHome,
|
||||||
|
executionTargetIsRemote,
|
||||||
|
executionCwd: effectiveExecutionCwd,
|
||||||
|
});
|
||||||
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
|
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
|
||||||
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
|
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
|
||||||
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
|
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
|
||||||
|
|
@ -416,10 +411,43 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
|
const finalSandboxCommand = executionTarget?.kind === "remote" && executionTarget.transport === "sandbox"
|
||||||
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
|
? await prepareCursorSandboxCommand({
|
||||||
runId,
|
runId,
|
||||||
target: executionTarget,
|
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,
|
runtimeRootDir: remoteRuntimeRootDir,
|
||||||
adapterKey: "cursor",
|
adapterKey: "cursor",
|
||||||
hostApiToken: env.PAPERCLIP_API_KEY,
|
hostApiToken: env.PAPERCLIP_API_KEY,
|
||||||
|
|
@ -442,7 +470,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const canResumeSession =
|
const canResumeSession =
|
||||||
runtimeSessionId.length > 0 &&
|
runtimeSessionId.length > 0 &&
|
||||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
||||||
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
|
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
||||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
||||||
await onLog(
|
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("Auto-added --yolo to bypass interactive prompts.");
|
||||||
}
|
}
|
||||||
notes.push("Prompt is piped to Cursor via stdin.");
|
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.`);
|
notes.push(`Remote sandbox runs prepend ${sandboxCommand.addedPathEntry} to PATH.`);
|
||||||
}
|
}
|
||||||
if (sandboxCommand.preferredCommandPath) {
|
if (sandboxCommand?.preferredCommandPath) {
|
||||||
notes.push(`Remote sandbox runs prefer ${sandboxCommand.preferredCommandPath} when using the default Cursor entrypoint.`);
|
notes.push(
|
||||||
|
`Remote sandbox runs prefer ${sandboxCommand.preferredCommandPath} when using the default Cursor entrypoint.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!instructionsFilePath) return notes;
|
if (!instructionsFilePath) return notes;
|
||||||
if (instructionsPrefix.length > 0) {
|
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,
|
cwd,
|
||||||
env,
|
env,
|
||||||
timeoutSec,
|
timeoutSec,
|
||||||
|
|
@ -647,7 +678,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
...(executionTargetIsRemote
|
...(executionTargetIsRemote
|
||||||
? {
|
? {
|
||||||
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
|
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
} as Record<string, unknown>)
|
} 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;
|
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 = {
|
type SandboxCursorRuntimeInfo = {
|
||||||
remoteSystemHomeDir: string | null;
|
remoteSystemHomeDir: string | null;
|
||||||
preferredCommandPath: string | null;
|
preferredCommandPath: string | null;
|
||||||
|
|
@ -40,10 +48,15 @@ async function readSandboxCursorRuntimeInfo(input: {
|
||||||
command: string;
|
command: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
|
remoteSystemHomeDirHint?: string | null;
|
||||||
timeoutSec: number;
|
timeoutSec: number;
|
||||||
graceSec: number;
|
graceSec: number;
|
||||||
}): Promise<SandboxCursorRuntimeInfo> {
|
}): 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 homeMarker = "__PAPERCLIP_CURSOR_HOME__:";
|
||||||
const preferredMarker = "__PAPERCLIP_CURSOR_AGENT__:";
|
const preferredMarker = "__PAPERCLIP_CURSOR_AGENT__:";
|
||||||
try {
|
try {
|
||||||
|
|
@ -51,9 +64,24 @@ async function readSandboxCursorRuntimeInfo(input: {
|
||||||
input.runId,
|
input.runId,
|
||||||
input.target,
|
input.target,
|
||||||
[
|
[
|
||||||
`printf ${JSON.stringify(`${homeMarker}%s\\n`)} "$HOME"`,
|
hintedRemoteSystemHomeDir
|
||||||
shouldCheckPreferredCommand
|
? `printf ${JSON.stringify(`${homeMarker}%s\\n`)} ${JSON.stringify(hintedRemoteSystemHomeDir)}`
|
||||||
? `if [ -x "$HOME/.local/bin/cursor-agent" ]; then printf ${JSON.stringify(`${preferredMarker}%s\\n`)} "$HOME/.local/bin/cursor-agent"; fi`
|
: `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("; "),
|
].filter(Boolean).join("; "),
|
||||||
{
|
{
|
||||||
|
|
@ -100,6 +128,7 @@ export async function prepareCursorSandboxCommand(input: {
|
||||||
command: string;
|
command: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
|
remoteSystemHomeDirHint?: string | null;
|
||||||
timeoutSec: number;
|
timeoutSec: number;
|
||||||
graceSec: number;
|
graceSec: number;
|
||||||
}): Promise<PreparedCursorSandboxCommand> {
|
}): Promise<PreparedCursorSandboxCommand> {
|
||||||
|
|
@ -119,10 +148,12 @@ export async function prepareCursorSandboxCommand(input: {
|
||||||
command: input.command,
|
command: input.command,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
env: input.env,
|
env: input.env,
|
||||||
|
remoteSystemHomeDirHint: input.remoteSystemHomeDirHint,
|
||||||
timeoutSec: input.timeoutSec,
|
timeoutSec: input.timeoutSec,
|
||||||
graceSec: input.graceSec,
|
graceSec: input.graceSec,
|
||||||
});
|
});
|
||||||
const remoteSystemHomeDir = runtimeInfo.remoteSystemHomeDir;
|
const remoteSystemHomeDir =
|
||||||
|
runtimeInfo.remoteSystemHomeDir ?? input.remoteSystemHomeDirHint?.trim() ?? null;
|
||||||
|
|
||||||
if (!remoteSystemHomeDir) {
|
if (!remoteSystemHomeDir) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue