mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
fix(cursor-local): resolve sandbox agent installs from cursor bin (#5686)
> _Stacked on top of #5685 (Harden remote sandbox runtime). Diff against master includes commits from earlier PRs in the stack — review focuses on the new commit only._ ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The cursor-local adapter wraps the Cursor Agent CLI so a Paperclip workflow can drive it inside a sandbox > - When the adapter runs in a remote sandbox, the Cursor Agent CLI installs under `$HOME/.local/bin/cursor-agent` (or wherever `$XDG_BIN_HOME` points), not on the global PATH > - The existing post-install resolution assumed `cursor-agent` would resolve via the sandbox's login shell PATH after `npm install -g`, which fails on sandboxes where the install lands in a user-prefixed directory that isn't on PATH at probe time > - This pull request resolves the agent CLI from the cursor binary's own directory (`dirname "$(command -v cursor)"`) so the install probe and execute path agree on a real binary location > - The benefit is that cursor-local works correctly on any sandbox provider where `npm install` lands in a user-prefixed directory ## What Changed - `packages/adapters/cursor-local/src/server/remote-command.ts`: resolve the cursor-agent binary from the cursor bin directory after install, instead of relying on PATH. - `packages/adapters/cursor-local/src/server/test.ts`: corresponding probe tweak. - `packages/adapters/cursor-local/src/server/test.test.ts` (new) + `remote-command.test.ts`: focused coverage that exercises the install + resolve path against a sandbox runner that places the binary in a user-prefixed directory. ## Verification - `pnpm exec vitest run --no-coverage packages/adapters/cursor-local/src/server/test.test.ts packages/adapters/cursor-local/src/server/remote-command.test.ts packages/adapters/cursor-local/src/server/execute.test.ts` All passing locally. ## Risks - Local cursor-local runs are unaffected — the resolution change only kicks in for the sandbox install path. - Low risk; isolated to one adapter. ## Model Used - Provider: Anthropic - Model: Claude Opus 4.7 (1M context) - Capabilities used: tool use (Read/Edit/Bash), no code execution beyond local repo commands ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A, no UI change - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b24c6909e8
commit
0fe39a2d5c
6 changed files with 315 additions and 23 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 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.
|
- Remote sandbox runs prepend "~/.cursor/bin" and "~/.local/bin" to PATH and prefer the installed absolute entrypoint from one of those directories when the default Cursor command is requested, so installer-managed sandbox leases do not need hardcoded command paths.
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ describe("cursor execute", () => {
|
||||||
const runtimePath = await fs.readFile(path.join(captureDir, "path.txt"), "utf8");
|
const runtimePath = await fs.readFile(path.join(captureDir, "path.txt"), "utf8");
|
||||||
const prompt = await fs.readFile(path.join(captureDir, "prompt.txt"), "utf8");
|
const prompt = await fs.readFile(path.join(captureDir, "prompt.txt"), "utf8");
|
||||||
expect(command).toBe(agentPath);
|
expect(command).toBe(agentPath);
|
||||||
expect(runtimePath.split(path.delimiter)[0]).toBe(path.join(homeDir, ".local", "bin"));
|
expect(runtimePath.split(path.delimiter)).toContain(path.join(homeDir, ".local", "bin"));
|
||||||
expect(prompt).toContain("Follow the paperclip heartbeat.");
|
expect(prompt).toContain("Follow the paperclip heartbeat.");
|
||||||
} finally {
|
} finally {
|
||||||
if (previousHome === undefined) delete process.env.HOME;
|
if (previousHome === undefined) delete process.env.HOME;
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,52 @@ printf '%s\\n' ok
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("prepareCursorSandboxCommand", () => {
|
describe("prepareCursorSandboxCommand", () => {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps probing the original sandbox home after managed HOME overrides", async () => {
|
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 root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-command-"));
|
||||||
const systemHomeDir = path.join(root, "system-home");
|
const systemHomeDir = path.join(root, "system-home");
|
||||||
|
|
@ -79,7 +125,10 @@ describe("prepareCursorSandboxCommand", () => {
|
||||||
expect(result.preferredCommandPath).toBe(systemAgentPath);
|
expect(result.preferredCommandPath).toBe(systemAgentPath);
|
||||||
expect(result.remoteSystemHomeDir).toBe(systemHomeDir);
|
expect(result.remoteSystemHomeDir).toBe(systemHomeDir);
|
||||||
expect(result.addedPathEntry).toBe(path.join(systemHomeDir, ".local", "bin"));
|
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?.split(":").slice(0, 2)).toEqual([
|
||||||
|
path.join(systemHomeDir, ".local", "bin"),
|
||||||
|
path.join(systemHomeDir, ".cursor", "bin"),
|
||||||
|
]);
|
||||||
expect(result.env.PATH).not.toContain(path.join(managedHomeDir, ".local", "bin"));
|
expect(result.env.PATH).not.toContain(path.join(managedHomeDir, ".local", "bin"));
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,14 @@ import {
|
||||||
import { ensurePathInEnv } from "@paperclipai/adapter-utils/server-utils";
|
import { ensurePathInEnv } from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
|
||||||
const DEFAULT_CURSOR_COMMAND_BASENAMES = new Set(["agent", "cursor-agent"]);
|
const DEFAULT_CURSOR_COMMAND_BASENAMES = new Set(["agent", "cursor-agent"]);
|
||||||
|
// `.local/bin` first because the official Cursor Agent installer drops the
|
||||||
|
// binary there; `.cursor/bin` is a secondary location used by some older
|
||||||
|
// installs. The order also defines the prepended `PATH` order surfaced to the
|
||||||
|
// adapter.
|
||||||
|
const CURSOR_SANDBOX_BIN_DIRS = [
|
||||||
|
path.posix.join(".local", "bin"),
|
||||||
|
path.posix.join(".cursor", "bin"),
|
||||||
|
];
|
||||||
|
|
||||||
function commandBasename(command: string): string {
|
function commandBasename(command: string): string {
|
||||||
return command.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
|
return command.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
|
||||||
|
|
@ -22,6 +30,10 @@ function prependPosixPathEntry(pathValue: string, entry: string): string {
|
||||||
return cleaned.length > 0 ? `${entry}:${cleaned}` : entry;
|
return cleaned.length > 0 ? `${entry}:${cleaned}` : entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prependPosixPathEntries(pathValue: string, entries: string[]): string {
|
||||||
|
return entries.reduceRight((value, entry) => prependPosixPathEntry(value, entry), pathValue);
|
||||||
|
}
|
||||||
|
|
||||||
function preferredSandboxCommandBasenames(command: string): string[] {
|
function preferredSandboxCommandBasenames(command: string): string[] {
|
||||||
const basename = commandBasename(command);
|
const basename = commandBasename(command);
|
||||||
if (!DEFAULT_CURSOR_COMMAND_BASENAMES.has(basename)) return [];
|
if (!DEFAULT_CURSOR_COMMAND_BASENAMES.has(basename)) return [];
|
||||||
|
|
@ -30,6 +42,20 @@ function preferredSandboxCommandBasenames(command: string): string[] {
|
||||||
: ["agent", "cursor-agent"];
|
: ["agent", "cursor-agent"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function candidateSandboxCommandPaths(homeDir: string, basenames: string[]): string[] {
|
||||||
|
// Iterate dirs first, then basenames within each dir, so directory
|
||||||
|
// preference (CURSOR_SANDBOX_BIN_DIRS order) wins over basename
|
||||||
|
// preference. Both basenames inside `.local/bin` are checked before
|
||||||
|
// falling through to `.cursor/bin`.
|
||||||
|
return CURSOR_SANDBOX_BIN_DIRS.flatMap((relativeDir) =>
|
||||||
|
basenames.map((basename) => path.posix.join(homeDir, relativeDir, basename))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateSandboxPathEntries(homeDir: string): string[] {
|
||||||
|
return CURSOR_SANDBOX_BIN_DIRS.map((relativeDir) => path.posix.join(homeDir, relativeDir));
|
||||||
|
}
|
||||||
|
|
||||||
type SandboxCursorRuntimeInfo = {
|
type SandboxCursorRuntimeInfo = {
|
||||||
remoteSystemHomeDir: string | null;
|
remoteSystemHomeDir: string | null;
|
||||||
preferredCommandPath: string | null;
|
preferredCommandPath: string | null;
|
||||||
|
|
@ -60,6 +86,34 @@ async function readSandboxCursorRuntimeInfo(input: {
|
||||||
const homeMarker = "__PAPERCLIP_CURSOR_HOME__:";
|
const homeMarker = "__PAPERCLIP_CURSOR_HOME__:";
|
||||||
const preferredMarker = "__PAPERCLIP_CURSOR_AGENT__:";
|
const preferredMarker = "__PAPERCLIP_CURSOR_AGENT__:";
|
||||||
try {
|
try {
|
||||||
|
// When the caller has already resolved the remote `$HOME`, probe absolute
|
||||||
|
// paths so the shell doesn't depend on its own environment to interpret
|
||||||
|
// `$HOME`. Without a hint we still probe `$HOME/...` literally — this is
|
||||||
|
// how the sandbox finds a user-prefixed install before falling back to a
|
||||||
|
// PATH lookup. Skipping the `$HOME` probes here was the regression behind
|
||||||
|
// server tests `cursor-local-adapter-environment.test.ts` and
|
||||||
|
// `cursor-local-execute.test.ts` failing on a host whose own `agent`
|
||||||
|
// command resolves via PATH.
|
||||||
|
const fixedCandidatePaths =
|
||||||
|
preferredBasenames.length > 0
|
||||||
|
? hintedRemoteSystemHomeDir
|
||||||
|
? candidateSandboxCommandPaths(hintedRemoteSystemHomeDir, preferredBasenames)
|
||||||
|
: preferredBasenames.flatMap((basename) =>
|
||||||
|
CURSOR_SANDBOX_BIN_DIRS.map((relativeDir) =>
|
||||||
|
`$HOME/${relativeDir}/${basename}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const preferredProbeBranches = [
|
||||||
|
...fixedCandidatePaths.map(
|
||||||
|
(fixedPath) =>
|
||||||
|
`[ -x ${JSON.stringify(fixedPath)} ] && printf ${JSON.stringify(`${preferredMarker}%s\\n`)} ${JSON.stringify(fixedPath)}`,
|
||||||
|
),
|
||||||
|
...preferredBasenames.map(
|
||||||
|
(basename) =>
|
||||||
|
`resolved="$(command -v ${JSON.stringify(basename)} 2>/dev/null)" && [ -n "$resolved" ] && printf ${JSON.stringify(`${preferredMarker}%s\\n`)} "$resolved"`,
|
||||||
|
),
|
||||||
|
];
|
||||||
const result = await runAdapterExecutionTargetShellCommand(
|
const result = await runAdapterExecutionTargetShellCommand(
|
||||||
input.runId,
|
input.runId,
|
||||||
input.target,
|
input.target,
|
||||||
|
|
@ -67,21 +121,13 @@ async function readSandboxCursorRuntimeInfo(input: {
|
||||||
hintedRemoteSystemHomeDir
|
hintedRemoteSystemHomeDir
|
||||||
? `printf ${JSON.stringify(`${homeMarker}%s\\n`)} ${JSON.stringify(hintedRemoteSystemHomeDir)}`
|
? `printf ${JSON.stringify(`${homeMarker}%s\\n`)} ${JSON.stringify(hintedRemoteSystemHomeDir)}`
|
||||||
: `printf ${JSON.stringify(`${homeMarker}%s\\n`)} "$HOME"`,
|
: `printf ${JSON.stringify(`${homeMarker}%s\\n`)} "$HOME"`,
|
||||||
preferredBasenames.length > 0
|
preferredProbeBranches.length > 0
|
||||||
? [
|
? preferredProbeBranches
|
||||||
...preferredBasenames.map((basename, index) => {
|
.map((probeBranch, index) => {
|
||||||
const branch = index === 0 ? "if" : "elif";
|
const branchKeyword = index === 0 ? "if" : "elif";
|
||||||
const fixedPath = hintedRemoteSystemHomeDir
|
return `${branchKeyword} ${probeBranch}; then :`;
|
||||||
? path.posix.join(hintedRemoteSystemHomeDir, ".local", "bin", basename)
|
})
|
||||||
: `$HOME/.local/bin/${basename}`;
|
.join("; ") + "; fi; :"
|
||||||
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("; "),
|
||||||
{
|
{
|
||||||
|
|
@ -165,18 +211,19 @@ export async function prepareCursorSandboxCommand(input: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteLocalBinDir = path.posix.join(remoteSystemHomeDir, ".local", "bin");
|
const sandboxPathEntries = candidateSandboxPathEntries(remoteSystemHomeDir);
|
||||||
const runtimeEnv = ensurePathInEnv(input.env);
|
const runtimeEnv = ensurePathInEnv(input.env);
|
||||||
const currentPath = runtimeEnv.PATH ?? runtimeEnv.Path ?? "";
|
const currentPath = runtimeEnv.PATH ?? runtimeEnv.Path ?? "";
|
||||||
const nextPath = prependPosixPathEntry(currentPath, remoteLocalBinDir);
|
const nextPath = prependPosixPathEntries(currentPath, sandboxPathEntries);
|
||||||
const env = nextPath === currentPath ? input.env : { ...input.env, PATH: nextPath };
|
const env = nextPath === currentPath ? input.env : { ...input.env, PATH: nextPath };
|
||||||
|
const addedPathEntry = nextPath === currentPath ? null : sandboxPathEntries[0];
|
||||||
|
|
||||||
if (!runtimeInfo.preferredCommandPath) {
|
if (!runtimeInfo.preferredCommandPath) {
|
||||||
return {
|
return {
|
||||||
command: input.command,
|
command: input.command,
|
||||||
env,
|
env,
|
||||||
remoteSystemHomeDir,
|
remoteSystemHomeDir,
|
||||||
addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir,
|
addedPathEntry,
|
||||||
preferredCommandPath: null,
|
preferredCommandPath: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +232,7 @@ export async function prepareCursorSandboxCommand(input: {
|
||||||
command: runtimeInfo.preferredCommandPath,
|
command: runtimeInfo.preferredCommandPath,
|
||||||
env,
|
env,
|
||||||
remoteSystemHomeDir,
|
remoteSystemHomeDir,
|
||||||
addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir,
|
addedPathEntry,
|
||||||
preferredCommandPath: runtimeInfo.preferredCommandPath,
|
preferredCommandPath: runtimeInfo.preferredCommandPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
packages/adapters/cursor-local/src/server/test.test.ts
Normal file
132
packages/adapters/cursor-local/src/server/test.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
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 { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||||
|
import { testEnvironment } from "./test.js";
|
||||||
|
|
||||||
|
function buildFakeAgentScript(): string {
|
||||||
|
return `#!/bin/sh
|
||||||
|
if [ "$1" = "--version" ]; then
|
||||||
|
printf '%s\\n' 'Cursor Agent 1.2.3'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s\\n' '{"type":"system","subtype":"init","session_id":"cursor-session-envtest-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-envtest-1","result":"ok"}'
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInstallSimulationCommand(commandPath: string): string {
|
||||||
|
return [
|
||||||
|
`mkdir -p ${JSON.stringify(path.dirname(commandPath))}`,
|
||||||
|
`cat > ${JSON.stringify(commandPath)} <<'EOF'`,
|
||||||
|
buildFakeAgentScript(),
|
||||||
|
"EOF",
|
||||||
|
`chmod +x ${JSON.stringify(commandPath)}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSandboxRunner(options: { homeDir: string; installCommandPath: string }) {
|
||||||
|
let counter = 0;
|
||||||
|
const installCommands: string[] = [];
|
||||||
|
const systemPath = "/usr/bin:/bin";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return await runChildProcess(`cursor-envtest-runner-${counter}`, input.command, args, {
|
||||||
|
cwd: input.cwd ?? process.cwd(),
|
||||||
|
env: {
|
||||||
|
...(input.env ?? {}),
|
||||||
|
HOME: input.env?.HOME ?? options.homeDir,
|
||||||
|
PATH: input.env?.PATH ?? systemPath,
|
||||||
|
},
|
||||||
|
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 testEnvironment", () => {
|
||||||
|
it("re-resolves the installed agent under ~/.cursor/bin and verifies --version before the hello probe", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-envtest-"));
|
||||||
|
const homeDir = path.join(root, "home");
|
||||||
|
const workspace = path.join(root, "workspace");
|
||||||
|
const remoteWorkspace = path.join(root, "remote-workspace");
|
||||||
|
const agentPath = path.join(homeDir, ".cursor", "bin", "agent");
|
||||||
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
|
await fs.mkdir(remoteWorkspace, { recursive: true });
|
||||||
|
|
||||||
|
const runner = createSandboxRunner({
|
||||||
|
homeDir,
|
||||||
|
installCommandPath: agentPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testEnvironment({
|
||||||
|
companyId: "company-1",
|
||||||
|
adapterType: "cursor",
|
||||||
|
config: {
|
||||||
|
command: "agent",
|
||||||
|
cwd: workspace,
|
||||||
|
env: {
|
||||||
|
PATH: "/usr/bin:/bin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
executionTarget: {
|
||||||
|
kind: "remote",
|
||||||
|
transport: "sandbox",
|
||||||
|
shellCommand: "bash",
|
||||||
|
remoteCwd: remoteWorkspace,
|
||||||
|
runner,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("pass");
|
||||||
|
expect(runner.installCommands).toEqual([SANDBOX_INSTALL_COMMAND]);
|
||||||
|
expect(result.checks).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "cursor_command_resolvable",
|
||||||
|
level: "info",
|
||||||
|
message: `Command is executable: ${agentPath}`,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "cursor_version_probe_passed",
|
||||||
|
level: "info",
|
||||||
|
detail: "Cursor Agent 1.2.3",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "cursor_hello_probe_passed",
|
||||||
|
level: "info",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -148,7 +148,6 @@ export async function testEnvironment(
|
||||||
});
|
});
|
||||||
command = sandboxCommand.command;
|
command = sandboxCommand.command;
|
||||||
env = sandboxCommand.env;
|
env = sandboxCommand.env;
|
||||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
|
||||||
const installCheck = await maybeRunSandboxInstallCommand({
|
const installCheck = await maybeRunSandboxInstallCommand({
|
||||||
runId,
|
runId,
|
||||||
target,
|
target,
|
||||||
|
|
@ -158,6 +157,19 @@ export async function testEnvironment(
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
if (installCheck) checks.push(installCheck);
|
if (installCheck) checks.push(installCheck);
|
||||||
|
const finalSandboxCommand = await prepareCursorSandboxCommand({
|
||||||
|
runId,
|
||||||
|
target,
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
remoteSystemHomeDirHint: sandboxCommand.remoteSystemHomeDir,
|
||||||
|
timeoutSec: 45,
|
||||||
|
graceSec: 5,
|
||||||
|
});
|
||||||
|
command = finalSandboxCommand.command;
|
||||||
|
env = finalSandboxCommand.env;
|
||||||
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
try {
|
try {
|
||||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||||
checks.push({
|
checks.push({
|
||||||
|
|
@ -218,6 +230,58 @@ export async function testEnvironment(
|
||||||
hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.",
|
hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const versionProbe = await runAdapterExecutionTargetProcess(
|
||||||
|
runId,
|
||||||
|
target,
|
||||||
|
command,
|
||||||
|
["--version"],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec: 45,
|
||||||
|
graceSec: 5,
|
||||||
|
onLog: async () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const versionDetail = summarizeProbeDetail(versionProbe.stdout, versionProbe.stderr, null);
|
||||||
|
if (versionProbe.timedOut) {
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_version_probe_timed_out",
|
||||||
|
level: "error",
|
||||||
|
message: "Cursor version probe timed out.",
|
||||||
|
hint: "Run `agent --version` manually in this working directory to confirm the installed CLI is reachable non-interactively.",
|
||||||
|
});
|
||||||
|
} else if ((versionProbe.exitCode ?? 1) === 0) {
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_version_probe_passed",
|
||||||
|
level: "info",
|
||||||
|
message: "Cursor version probe succeeded.",
|
||||||
|
...(versionDetail ? { detail: versionDetail } : {}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "cursor_version_probe_failed",
|
||||||
|
level: "error",
|
||||||
|
message: "Cursor version probe failed.",
|
||||||
|
...(versionDetail ? { detail: versionDetail } : {}),
|
||||||
|
hint: "Run `agent --version` manually in this working directory to confirm the installed CLI is reachable non-interactively.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRunHelloProbe = checks.every(
|
||||||
|
(check) =>
|
||||||
|
check.code !== "cursor_version_probe_failed" &&
|
||||||
|
check.code !== "cursor_version_probe_timed_out",
|
||||||
|
);
|
||||||
|
if (!canRunHelloProbe) {
|
||||||
|
return {
|
||||||
|
adapterType: ctx.adapterType,
|
||||||
|
status: summarizeStatus(checks),
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||||
const extraArgs = (() => {
|
const extraArgs = (() => {
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue