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.
2026-05-07 15:00:28 -07:00
|
|
|
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", () => {
|
2026-05-11 00:41:20 -07:00
|
|
|
it("prefers the Cursor installer bin directory when the default agent entrypoint is installed there", async () => {
|
|
|
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-command-cursor-bin-"));
|
|
|
|
|
const systemHomeDir = path.join(root, "system-home");
|
|
|
|
|
const managedHomeDir = path.join(root, "managed-home");
|
|
|
|
|
const remoteWorkspace = path.join(root, "workspace");
|
|
|
|
|
const cursorAgentPath = path.join(systemHomeDir, ".cursor", "bin", "agent");
|
|
|
|
|
await fs.mkdir(remoteWorkspace, { recursive: true });
|
|
|
|
|
await writeFakeAgent(cursorAgentPath);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await prepareCursorSandboxCommand({
|
|
|
|
|
runId: "run-remote-command-cursor-bin",
|
|
|
|
|
target: {
|
|
|
|
|
kind: "remote",
|
|
|
|
|
transport: "sandbox",
|
|
|
|
|
shellCommand: "bash",
|
|
|
|
|
remoteCwd: remoteWorkspace,
|
|
|
|
|
runner: createLocalSandboxRunner(),
|
|
|
|
|
timeoutMs: 30_000,
|
|
|
|
|
},
|
|
|
|
|
command: "agent",
|
|
|
|
|
cwd: remoteWorkspace,
|
|
|
|
|
env: {
|
|
|
|
|
HOME: managedHomeDir,
|
|
|
|
|
PATH: "/usr/bin:/bin",
|
|
|
|
|
},
|
|
|
|
|
remoteSystemHomeDirHint: systemHomeDir,
|
|
|
|
|
timeoutSec: 30,
|
|
|
|
|
graceSec: 5,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.command).toBe(cursorAgentPath);
|
|
|
|
|
expect(result.preferredCommandPath).toBe(cursorAgentPath);
|
|
|
|
|
expect(result.remoteSystemHomeDir).toBe(systemHomeDir);
|
|
|
|
|
expect(result.addedPathEntry).toBe(path.join(systemHomeDir, ".local", "bin"));
|
|
|
|
|
expect(result.env.PATH?.split(":").slice(0, 2)).toEqual([
|
|
|
|
|
path.join(systemHomeDir, ".local", "bin"),
|
|
|
|
|
path.join(systemHomeDir, ".cursor", "bin"),
|
|
|
|
|
]);
|
|
|
|
|
expect(result.env.PATH).not.toContain(path.join(managedHomeDir, ".cursor", "bin"));
|
|
|
|
|
expect(result.env.PATH).not.toContain(path.join(managedHomeDir, ".local", "bin"));
|
|
|
|
|
} finally {
|
|
|
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
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.
2026-05-07 15:00:28 -07:00
|
|
|
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"));
|
2026-05-11 00:41:20 -07:00
|
|
|
expect(result.env.PATH?.split(":").slice(0, 2)).toEqual([
|
|
|
|
|
path.join(systemHomeDir, ".local", "bin"),
|
|
|
|
|
path.join(systemHomeDir, ".cursor", "bin"),
|
|
|
|
|
]);
|
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.
2026-05-07 15:00:28 -07:00
|
|
|
expect(result.env.PATH).not.toContain(path.join(managedHomeDir, ".local", "bin"));
|
|
|
|
|
} finally {
|
|
|
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|