Merge upstream/master into dev (76 commits)

Resolved 5 conflicts:
- .github/workflows/docker.yml, release.yml: kept fork stubs (CI handled by build-prod/build-dev)
- server/src/routes/secrets.ts: kept fork's /usages route alongside upstream's /usage, /access-events
- server/src/services/secrets.ts: kept fork's usages() function and in-use deletion guard,
  layered before upstream's soft-delete + provider cleanup in remove()
- ui/src/api/secrets.ts: kept fork's usages() method alongside upstream's vault methods

Typechecks pass on @paperclipai/shared, @paperclipai/server, @paperclipai/ui.
This commit is contained in:
2026-05-11 18:01:34 -04:00
625 changed files with 145314 additions and 4442 deletions
@@ -55,9 +55,15 @@ describe("command managed runtime", () => {
...process.env,
...input.env,
};
const command = input.command === "sh" ? "/bin/sh" : input.command;
const command =
input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
const args = [...(input.args ?? [])];
if (input.stdin != null && input.command === "sh" && args[0] === "-lc" && typeof args[1] === "string") {
if (
input.stdin != null &&
(input.command === "sh" || input.command === "bash") &&
(args[0] === "-c" || args[0] === "-lc") &&
typeof args[1] === "string"
) {
env.PAPERCLIP_TEST_STDIN = input.stdin;
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
}
@@ -125,4 +131,90 @@ describe("command managed runtime", () => {
.toMatchObject({ code: "ENOENT" });
expect(calls.every((call) => call.stdin == null)).toBe(true);
});
it("runs setup commands from a stable root cwd when staging into a nested remote workspace dir", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-nested-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteBaseDir = path.join(rootDir, "remote-base");
const remoteWorkspaceDir = path.join(remoteBaseDir, ".paperclip-runtime", "runs", "test", "workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(remoteBaseDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
const calls: Array<{
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}> = [];
const runner = {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}): Promise<RunProcessResult> => {
calls.push({ ...input });
const startedAt = new Date().toISOString();
try {
const result = await execFile(input.command === "sh" ? "/bin/sh" : input.command, input.args ?? [], {
cwd: input.cwd,
env: {
...process.env,
...input.env,
},
maxBuffer: 32 * 1024 * 1024,
timeout: input.timeoutMs,
});
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: result.stdout,
stderr: result.stderr,
pid: null,
startedAt,
};
} catch (error) {
const err = error as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
code?: string | number | null;
signal?: NodeJS.Signals | null;
killed?: boolean;
};
return {
exitCode: typeof err.code === "number" ? err.code : null,
signal: err.signal ?? null,
timedOut: Boolean(err.killed && input.timeoutMs),
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
pid: null,
startedAt,
};
}
},
};
await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteBaseDir,
timeoutMs: 30_000,
},
adapterKey: "codex",
workspaceLocalDir: localWorkspaceDir,
workspaceRemoteDir: remoteWorkspaceDir,
});
expect(calls.length).toBeGreaterThan(0);
expect(calls.every((call) => call.cwd === "/")).toBe(true);
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
});
});
@@ -6,6 +6,7 @@ import {
type SandboxManagedRuntimeClient,
type SandboxRemoteExecutionSpec,
} from "./sandbox-managed-runtime.js";
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
import type { RunProcessResult } from "./server-utils.js";
export interface CommandManagedRuntimeRunner {
@@ -23,10 +24,10 @@ export interface CommandManagedRuntimeRunner {
export interface CommandManagedRuntimeSpec {
providerKey?: string | null;
shellCommand?: "bash" | "sh" | null;
leaseId?: string | null;
remoteCwd: string;
timeoutMs?: number | null;
paperclipApiUrl?: string | null;
}
export type CommandManagedRuntimeAsset = SandboxManagedRuntimeAsset;
@@ -56,14 +57,16 @@ function requireSuccessfulResult(result: RunProcessResult, action: string): void
export function createCommandManagedRuntimeClient(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
commandCwd: string;
timeoutMs: number;
shellCommand?: "bash" | "sh" | null;
}): SandboxManagedRuntimeClient {
const shellCommand = preferredShellForSandbox(input.shellCommand);
const runShell = async (script: string, opts: { stdin?: string; timeoutMs?: number } = {}) => {
const result = await input.runner.execute({
command: "sh",
args: ["-lc", script],
cwd: input.remoteCwd,
command: shellCommand,
args: shellCommandArgs(script),
cwd: input.commandCwd,
stdin: opts.stdin,
timeoutMs: opts.timeoutMs ?? input.timeoutMs,
});
@@ -112,18 +115,18 @@ export function createCommandManagedRuntimeClient(input: {
},
remove: async (remotePath) => {
const result = await input.runner.execute({
command: "sh",
args: ["-lc", `rm -rf ${shellQuote(remotePath)}`],
cwd: input.remoteCwd,
command: shellCommand,
args: shellCommandArgs(`rm -rf ${shellQuote(remotePath)}`),
cwd: input.commandCwd,
timeoutMs: input.timeoutMs,
});
requireSuccessfulResult(result, `remove ${remotePath}`);
},
run: async (command, options) => {
const result = await input.runner.execute({
command: "sh",
args: ["-lc", command],
cwd: input.remoteCwd,
command: shellCommand,
args: shellCommandArgs(command),
cwd: input.commandCwd,
timeoutMs: options.timeoutMs,
});
requireSuccessfulResult(result, command);
@@ -141,9 +144,15 @@ export async function prepareCommandManagedRuntime(input: {
preserveAbsentOnRestore?: string[];
assets?: CommandManagedRuntimeAsset[];
installCommand?: string | null;
/** When provided alongside `installCommand`, skip the install if `command -v <detectCommand>` succeeds. */
detectCommand?: string | null;
}): Promise<PreparedSandboxManagedRuntime> {
const timeoutMs = input.spec.timeoutMs && input.spec.timeoutMs > 0 ? input.spec.timeoutMs : 300_000;
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
// Managed-runtime sync/restore scripts use absolute paths throughout, so
// run them from a stable cwd. The target workspace itself may be removed or
// recreated during a run, which breaks shell startup if we chdir into it.
const commandCwd = "/";
const runtimeSpec: SandboxRemoteExecutionSpec = {
transport: "sandbox",
provider: input.spec.providerKey ?? "sandbox",
@@ -151,22 +160,62 @@ export async function prepareCommandManagedRuntime(input: {
remoteCwd: workspaceRemoteDir,
timeoutMs,
apiKey: null,
paperclipApiUrl: input.spec.paperclipApiUrl ?? null,
};
const client = createCommandManagedRuntimeClient({
runner: input.runner,
remoteCwd: workspaceRemoteDir,
commandCwd,
timeoutMs,
shellCommand: input.spec.shellCommand,
});
const shellCommand = preferredShellForSandbox(input.spec.shellCommand);
if (input.installCommand?.trim()) {
const installCommand = input.installCommand.trim();
const detectCommand = input.detectCommand?.trim();
// Skip the install when the binary is already on PATH. Without this
// probe the install runs unconditionally on every execute() call (and
// also runs a second time after `ensureAdapterExecutionTargetCommandResolvable`
// has already installed it during the resolvability gate).
if (detectCommand) {
const probe = await input.runner.execute({
command: shellCommand,
args: shellCommandArgs(`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`),
cwd: commandCwd,
timeoutMs,
});
if (!probe.timedOut && (probe.exitCode ?? 1) === 0) {
return await prepareSandboxManagedRuntime({
spec: runtimeSpec,
client,
adapterKey: input.adapterKey,
workspaceLocalDir: input.workspaceLocalDir,
workspaceRemoteDir,
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
});
}
}
const result = await input.runner.execute({
command: "sh",
args: ["-lc", input.installCommand.trim()],
cwd: workspaceRemoteDir,
command: shellCommand,
args: shellCommandArgs(installCommand),
cwd: commandCwd,
timeoutMs,
});
requireSuccessfulResult(result, input.installCommand.trim());
// A failed install is not always fatal: the CLI may already be on PATH
// from a previous lease, the template image, or another path entry. Log
// and continue rather than aborting the agent run; downstream code that
// exec's the CLI will surface a clear "command not found" if it is in
// fact missing. The test path's `maybeRunSandboxInstallCommand` already
// honors this contract — keep them consistent.
if (result.timedOut || (result.exitCode ?? 0) !== 0) {
const tail = (text: string) =>
text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-3).join(" | ").slice(0, 480);
const reason = result.timedOut ? "timed out" : `exited ${result.exitCode ?? "?"}`;
console.warn(
`[paperclip] managed-runtime install command ${reason}: ${installCommand} :: ${tail(result.stderr || result.stdout)}`,
);
}
}
return await prepareSandboxManagedRuntime({
@@ -5,8 +5,12 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetToRemoteSpec,
adapterExecutionTargetUsesPaperclipBridge,
ensureAdapterExecutionTargetCommandResolvable,
resolveAdapterExecutionTargetTimeoutSec,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
@@ -18,6 +22,7 @@ describe("sandbox adapter execution targets", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
@@ -39,7 +44,8 @@ describe("sandbox adapter execution targets", () => {
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}) => {
counter += 1;
return runChildProcess(`sandbox-run-${counter}`, input.command, input.args ?? [], {
const command = input.command === "bash" ? "/bin/bash" : input.command;
return runChildProcess(`sandbox-run-${counter}`, command, input.args ?? [], {
cwd: input.cwd ?? process.cwd(),
env: input.env ?? {},
stdin: input.stdin,
@@ -103,10 +109,92 @@ describe("sandbox adapter execution targets", () => {
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd: "/workspace",
paperclipTransport: "bridge",
});
});
it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => {
const sandboxTarget: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
remoteCwd: "/workspace",
runner: createLocalSandboxRunner(),
};
expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 0)).toBe(
DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
);
expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 90)).toBe(90);
expect(resolveAdapterExecutionTargetTimeoutSec({
kind: "remote",
transport: "ssh",
remoteCwd: "/workspace",
spec: {
host: "127.0.0.1",
port: 22,
username: "fixture",
remoteWorkspacePath: "/workspace",
remoteCwd: "/workspace",
privateKey: "KEY",
knownHosts: "host key",
strictHostKeyChecking: true,
},
}, 0)).toBe(0);
});
it("uses the caller timeout override when installing a missing sandbox command", async () => {
const runner = {
execute: vi.fn()
.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
})
.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
})
.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "/usr/bin/opencode\n",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
remoteCwd: "/workspace",
timeoutMs: 300_000,
runner,
};
await ensureAdapterExecutionTargetCommandResolvable(
"opencode",
target,
"/local/workspace",
{},
{ installCommand: "npm install -g opencode", timeoutSec: 1800 },
);
expect(runner.execute).toHaveBeenNthCalledWith(2, expect.objectContaining({
command: "sh",
args: ["-c", "npm install -g opencode"],
timeoutMs: 1_800_000,
}));
});
it("runs shell commands through the same runner", async () => {
const runner = {
execute: vi.fn(async () => ({
@@ -134,7 +222,155 @@ describe("sandbox adapter execution targets", () => {
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
command: "sh",
args: ["-lc", 'printf %s "$HOME"'],
args: ["-c", 'printf %s "$HOME"'],
cwd: "/workspace",
timeoutMs: 7000,
}));
});
it("strips inherited host identity env before sandbox execution", async () => {
vi.stubEnv("PATH", "/host/bin:/usr/bin");
vi.stubEnv("HOME", "/Users/local");
vi.stubEnv("TMPDIR", "/var/folders/local/T");
const runner = {
execute: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok\n",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
})),
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
remoteCwd: "/workspace",
runner,
};
await runAdapterExecutionTargetProcess("run-1b", target, "agent-cli", ["--json"], {
cwd: "/local/workspace",
env: {
PATH: "/host/bin:/usr/bin",
HOME: "/Users/local",
TMPDIR: "/var/folders/local/T",
SAFE_VALUE: "visible",
},
timeoutSec: 5,
graceSec: 1,
onLog: async () => {},
});
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
env: {
SAFE_VALUE: "visible",
},
}));
});
it("preserves explicit remote identity env overrides for sandbox execution", async () => {
vi.stubEnv("PATH", "/host/bin:/usr/bin");
vi.stubEnv("HOME", "/Users/local");
const runner = {
execute: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok\n",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
})),
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
remoteCwd: "/workspace",
runner,
};
await runAdapterExecutionTargetProcess("run-1c", target, "agent-cli", ["--json"], {
cwd: "/local/workspace",
env: {
PATH: "/custom/remote/bin:/usr/bin",
HOME: "/home/sandbox",
SAFE_VALUE: "visible",
},
timeoutSec: 5,
graceSec: 1,
onLog: async () => {},
});
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
env: {
PATH: "/custom/remote/bin:/usr/bin",
HOME: "/home/sandbox",
SAFE_VALUE: "visible",
},
}));
});
it("treats SSH targets as bridge-only", () => {
const target = {
kind: "remote" as const,
transport: "ssh" as const,
remoteCwd: "/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "paperclip",
remoteWorkspacePath: "/workspace",
remoteCwd: "/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
};
expect(adapterExecutionTargetUsesPaperclipBridge(target)).toBe(true);
expect(adapterExecutionTargetSessionIdentity(target)).toEqual({
transport: "ssh",
host: "ssh.example.test",
port: 22,
username: "paperclip",
remoteCwd: "/workspace",
});
});
it("uses the provider-declared shell for sandbox helper commands", async () => {
const runner = {
execute: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "/home/sandbox",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
})),
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "custom-provider",
shellCommand: "bash",
remoteCwd: "/workspace",
runner,
};
await runAdapterExecutionTargetShellCommand("run-2b", target, 'printf %s "$HOME"', {
cwd: "/local/workspace",
env: {},
timeoutSec: 7,
});
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
command: "bash",
args: ["-c", 'printf %s "$HOME"'],
cwd: "/workspace",
timeoutMs: 7000,
}));
@@ -174,7 +410,6 @@ describe("sandbox adapter execution targets", () => {
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
paperclipTransport: "bridge",
runner: createLocalSandboxRunner(),
timeoutMs: 30_000,
};
@@ -214,6 +449,60 @@ describe("sandbox adapter execution targets", () => {
}
});
it("uses the effective adapter timeout when starting the sandbox callback bridge", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-timeout-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
await mkdir(runtimeRootDir, { recursive: true });
const delegateRunner = createLocalSandboxRunner();
const runner = {
execute: vi.fn(async (input: Parameters<typeof delegateRunner.execute>[0]) => delegateRunner.execute(input)),
};
const apiServer = createServer((req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
await new Promise<void>((resolve, reject) => {
apiServer.once("error", reject);
apiServer.listen(0, "127.0.0.1", () => resolve());
});
const address = apiServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected the bridge timeout test API server to listen on a TCP port.");
}
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "cloudflare",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
runner,
timeoutMs: 30_000,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-bridge-timeout",
target,
runtimeRootDir,
adapterKey: "codex",
timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${address.port}`,
});
try {
expect(bridge).not.toBeNull();
expect(runner.execute).toHaveBeenCalled();
expect(runner.execute.mock.calls.some(([input]) => input.timeoutMs === 1_800_000)).toBe(true);
} finally {
await bridge?.stop();
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
}
});
it("fails oversized host responses with a 502 before returning them to the sandbox client", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-limit-"));
cleanupDirs.push(rootDir);
@@ -252,7 +541,6 @@ describe("sandbox adapter execution targets", () => {
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
paperclipTransport: "bridge",
runner: createLocalSandboxRunner(),
timeoutMs: 30_000,
};
@@ -1,14 +1,18 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as ssh from "./ssh.js";
import * as serverUtils from "./server-utils.js";
import {
adapterExecutionTargetUsesManagedHome,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
} from "./execution-target.js";
describe("runAdapterExecutionTargetShellCommand", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("quotes remote shell commands with the shared SSH quoting helper", async () => {
@@ -41,16 +45,68 @@ describe("runAdapterExecutionTargetShellCommand", () => {
},
);
// runSshCommand owns profile sourcing and the outer shell wrapper —
// the caller passes the raw command string. Wrapping it here would
// double-nest the login shell and re-source profiles after the explicit
// env override, silently undoing identity-var preservation.
expect(runSshCommandSpy).toHaveBeenCalledWith(
expect.objectContaining({
host: "ssh.example.test",
username: "ssh-user",
}),
`sh -lc ${ssh.shellQuote(`printf '%s\\n' "$HOME" && echo "it's ok"`)}`,
`printf '%s\\n' "$HOME" && echo "it's ok"`,
expect.any(Object),
);
});
it("sanitizes inherited host env before SSH shell execution", async () => {
vi.stubEnv("PATH", "/host/bin:/usr/bin");
vi.stubEnv("HOME", "/Users/local");
const runSshCommandSpy = vi.spyOn(ssh, "runSshCommand").mockResolvedValue({
stdout: "",
stderr: "",
});
await runAdapterExecutionTargetShellCommand(
"run-1b",
{
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
"env",
{
cwd: "/tmp/local",
env: {
PATH: "/host/bin:/usr/bin",
HOME: "/Users/local",
SAFE_VALUE: "visible",
},
},
);
expect(runSshCommandSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(String),
expect.objectContaining({
env: {
SAFE_VALUE: "visible",
},
}),
);
});
it("returns a timedOut result when the SSH shell command times out", async () => {
vi.spyOn(ssh, "runSshCommand").mockRejectedValue(Object.assign(new Error("timed out"), {
code: "ETIMEDOUT",
@@ -161,6 +217,145 @@ describe("runAdapterExecutionTargetShellCommand", () => {
});
});
describe("runAdapterExecutionTargetProcess", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("sanitizes inherited host env before SSH process execution", async () => {
vi.stubEnv("PATH", "/host/bin:/usr/bin");
vi.stubEnv("HOME", "/Users/local");
const runChildProcessSpy = vi.spyOn(serverUtils, "runChildProcess").mockResolvedValue({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
});
await runAdapterExecutionTargetProcess(
"run-ssh-process",
{
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
"agent-cli",
["--json"],
{
cwd: "/tmp/local",
env: {
PATH: "/host/bin:/usr/bin",
HOME: "/Users/local",
SAFE_VALUE: "visible",
},
timeoutSec: 5,
graceSec: 1,
onLog: async () => {},
},
);
expect(runChildProcessSpy).toHaveBeenCalledWith(
"run-ssh-process",
"agent-cli",
["--json"],
expect.objectContaining({
env: {
SAFE_VALUE: "visible",
},
}),
);
});
});
describe("ensureAdapterExecutionTargetRuntimeCommandInstalled", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("runs install commands for sandbox targets", async () => {
const runner = {
execute: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
})),
};
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId: "run-install",
target: {
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
remoteCwd: "/remote/workspace",
runner,
},
installCommand: "npm install -g @google/gemini-cli",
cwd: "/local/workspace",
env: { PATH: "/usr/bin" },
timeoutSec: 30,
});
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
command: "sh",
args: ["-c", "npm install -g @google/gemini-cli"],
cwd: "/remote/workspace",
env: { PATH: "/usr/bin" },
timeoutMs: 30_000,
}));
});
it("skips install commands for SSH targets", async () => {
const runSshCommandSpy = vi.spyOn(ssh, "runSshCommand").mockResolvedValue({
stdout: "",
stderr: "",
});
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId: "run-skip",
target: {
kind: "remote",
transport: "ssh",
remoteCwd: "/srv/paperclip/workspace",
spec: {
host: "ssh.example.test",
port: 22,
username: "ssh-user",
remoteCwd: "/srv/paperclip/workspace",
remoteWorkspacePath: "/srv/paperclip/workspace",
privateKey: null,
knownHosts: null,
strictHostKeyChecking: true,
},
},
installCommand: "npm install -g @google/gemini-cli",
cwd: "/tmp/local",
env: {},
});
expect(runSshCommandSpy).not.toHaveBeenCalled();
});
});
describe("resolveAdapterExecutionTargetCwd", () => {
const sshTarget = {
kind: "remote" as const,
+437 -51
View File
@@ -18,7 +18,7 @@ import {
startSandboxCallbackBridgeServer,
startSandboxCallbackBridgeWorker,
} from "./sandbox-callback-bridge.js";
import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
import {
ensureCommandResolvable,
resolveCommandForLogs,
@@ -26,6 +26,8 @@ import {
type RunProcessResult,
type TerminalResultCleanupOptions,
} from "./server-utils.js";
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
export interface AdapterLocalExecutionTarget {
kind: "local";
@@ -39,7 +41,6 @@ export interface AdapterSshExecutionTarget {
environmentId?: string | null;
leaseId?: string | null;
remoteCwd: string;
paperclipApiUrl?: string | null;
spec: SshRemoteExecutionSpec;
}
@@ -47,11 +48,10 @@ export interface AdapterSandboxExecutionTarget {
kind: "remote";
transport: "sandbox";
providerKey?: string | null;
shellCommand?: "bash" | "sh" | null;
environmentId?: string | null;
leaseId?: string | null;
remoteCwd: string;
paperclipApiUrl?: string | null;
paperclipTransport?: "direct" | "bridge";
timeoutMs?: number | null;
runner?: CommandManagedRuntimeRunner;
}
@@ -67,6 +67,7 @@ export type AdapterManagedRuntimeAsset = RemoteManagedRuntimeAsset;
export interface PreparedAdapterExecutionTargetRuntime {
target: AdapterExecutionTarget;
workspaceRemoteDir: string | null;
runtimeRootDir: string | null;
assetDirs: Record<string, string>;
restoreWorkspace(): Promise<void>;
@@ -96,6 +97,10 @@ export interface AdapterExecutionTargetPaperclipBridgeHandle {
stop(): Promise<void>;
}
export { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
export const DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC = 1_800;
function parseObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -126,13 +131,9 @@ function resolveDefaultPaperclipApiUrl(): string {
return `http://${runtimeHost}:${runtimePort}`;
}
function resolveSandboxPaperclipTransport(
target: Pick<AdapterSandboxExecutionTarget, "paperclipTransport" | "paperclipApiUrl">,
): "direct" | "bridge" {
if (target.paperclipTransport === "direct" || target.paperclipTransport === "bridge") {
return target.paperclipTransport;
}
return target.paperclipApiUrl ? "direct" : "bridge";
function isBridgeDebugEnabled(env: NodeJS.ProcessEnv): boolean {
const value = env.PAPERCLIP_BRIDGE_DEBUG?.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget {
@@ -169,6 +170,33 @@ export function adapterExecutionTargetRemoteCwd(
return target?.kind === "remote" ? target.remoteCwd : localCwd;
}
export function overrideAdapterExecutionTargetRemoteCwd(
target: AdapterExecutionTarget | null | undefined,
remoteCwd: string | null | undefined,
): AdapterExecutionTarget | null | undefined {
const nextRemoteCwd = remoteCwd?.trim();
if (!target || target.kind !== "remote" || !nextRemoteCwd) {
return target;
}
if (target.remoteCwd === nextRemoteCwd) {
return target;
}
if (target.transport === "ssh") {
return {
...target,
remoteCwd: nextRemoteCwd,
spec: {
...target.spec,
remoteCwd: nextRemoteCwd,
},
};
}
return {
...target,
remoteCwd: nextRemoteCwd,
};
}
export function resolveAdapterExecutionTargetCwd(
target: AdapterExecutionTarget | null | undefined,
configuredCwd: string | null | undefined,
@@ -180,21 +208,10 @@ export function resolveAdapterExecutionTargetCwd(
return adapterExecutionTargetRemoteCwd(target, localFallbackCwd);
}
export function adapterExecutionTargetPaperclipApiUrl(
target: AdapterExecutionTarget | null | undefined,
): string | null {
if (target?.kind !== "remote") return null;
if (target.transport === "ssh") return target.paperclipApiUrl ?? target.spec.paperclipApiUrl ?? null;
if (resolveSandboxPaperclipTransport(target) === "bridge") return null;
return target.paperclipApiUrl ?? null;
}
export function adapterExecutionTargetUsesPaperclipBridge(
target: AdapterExecutionTarget | null | undefined,
): boolean {
return target?.kind === "remote" &&
target.transport === "sandbox" &&
resolveSandboxPaperclipTransport(target) === "bridge";
return target?.kind === "remote";
}
export function describeAdapterExecutionTarget(
@@ -207,6 +224,26 @@ export function describeAdapterExecutionTarget(
return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`;
}
export function resolveAdapterExecutionTargetTimeoutSec(
target: AdapterExecutionTarget | null | undefined,
configuredTimeoutSec: number | null | undefined,
): number {
const normalizedConfiguredTimeoutSec =
typeof configuredTimeoutSec === "number" && Number.isFinite(configuredTimeoutSec) && configuredTimeoutSec > 0
? Math.floor(configuredTimeoutSec)
: 0;
if (normalizedConfiguredTimeoutSec > 0) return normalizedConfiguredTimeoutSec;
// Local and SSH adapters preserve the historical "0 means no adapter
// timeout" behavior. Sandbox-backed runs execute through provider RPCs
// that usually apply their own shorter command defaults, so request an
// explicit longer timeout for full adapter runs when the adapter leaves
// timeoutSec unset.
if (target?.kind === "remote" && target.transport === "sandbox") {
return DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC;
}
return 0;
}
function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner {
if (target.runner) return target.runner;
throw new Error(
@@ -214,13 +251,47 @@ function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandMan
);
}
function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | "sh" {
return preferredShellForSandbox(target.shellCommand);
}
type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget;
function adapterExecutionTargetCommandRunner(target: AdapterCommandCapableExecutionTarget): CommandManagedRuntimeRunner {
if (target.transport === "ssh") {
return createSshCommandManagedRuntimeRunner({
spec: target.spec,
defaultCwd: target.remoteCwd,
maxBufferBytes: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4,
});
}
return requireSandboxRunner(target);
}
function adapterExecutionTargetShellCommand(target: AdapterCommandCapableExecutionTarget): "bash" | "sh" {
return target.transport === "ssh" ? "sh" : preferredSandboxShell(target);
}
function adapterExecutionTargetTimeoutMs(
target: AdapterCommandCapableExecutionTarget,
): number | null | undefined {
return target.transport === "sandbox" ? target.timeoutMs : undefined;
}
export async function ensureAdapterExecutionTargetCommandResolvable(
command: string,
target: AdapterExecutionTarget | null | undefined,
cwd: string,
env: NodeJS.ProcessEnv,
options: { installCommand?: string | null; timeoutSec?: number | null } = {},
) {
if (target?.kind === "remote" && target.transport === "sandbox") {
await ensureSandboxCommandResolvable(
command,
target,
options.installCommand?.trim() || null,
options.timeoutSec,
);
return;
}
await ensureCommandResolvable(command, cwd, env, {
@@ -228,6 +299,87 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
});
}
async function probeSandboxCommandResolvable(
command: string,
target: AdapterSandboxExecutionTarget,
): Promise<{ resolved: boolean; timedOut: boolean; stderr: string }> {
const runner = requireSandboxRunner(target);
const probeScript = `command -v ${shellQuote(command)}`;
const result = await runner.execute({
command: "sh",
args: ["-c", probeScript],
cwd: target.remoteCwd,
timeoutMs: target.timeoutMs ?? 15_000,
});
return {
resolved: !result.timedOut && (result.exitCode ?? 1) === 0,
timedOut: result.timedOut,
stderr: result.stderr.trim(),
};
}
async function ensureSandboxCommandResolvable(
command: string,
target: AdapterSandboxExecutionTarget,
installCommand: string | null,
timeoutSec?: number | null,
): Promise<void> {
// Probe whether the binary is resolvable inside the sandbox. We previously
// short-circuited this for sandbox targets, which let the caller report a
// success message even when the CLI was missing from the image. Now we run
// a real `command -v` through the same runner the hello probe will use, so
// the first step honestly reflects whether the binary is on PATH. The
// sandbox provider is responsible for sourcing login profiles (e2b mirrors
// SSH's buildSshSpawnTarget) so this and the hello probe agree on PATH.
let probe = await probeSandboxCommandResolvable(command, target);
if (probe.resolved) return;
if (probe.timedOut) {
throw new Error(`Timed out checking command "${command}" on sandbox target.`);
}
// If the caller supplied an install command, attempt the install once via
// the sandbox runner (which the sandbox provider wraps in a login shell)
// and re-probe before reporting failure. This lets fresh sandbox leases
// bring up the CLI before the resolvability gate, mirroring the test path.
let installFailureDetail: string | null = null;
if (installCommand) {
const runner = requireSandboxRunner(target);
const installTimeoutMs =
typeof timeoutSec === "number" && Number.isFinite(timeoutSec) && timeoutSec > 0
? Math.floor(timeoutSec * 1000)
: target.timeoutMs ?? 300_000;
try {
const installResult = await runner.execute({
command: "sh",
args: shellCommandArgs(installCommand),
cwd: target.remoteCwd,
timeoutMs: installTimeoutMs,
});
if (installResult.timedOut) {
installFailureDetail = `install command timed out: ${installCommand}`;
} else if ((installResult.exitCode ?? 0) !== 0) {
const tail = (text: string) =>
text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-2).join(" | ").slice(0, 240);
const reason = tail(installResult.stderr || installResult.stdout) || `exit ${installResult.exitCode ?? "?"}`;
installFailureDetail = `install command exited ${installResult.exitCode ?? "?"}: ${reason}`;
}
} catch (err) {
installFailureDetail = `install command threw: ${err instanceof Error ? err.message : String(err)}`;
}
probe = await probeSandboxCommandResolvable(command, target);
if (probe.resolved) return;
if (probe.timedOut) {
throw new Error(`Timed out checking command "${command}" on sandbox target.`);
}
}
const probeStderr = probe.stderr.length > 0 ? ` probe stderr: ${probe.stderr}` : "";
const installDetail = installFailureDetail ? `; ${installFailureDetail}` : "";
throw new Error(
`Command "${command}" is not installed or not on PATH in the sandbox environment${installDetail}.${probeStderr}`,
);
}
export async function resolveAdapterExecutionTargetCommandForLogs(
command: string,
target: AdapterExecutionTarget | null | undefined,
@@ -251,11 +403,12 @@ export async function runAdapterExecutionTargetProcess(
): Promise<RunProcessResult> {
if (target?.kind === "remote" && target.transport === "sandbox") {
const runner = requireSandboxRunner(target);
const env = sanitizeRemoteExecutionEnv(options.env);
return await runner.execute({
command,
args,
cwd: target.remoteCwd,
env: options.env,
env,
stdin: options.stdin,
timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined,
onLog: options.onLog,
@@ -265,9 +418,14 @@ export async function runAdapterExecutionTargetProcess(
});
}
const env =
target?.kind === "remote" && target.transport === "ssh"
? sanitizeRemoteExecutionEnv(options.env)
: options.env;
return await runChildProcess(runId, command, args, {
cwd: options.cwd,
env: options.env,
env,
stdin: options.stdin,
timeoutSec: options.timeoutSec,
graceSec: options.graceSec,
@@ -287,9 +445,16 @@ export async function runAdapterExecutionTargetShellCommand(
const onLog = options.onLog ?? (async () => {});
if (target?.kind === "remote") {
const startedAt = new Date().toISOString();
const env = sanitizeRemoteExecutionEnv(options.env);
if (target.transport === "ssh") {
try {
const result = await runSshCommand(target.spec, `sh -lc ${shellQuote(command)}`, {
// Pass the raw command — `runSshCommand` owns profile sourcing and
// the outer shell wrapper. Wrapping again here would nest a second
// shell after the explicit `env KEY=VAL` overrides, re-sourcing
// login profiles AFTER the override and silently undoing any
// identity var (NVM_DIR / PATH / etc.) that a profile re-exports.
const result = await runSshCommand(target.spec, command, {
env,
timeoutMs: (options.timeoutSec ?? 15) * 1000,
});
if (result.stdout) await onLog("stdout", result.stdout);
@@ -341,11 +506,12 @@ export async function runAdapterExecutionTargetShellCommand(
}
}
const shellCommand = preferredSandboxShell(target);
return await requireSandboxRunner(target).execute({
command: "sh",
args: ["-lc", command],
command: shellCommand,
args: shellCommandArgs(command),
cwd: target.remoteCwd,
env: options.env,
env,
timeoutMs: (options.timeoutSec ?? 15) * 1000,
onLog,
});
@@ -366,6 +532,111 @@ export async function runAdapterExecutionTargetShellCommand(
);
}
export interface AdapterSandboxInstallCommandCheck {
code: string;
level: "info" | "warn" | "error";
message: string;
detail?: string;
hint?: string;
}
// Best-effort run of an adapter-supplied install command on a sandbox target
// before the resolvability + hello probe. Returns null for non-sandbox
// targets so callers can no-op. Returns a structured check otherwise — never
// throws — so the rest of the test still runs and reports the post-install
// state honestly. Caller pushes the check into its result array; the test
// report shows whether install was attempted and what came back.
export async function maybeRunSandboxInstallCommand(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
adapterKey: string;
installCommand: string;
/** When provided, skip the install if `command -v <detectCommand>` succeeds. */
detectCommand?: string | null;
env?: Record<string, string>;
timeoutSec?: number;
}): Promise<AdapterSandboxInstallCommandCheck | null> {
const { target, adapterKey, installCommand } = input;
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
return null;
}
const trimmed = installCommand.trim();
if (trimmed.length === 0) return null;
const code = `${adapterKey}_install_command_run`;
// Skip install when the binary is already on PATH. Avoids running
// network-dependent installers (e.g. `curl ... | bash`) on every test
// probe when the CLI is preinstalled on the lease/template.
const detectCommand = input.detectCommand?.trim();
if (detectCommand) {
try {
const probe = await runAdapterExecutionTargetShellCommand(
input.runId,
target,
`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`,
{
cwd: target.remoteCwd,
env: input.env ?? {},
timeoutSec: 30,
graceSec: 5,
},
);
if (!probe.timedOut && probe.exitCode === 0) {
return {
code,
level: "info",
message: `${detectCommand} already on PATH; skipped install.`,
};
}
} catch {
// Fall through to actually running the install — failure to probe
// is not a reason to skip the install gate.
}
}
let result;
try {
result = await runAdapterExecutionTargetShellCommand(input.runId, target, trimmed, {
cwd: target.remoteCwd,
env: input.env ?? {},
timeoutSec: input.timeoutSec ?? 240,
graceSec: 10,
});
} catch (err) {
return {
code,
level: "warn",
message: "Install command threw before completion.",
detail: err instanceof Error ? err.message : String(err),
};
}
const tail = (text: string) =>
text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-3).join(" | ").slice(0, 480);
if (result.timedOut) {
return {
code,
level: "warn",
message: `Install command timed out: ${trimmed}`,
detail: tail(result.stderr || result.stdout),
};
}
if ((result.exitCode ?? 1) === 0) {
return {
code,
level: "info",
message: `Install command ran: ${trimmed}`,
...(tail(result.stdout) ? { detail: tail(result.stdout) } : {}),
};
}
return {
code,
level: "warn",
message: `Install command exited ${result.exitCode}: ${trimmed}`,
detail: tail(result.stderr || result.stdout),
};
}
export async function readAdapterExecutionTargetHomeDir(
runId: string,
target: AdapterExecutionTarget | null | undefined,
@@ -381,6 +652,91 @@ export async function readAdapterExecutionTargetHomeDir(
return homeDir.length > 0 ? homeDir : null;
}
export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
installCommand?: string | null;
detectCommand?: string | null;
cwd: string;
env: Record<string, string>;
timeoutSec?: number;
graceSec?: number;
onLog?: AdapterExecutionTargetShellOptions["onLog"];
}): Promise<void> {
const installCommand = input.installCommand?.trim();
if (!installCommand || input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
return;
}
const detectCommand = input.detectCommand?.trim();
if (detectCommand) {
const probe = await runAdapterExecutionTargetShellCommand(
input.runId,
input.target,
`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`,
{
cwd: input.cwd,
env: input.env,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
},
);
if (!probe.timedOut && probe.exitCode === 0) {
return;
}
}
const result = await runAdapterExecutionTargetShellCommand(
input.runId,
input.target,
installCommand,
{
cwd: input.cwd,
env: input.env,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
onLog: input.onLog,
},
);
// A failed or timed-out install is not necessarily fatal: the CLI may already
// be on PATH from a previous lease's install, the template image, or another
// path entry. Re-run the detect probe (when one is configured) so a transient
// install failure does not abort the agent run when the binary is reachable.
const installFailed = result.timedOut || (result.exitCode ?? 0) !== 0;
if (!installFailed) {
return;
}
if (detectCommand) {
const recheck = await runAdapterExecutionTargetShellCommand(
input.runId,
input.target,
`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`,
{
cwd: input.cwd,
env: input.env,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
},
);
if (!recheck.timedOut && recheck.exitCode === 0) {
if (input.onLog) {
const reason = result.timedOut ? "timed out" : `exited ${result.exitCode ?? "?"}`;
await input.onLog(
"stderr",
`[paperclip] Install command ${reason} (${installCommand}) but ${detectCommand} is on PATH; continuing.\n`,
);
}
return;
}
}
if (result.timedOut) {
throw new Error(`Timed out while installing the adapter runtime command via: ${installCommand}`);
}
throw new Error(`Failed to install the adapter runtime command via: ${installCommand}`);
}
export async function ensureAdapterExecutionTargetFile(
runId: string,
target: AdapterExecutionTarget | null | undefined,
@@ -458,15 +814,12 @@ export function adapterExecutionTargetSessionIdentity(
): Record<string, unknown> | null {
if (!target || target.kind === "local") return null;
if (target.transport === "ssh") return buildRemoteExecutionSessionIdentity(target.spec);
const paperclipTransport = resolveSandboxPaperclipTransport(target);
return {
transport: "sandbox",
providerKey: target.providerKey ?? null,
environmentId: target.environmentId ?? null,
leaseId: target.leaseId ?? null,
remoteCwd: target.remoteCwd,
paperclipTransport,
...(paperclipTransport === "direct" && target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}),
};
}
@@ -485,9 +838,7 @@ export function adapterExecutionTargetSessionMatches(
readStringMeta(parsedSaved, "providerKey") === current?.providerKey &&
readStringMeta(parsedSaved, "environmentId") === current?.environmentId &&
readStringMeta(parsedSaved, "leaseId") === current?.leaseId &&
readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd &&
readStringMeta(parsedSaved, "paperclipTransport") === (current?.paperclipTransport ?? null) &&
readStringMeta(parsedSaved, "paperclipApiUrl") === (current?.paperclipApiUrl ?? null)
readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd
);
}
@@ -512,14 +863,12 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
environmentId: readStringMeta(parsed, "environmentId"),
leaseId: readStringMeta(parsed, "leaseId"),
remoteCwd: spec.remoteCwd,
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl") ?? spec.paperclipApiUrl ?? null,
spec,
};
}
if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") {
const remoteCwd = readStringMeta(parsed, "remoteCwd");
const paperclipTransport = readStringMeta(parsed, "paperclipTransport");
if (!remoteCwd) return null;
return {
kind: "remote",
@@ -528,11 +877,6 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
environmentId: readStringMeta(parsed, "environmentId"),
leaseId: readStringMeta(parsed, "leaseId"),
remoteCwd,
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl"),
paperclipTransport:
paperclipTransport === "direct" || paperclipTransport === "bridge"
? paperclipTransport
: undefined,
timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null,
};
}
@@ -553,7 +897,6 @@ export function adapterExecutionTargetFromRemoteExecution(
environmentId: metadata.environmentId ?? null,
leaseId: metadata.leaseId ?? null,
remoteCwd: ssh.remoteCwd,
paperclipApiUrl: ssh.paperclipApiUrl ?? null,
spec: ssh,
};
}
@@ -575,18 +918,24 @@ export function readAdapterExecutionTarget(input: {
}
export async function prepareAdapterExecutionTargetRuntime(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
adapterKey: string;
workspaceLocalDir: string;
timeoutSec?: number;
workspaceRemoteDir?: string;
workspaceExclude?: string[];
preserveAbsentOnRestore?: string[];
assets?: AdapterManagedRuntimeAsset[];
installCommand?: string | null;
/** When provided alongside `installCommand`, skip the install if the binary is already on PATH. */
detectCommand?: string | null;
}): Promise<PreparedAdapterExecutionTargetRuntime> {
const target = input.target ?? { kind: "local" as const };
if (target.kind === "local") {
return {
target,
workspaceRemoteDir: null,
runtimeRootDir: null,
assetDirs: {},
restoreWorkspace: async () => {},
@@ -596,12 +945,15 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
if (target.transport === "ssh") {
const prepared = await prepareRemoteManagedRuntime({
spec: target.spec,
runId: input.runId,
adapterKey: input.adapterKey,
workspaceLocalDir: input.workspaceLocalDir,
workspaceRemoteDir: input.workspaceRemoteDir,
assets: input.assets,
});
return {
target,
workspaceRemoteDir: prepared.workspaceRemoteDir,
runtimeRootDir: prepared.runtimeRootDir,
assetDirs: prepared.assetDirs,
restoreWorkspace: prepared.restoreWorkspace,
@@ -612,20 +964,26 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
runner: requireSandboxRunner(target),
spec: {
providerKey: target.providerKey,
shellCommand: target.shellCommand,
leaseId: target.leaseId,
remoteCwd: target.remoteCwd,
timeoutMs: target.timeoutMs,
paperclipApiUrl: target.paperclipApiUrl,
timeoutMs:
input.timeoutSec && input.timeoutSec > 0
? input.timeoutSec * 1000
: target.timeoutMs,
},
adapterKey: input.adapterKey,
workspaceLocalDir: input.workspaceLocalDir,
workspaceRemoteDir: input.workspaceRemoteDir,
workspaceExclude: input.workspaceExclude,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
installCommand: input.installCommand,
detectCommand: input.detectCommand,
});
return {
target,
workspaceRemoteDir: prepared.workspaceRemoteDir,
runtimeRootDir: prepared.runtimeRootDir,
assetDirs: prepared.assetDirs,
restoreWorkspace: prepared.restoreWorkspace,
@@ -695,6 +1053,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
target: AdapterExecutionTarget | null | undefined;
runtimeRootDir: string | null | undefined;
adapterKey: string;
timeoutSec?: number | null;
hostApiToken: string | null | undefined;
hostApiUrl?: string | null;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
@@ -703,7 +1062,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) {
return null;
}
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
if (!input.target || input.target.kind !== "remote") {
return null;
}
@@ -731,6 +1090,12 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
process.env.PAPERCLIP_RUNTIME_API_URL?.trim() ||
process.env.PAPERCLIP_API_URL?.trim() ||
resolveDefaultPaperclipApiUrl();
const shellCommand = adapterExecutionTargetShellCommand(target);
const runner = adapterExecutionTargetCommandRunner(target);
const bridgeTimeoutMs =
typeof input.timeoutSec === "number" && Number.isFinite(input.timeoutSec) && input.timeoutSec > 0
? Math.trunc(input.timeoutSec * 1000)
: adapterExecutionTargetTimeoutMs(target);
await onLog(
"stdout",
@@ -742,15 +1107,30 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
let worker: Awaited<ReturnType<typeof startSandboxCallbackBridgeWorker>> | null = null;
try {
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
runner: requireSandboxRunner(target),
runner,
remoteCwd: target.remoteCwd,
timeoutMs: target.timeoutMs,
timeoutMs: bridgeTimeoutMs,
shellCommand,
});
// PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge
// proxy request/response. The query string is logged verbatim, so callers
// who pass auth tokens or other sensitive values as query parameters
// should be aware those values appear in the host process's stdout when
// this flag is enabled. Only intended for active debugging in trusted
// environments.
const bridgeDebugEnabled = isBridgeDebugEnabled(process.env);
worker = await startSandboxCallbackBridgeWorker({
client,
queueDir,
maxBodyBytes,
handleRequest: async (request) => {
const method = request.method.trim().toUpperCase() || "GET";
if (bridgeDebugEnabled) {
await onLog(
"stdout",
`[paperclip] Bridge proxy ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
);
}
const headers = new Headers();
for (const [key, value] of Object.entries(request.headers)) {
if (value.trim().length === 0) continue;
@@ -758,13 +1138,18 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
}
headers.set("authorization", `Bearer ${hostApiToken}`);
headers.set("x-paperclip-run-id", input.runId);
const method = request.method.trim().toUpperCase() || "GET";
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), {
method,
headers,
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
signal: AbortSignal.timeout(30_000),
});
if (bridgeDebugEnabled) {
await onLog(
"stdout",
`[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
);
}
return {
status: response.status,
headers: buildBridgeResponseHeaders(response),
@@ -773,14 +1158,15 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
},
});
server = await startSandboxCallbackBridgeServer({
runner: requireSandboxRunner(target),
runner,
remoteCwd: target.remoteCwd,
assetRemoteDir,
queueDir,
bridgeToken,
bridgeAsset,
timeoutMs: target.timeoutMs,
timeoutMs: bridgeTimeoutMs,
maxBodyBytes,
shellCommand,
});
} catch (error) {
await Promise.allSettled([
+2
View File
@@ -27,6 +27,7 @@ export type {
ConfigFieldOption,
ConfigFieldSchema,
AdapterConfigSchema,
AdapterRuntimeCommandSpec,
ServerAdapterModule,
QuotaWindow,
ProviderQuotaResult,
@@ -59,6 +60,7 @@ export {
REDACTED_COMMAND_TEXT_VALUE,
redactCommandText,
} from "./command-redaction.js";
export { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js";
export { inferOpenAiCompatibleBiller } from "./billing.js";
// Keep the root adapter-utils entry browser-safe because the UI imports it.
// The sandbox callback bridge stays available via its dedicated subpath export.
@@ -0,0 +1,49 @@
const REMOTE_EXECUTION_ENV_IDENTITY_KEYS = new Set([
"PATH",
"HOME",
"PWD",
"SHELL",
"USER",
"LOGNAME",
"NVM_DIR",
"TMPDIR",
"TMP",
"TEMP",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"XDG_RUNTIME_DIR",
]);
function readEnvValueCaseInsensitive(env: NodeJS.ProcessEnv, key: string): string | undefined {
const direct = env[key];
if (typeof direct === "string") return direct;
const upper = key.toUpperCase();
for (const [candidateKey, candidateValue] of Object.entries(env)) {
if (candidateKey.toUpperCase() === upper && typeof candidateValue === "string") {
return candidateValue;
}
}
return undefined;
}
export function sanitizeRemoteExecutionEnv(
env: Record<string, string>,
inheritedEnv: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
const sanitized: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
const normalizedKey = key.toUpperCase();
if (!REMOTE_EXECUTION_ENV_IDENTITY_KEYS.has(normalizedKey)) {
sanitized[key] = value;
continue;
}
const inheritedValue = readEnvValueCaseInsensitive(inheritedEnv, key);
if (typeof inheritedValue === "string" && inheritedValue === value) {
continue;
}
sanitized[key] = value;
}
return sanitized;
}
@@ -5,6 +5,7 @@ import {
restoreWorkspaceFromSshExecution,
syncDirectoryToSsh,
} from "./ssh.js";
import { captureDirectorySnapshot } from "./workspace-restore-merge.js";
export interface RemoteManagedRuntimeAsset {
key: string;
@@ -44,7 +45,6 @@ export function buildRemoteExecutionSessionIdentity(spec: SshRemoteExecutionSpec
port: spec.port,
username: spec.username,
remoteCwd: spec.remoteCwd,
...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}),
} as const;
}
@@ -58,26 +58,37 @@ export function remoteExecutionSessionMatches(saved: unknown, current: SshRemote
asString(parsedSaved.host) === currentIdentity.host &&
asNumber(parsedSaved.port) === currentIdentity.port &&
asString(parsedSaved.username) === currentIdentity.username &&
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd &&
asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl)
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd
);
}
export async function prepareRemoteManagedRuntime(input: {
spec: SshRemoteExecutionSpec;
runId: string;
adapterKey: string;
workspaceLocalDir: string;
workspaceRemoteDir?: string;
assets?: RemoteManagedRuntimeAsset[];
}): Promise<PreparedRemoteManagedRuntime> {
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
const baseWorkspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
const workspaceRemoteDir = path.posix.join(
baseWorkspaceRemoteDir,
".paperclip-runtime",
"runs",
input.runId,
"workspace",
);
const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey);
await prepareWorkspaceForSshExecution({
const preparedWorkspace = await prepareWorkspaceForSshExecution({
spec: input.spec,
localDir: input.workspaceLocalDir,
remoteDir: workspaceRemoteDir,
});
const restoreExclude = preparedWorkspace.gitBacked ? [".git", ".paperclip-runtime"] : [".paperclip-runtime"];
const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, {
exclude: restoreExclude,
});
const assetDirs: Record<string, string> = {};
try {
@@ -97,6 +108,8 @@ export async function prepareRemoteManagedRuntime(input: {
spec: input.spec,
localDir: input.workspaceLocalDir,
remoteDir: workspaceRemoteDir,
baselineSnapshot,
restoreGitHistory: preparedWorkspace.gitBacked,
});
throw error;
}
@@ -112,6 +125,8 @@ export async function prepareRemoteManagedRuntime(input: {
spec: input.spec,
localDir: input.workspaceLocalDir,
remoteDir: workspaceRemoteDir,
baselineSnapshot,
restoreGitHistory: preparedWorkspace.gitBacked,
});
},
};
@@ -3,14 +3,17 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promis
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareCommandManagedRuntime } from "./command-managed-runtime.js";
import {
authorizeSandboxCallbackBridgeRequestWithRoutes,
createCommandManagedSandboxCallbackBridgeQueueClient,
createFileSystemSandboxCallbackBridgeQueueClient,
createSandboxCallbackBridgeAsset,
createSandboxCallbackBridgeToken,
sandboxCallbackBridgeDirectories,
syncSandboxCallbackBridgeEntrypoint,
startSandboxCallbackBridgeServer,
startSandboxCallbackBridgeWorker,
} from "./sandbox-callback-bridge.js";
@@ -37,9 +40,15 @@ describe("sandbox callback bridge", () => {
...process.env,
...input.env,
};
const command = input.command === "sh" ? "/bin/sh" : input.command;
const command =
input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
const args = [...(input.args ?? [])];
if (input.stdin != null && input.command === "sh" && args[0] === "-lc" && typeof args[1] === "string") {
if (
input.stdin != null &&
(input.command === "sh" || input.command === "bash") &&
(args[0] === "-c" || args[0] === "-lc") &&
typeof args[1] === "string"
) {
env.PAPERCLIP_TEST_STDIN = input.stdin;
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
}
@@ -413,6 +422,145 @@ describe("sandbox callback bridge", () => {
);
});
it("handles SSH queue polling failures without emitting an unhandled rejection", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-ssh-failure-"));
cleanupDirs.push(rootDir);
const queueDir = path.posix.join(rootDir, "queue");
const unhandled: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
try {
const worker = await startSandboxCallbackBridgeWorker({
client: {
makeDir: async () => {},
listJsonFiles: async () => {
throw new Error(
"list /remote/.paperclip-runtime/gemini/paperclip-bridge/queue/requests failed with exit code 255: kex_exchange_identification: read: Connection reset by peer",
);
},
readTextFile: async () => {
throw new Error("unexpected readTextFile");
},
writeTextFile: async () => {
throw new Error("unexpected writeTextFile");
},
rename: async () => {
throw new Error("unexpected rename");
},
remove: async () => {},
},
queueDir,
authorizeRequest: async () => null,
handleRequest: async () => ({
status: 200,
body: "ok",
}),
});
await new Promise((resolve) => setTimeout(resolve, 50));
await worker.stop();
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
it("serializes remote response writes so stop does not recreate a late orphaned response", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-response-lock-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(remoteWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "bridge response lock test\n", "utf8");
const runner = createExecRunner();
const bridgeAsset = await createSandboxCallbackBridgeAsset();
cleanupFns.push(bridgeAsset.cleanup);
const prepared = await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
},
adapterKey: "codex",
workspaceLocalDir: localWorkspaceDir,
assets: [{ key: "bridge", localDir: bridgeAsset.localDir }],
});
const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge");
const directories = sandboxCallbackBridgeDirectories(queueDir);
const bridgeToken = createSandboxCallbackBridgeToken();
const seenRequestIds: string[] = [];
const worker = await startSandboxCallbackBridgeWorker({
client: createCommandManagedSandboxCallbackBridgeQueueClient({
runner,
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
}),
queueDir,
authorizeRequest: async () => null,
handleRequest: async (request) => {
seenRequestIds.push(request.id);
await new Promise((resolve) => setTimeout(resolve, 250));
return {
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, id: request.id }),
};
},
});
cleanupFns.push(async () => {
await worker.stop();
});
const bridge = await startSandboxCallbackBridgeServer({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: prepared.assetDirs.bridge,
queueDir,
bridgeToken,
timeoutMs: 30_000,
});
cleanupFns.push(async () => {
await bridge.stop();
});
const responsePromise = fetch(`${bridge.baseUrl}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridgeToken}`,
},
});
for (let attempt = 0; attempt < 50 && seenRequestIds.length === 0; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(seenRequestIds).toHaveLength(1);
await worker.stop({ drainTimeoutMs: 10 });
const response = await responsePromise;
expect(response.status).toBe(503);
await expect(response.json()).resolves.toEqual({
error: "Bridge worker stopped before request could be handled.",
});
await new Promise((resolve) => setTimeout(resolve, 300));
await expect(readdir(directories.responsesDir)).resolves.toEqual([]);
await expect(
readdir(directories.responsesDir).then((entries) =>
entries.filter((entry) => entry.endsWith(".tmp") || entry.includes(".paperclip-write.lock")),
),
).resolves.toEqual([]);
});
it("rejects non-JSON request bodies and full queues at the bridge server", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-server-guards-"));
cleanupDirs.push(rootDir);
@@ -607,4 +755,229 @@ describe("sandbox callback bridge", () => {
error: expect.stringMatching(/JSON|Unexpected|Unterminated/i),
});
});
it("reuses an already-uploaded bridge entrypoint when the remote file hash matches", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-sync-"));
cleanupDirs.push(rootDir);
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const remoteAssetDir = path.posix.join(
remoteWorkspaceDir,
".paperclip-runtime",
"codex",
"paperclip-bridge",
"server",
);
await mkdir(remoteWorkspaceDir, { recursive: true });
const bridgeAsset = await createSandboxCallbackBridgeAsset();
cleanupFns.push(bridgeAsset.cleanup);
const originalSource = await readFile(bridgeAsset.entrypoint, "utf8");
const expandedSource = `${originalSource}\n// bridge payload padding\n`;
await writeFile(bridgeAsset.entrypoint, expandedSource, "utf8");
const runner = createExecRunner();
const first = await syncSandboxCallbackBridgeEntrypoint({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: remoteAssetDir,
bridgeAsset,
timeoutMs: 30_000,
});
const second = await syncSandboxCallbackBridgeEntrypoint({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: remoteAssetDir,
bridgeAsset,
timeoutMs: 30_000,
});
expect(first.uploaded).toBe(true);
expect(second.uploaded).toBe(false);
await expect(readFile(path.posix.join(remoteAssetDir, "paperclip-bridge-server.mjs"), "utf8")).resolves.toBe(expandedSource);
await expect(
readdir(remoteAssetDir).then((entries) =>
entries.filter(
(entry) =>
entry.endsWith(".paperclip-upload.b64") ||
entry.endsWith(".partial") ||
entry === ".paperclip-bridge-upload.lock",
),
),
).resolves.toEqual([]);
});
it("rejects a corrupted bridge entrypoint upload without committing a torn remote file", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-sync-corrupt-"));
cleanupDirs.push(rootDir);
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const remoteAssetDir = path.posix.join(
remoteWorkspaceDir,
".paperclip-runtime",
"codex",
"paperclip-bridge",
"server",
);
await mkdir(remoteWorkspaceDir, { recursive: true });
const bridgeAsset = await createSandboxCallbackBridgeAsset();
cleanupFns.push(bridgeAsset.cleanup);
const runner = {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}) =>
await createExecRunner().execute({
...input,
stdin: input.stdin != null ? "" : input.stdin,
}),
};
await expect(
syncSandboxCallbackBridgeEntrypoint({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: remoteAssetDir,
bridgeAsset,
timeoutMs: 30_000,
}),
).rejects.toThrow(/sha mismatch/i);
await expect(readFile(path.posix.join(remoteAssetDir, "paperclip-bridge-server.mjs"), "utf8")).rejects.toThrow();
await expect(
readdir(remoteAssetDir).then((entries) =>
entries.filter(
(entry) =>
entry.endsWith(".paperclip-upload.b64") ||
entry.endsWith(".partial") ||
entry === ".paperclip-bridge-upload.lock",
),
),
).resolves.toEqual([]);
});
it("permits the documented heartbeat surface and denies unrelated routes", () => {
const allowed: Array<{ method: string; path: string }> = [
{ method: "GET", path: "/api/agents/me" },
{ method: "GET", path: "/api/agents/me/inbox-lite" },
{ method: "GET", path: "/api/agents/me/inbox/mine" },
{ method: "GET", path: "/api/agents/agent-1" },
{ method: "GET", path: "/api/agents/agent-1/skills" },
{ method: "POST", path: "/api/agents/agent-1/skills/sync" },
{ method: "PATCH", path: "/api/agents/agent-1/instructions-path" },
{ method: "GET", path: "/api/companies/co-1" },
{ method: "GET", path: "/api/companies/co-1/dashboard" },
{ method: "GET", path: "/api/companies/co-1/agents" },
{ method: "GET", path: "/api/companies/co-1/issues" },
{ method: "GET", path: "/api/companies/co-1/projects" },
{ method: "GET", path: "/api/companies/co-1/goals" },
{ method: "GET", path: "/api/companies/co-1/org" },
{ method: "GET", path: "/api/companies/co-1/approvals" },
{ method: "GET", path: "/api/companies/co-1/routines" },
{ method: "GET", path: "/api/companies/co-1/skills" },
{ method: "GET", path: "/api/projects/proj-1" },
{ method: "GET", path: "/api/goals/goal-1" },
{ method: "GET", path: "/api/issues/issue-1" },
{ method: "GET", path: "/api/issues/issue-1/heartbeat-context" },
{ method: "GET", path: "/api/issues/issue-1/comments" },
{ method: "GET", path: "/api/issues/issue-1/comments/c-1" },
{ method: "POST", path: "/api/issues/issue-1/comments" },
{ method: "GET", path: "/api/issues/issue-1/documents" },
{ method: "GET", path: "/api/issues/issue-1/documents/plan" },
{ method: "GET", path: "/api/issues/issue-1/documents/plan/revisions" },
{ method: "PUT", path: "/api/issues/issue-1/documents/plan" },
{ method: "POST", path: "/api/issues/issue-1/checkout" },
{ method: "POST", path: "/api/issues/issue-1/release" },
{ method: "PATCH", path: "/api/issues/issue-1" },
{ method: "GET", path: "/api/issues/issue-1/approvals" },
{ method: "GET", path: "/api/issues/issue-1/interactions" },
{ method: "GET", path: "/api/issues/issue-1/interactions/inter-1" },
{ method: "POST", path: "/api/issues/issue-1/interactions" },
{ method: "POST", path: "/api/issues/issue-1/interactions/inter-1/accept" },
{ method: "POST", path: "/api/issues/issue-1/interactions/inter-1/reject" },
{ method: "POST", path: "/api/issues/issue-1/interactions/inter-1/respond" },
{ method: "POST", path: "/api/companies/co-1/issues" },
{ method: "GET", path: "/api/approvals/ap-1" },
{ method: "GET", path: "/api/approvals/ap-1/issues" },
{ method: "GET", path: "/api/approvals/ap-1/comments" },
{ method: "POST", path: "/api/approvals/ap-1/comments" },
{ method: "POST", path: "/api/companies/co-1/approvals" },
{ method: "GET", path: "/api/execution-workspaces/ws-1" },
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/start" },
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/stop" },
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/restart" },
{ method: "GET", path: "/api/routines/r-1" },
{ method: "GET", path: "/api/routines/r-1/runs" },
{ method: "POST", path: "/api/companies/co-1/routines" },
{ method: "PATCH", path: "/api/routines/r-1" },
{ method: "POST", path: "/api/routines/r-1/run" },
{ method: "POST", path: "/api/routines/r-1/triggers" },
{ method: "PATCH", path: "/api/routine-triggers/t-1" },
{ method: "DELETE", path: "/api/routine-triggers/t-1" },
];
for (const request of allowed) {
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBeNull();
}
const denied: Array<{ method: string; path: string }> = [
{ method: "DELETE", path: "/api/secrets" },
// Pin the runtime-services regex to start/stop/restart only — anything
// else (delete, reset, wipe, etc.) must stay denied even if the API
// grows new actions later.
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/delete" },
{ method: "POST", path: "/api/companies/co-1/agents" },
{ method: "POST", path: "/api/agents/agent-1/pause" },
{ method: "POST", path: "/api/agents/agent-1/terminate" },
{ method: "POST", path: "/api/agents/agent-1/keys" },
{ method: "POST", path: "/api/companies/co-1/exports" },
{ method: "POST", path: "/api/companies/co-1/imports/apply" },
{ method: "POST", path: "/api/companies/co-1/archive" },
{ method: "DELETE", path: "/api/issues/issue-1/documents/plan" },
{ method: "DELETE", path: "/api/issues/issue-1/approvals/ap-1" },
{ method: "POST", path: "/api/approvals/ap-1/approve" },
{ method: "POST", path: "/api/approvals/ap-1/reject" },
{ method: "POST", path: "/api/companies/co-1/logo" },
{ method: "GET", path: "/api/companies/co-1/secrets" },
{ method: "PATCH", path: "/api/secrets/secret-1" },
];
for (const request of denied) {
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBe(
`Route not allowed: ${request.method} ${request.path}`,
);
}
});
it("marks command-managed bridge operations with the bridge execution channel", async () => {
const runner = {
execute: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
})),
};
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
runner,
remoteCwd: "/workspace",
timeoutMs: 30_000,
});
await client.makeDir("/workspace/.paperclip-runtime/codex/paperclip-bridge/queue");
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
env: {
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
},
}));
});
});
@@ -1,9 +1,10 @@
import { randomBytes, randomUUID } from "node:crypto";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
import type { RunProcessResult } from "./server-utils.js";
const DEFAULT_BRIDGE_TOKEN_BYTES = 24;
@@ -14,6 +15,8 @@ const DEFAULT_BRIDGE_MAX_QUEUE_DEPTH = 64;
const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024;
const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
const SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT = "paperclip-bridge-server.mjs";
const SANDBOX_EXEC_CHANNEL_ENV = "PAPERCLIP_SANDBOX_EXEC_CHANNEL";
const SANDBOX_EXEC_CHANNEL_BRIDGE = "bridge";
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES = DEFAULT_BRIDGE_MAX_BODY_BYTES;
@@ -22,15 +25,76 @@ export interface SandboxCallbackBridgeRouteRule {
path: RegExp;
}
// Routes the in-sandbox heartbeat skill is documented to call. The server
// still enforces actor-level permissions on top of this allowlist; the list
// exists to bound the surface area a compromised CLI could reach via the
// reverse bridge. Keep this in sync with the Paperclip skill in
// `skills/paperclip/SKILL.md` and `references/api-reference.md`.
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCallbackBridgeRouteRule[] = [
// Identity, inbox, agent self-management
{ method: "GET", path: /^\/api\/agents\/me$/ },
{ method: "GET", path: /^\/api\/agents\/me\/inbox-lite$/ },
{ method: "GET", path: /^\/api\/agents\/me\/inbox\/mine$/ },
{ method: "GET", path: /^\/api\/agents\/[^/]+$/ },
{ method: "GET", path: /^\/api\/agents\/[^/]+\/skills$/ },
{ method: "POST", path: /^\/api\/agents\/[^/]+\/skills\/sync$/ },
{ method: "PATCH", path: /^\/api\/agents\/[^/]+\/instructions-path$/ },
// Company-level reads used to discover work and context
{ method: "GET", path: /^\/api\/companies\/[^/]+$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/dashboard$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/agents$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/issues$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/projects$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/goals$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/org$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/approvals$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/routines$/ },
{ method: "GET", path: /^\/api\/companies\/[^/]+\/skills$/ },
{ method: "GET", path: /^\/api\/projects\/[^/]+$/ },
{ method: "GET", path: /^\/api\/goals\/[^/]+$/ },
// Issue lifecycle: read context, checkout, update, comment, document, release
{ method: "GET", path: /^\/api\/issues\/[^/]+$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/heartbeat-context$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/comments(?:\/[^/]+)?$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/checkout$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/comments$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents\/[^/]+\/revisions$/ },
{ method: "PUT", path: /^\/api\/issues\/[^/]+\/documents\/[^/]+$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/checkout$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/release$/ },
{ method: "PATCH", path: /^\/api\/issues\/[^/]+$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/approvals$/ },
// Issue-thread interactions (suggest tasks, ask questions, request confirmation)
{ method: "GET", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/interactions\/[^/]+\/(?:accept|reject|respond)$/ },
// Subtasks / delegation
{ method: "POST", path: /^\/api\/companies\/[^/]+\/issues$/ },
// Approvals (request, read, comment)
{ method: "GET", path: /^\/api\/approvals\/[^/]+$/ },
{ method: "GET", path: /^\/api\/approvals\/[^/]+\/issues$/ },
{ method: "GET", path: /^\/api\/approvals\/[^/]+\/comments$/ },
{ method: "POST", path: /^\/api\/approvals\/[^/]+\/comments$/ },
{ method: "POST", path: /^\/api\/companies\/[^/]+\/approvals$/ },
// Execution workspaces and runtime services (start/stop/restart dev servers)
{ method: "GET", path: /^\/api\/execution-workspaces\/[^/]+$/ },
{ method: "POST", path: /^\/api\/execution-workspaces\/[^/]+\/runtime-services\/(?:start|stop|restart)$/ },
// Routines (agents manage their own routines and triggers)
{ method: "GET", path: /^\/api\/routines\/[^/]+$/ },
{ method: "GET", path: /^\/api\/routines\/[^/]+\/runs$/ },
{ method: "POST", path: /^\/api\/companies\/[^/]+\/routines$/ },
{ method: "PATCH", path: /^\/api\/routines\/[^/]+$/ },
{ method: "POST", path: /^\/api\/routines\/[^/]+\/run$/ },
{ method: "POST", path: /^\/api\/routines\/[^/]+\/triggers$/ },
{ method: "PATCH", path: /^\/api\/routine-triggers\/[^/]+$/ },
{ method: "DELETE", path: /^\/api\/routine-triggers\/[^/]+$/ },
] as const;
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [
@@ -83,6 +147,13 @@ export interface SandboxCallbackBridgeQueueClient {
listJsonFiles(remotePath: string): Promise<string[]>;
readTextFile(remotePath: string): Promise<string>;
writeTextFile(remotePath: string, body: string): Promise<void>;
writeResponseFile?(
responsePath: string,
body: string,
options?: {
requestPath?: string | null;
},
): Promise<{ wrote: boolean }>;
rename(fromPath: string, toPath: string): Promise<void>;
remove(remotePath: string): Promise<void>;
}
@@ -133,12 +204,18 @@ async function runShell(
cwd: string,
script: string,
timeoutMs: number,
shellCommand: "bash" | "sh" = "sh",
stdin?: string,
): Promise<RunProcessResult> {
return await runner.execute({
command: "sh",
args: ["-lc", script],
command: shellCommand,
args: shellCommandArgs(script),
cwd,
env: {
[SANDBOX_EXEC_CHANNEL_ENV]: SANDBOX_EXEC_CHANNEL_BRIDGE,
},
timeoutMs,
stdin,
});
}
@@ -155,6 +232,43 @@ function base64Chunks(body: string): string[] {
return out;
}
async function pathExists(filePath: string): Promise<boolean> {
return await fs.stat(filePath).then(() => true).catch(() => false);
}
function buildRemotePidLockAcquireScript(lockDirExpr: string, timeoutMessage: string): string[] {
return [
"attempts=0",
`while ! mkdir ${lockDirExpr} 2>/dev/null; do`,
" holder_pid=\"\"",
` if [ -s ${lockDirExpr}/pid ]; then`,
` holder_pid="$(cat ${lockDirExpr}/pid 2>/dev/null || true)"`,
" fi",
" if [ -n \"$holder_pid\" ] && ! kill -0 \"$holder_pid\" 2>/dev/null; then",
` rm -rf ${lockDirExpr}`,
" continue",
" fi",
" attempts=$((attempts + 1))",
" if [ \"$attempts\" -ge 600 ]; then",
` echo ${shellQuote(timeoutMessage)} >&2`,
" exit 1",
" fi",
" sleep 0.05",
"done",
`printf '%s\\n' "$$" > ${lockDirExpr}/pid`,
];
}
function buildRemotePidLockCleanupScript(lockDirExpr: string, cleanupLines: string[]): string[] {
return [
"cleanup() {",
...cleanupLines.map((line) => ` ${line}`),
` rm -rf ${lockDirExpr}`,
"}",
"trap cleanup EXIT INT TERM",
];
}
export function createSandboxCallbackBridgeToken(bytes = DEFAULT_BRIDGE_TOKEN_BYTES): string {
return randomBytes(bytes).toString("base64url");
}
@@ -252,6 +366,80 @@ export function createFileSystemSandboxCallbackBridgeQueueClient(): SandboxCallb
await fs.mkdir(path.posix.dirname(remotePath), { recursive: true });
await fs.writeFile(remotePath, body, "utf8");
},
writeResponseFile: async (responsePath, body, options = {}) => {
const responseDir = path.posix.dirname(responsePath);
const tempPath = `${responsePath}.tmp`;
const lockDir = `${responsePath}.paperclip-write.lock`;
const lockPidFile = `${lockDir}/pid`;
if (options.requestPath) {
const requestExists = await pathExists(options.requestPath);
if (!requestExists) {
return { wrote: false };
}
}
await fs.mkdir(responseDir, { recursive: true });
// PID-liveness mkdir-mutex: mirrors the shell-based bridge mutex so a
// crashed holder (SIGKILL / OOM) doesn't deadlock subsequent writers
// for the full timeout window.
let attempts = 0;
while (true) {
try {
await fs.mkdir(lockDir);
await fs.writeFile(lockPidFile, `${process.pid}\n`, "utf8");
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code !== "EEXIST") {
throw error;
}
let holderPid: number | null = null;
try {
const raw = await fs.readFile(lockPidFile, "utf8");
const parsed = Number.parseInt(raw.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) holderPid = parsed;
} catch {
// pid file missing or unreadable — treat as stale lock
}
let holderAlive = false;
if (holderPid !== null) {
try {
process.kill(holderPid, 0);
holderAlive = true;
} catch {
holderAlive = false;
}
}
if (!holderAlive) {
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
continue;
}
attempts += 1;
if (attempts >= 600) {
throw new Error("Timed out acquiring sandbox callback bridge response lock.");
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
try {
if (options.requestPath) {
const requestExists = await pathExists(options.requestPath);
if (!requestExists) {
return { wrote: false };
}
}
const responseExists = await pathExists(responsePath);
if (responseExists) {
return { wrote: false };
}
await fs.writeFile(tempPath, body, "utf8");
await fs.rename(tempPath, responsePath);
return { wrote: true };
} finally {
await fs.rm(tempPath, { force: true }).catch(() => undefined);
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
}
},
rename: async (fromPath, toPath) => {
await fs.mkdir(path.posix.dirname(toPath), { recursive: true });
await fs.rename(fromPath, toPath);
@@ -266,10 +454,12 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
timeoutMs?: number | null;
shellCommand?: "bash" | "sh" | null;
}): SandboxCallbackBridgeQueueClient {
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
const shellCommand = preferredShellForSandbox(input.shellCommand);
const runChecked = async (action: string, script: string) =>
requireSuccessfulResult(action, await runShell(input.runner, input.remoteCwd, script, timeoutMs));
requireSuccessfulResult(action, await runShell(input.runner, input.remoteCwd, script, timeoutMs, shellCommand));
return {
makeDir: async (remotePath) => {
@@ -288,6 +478,7 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
"fi",
].join("\n"),
timeoutMs,
shellCommand,
);
requireSuccessfulResult(`list ${remotePath}`, result);
return result.stdout
@@ -319,6 +510,53 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
`base64 -d < ${shellQuote(tempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(tempPath)}`,
);
},
writeResponseFile: async (responsePath, body, options = {}) => {
const responseDir = path.posix.dirname(responsePath);
const tempPath = `${responsePath}.tmp`;
const lockDir = `${responsePath}.paperclip-write.lock`;
const requestPath = options.requestPath?.trim() || "";
const result = await runShell(
input.runner,
input.remoteCwd,
[
"set -eu",
`response_dir=${shellQuote(responseDir)}`,
`response_path=${shellQuote(responsePath)}`,
`temp_path=${shellQuote(tempPath)}`,
`lock_dir=${shellQuote(lockDir)}`,
`request_path=${shellQuote(requestPath)}`,
"mkdir -p \"$response_dir\"",
...buildRemotePidLockAcquireScript("\"$lock_dir\"", "Timed out acquiring sandbox callback bridge response lock."),
...buildRemotePidLockCleanupScript("\"$lock_dir\"", [
"rm -f \"$temp_path\"",
]),
"if [ -n \"$request_path\" ] && [ ! -f \"$request_path\" ]; then",
" printf '{\"wrote\":false}\\n'",
" exit 0",
"fi",
"if [ -f \"$response_path\" ]; then",
" printf '{\"wrote\":false}\\n'",
" exit 0",
"fi",
"cat > \"$temp_path\"",
"mv \"$temp_path\" \"$response_path\"",
"printf '{\"wrote\":true}\\n'",
].join("\n"),
timeoutMs,
shellCommand,
body,
);
requireSuccessfulResult(`write bridge response ${responsePath}`, result);
try {
return {
wrote: JSON.parse(result.stdout.trim())?.wrote === true,
};
} catch (error) {
throw new Error(
`Sandbox callback bridge response write wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
},
rename: async (fromPath, toPath) => {
await runChecked(
`rename ${fromPath}`,
@@ -333,11 +571,18 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
async function writeBridgeResponse(
client: SandboxCallbackBridgeQueueClient,
requestPath: string,
responsePath: string,
response: SandboxCallbackBridgeResponse,
options: { requireRequestPath?: boolean } = {},
) {
const body = `${JSON.stringify(response)}\n`;
if (client.writeResponseFile) {
await client.writeResponseFile(responsePath, body, options.requireRequestPath === false ? {} : { requestPath });
return;
}
const tempPath = `${responsePath}.tmp`;
await client.writeTextFile(tempPath, `${JSON.stringify(response)}\n`);
await client.writeTextFile(tempPath, body);
await client.rename(tempPath, responsePath);
}
@@ -371,6 +616,8 @@ export async function startSandboxCallbackBridgeWorker(input: {
});
const authorizeRequest = input.authorizeRequest ??
((request: SandboxCallbackBridgeRequest) => authorizeSandboxCallbackBridgeRequestWithRoutes(request));
const buildWorkerFailureMessage = (error: unknown) =>
`Sandbox callback bridge worker failed: ${error instanceof Error ? error.message : String(error)}`;
const processRequestFile = async (fileName: string) => {
const requestPath = path.posix.join(directories.requestsDir, fileName);
@@ -381,7 +628,7 @@ export async function startSandboxCallbackBridgeWorker(input: {
request = JSON.parse(raw) as SandboxCallbackBridgeRequest;
} catch {
const requestId = fileName.replace(/\.json$/i, "") || randomUUID();
await writeBridgeResponse(input.client, responsePath, {
await writeBridgeResponse(input.client, requestPath, responsePath, {
id: requestId,
status: 400,
headers: { "content-type": "application/json" },
@@ -394,7 +641,7 @@ export async function startSandboxCallbackBridgeWorker(input: {
const denialReason = await authorizeRequest(request);
if (denialReason) {
await writeBridgeResponse(input.client, responsePath, {
await writeBridgeResponse(input.client, requestPath, responsePath, {
id: request.id,
status: 403,
headers: { "content-type": "application/json" },
@@ -411,7 +658,7 @@ export async function startSandboxCallbackBridgeWorker(input: {
if (Buffer.byteLength(responseBody, "utf8") > maxBodyBytes) {
throw new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
}
await writeBridgeResponse(input.client, responsePath, {
await writeBridgeResponse(input.client, requestPath, responsePath, {
id: request.id,
status: result.status,
headers: result.headers ?? {},
@@ -422,7 +669,7 @@ export async function startSandboxCallbackBridgeWorker(input: {
console.warn(
`[paperclip] sandbox callback bridge handler failed for ${request.id}: ${error instanceof Error ? error.message : String(error)}`,
);
await writeBridgeResponse(input.client, responsePath, {
await writeBridgeResponse(input.client, requestPath, responsePath, {
id: request.id,
status: 502,
headers: { "content-type": "application/json" },
@@ -445,12 +692,15 @@ export async function startSandboxCallbackBridgeWorker(input: {
try {
const raw = await input.client.readTextFile(requestPath);
const parsed = JSON.parse(raw) as Partial<SandboxCallbackBridgeRequest>;
await writeBridgeResponse(input.client, responsePath, {
await input.client.remove(requestPath).catch(() => undefined);
await writeBridgeResponse(input.client, requestPath, responsePath, {
id: typeof parsed.id === "string" && parsed.id.length > 0 ? parsed.id : requestId,
status: 503,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: message }),
completedAt: new Date().toISOString(),
}, {
requireRequestPath: false,
});
} catch (error) {
console.warn(
@@ -486,6 +736,16 @@ export async function startSandboxCallbackBridgeWorker(input: {
break;
}
}
} catch (error) {
const message = buildWorkerFailureMessage(error);
console.warn(`[paperclip] ${message}`);
try {
await failPendingRequests(message);
} catch (failPendingError) {
console.warn(
`[paperclip] sandbox callback bridge failed to abort queued requests after worker failure: ${failPendingError instanceof Error ? failPendingError.message : String(failPendingError)}`,
);
}
} finally {
settled = true;
if (settleResolve) {
@@ -512,6 +772,99 @@ export async function startSandboxCallbackBridgeWorker(input: {
};
}
export async function syncSandboxCallbackBridgeEntrypoint(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
assetRemoteDir: string;
bridgeAsset: SandboxCallbackBridgeAsset;
timeoutMs?: number | null;
shellCommand?: "bash" | "sh" | null;
}): Promise<{ remoteEntrypoint: string; sha256: string; uploaded: boolean }> {
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
const shellCommand = preferredShellForSandbox(input.shellCommand);
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
const remoteEntrypointPartial = `${remoteEntrypoint}.partial`;
const remoteUploadPath = `${remoteEntrypoint}.paperclip-upload.b64`;
const remoteLockDir = path.posix.join(input.assetRemoteDir, ".paperclip-bridge-upload.lock");
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
const entrypointBase64 = toBuffer(Buffer.from(entrypointSource, "utf8")).toString("base64");
const sha256 = createHash("sha256").update(entrypointSource, "utf8").digest("hex");
const syncResult = await runShell(
input.runner,
input.remoteCwd,
[
"set -eu",
`remote_dir=${shellQuote(input.assetRemoteDir)}`,
`remote_path=${shellQuote(remoteEntrypoint)}`,
`remote_partial=${shellQuote(remoteEntrypointPartial)}`,
`remote_upload=${shellQuote(remoteUploadPath)}`,
`lock_dir=${shellQuote(remoteLockDir)}`,
`expected_sha=${shellQuote(sha256)}`,
"hash_file() {",
" if command -v sha256sum >/dev/null 2>&1; then",
" sha256sum \"$1\" | awk '{print $1}'",
" return 0",
" fi",
" if command -v shasum >/dev/null 2>&1; then",
" shasum -a 256 \"$1\" | awk '{print $1}'",
" return 0",
" fi",
" return 127",
"}",
"mkdir -p \"$remote_dir\"",
...buildRemotePidLockAcquireScript("\"$lock_dir\"", "Timed out acquiring sandbox callback bridge upload lock."),
...buildRemotePidLockCleanupScript("\"$lock_dir\"", [
"rm -f \"$remote_upload\" \"$remote_partial\"",
]),
"current_sha=\"\"",
"if [ -f \"$remote_path\" ]; then",
" current_sha=\"$(hash_file \"$remote_path\" 2>/dev/null)\" || current_sha=\"\"",
"fi",
"if [ -n \"$current_sha\" ] && [ \"$current_sha\" = \"$expected_sha\" ]; then",
" printf '{\"uploaded\":false}\\n'",
" exit 0",
"fi",
"rm -f \"$remote_upload\" \"$remote_partial\"",
"cat > \"$remote_upload\"",
"base64 -d < \"$remote_upload\" > \"$remote_partial\"",
// Verify upload integrity. If neither sha256sum nor shasum is on PATH
// (minimal Alpine/scratch images), surface the missing-tool error
// instead of a misleading "sha mismatch" — the verify step is then
// best-effort and we trust base64-decode + atomic rename below.
"if partial_sha=\"$(hash_file \"$remote_partial\" 2>/dev/null)\"; then",
" if [ \"$partial_sha\" != \"$expected_sha\" ]; then",
" echo \"Sandbox callback bridge entrypoint upload sha mismatch.\" >&2",
" exit 1",
" fi",
"else",
" echo \"Sandbox callback bridge entrypoint sha verify skipped: no sha256sum/shasum on remote.\" >&2",
"fi",
"mv \"$remote_partial\" \"$remote_path\"",
"printf '{\"uploaded\":true}\\n'",
].join("\n"),
timeoutMs,
shellCommand,
entrypointBase64,
);
requireSuccessfulResult("sync sandbox callback bridge entrypoint", syncResult);
let uploaded = false;
try {
uploaded = JSON.parse(syncResult.stdout.trim())?.uploaded === true;
} catch (error) {
throw new Error(
`Sandbox callback bridge sync wrote invalid result JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
return {
remoteEntrypoint,
sha256,
uploaded,
};
}
export async function startSandboxCallbackBridgeServer(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
@@ -525,21 +878,24 @@ export async function startSandboxCallbackBridgeServer(input: {
responseTimeoutMs?: number | null;
timeoutMs?: number | null;
nodeCommand?: string;
shellCommand?: "bash" | "sh" | null;
maxQueueDepth?: number | null;
maxBodyBytes?: number | null;
}): Promise<StartedSandboxCallbackBridgeServer> {
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
const shellCommand = preferredShellForSandbox(input.shellCommand);
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
let remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
if (input.bridgeAsset) {
const assetClient = createCommandManagedSandboxCallbackBridgeQueueClient({
const assetSync = await syncSandboxCallbackBridgeEntrypoint({
runner: input.runner,
remoteCwd: input.remoteCwd,
assetRemoteDir: input.assetRemoteDir,
bridgeAsset: input.bridgeAsset,
timeoutMs,
shellCommand,
});
await assetClient.makeDir(input.assetRemoteDir);
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
await assetClient.writeTextFile(remoteEntrypoint, entrypointSource);
remoteEntrypoint = assetSync.remoteEntrypoint;
}
const env = buildSandboxCallbackBridgeEnv({
queueDir: input.queueDir,
@@ -553,9 +909,8 @@ export async function startSandboxCallbackBridgeServer(input: {
});
const nodeCommand = input.nodeCommand?.trim() || "node";
const startResult = await input.runner.execute({
command: "sh",
args: [
"-lc",
command: shellCommand,
args: shellCommandArgs(
[
`mkdir -p ${shellQuote(directories.requestsDir)} ${shellQuote(directories.responsesDir)} ${shellQuote(directories.logsDir)}`,
`rm -f ${shellQuote(directories.readyFile)} ${shellQuote(directories.pidFile)}`,
@@ -566,8 +921,11 @@ export async function startSandboxCallbackBridgeServer(input: {
`printf '%s\\n' \"$pid\" > ${shellQuote(directories.pidFile)}`,
"printf '{\"pid\":%s}\\n' \"$pid\"",
].join("\n"),
],
),
cwd: input.remoteCwd,
env: {
[SANDBOX_EXEC_CHANNEL_ENV]: SANDBOX_EXEC_CHANNEL_BRIDGE,
},
timeoutMs,
});
requireSuccessfulResult("start sandbox callback bridge", startResult);
@@ -594,6 +952,7 @@ export async function startSandboxCallbackBridgeServer(input: {
"exit 1",
].join("\n"),
timeoutMs,
shellCommand,
);
requireSuccessfulResult("wait for sandbox callback bridge readiness", readyResult);
@@ -626,9 +985,8 @@ export async function startSandboxCallbackBridgeServer(input: {
directories,
stop: async () => {
const stopResult = await input.runner.execute({
command: "sh",
args: [
"-lc",
command: shellCommand,
args: shellCommandArgs(
[
`if [ -s ${shellQuote(directories.pidFile)} ]; then`,
` pid="$(cat ${shellQuote(directories.pidFile)})"`,
@@ -641,8 +999,11 @@ export async function startSandboxCallbackBridgeServer(input: {
"fi",
`rm -f ${shellQuote(directories.pidFile)} ${shellQuote(directories.readyFile)}`,
].join("\n"),
],
),
cwd: input.remoteCwd,
env: {
[SANDBOX_EXEC_CHANNEL_ENV]: SANDBOX_EXEC_CHANNEL_BRIDGE,
},
timeoutMs,
});
if (stopResult.timedOut) {
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js";
describe("buildSandboxNpmInstallCommand", () => {
it("installs globally as root, via sudo when available, and under ~/.local otherwise", () => {
expect(buildSandboxNpmInstallCommand("@google/gemini-cli")).toBe(
'if [ "$(id -u)" -eq 0 ]; then npm install -g \'@google/gemini-cli\'; elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -E npm install -g \'@google/gemini-cli\'; else mkdir -p "$HOME/.local" && npm install -g --prefix "$HOME/.local" \'@google/gemini-cli\'; fi',
);
});
it("shell-quotes package names", () => {
expect(buildSandboxNpmInstallCommand("odd'pkg")).toContain("'odd'\"'\"'pkg'");
});
});
@@ -0,0 +1,16 @@
function shellSingleQuote(value: string): string {
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
}
export function buildSandboxNpmInstallCommand(packageName: string): string {
const quotedPackageName = shellSingleQuote(packageName);
return [
'if [ "$(id -u)" -eq 0 ]; then',
`npm install -g ${quotedPackageName};`,
'elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then',
`sudo -E npm install -g ${quotedPackageName};`,
"else",
`mkdir -p "$HOME/.local" && npm install -g --prefix "$HOME/.local" ${quotedPackageName};`,
"fi",
].join(" ");
}
@@ -84,7 +84,7 @@ describe("sandbox managed runtime", () => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
await execFile("sh", ["-lc", command], {
await execFile("sh", ["-c", command], {
maxBuffer: 32 * 1024 * 1024,
});
},
@@ -126,7 +126,7 @@ describe("sandbox managed runtime", () => {
await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe("remote workspace\n");
await expect(readFile(path.join(localWorkspaceDir, "remote-only.txt"), "utf8")).resolves.toBe("sync back\n");
await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).resolves.toBe("remove\n");
await expect(readFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"local\":true}\n");
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n");
});
@@ -3,6 +3,7 @@ import { constants as fsConstants, promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
const execFile = promisify(execFileCallback);
@@ -13,7 +14,6 @@ export interface SandboxRemoteExecutionSpec {
remoteCwd: string;
timeoutMs: number;
apiKey: string | null;
paperclipApiUrl?: string | null;
}
export interface SandboxManagedRuntimeAsset {
@@ -85,7 +85,6 @@ export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteEx
remoteCwd,
timeoutMs,
apiKey: asString(parsed.apiKey).trim() || null,
paperclipApiUrl: asString(parsed.paperclipApiUrl).trim() || null,
};
}
@@ -96,7 +95,6 @@ export function buildSandboxExecutionSessionIdentity(spec: SandboxRemoteExecutio
provider: spec.provider,
sandboxId: spec.sandboxId,
remoteCwd: spec.remoteCwd,
...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}),
} as const;
}
@@ -108,8 +106,7 @@ export function sandboxExecutionSessionMatches(saved: unknown, current: SandboxR
asString(parsedSaved.transport) === currentIdentity.transport &&
asString(parsedSaved.provider) === currentIdentity.provider &&
asString(parsedSaved.sandboxId) === currentIdentity.sandboxId &&
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd &&
asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl)
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd
);
}
@@ -252,6 +249,9 @@ export async function prepareSandboxManagedRuntime(input: {
}): Promise<PreparedSandboxManagedRuntime> {
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey);
const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, {
exclude: [...new Set([".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? []), ...(input.workspaceExclude ?? [])])],
});
await withTempDir("paperclip-sandbox-sync-", async (tempDir) => {
const workspaceTarPath = path.join(tempDir, "workspace.tar");
@@ -267,7 +267,7 @@ export async function prepareSandboxManagedRuntime(input: {
const preservedNames = new Set([".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? [])]);
const findPreserveArgs = [...preservedNames].map((entry) => `! -name ${shellQuote(entry)}`).join(" ");
await input.client.run(
`sh -lc ${shellQuote(
`sh -c ${shellQuote(
`mkdir -p ${shellQuote(workspaceRemoteDir)} && ` +
`find ${shellQuote(workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ${findPreserveArgs} -exec rm -rf -- {} + && ` +
`tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` +
@@ -289,7 +289,7 @@ export async function prepareSandboxManagedRuntime(input: {
const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
await input.client.writeFile(remoteAssetTar, toArrayBuffer(assetTarBytes));
await input.client.run(
`sh -lc ${shellQuote(
`sh -c ${shellQuote(
`rm -rf ${shellQuote(remoteAssetDir)} && ` +
`mkdir -p ${shellQuote(remoteAssetDir)} && ` +
`tar -xf ${shellQuote(remoteAssetTar)} -C ${shellQuote(remoteAssetDir)} && ` +
@@ -314,7 +314,7 @@ export async function prepareSandboxManagedRuntime(input: {
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
await input.client.run(
`sh -lc ${shellQuote(
`sh -c ${shellQuote(
`mkdir -p ${shellQuote(runtimeRootDir)} && ` +
`tar -cf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} ` +
`${tarExcludeFlags(input.workspaceExclude)} .`,
@@ -330,8 +330,10 @@ export async function prepareSandboxManagedRuntime(input: {
archivePath: localArchivePath,
localDir: extractedDir,
});
await mirrorDirectory(extractedDir, input.workspaceLocalDir, {
preserveAbsent: [".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? [])],
await mergeDirectoryWithBaseline({
baseline: baselineSnapshot,
sourceDir: extractedDir,
targetDir: input.workspaceLocalDir,
});
});
},
@@ -0,0 +1,7 @@
export function preferredShellForSandbox(shellCommand: string | null | undefined): "bash" | "sh" {
return shellCommand === "bash" ? "bash" : "sh";
}
export function shellCommandArgs(script: string): string[] {
return ["-c", script];
}
+376 -2
View File
@@ -9,9 +9,13 @@ import {
buildInvocationEnvForLogs,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
materializePaperclipSkillCopy,
refreshPaperclipWorkspaceEnvForExecution,
renderPaperclipWakePrompt,
runningProcesses,
runChildProcess,
sanitizeSshRemoteEnv,
shapePaperclipWorkspaceEnvForExecution,
rewriteWorkspaceCwdEnvVarsForExecution,
stringifyPaperclipWakePayload,
} from "./server-utils.js";
@@ -60,6 +64,86 @@ describe("buildInvocationEnvForLogs", () => {
});
});
describe("sanitizeSshRemoteEnv", () => {
it("drops inherited host shell identity variables for SSH remote execution", () => {
expect(
sanitizeSshRemoteEnv(
{
PATH: "/host/bin:/usr/bin",
HOME: "/Users/local",
NVM_DIR: "/Users/local/.nvm",
TMPDIR: "/var/folders/local/T",
XDG_CONFIG_HOME: "/Users/local/.config",
SAFE_VALUE: "visible",
},
{
PATH: "/host/bin:/usr/bin",
HOME: "/Users/local",
NVM_DIR: "/Users/local/.nvm",
TMPDIR: "/var/folders/local/T",
XDG_CONFIG_HOME: "/Users/local/.config",
},
),
).toEqual({
SAFE_VALUE: "visible",
});
});
it("preserves explicit remote overrides even for filtered key names", () => {
expect(
sanitizeSshRemoteEnv(
{
PATH: "/custom/remote/bin:/usr/bin",
HOME: "/home/agent",
TMPDIR: "/tmp",
SAFE_VALUE: "visible",
},
{
PATH: "/host/bin:/usr/bin",
HOME: "/Users/local",
TMPDIR: "/var/folders/local/T",
},
),
).toEqual({
PATH: "/custom/remote/bin:/usr/bin",
HOME: "/home/agent",
TMPDIR: "/tmp",
SAFE_VALUE: "visible",
});
});
it("filters identity keys via case-insensitive match against the inherited env", () => {
expect(
sanitizeSshRemoteEnv(
{
// Caller passed PATH in upper case while the inherited (Windows-style)
// host env exposes it as Path. The lookup must still treat them as
// equal so the leaked host PATH gets stripped.
PATH: "/host/bin:/usr/bin",
HOME: "/host/home",
},
{
Path: "/host/bin:/usr/bin",
home: "/host/home",
},
),
).toEqual({});
});
it("preserves explicitly-set identity keys when the inherited env disagrees in case but not in value", () => {
expect(
sanitizeSshRemoteEnv(
{
PATH: "/explicit/remote/bin",
},
{
Path: "/host/bin:/usr/bin",
},
),
).toEqual({ PATH: "/explicit/remote/bin" });
});
});
describe("materializePaperclipSkillCopy", () => {
it("refuses to materialize into an ancestor of the source", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-"));
@@ -336,6 +420,9 @@ describe("renderPaperclipWakePrompt", () => {
it("keeps the default local-agent prompt action-oriented", () => {
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("clear final disposition");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("evidence, not valid liveness paths by themselves");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("keep `in_progress` only when a live continuation path exists");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Prefer the smallest verification that proves the change");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes");
@@ -369,8 +456,118 @@ describe("renderPaperclipWakePrompt", () => {
expect(prompt).toContain("## Paperclip Wake Payload");
expect(prompt).toContain("Execution contract: take concrete action in this heartbeat");
expect(prompt).toContain("use child issues instead of polling");
expect(prompt).toContain("mark blocked work with the unblock owner/action");
expect(prompt).toContain("clear final disposition");
expect(prompt).toContain("evidence, not valid liveness paths by themselves");
expect(prompt).toContain("Use child issues for long or parallel delegated work instead of polling");
expect(prompt).toContain("named unblock owner/action");
});
it("renders planning-mode directives for assignment and comment wakes", () => {
const assignmentPrompt = renderPaperclipWakePrompt({
reason: "issue_assigned",
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
status: "in_progress",
workMode: "planning",
},
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
comments: [],
fallbackFetchNeeded: false,
});
expect(assignmentPrompt).toContain("- issue work mode: planning");
expect(assignmentPrompt).toContain("Make the plan only. Do not write code or perform implementation work.");
const commentPrompt = renderPaperclipWakePrompt({
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
status: "in_progress",
workMode: "planning",
},
commentIds: ["comment-1"],
latestCommentId: "comment-1",
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
comments: [{ id: "comment-1", body: "Revise the plan" }],
fallbackFetchNeeded: false,
});
expect(commentPrompt).toContain("Update the plan only. Do not write code or perform implementation work.");
});
it("does not render stale accepted-plan continuation guidance for later planning comment wakes", () => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
status: "in_progress",
workMode: "planning",
},
interactionKind: "request_confirmation",
interactionStatus: "accepted",
commentIds: ["comment-1"],
latestCommentId: "comment-1",
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
comments: [{ id: "comment-1", body: "Revise the plan" }],
fallbackFetchNeeded: false,
});
expect(prompt).toContain("Update the plan only. Do not write code or perform implementation work.");
expect(prompt).not.toContain("accepted-plan continuation");
expect(prompt).not.toContain("Create child issues from the approved plan only");
});
it("renders accepted-plan continuation guidance for planning issues", () => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
status: "in_progress",
workMode: "planning",
},
interactionKind: "request_confirmation",
interactionStatus: "accepted",
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
comments: [],
fallbackFetchNeeded: false,
});
expect(prompt).toContain("accepted-plan continuation");
expect(prompt).toContain("Create child issues from the approved plan only");
expect(prompt).toContain("may create child implementation issues");
expect(prompt).toContain("must not start implementation work on the planning issue itself");
});
it("keeps accepted-plan guidance when stale comment ids have no loaded comments", () => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
status: "in_progress",
workMode: "planning",
},
interactionKind: "request_confirmation",
interactionStatus: "accepted",
commentIds: ["stale-comment-1"],
latestCommentId: "stale-comment-1",
commentWindow: { requestedCount: 1, includedCount: 0, missingCount: 1 },
comments: [],
fallbackFetchNeeded: true,
});
expect(prompt).toContain("accepted-plan continuation");
expect(prompt).toContain("Create child issues from the approved plan only");
expect(prompt).not.toContain("Update the plan only");
});
it("renders dependency-blocked interaction guidance", () => {
@@ -551,6 +748,183 @@ describe("applyPaperclipWorkspaceEnv", () => {
});
});
describe("shapePaperclipWorkspaceEnvForExecution", () => {
it("rewrites workspace env paths for remote execution", () => {
const shaped = shapePaperclipWorkspaceEnvForExecution({
workspaceCwd: "/tmp/workspace",
workspaceWorktreePath: "/tmp/worktree",
workspaceHints: [
{
workspaceId: "workspace-1",
cwd: "/tmp/workspace",
repoUrl: "https://github.com/paperclipai/paperclip.git",
},
{
workspaceId: "workspace-2",
cwd: "/tmp/other-workspace",
repoUrl: "https://github.com/paperclipai/paperclip.git",
},
{
workspaceId: "workspace-3",
repoUrl: "https://github.com/paperclipai/paperclip.git",
},
],
executionTargetIsRemote: true,
executionCwd: "/remote/workspace",
});
expect(shaped).toEqual({
workspaceCwd: "/remote/workspace",
workspaceWorktreePath: null,
workspaceHints: [
{
workspaceId: "workspace-1",
cwd: "/remote/workspace",
repoUrl: "https://github.com/paperclipai/paperclip.git",
},
{
workspaceId: "workspace-2",
repoUrl: "https://github.com/paperclipai/paperclip.git",
},
{
workspaceId: "workspace-3",
repoUrl: "https://github.com/paperclipai/paperclip.git",
},
],
});
});
it("leaves local execution workspace paths unchanged", () => {
const workspaceHints = [{ workspaceId: "workspace-1", cwd: "/tmp/workspace" }];
const shaped = shapePaperclipWorkspaceEnvForExecution({
workspaceCwd: "/tmp/workspace",
workspaceWorktreePath: "/tmp/worktree",
workspaceHints,
executionTargetIsRemote: false,
executionCwd: "/remote/workspace",
});
expect(shaped).toEqual({
workspaceCwd: "/tmp/workspace",
workspaceWorktreePath: "/tmp/worktree",
workspaceHints,
});
});
});
describe("rewriteWorkspaceCwdEnvVarsForExecution", () => {
it("rewrites custom *_WORKSPACE_CWD env vars for remote execution", () => {
const env = rewriteWorkspaceCwdEnvVarsForExecution({
workspaceCwd: "/host/workspace",
executionCwd: "/remote/workspace",
executionTargetIsRemote: true,
env: {
QA_PROJECT_WORKSPACE_CWD: "/host/workspace",
RANDOM_WORKSPACE_CWD: "/host/workspace",
OTHER_ENV: "/host/workspace",
},
});
expect(env).toEqual({
QA_PROJECT_WORKSPACE_CWD: "/remote/workspace",
RANDOM_WORKSPACE_CWD: "/remote/workspace",
OTHER_ENV: "/host/workspace",
});
});
it("does not rewrite matching values for local execution", () => {
const env = rewriteWorkspaceCwdEnvVarsForExecution({
workspaceCwd: "/host/workspace",
executionCwd: "/remote/workspace",
executionTargetIsRemote: false,
env: {
QA_PROJECT_WORKSPACE_CWD: "/host/workspace",
RANDOM_WORKSPACE_CWD_TOKEN: "/host/workspace",
},
});
expect(env).toEqual({
QA_PROJECT_WORKSPACE_CWD: "/host/workspace",
RANDOM_WORKSPACE_CWD_TOKEN: "/host/workspace",
});
});
it("only rewrites matching *_WORKSPACE_CWD string values", () => {
const env = rewriteWorkspaceCwdEnvVarsForExecution({
workspaceCwd: "/host/workspace",
executionCwd: "/remote/workspace",
executionTargetIsRemote: true,
env: {
MATCHING_WORKSPACE_CWD: "/host/workspace/.",
DIFFERENT_WORKSPACE_CWD: "/host/other-workspace",
BLANK_WORKSPACE_CWD: " ",
NON_STRING_WORKSPACE_CWD: 42,
},
});
expect(env).toEqual({
MATCHING_WORKSPACE_CWD: "/remote/workspace",
DIFFERENT_WORKSPACE_CWD: "/host/other-workspace",
BLANK_WORKSPACE_CWD: " ",
});
});
});
describe("refreshPaperclipWorkspaceEnvForExecution", () => {
it("rewrites Paperclip workspace env to the prepared remote runtime cwd", () => {
const env: Record<string, string> = {
PAPERCLIP_WORKSPACE_CWD: "/remote/workspace",
PAPERCLIP_WORKSPACE_WORKTREE_PATH: "/host/worktree",
PAPERCLIP_WORKSPACES_JSON: JSON.stringify([
{ workspaceId: "workspace-1", cwd: "/remote/workspace" },
{ workspaceId: "workspace-2", cwd: "/tmp/other" },
]),
QA_PROJECT_WORKSPACE_CWD: "/remote/workspace",
};
const shaped = refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig: {
QA_PROJECT_WORKSPACE_CWD: "/host/workspace",
},
workspaceCwd: "/host/workspace",
workspaceWorktreePath: "/host/worktree",
workspaceHints: [
{ workspaceId: "workspace-1", cwd: "/host/workspace" },
{ workspaceId: "workspace-2", cwd: "/tmp/other" },
],
executionTargetIsRemote: true,
executionCwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace",
});
expect(shaped).toEqual({
workspaceCwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace",
workspaceWorktreePath: null,
workspaceHints: [
{
workspaceId: "workspace-1",
cwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace",
},
{
workspaceId: "workspace-2",
},
],
});
expect(env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace/.paperclip-runtime/runs/run-1/workspace");
expect(env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined();
expect(env.QA_PROJECT_WORKSPACE_CWD).toBe("/remote/workspace/.paperclip-runtime/runs/run-1/workspace");
expect(JSON.parse(env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
{
workspaceId: "workspace-1",
cwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace",
},
{
workspaceId: "workspace-2",
},
]);
});
});
describe("appendWithByteCap", () => {
it("keeps valid UTF-8 when trimming through multibyte text", () => {
const output = appendWithByteCap("prefix ", "hello — world", 7);
+244 -3
View File
@@ -1,7 +1,9 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js";
import { redactCommandText } from "./command-redaction.js";
import type {
@@ -77,6 +79,8 @@ export const runningProcesses = new Map<string, RunningProcess>();
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
export const MAX_EXCERPT_BYTES = 32 * 1024;
const TERMINAL_RESULT_SCAN_OVERLAP_CHARS = 64 * 1024;
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
const REDACTED_LOG_VALUE = "***REDACTED***";
const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
@@ -87,12 +91,33 @@ const MATERIALIZED_SKILL_SENTINEL = ".paperclip-materialized-skill.json";
const MATERIALIZED_SKILL_LOCK_OWNER = "owner.json";
const MATERIALIZED_SKILL_LOCK_STALE_MS = 30_000;
function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
export function resolvePaperclipInstanceRootForAdapter(input: {
homeDir?: string;
instanceId?: string;
env?: NodeJS.ProcessEnv;
} = {}): string {
const env = input.env ?? process.env;
const homeRaw = input.homeDir?.trim() || env.PAPERCLIP_HOME?.trim();
const homeDir = path.resolve(homeRaw ? expandHomePrefix(homeRaw) : path.resolve(os.homedir(), ".paperclip"));
const instanceId = input.instanceId?.trim() || env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID;
if (!PATH_SEGMENT_RE.test(instanceId)) throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${instanceId}'.`);
return path.resolve(homeDir, "instances", instanceId);
}
export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
"",
"Execution contract:",
"- Start actionable work in this heartbeat; do not stop at a plan unless the issue asks for planning.",
"- Leave durable progress in comments, documents, or work products with a clear next action.",
"- Leave durable progress in comments, documents, or work products, then update the issue to a clear final disposition before ending the heartbeat.",
"- Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
"- Final disposition checklist: mark `done` when complete; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists.",
"- Prefer the smallest verification that proves the change; do not default to full workspace typecheck/build/test on every heartbeat unless the task scope warrants it.",
"- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.",
"- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.",
@@ -280,6 +305,7 @@ type PaperclipWakeIssue = {
identifier: string | null;
title: string | null;
status: string | null;
workMode: string | null;
priority: string | null;
};
@@ -365,6 +391,8 @@ type PaperclipWakePayload = {
executionStage: PaperclipWakeExecutionStage | null;
continuationSummary: PaperclipWakeContinuationSummary | null;
livenessContinuation: PaperclipWakeLivenessContinuation | null;
interactionKind: string | null;
interactionStatus: string | null;
childIssueSummaries: PaperclipWakeChildIssueSummary[];
childIssueSummaryTruncated: boolean;
commentIds: string[];
@@ -383,6 +411,7 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null
const identifier = asString(issue.identifier, "").trim() || null;
const title = asString(issue.title, "").trim() || null;
const status = asString(issue.status, "").trim() || null;
const workMode = asString(issue.workMode, "").trim() || null;
const priority = asString(issue.priority, "").trim() || null;
if (!id && !identifier && !title) return null;
return {
@@ -390,6 +419,7 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null
identifier,
title,
status,
workMode,
priority,
};
}
@@ -572,6 +602,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
executionStage,
continuationSummary,
livenessContinuation,
interactionKind: asString(payload.interactionKind, "").trim() || null,
interactionStatus: asString(payload.interactionStatus, "").trim() || null,
childIssueSummaries,
childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false),
commentIds,
@@ -591,6 +623,15 @@ export function stringifyPaperclipWakePayload(value: unknown): string | null {
return JSON.stringify(normalized);
}
export function readPaperclipIssueWorkModeFromContext(value: unknown): string | null {
const context = parseObject(value);
const issue = parseObject(context.paperclipIssue);
const direct = asString(issue.workMode, "").trim();
if (direct) return direct;
const wake = normalizePaperclipWakePayload(context.paperclipWake);
return wake?.issue?.workMode ?? null;
}
export function renderPaperclipWakePrompt(
value: unknown,
options: { resumedSession?: boolean } = {},
@@ -614,7 +655,7 @@ export function renderPaperclipWakePrompt(
"Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.",
"Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.",
"",
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.",
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
"",
`- reason: ${normalized.reason ?? "unknown"}`,
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
@@ -631,7 +672,7 @@ export function renderPaperclipWakePrompt(
"Use this inline wake data first before refetching the issue thread.",
"Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.",
"",
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.",
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
"",
`- reason: ${normalized.reason ?? "unknown"}`,
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
@@ -643,9 +684,31 @@ export function renderPaperclipWakePrompt(
if (normalized.issue?.status) {
lines.push(`- issue status: ${normalized.issue.status}`);
}
if (normalized.issue?.workMode) {
lines.push(`- issue work mode: ${normalized.issue.workMode}`);
}
if (normalized.issue?.priority) {
lines.push(`- issue priority: ${normalized.issue.priority}`);
}
if (normalized.issue?.workMode === "planning") {
const hasWakeComments = normalized.comments.length > 0;
const acceptedPlanContinuation =
!hasWakeComments &&
normalized.interactionKind === "request_confirmation" && normalized.interactionStatus === "accepted";
let directive = "Make the plan only. Do not write code or perform implementation work.";
if (hasWakeComments) {
directive = "Update the plan only. Do not write code or perform implementation work.";
}
if (acceptedPlanContinuation) {
directive = "Create child issues from the approved plan only. Do not write code or perform implementation work on the planning issue.";
}
lines.push(`- planning directive: ${directive}`);
if (acceptedPlanContinuation) {
lines.push(
"- accepted-plan continuation: you may create child implementation issues from the approved plan, but must not start implementation work on the planning issue itself",
);
}
}
if (normalized.checkedOutByHarness) {
lines.push("- checkout: already claimed by the harness for this run");
}
@@ -885,6 +948,177 @@ export function applyPaperclipWorkspaceEnv(
return env;
}
export function shapePaperclipWorkspaceEnvForExecution(input: {
workspaceCwd?: string | null;
workspaceWorktreePath?: string | null;
workspaceHints?: Array<Record<string, unknown>>;
executionTargetIsRemote?: boolean;
executionCwd?: string | null;
}): {
workspaceCwd: string | null;
workspaceWorktreePath: string | null;
workspaceHints: Array<Record<string, unknown>>;
} {
const workspaceCwd =
typeof input.workspaceCwd === "string" && input.workspaceCwd.trim().length > 0
? input.workspaceCwd.trim()
: null;
const workspaceWorktreePath =
typeof input.workspaceWorktreePath === "string" && input.workspaceWorktreePath.trim().length > 0
? input.workspaceWorktreePath.trim()
: null;
const workspaceHints = Array.isArray(input.workspaceHints) ? input.workspaceHints : [];
if (!input.executionTargetIsRemote) {
return {
workspaceCwd,
workspaceWorktreePath,
workspaceHints,
};
}
const executionCwd =
typeof input.executionCwd === "string" && input.executionCwd.trim().length > 0
? input.executionCwd.trim()
: null;
// On a remote target we must never fall back to the local workspaceCwd —
// doing so leaks host paths into the remote env (the exact failure mode
// this helper exists to prevent). Callers are expected to resolve
// executionCwd via adapterExecutionTargetRemoteCwd before calling this
// helper, which always returns a non-empty string. Surface a warning so
// future callers don't silently regress to the leak.
if (executionCwd === null) {
// eslint-disable-next-line no-console
console.warn(
"[paperclip] shapePaperclipWorkspaceEnvForExecution called with executionCwd=null on a remote target; " +
"stripping workspaceCwd to avoid leaking local paths into the remote environment.",
);
}
const realizedWorkspaceCwd = executionCwd;
const localWorkspaceCwd = workspaceCwd ? path.resolve(workspaceCwd) : null;
const shapedWorkspaceHints = workspaceHints.map((hint) => {
const nextHint = { ...hint };
const hintCwd = typeof nextHint.cwd === "string" ? nextHint.cwd.trim() : "";
if (!hintCwd) return nextHint;
if (localWorkspaceCwd && path.resolve(hintCwd) === localWorkspaceCwd) {
if (realizedWorkspaceCwd) {
nextHint.cwd = realizedWorkspaceCwd;
} else {
delete nextHint.cwd;
}
return nextHint;
}
delete nextHint.cwd;
return nextHint;
});
return {
workspaceCwd: realizedWorkspaceCwd,
workspaceWorktreePath: null,
workspaceHints: shapedWorkspaceHints,
};
}
export function rewriteWorkspaceCwdEnvVarsForExecution(input: {
env: Record<string, unknown>;
workspaceCwd?: string | null;
executionCwd?: string | null;
executionTargetIsRemote?: boolean;
}): Record<string, string> {
const nextEnv = Object.fromEntries(
Object.entries(input.env)
.filter((entry): entry is [string, string] => typeof entry[1] === "string"),
) as Record<string, string>;
const localWorkspaceCwd = typeof input.workspaceCwd === "string" && input.workspaceCwd.trim().length > 0
? path.resolve(input.workspaceCwd)
: null;
// executionCwd is a remote path on the target host; we deliberately do not
// run `path.resolve` against it because that applies host-Node semantics
// (current working directory, host path separator) to a path that lives on
// the remote shell. Callers always pass absolute remote paths, so we
// forward the trimmed value verbatim.
const remoteWorkspaceCwd = typeof input.executionCwd === "string" && input.executionCwd.trim().length > 0
? input.executionCwd.trim()
: null;
if (!input.executionTargetIsRemote || !localWorkspaceCwd || !remoteWorkspaceCwd) {
return nextEnv;
}
for (const [key, value] of Object.entries(nextEnv)) {
if (!key.endsWith("_WORKSPACE_CWD")) continue;
const trimmed = value.trim();
if (!trimmed) continue;
if (path.resolve(trimmed) !== localWorkspaceCwd) continue;
nextEnv[key] = remoteWorkspaceCwd;
}
return nextEnv;
}
export function refreshPaperclipWorkspaceEnvForExecution(input: {
env: Record<string, string>;
envConfig?: Record<string, unknown>;
workspaceCwd?: string | null;
workspaceSource?: string | null;
workspaceStrategy?: string | null;
workspaceId?: string | null;
workspaceRepoUrl?: string | null;
workspaceRepoRef?: string | null;
workspaceBranch?: string | null;
workspaceWorktreePath?: string | null;
workspaceHints?: Array<Record<string, unknown>>;
agentHome?: string | null;
executionTargetIsRemote?: boolean;
executionCwd?: string | null;
}): {
workspaceCwd: string | null;
workspaceWorktreePath: string | null;
workspaceHints: Array<Record<string, unknown>>;
} {
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
workspaceCwd: input.workspaceCwd,
workspaceWorktreePath: input.workspaceWorktreePath,
workspaceHints: input.workspaceHints,
executionTargetIsRemote: input.executionTargetIsRemote,
executionCwd: input.executionCwd,
});
delete input.env.PAPERCLIP_WORKSPACE_CWD;
delete input.env.PAPERCLIP_WORKSPACE_WORKTREE_PATH;
delete input.env.PAPERCLIP_WORKSPACES_JSON;
applyPaperclipWorkspaceEnv(input.env, {
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
workspaceSource: input.workspaceSource,
workspaceStrategy: input.workspaceStrategy,
workspaceId: input.workspaceId,
workspaceRepoUrl: input.workspaceRepoUrl,
workspaceRepoRef: input.workspaceRepoRef,
workspaceBranch: input.workspaceBranch,
workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath,
agentHome: input.agentHome,
});
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
input.env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
}
const shapedEnvConfig = rewriteWorkspaceCwdEnvVarsForExecution({
env: input.envConfig ?? {},
workspaceCwd: input.workspaceCwd,
executionCwd: shapedWorkspaceEnv.workspaceCwd,
executionTargetIsRemote: input.executionTargetIsRemote,
});
for (const [key, value] of Object.entries(shapedEnvConfig)) {
input.env[key] = value;
}
return shapedWorkspaceEnv;
}
export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...baseEnv };
for (const key of Object.keys(env)) {
@@ -966,6 +1200,13 @@ function quoteForCmd(arg: string) {
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
}
export function sanitizeSshRemoteEnv(
env: Record<string, string>,
inheritedEnv: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
return sanitizeRemoteExecutionEnv(env, inheritedEnv);
}
function resolveWindowsCmdShell(env: NodeJS.ProcessEnv): string {
const fallbackRoot = env.SystemRoot || process.env.SystemRoot || "C:\\Windows";
return path.join(fallbackRoot, "System32", "cmd.exe");
@@ -40,6 +40,7 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
"acpx_local",
"claude_local",
"codex_local",
"cursor_cloud",
"cursor",
"gemini_local",
"hermes_local",
@@ -63,6 +64,11 @@ export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement
nativeContextManagement: "confirmed",
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
},
cursor_cloud: {
supportsSessionResume: true,
nativeContextManagement: "unknown",
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
},
cursor: {
supportsSessionResume: true,
nativeContextManagement: "unknown",
+298 -59
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -15,6 +15,10 @@ import {
startSshEnvLabFixture,
stopSshEnvLabFixture,
} from "./ssh.js";
import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js";
const SSH_FIXTURE_TEST_TIMEOUT_MS = 30_000;
let sshEnvLabUnsupportedReason: string | null = null;
async function git(cwd: string, args: string[]): Promise<string> {
return await new Promise((resolve, reject) => {
@@ -28,6 +32,28 @@ async function git(cwd: string, args: string[]): Promise<string> {
});
}
async function startSshEnvLabFixtureOrSkip(statePath: string, label: string) {
if (sshEnvLabUnsupportedReason) {
console.warn(`Skipping ${label}: ${sshEnvLabUnsupportedReason}`);
return null;
}
const support = await getSshEnvLabSupport();
if (!support.supported) {
sshEnvLabUnsupportedReason = support.reason ?? "unsupported environment";
console.warn(`Skipping ${label}: ${sshEnvLabUnsupportedReason}`);
return null;
}
try {
return await startSshEnvLabFixture({ statePath });
} catch (error) {
sshEnvLabUnsupportedReason = error instanceof Error ? error.message : String(error);
console.warn(`Skipping ${label}: ${sshEnvLabUnsupportedReason}`);
return null;
}
}
describe("ssh env-lab fixture", () => {
const cleanupDirs: string[] = [];
@@ -40,24 +66,17 @@ describe("ssh env-lab fixture", () => {
});
it("starts an isolated sshd fixture and executes commands through it", async () => {
const support = await getSshEnvLabSupport();
if (!support.supported) {
console.warn(
`Skipping SSH env-lab fixture test: ${support.reason ?? "unsupported environment"}`,
);
return;
}
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const started = await startSshEnvLabFixture({ statePath });
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const quotedWorkspace = JSON.stringify(started.workspaceDir);
const result = await runSshCommand(
config,
`sh -lc 'cd ${quotedWorkspace} && pwd'`,
`cd ${quotedWorkspace} && pwd`,
);
expect(result.stdout.trim()).toBe(started.workspaceDir);
@@ -68,22 +87,44 @@ describe("ssh env-lab fixture", () => {
const stopped = await readSshEnvLabFixtureStatus(statePath);
expect(stopped.running).toBe(false);
});
it("does not treat an unrelated reused pid as the running fixture", async () => {
const support = await getSshEnvLabSupport();
if (!support.supported) {
console.warn(
`Skipping SSH env-lab fixture test: ${support.reason ?? "unsupported environment"}`,
);
return;
}
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("forwards stdin to remote SSH commands", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const started = await startSshEnvLabFixture({ statePath });
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH stdin forwarding test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const remotePath = path.posix.join(started.workspaceDir, "stdin-forwarded.txt");
await runSshCommand(
config,
`cat > ${JSON.stringify(remotePath)}`,
{
stdin: "hello over ssh stdin\n",
timeoutMs: 30_000,
maxBuffer: 256 * 1024,
},
);
const result = await runSshCommand(
config,
`cat ${JSON.stringify(remotePath)}`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
expect(result.stdout).toBe("hello over ssh stdin\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("does not treat an unrelated reused pid as the running fixture", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture test");
if (!started) return;
await stopSshEnvLabFixture(statePath);
await mkdir(path.dirname(statePath), { recursive: true });
@@ -96,11 +137,12 @@ describe("ssh env-lab fixture", () => {
const staleStatus = await readSshEnvLabFixtureStatus(statePath);
expect(staleStatus.running).toBe(false);
const restarted = await startSshEnvLabFixture({ statePath });
const restarted = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture restart test");
if (!restarted) return;
expect(restarted.pid).not.toBe(process.pid);
await stopSshEnvLabFixture(statePath);
});
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("rejects invalid environment variable keys when constructing SSH spawn targets", async () => {
await expect(
@@ -125,14 +167,6 @@ describe("ssh env-lab fixture", () => {
});
it("syncs a local directory into the remote fixture workspace", async () => {
const support = await getSshEnvLabSupport();
if (!support.supported) {
console.warn(
`Skipping SSH env-lab fixture test: ${support.reason ?? "unsupported environment"}`,
);
return;
}
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
@@ -142,7 +176,8 @@ describe("ssh env-lab fixture", () => {
await writeFile(path.join(localDir, "message.txt"), "hello from paperclip\n", "utf8");
await writeFile(path.join(localDir, "._message.txt"), "should never sync\n", "utf8");
const started = await startSshEnvLabFixture({ statePath });
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH env-lab fixture test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const remoteDir = path.posix.join(started.workspaceDir, "overlay");
@@ -157,22 +192,14 @@ describe("ssh env-lab fixture", () => {
const result = await runSshCommand(
config,
`sh -lc 'cat ${JSON.stringify(path.posix.join(remoteDir, "message.txt"))} && if [ -e ${JSON.stringify(path.posix.join(remoteDir, "._message.txt"))} ]; then echo appledouble-present; fi'`,
`cat ${JSON.stringify(path.posix.join(remoteDir, "message.txt"))} && if [ -e ${JSON.stringify(path.posix.join(remoteDir, "._message.txt"))} ]; then echo appledouble-present; fi`,
);
expect(result.stdout).toContain("hello from paperclip");
expect(result.stdout).not.toContain("appledouble-present");
});
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("can dereference local symlinks while syncing to the remote fixture", async () => {
const support = await getSshEnvLabSupport();
if (!support.supported) {
console.warn(
`Skipping SSH symlink sync test: ${support.reason ?? "unsupported environment"}`,
);
return;
}
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
@@ -184,7 +211,8 @@ describe("ssh env-lab fixture", () => {
await writeFile(path.join(sourceDir, "auth.json"), "{\"token\":\"secret\"}\n", "utf8");
await symlink(path.join(sourceDir, "auth.json"), path.join(localDir, "auth.json"));
const started = await startSshEnvLabFixture({ statePath });
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH symlink sync test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const remoteDir = path.posix.join(started.workspaceDir, "overlay-follow-links");
@@ -200,29 +228,22 @@ describe("ssh env-lab fixture", () => {
const result = await runSshCommand(
config,
`sh -lc 'if [ -L ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))} ]; then echo symlink; else echo regular; fi && cat ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))}'`,
`if [ -L ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))} ]; then echo symlink; else echo regular; fi && cat ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))}`,
);
expect(result.stdout).toContain("regular");
expect(result.stdout).toContain("{\"token\":\"secret\"}");
});
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("round-trips a git workspace through the SSH fixture", async () => {
const support = await getSshEnvLabSupport();
if (!support.supported) {
console.warn(
`Skipping SSH workspace round-trip test: ${support.reason ?? "unsupported environment"}`,
);
return;
}
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init", "-b", "main"]);
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
@@ -233,7 +254,8 @@ describe("ssh env-lab fixture", () => {
await writeFile(path.join(localRepo, "tracked.txt"), "dirty local\n", "utf8");
await writeFile(path.join(localRepo, "untracked.txt"), "from local\n", "utf8");
const started = await startSshEnvLabFixture({ statePath });
const started = await startSshEnvLabFixtureOrSkip(statePath, "SSH workspace round-trip test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
@@ -248,7 +270,7 @@ describe("ssh env-lab fixture", () => {
const remoteStatus = await runSshCommand(
config,
`sh -lc 'cd ${JSON.stringify(started.workspaceDir)} && git status --short'`,
`cd ${JSON.stringify(started.workspaceDir)} && git status --short`,
);
expect(remoteStatus.stdout).toContain("M tracked.txt");
expect(remoteStatus.stdout).toContain("?? untracked.txt");
@@ -256,7 +278,7 @@ describe("ssh env-lab fixture", () => {
await runSshCommand(
config,
`sh -lc 'cd ${JSON.stringify(started.workspaceDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && git add tracked.txt untracked.txt && git commit -m "remote update" >/dev/null && printf "remote dirty\\n" > tracked.txt && printf "remote extra\\n" > remote-only.txt'`,
`cd ${JSON.stringify(started.workspaceDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && git add tracked.txt untracked.txt && git commit -m "remote update" >/dev/null && printf "remote dirty\\n" > tracked.txt && printf "remote extra\\n" > remote-only.txt`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
@@ -271,5 +293,222 @@ describe("ssh env-lab fixture", () => {
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update");
expect(await git(localRepo, ["status", "--short"])).toContain("M tracked.txt");
expect(await git(localRepo, ["status", "--short"])).not.toContain("._tracked.txt");
});
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("preserves both concurrent SSH restores in a shared git workspace", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
const started = await startSshEnvLabFixtureOrSkip(statePath, "concurrent SSH restore test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const preparedA = await prepareRemoteManagedRuntime({
spec,
runId: "run-a",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
const preparedB = await prepareRemoteManagedRuntime({
spec,
runId: "run-b",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
expect(preparedA.workspaceRemoteDir).not.toBe(preparedB.workspaceRemoteDir);
await runSshCommand(
config,
`printf "from run a\\n" > ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "run-a.txt"))}`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await runSshCommand(
config,
`printf "from run b\\n" > ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "run-b.txt"))}`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await Promise.all([
preparedA.restoreWorkspace(),
preparedB.restoreWorkspace(),
]);
await expect(readFile(path.join(localRepo, "run-a.txt"), "utf8")).resolves.toBe("from run a\n");
await expect(readFile(path.join(localRepo, "run-b.txt"), "utf8")).resolves.toBe("from run b\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("preserves nested per-run files across sequential SSH restores with stale baselines", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
const started = await startSshEnvLabFixtureOrSkip(statePath, "sequential nested SSH restore test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const preparedA = await prepareRemoteManagedRuntime({
spec,
runId: "run-a",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
const preparedB = await prepareRemoteManagedRuntime({
spec,
runId: "run-b",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
await runSshCommand(
config,
`mkdir -p ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "manual-qa/environment-matrix/ssh"))} && printf "from run a\\n" > ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "manual-qa/environment-matrix/ssh/claude_local.md"))}`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await runSshCommand(
config,
`mkdir -p ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "manual-qa/environment-matrix/ssh"))} && printf "from run b\\n" > ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "manual-qa/environment-matrix/ssh/codex_local.md"))}`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await preparedA.restoreWorkspace();
await preparedB.restoreWorkspace();
await expect(readFile(path.join(localRepo, "manual-qa/environment-matrix/ssh/claude_local.md"), "utf8")).resolves
.toBe("from run a\n");
await expect(readFile(path.join(localRepo, "manual-qa/environment-matrix/ssh/codex_local.md"), "utf8")).resolves
.toBe("from run b\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("round-trips remote git commits through the managed runtime restore path", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
const started = await startSshEnvLabFixtureOrSkip(statePath, "managed-runtime SSH git round-trip test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const prepared = await prepareRemoteManagedRuntime({
spec,
runId: "run-commit",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
await runSshCommand(
config,
`cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "committed\\n" > tracked.txt && git add tracked.txt && git commit -m "remote update" >/dev/null && printf "dirty remote\\n" > tracked.txt`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await prepared.restoreWorkspace();
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update");
await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
it("merges concurrent remote commits through the managed runtime restore path", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-"));
cleanupDirs.push(rootDir);
const statePath = path.join(rootDir, "state.json");
const localRepo = path.join(rootDir, "local-workspace");
await mkdir(localRepo, { recursive: true });
await git(localRepo, ["init"]);
await git(localRepo, ["checkout", "-b", "main"]);
await git(localRepo, ["config", "user.name", "Paperclip Test"]);
await git(localRepo, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8");
await git(localRepo, ["add", "tracked.txt"]);
await git(localRepo, ["commit", "-m", "initial"]);
const started = await startSshEnvLabFixtureOrSkip(statePath, "concurrent managed-runtime SSH git merge test");
if (!started) return;
const config = await buildSshEnvLabFixtureConfig(started);
const spec = {
...config,
remoteCwd: started.workspaceDir,
} as const;
const preparedA = await prepareRemoteManagedRuntime({
spec,
runId: "run-commit-a",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
const preparedB = await prepareRemoteManagedRuntime({
spec,
runId: "run-commit-b",
adapterKey: "test-adapter",
workspaceLocalDir: localRepo,
});
await runSshCommand(
config,
`cd ${JSON.stringify(preparedA.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "from run a\\n" > run-a.txt && git add run-a.txt && git commit -m "remote update a" >/dev/null`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await runSshCommand(
config,
`cd ${JSON.stringify(preparedB.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "from run b\\n" > run-b.txt && git add run-b.txt && git commit -m "remote update b" >/dev/null`,
{ timeoutMs: 30_000, maxBuffer: 256 * 1024 },
);
await Promise.all([
preparedA.restoreWorkspace(),
preparedB.restoreWorkspace(),
]);
await expect(readFile(path.join(localRepo, "run-a.txt"), "utf8")).resolves.toBe("from run a\n");
await expect(readFile(path.join(localRepo, "run-b.txt"), "utf8")).resolves.toBe("from run b\n");
expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toContain("Paperclip SSH sync merge");
const recentSubjects = await git(localRepo, ["log", "--pretty=%s", "-3"]);
expect(recentSubjects).toContain("remote update a");
expect(recentSubjects).toContain("remote update b");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
});
+386 -74
View File
@@ -1,8 +1,13 @@
import { randomUUID } from "node:crypto";
import { execFile, spawn } from "node:child_process";
import { constants as fsConstants, createReadStream, createWriteStream, promises as fs } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
import type { RunProcessResult } from "./server-utils.js";
import type { DirectorySnapshot } from "./workspace-restore-merge.js";
import { mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
export interface SshConnectionConfig {
host: string;
@@ -21,7 +26,85 @@ export interface SshCommandResult {
export interface SshRemoteExecutionSpec extends SshConnectionConfig {
remoteCwd: string;
paperclipApiUrl?: string | null;
}
export function createSshCommandManagedRuntimeRunner(input: {
spec: SshRemoteExecutionSpec;
defaultCwd?: string | null;
maxBufferBytes?: number | null;
}): CommandManagedRuntimeRunner {
const defaultCwd = input.defaultCwd?.trim() || input.spec.remoteCwd;
const maxBufferBytes =
typeof input.maxBufferBytes === "number" && Number.isFinite(input.maxBufferBytes) && input.maxBufferBytes > 0
? Math.trunc(input.maxBufferBytes)
: 1024 * 1024;
return {
execute: async (commandInput): Promise<RunProcessResult> => {
const startedAt = new Date().toISOString();
const command = commandInput.command.trim();
const args = commandInput.args ?? [];
const cwd = commandInput.cwd?.trim() || defaultCwd;
const envEntries = Object.entries(commandInput.env ?? {})
.filter((entry): entry is [string, string] => typeof entry[1] === "string");
const envPrefix = envEntries.length > 0
? `env ${envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} `
: "";
const exportPrefix = envEntries.length > 0
? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " "
: "";
const commandScript = command === "sh" || command === "bash"
? (args[0] === "-c" || args[0] === "-lc") && typeof args[1] === "string"
? `${exportPrefix}${args[1]}`
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`
: `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`;
const remoteCommand = `cd ${shellQuote(cwd)} && ${commandScript}`;
try {
const result = await runSshCommand(input.spec, remoteCommand, {
stdin: commandInput.stdin,
timeoutMs: commandInput.timeoutMs,
maxBuffer: maxBufferBytes,
});
if (result.stdout) await commandInput.onLog?.("stdout", result.stdout);
if (result.stderr) await commandInput.onLog?.("stderr", result.stderr);
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: result.stdout,
stderr: result.stderr,
pid: null,
startedAt,
};
} catch (error) {
const failure = error as {
stdout?: unknown;
stderr?: unknown;
code?: unknown;
signal?: unknown;
killed?: unknown;
};
const stdout = typeof failure.stdout === "string" ? failure.stdout : "";
const stderr = typeof failure.stderr === "string"
? failure.stderr
: error instanceof Error
? error.message
: String(error);
if (stdout) await commandInput.onLog?.("stdout", stdout);
if (stderr) await commandInput.onLog?.("stderr", stderr);
return {
exitCode: typeof failure.code === "number" ? failure.code : null,
signal: typeof failure.signal === "string" ? failure.signal : null,
timedOut: failure.killed === true,
stdout,
stderr,
pid: null,
startedAt,
};
}
},
};
}
export interface SshEnvLabSupport {
@@ -83,10 +166,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS
port: portValue,
username,
remoteCwd,
paperclipApiUrl:
typeof parsed.paperclipApiUrl === "string" && parsed.paperclipApiUrl.trim().length > 0
? parsed.paperclipApiUrl.trim()
: null,
remoteWorkspacePath:
typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0
? parsed.remoteWorkspacePath.trim()
@@ -98,50 +177,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS
};
}
function normalizeHttpUrlCandidate(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return null;
}
return parsed.origin;
} catch {
return null;
}
}
export async function findReachablePaperclipApiUrlOverSsh(input: {
config: SshConnectionConfig;
candidates: string[];
timeoutMs?: number;
}): Promise<string | null> {
const uniqueCandidates = Array.from(
new Set(
input.candidates
.map((candidate) => normalizeHttpUrlCandidate(candidate))
.filter((candidate): candidate is string => candidate !== null),
),
);
for (const candidate of uniqueCandidates) {
const healthUrl = new URL("/api/health", candidate).toString();
try {
await runSshCommand(
input.config,
`sh -lc ${shellQuote(`curl -fsS -m ${Math.max(1, Math.ceil((input.timeoutMs ?? 5_000) / 1000))} ${shellQuote(healthUrl)} >/dev/null`)}`,
{ timeoutMs: input.timeoutMs ?? 5_000 },
);
return candidate;
} catch {
continue;
}
}
return null;
}
async function execFileText(
file: string,
args: string[],
@@ -172,6 +207,113 @@ async function execFileText(
});
}
async function spawnText(
file: string,
args: string[],
options: {
stdin?: string;
timeout?: number;
maxBuffer?: number;
} = {},
): Promise<SshCommandResult> {
return await new Promise<SshCommandResult>((resolve, reject) => {
const child = spawn(file, args, {
stdio: [options.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
});
const maxBuffer = options.maxBuffer ?? 1024 * 128;
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
const finishReject = (error: Error & { stdout?: string; stderr?: string; code?: number | null; killed?: boolean }) => {
if (settled) return;
settled = true;
error.stdout = stdout;
error.stderr = stderr;
error.killed = timedOut;
reject(error);
};
const append = (
streamName: "stdout" | "stderr",
chunk: unknown,
) => {
const text = String(chunk);
if (streamName === "stdout") {
stdout += text;
} else {
stderr += text;
}
if (Buffer.byteLength(stdout, "utf8") > maxBuffer || Buffer.byteLength(stderr, "utf8") > maxBuffer) {
child.kill("SIGTERM");
finishReject(Object.assign(new Error(`Process output exceeded maxBuffer of ${maxBuffer} bytes.`), {
code: null,
}));
}
};
let killEscalation: NodeJS.Timeout | null = null;
const timeout = options.timeout && options.timeout > 0
? setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
// Escalate to SIGKILL after a 5s grace window so a hung remote
// command that ignores SIGTERM cannot keep the child alive
// indefinitely.
killEscalation = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
// child may have already exited between the SIGTERM and the
// escalation — that's fine.
}
}, 5_000);
killEscalation.unref?.();
}, options.timeout)
: null;
const clearTimers = () => {
if (timeout) clearTimeout(timeout);
if (killEscalation) clearTimeout(killEscalation);
};
child.stdout?.on("data", (chunk) => {
append("stdout", chunk);
});
child.stderr?.on("data", (chunk) => {
append("stderr", chunk);
});
child.on("error", (error) => {
clearTimers();
finishReject(Object.assign(error, { code: null }));
});
child.on("close", (code, signal) => {
clearTimers();
if (settled) return;
settled = true;
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(Object.assign(new Error(stderr.trim() || stdout.trim() || `Process exited with code ${code ?? -1}`), {
stdout,
stderr,
code,
signal,
killed: timedOut,
}));
});
if (options.stdin != null && child.stdin) {
child.stdin.end(options.stdin);
}
});
}
async function runLocalGit(
localDir: string,
args: string[],
@@ -189,7 +331,7 @@ async function commandExists(command: string): Promise<boolean> {
async function resolveCommandPath(command: string): Promise<string | null> {
try {
const result = await execFileText("sh", ["-lc", `command -v ${shellQuote(command)}`], {
const result = await execFileText("sh", ["-c", `command -v ${shellQuote(command)}`], {
timeout: 5_000,
maxBuffer: 8 * 1024,
});
@@ -277,7 +419,7 @@ async function runSshScript(
): Promise<SshCommandResult> {
return await runSshCommand(
config,
`sh -lc ${shellQuote(script)}`,
script,
options,
);
}
@@ -358,7 +500,7 @@ async function streamLocalFileToSsh(input: {
"-p",
String(input.spec.port),
`${input.spec.username}@${input.spec.host}`,
`sh -lc ${shellQuote(input.remoteScript)}`,
`sh -c ${shellQuote(input.remoteScript)}`,
];
await new Promise<void>((resolve, reject) => {
@@ -407,7 +549,7 @@ async function streamSshToLocalFile(input: {
"-p",
String(input.spec.port),
`${input.spec.username}@${input.spec.host}`,
`sh -lc ${shellQuote(input.remoteScript)}`,
`sh -c ${shellQuote(input.remoteScript)}`,
];
await new Promise<void>((resolve, reject) => {
@@ -455,7 +597,9 @@ async function importGitWorkspaceToSsh(input: {
}): Promise<void> {
const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-bundle-"));
const bundlePath = path.join(bundleDir, "workspace.bundle");
const tempRef = "refs/paperclip/ssh-sync/import";
// Per-import unique ref so concurrent imports against the same local repo
// can't race on `update-ref` between this run's update and bundle create.
const tempRef = `refs/paperclip/ssh-sync/import/${randomUUID()}`;
try {
await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], {
@@ -480,6 +624,8 @@ async function importGitWorkspaceToSsh(input: {
: `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --force --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
`git -C ${shellQuote(input.remoteDir)} reset --hard ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
`git -C ${shellQuote(input.remoteDir)} clean -fdx -e .paperclip-runtime >/dev/null`,
// Drop the per-import ref on the remote side too so it can't accumulate.
`git -C ${shellQuote(input.remoteDir)} update-ref -d ${shellQuote(tempRef)} >/dev/null 2>&1 || true`,
].join("\n");
await streamLocalFileToSsh({
@@ -500,10 +646,12 @@ async function exportGitWorkspaceFromSsh(input: {
spec: SshRemoteExecutionSpec;
remoteDir: string;
localDir: string;
}): Promise<void> {
importedRef?: string;
resetLocalWorkspace?: boolean;
}): Promise<string> {
const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-bundle-"));
const bundlePath = path.join(bundleDir, "workspace.bundle");
const importedRef = "refs/paperclip/ssh-sync/imported";
const importedRef = input.importedRef ?? `refs/paperclip/ssh-sync/imported/${randomUUID()}`;
try {
const exportScript = [
@@ -527,19 +675,97 @@ async function exportGitWorkspaceFromSsh(input: {
timeout: 60_000,
maxBuffer: 1024 * 1024,
});
await runLocalGit(input.localDir, ["reset", "--hard", importedRef], {
timeout: 60_000,
maxBuffer: 1024 * 1024,
});
} finally {
await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], {
if (input.resetLocalWorkspace !== false) {
await runLocalGit(input.localDir, ["reset", "--hard", importedRef], {
timeout: 60_000,
maxBuffer: 1024 * 1024,
});
}
const importedHead = await runLocalGit(input.localDir, ["rev-parse", importedRef], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch(() => undefined);
});
return importedHead.stdout.trim();
} finally {
if (input.resetLocalWorkspace !== false) {
await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch(() => undefined);
}
await fs.rm(bundleDir, { recursive: true, force: true }).catch(() => undefined);
}
}
async function integrateImportedGitHead(input: {
localDir: string;
importedHead: string;
}): Promise<void> {
const snapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
if (!snapshot) return;
const currentHead = snapshot.headCommit;
if (!currentHead || currentHead === input.importedHead) return;
const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD";
const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch(() => null);
const mergeBaseHead = mergeBase?.stdout.trim() ?? "";
if (mergeBaseHead === input.importedHead) {
return;
}
if (mergeBaseHead === currentHead) {
await runLocalGit(input.localDir, ["update-ref", headRef, input.importedHead, currentHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
return;
}
let mergedTree;
try {
mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {
timeout: 60_000,
maxBuffer: 256 * 1024,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to merge concurrent SSH git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`,
);
}
const mergedTreeId = mergedTree.stdout.trim().split("\n")[0]?.trim() ?? "";
if (!mergedTreeId) {
throw new Error("Failed to compute a merged git tree for SSH workspace restore.");
}
const mergeCommit = await runLocalGit(
input.localDir,
[
"commit-tree",
mergedTreeId,
"-p",
currentHead,
"-p",
input.importedHead,
"-m",
`Paperclip SSH sync merge ${input.importedHead.slice(0, 12)}`,
],
{
timeout: 60_000,
maxBuffer: 64 * 1024,
},
);
await runLocalGit(input.localDir, ["update-ref", headRef, mergeCommit.stdout.trim(), currentHead], {
timeout: 10_000,
maxBuffer: 16 * 1024,
});
}
async function clearRemoteDirectory(input: {
spec: SshConnectionConfig;
remoteDir: string;
@@ -661,6 +887,13 @@ async function isSshEnvLabFixtureProcess(state: Pick<SshEnvLabFixtureState, "pid
}
export async function getSshEnvLabSupport(): Promise<SshEnvLabSupport> {
if (process.platform === "darwin" && process.env.PAPERCLIP_ENABLE_DARWIN_SSH_ENV_LAB !== "1") {
return {
supported: false,
reason: "SSH env-lab fixture is disabled on macOS; set PAPERCLIP_ENABLE_DARWIN_SSH_ENV_LAB=1 to opt in.",
};
}
for (const command of ["ssh", "sshd", "ssh-keygen"]) {
if (!(await commandExists(command))) {
return {
@@ -688,6 +921,8 @@ export async function runSshCommand(
config: SshConnectionConfig,
remoteCommand: string,
options: {
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
maxBuffer?: number;
} = {},
@@ -697,18 +932,45 @@ export async function runSshCommand(
const auth = await createSshAuthArgs(config);
cleanup = auth.cleanup;
const sshArgs = [...auth.args];
const envEntries = Object.entries(options.env ?? {})
.filter((entry): entry is [string, string] => typeof entry[1] === "string");
for (const [key] of envEntries) {
if (!isValidShellEnvKey(key)) {
throw new Error(`Invalid SSH environment variable key: ${key}`);
}
}
// Mirror buildSshSpawnTarget: source login profiles first, then run
// `env KEY=VAL cmd` so user-supplied identity overrides win over anything
// a profile re-exports. Without this, a remote profile that resets HOME
// / NVM_DIR / etc. would silently undo the explicit env passed in here.
const envArgs = envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`);
const remoteScript = [
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
envArgs.length > 0
? `exec env ${envArgs.join(" ")} sh -c ${shellQuote(remoteCommand)}`
: `exec sh -c ${shellQuote(remoteCommand)}`,
].join(" && ");
sshArgs.push(
"-p",
String(config.port),
`${config.username}@${config.host}`,
remoteCommand,
`sh -c ${shellQuote(remoteScript)}`,
);
return await execFileText("ssh", sshArgs, {
timeout: options.timeoutMs ?? 15_000,
maxBuffer: options.maxBuffer ?? 1024 * 128,
});
return options.stdin != null
? await spawnText("ssh", sshArgs, {
stdin: options.stdin,
timeout: options.timeoutMs ?? 15_000,
maxBuffer: options.maxBuffer ?? 1024 * 128,
})
: await execFileText("ssh", sshArgs, {
timeout: options.timeoutMs ?? 15_000,
maxBuffer: options.maxBuffer ?? 1024 * 128,
});
} finally {
await cleanup();
}
@@ -751,7 +1013,7 @@ export async function buildSshSpawnTarget(input: {
"-p",
String(input.spec.port),
`${input.spec.username}@${input.spec.host}`,
`sh -lc ${shellQuote(remoteScript)}`,
`sh -c ${shellQuote(remoteScript)}`,
);
return {
@@ -774,7 +1036,7 @@ export async function syncDirectoryToSsh(input: {
"-p",
String(input.spec.port),
`${input.spec.username}@${input.spec.host}`,
`sh -lc ${shellQuote(`mkdir -p ${shellQuote(input.remoteDir)} && tar -xf - -C ${shellQuote(input.remoteDir)}`)}`,
`sh -c ${shellQuote(`mkdir -p ${shellQuote(input.remoteDir)} && tar -xf - -C ${shellQuote(input.remoteDir)}`)}`,
];
await new Promise<void>((resolve, reject) => {
@@ -870,7 +1132,7 @@ export async function syncDirectoryFromSsh(input: {
"-p",
String(input.spec.port),
`${input.spec.username}@${input.spec.host}`,
`sh -lc ${shellQuote(remoteTarScript)}`,
`sh -c ${shellQuote(remoteTarScript)}`,
];
try {
@@ -947,7 +1209,7 @@ export async function prepareWorkspaceForSshExecution(input: {
spec: SshRemoteExecutionSpec;
localDir: string;
remoteDir?: string;
}): Promise<void> {
}): Promise<{ gitBacked: boolean }> {
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
@@ -969,7 +1231,7 @@ export async function prepareWorkspaceForSshExecution(input: {
remoteDir,
deletedPaths: gitSnapshot.deletedPaths,
});
return;
return { gitBacked: true };
}
await clearRemoteDirectory({
@@ -983,14 +1245,64 @@ export async function prepareWorkspaceForSshExecution(input: {
remoteDir,
exclude: [".paperclip-runtime"],
});
return { gitBacked: false };
}
export async function restoreWorkspaceFromSshExecution(input: {
spec: SshRemoteExecutionSpec;
localDir: string;
remoteDir?: string;
baselineSnapshot?: DirectorySnapshot;
restoreGitHistory?: boolean;
}): Promise<void> {
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
if (input.baselineSnapshot) {
const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-sync-back-"));
const importedRef = input.restoreGitHistory
? `refs/paperclip/ssh-sync/imported/${randomUUID()}`
: null;
try {
const importedHead = input.restoreGitHistory
? await exportGitWorkspaceFromSsh({
spec: input.spec,
remoteDir,
localDir: input.localDir,
importedRef: importedRef ?? undefined,
resetLocalWorkspace: false,
})
: null;
await syncDirectoryFromSsh({
spec: input.spec,
remoteDir,
localDir: stagingDir,
exclude: input.baselineSnapshot.exclude,
});
await mergeDirectoryWithBaseline({
baseline: input.baselineSnapshot,
sourceDir: stagingDir,
targetDir: input.localDir,
// Git history advances via integrateImportedGitHead; the working tree
// still comes from the remote file snapshot so dirty remote edits win.
beforeApply: importedHead
? async () => {
await integrateImportedGitHead({
localDir: input.localDir,
importedHead,
});
}
: undefined,
});
} finally {
if (importedRef) {
await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], {
timeout: 10_000,
maxBuffer: 16 * 1024,
}).catch(() => undefined);
}
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
}
return;
}
const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
if (gitSnapshot) {
@@ -1022,7 +1334,7 @@ export async function ensureSshWorkspaceReady(
): Promise<{ remoteCwd: string }> {
const result = await runSshCommand(
config,
`sh -lc ${shellQuote(`mkdir -p ${shellQuote(config.remoteWorkspacePath)} && cd ${shellQuote(config.remoteWorkspacePath)} && pwd`)}`,
`mkdir -p ${shellQuote(config.remoteWorkspacePath)} && cd ${shellQuote(config.remoteWorkspacePath)} && pwd`,
);
return {
remoteCwd: result.stdout.trim(),
+23
View File
@@ -125,6 +125,7 @@ export interface AdapterExecutionContext {
runtime: AdapterRuntime;
config: Record<string, unknown>;
context: Record<string, unknown>;
runtimeCommandSpec?: AdapterRuntimeCommandSpec | null;
executionTarget?: AdapterExecutionTarget | null;
/**
* Legacy remote transport view. Prefer `executionTarget`, which is the
@@ -328,6 +329,23 @@ export interface AdapterConfigSchema {
fields: ConfigFieldSchema[];
}
export interface AdapterRuntimeCommandSpec {
/**
* The command Paperclip should execute for this adapter in the current config.
*/
command: string;
/**
* Optional command name/path to probe for availability before launch.
* Defaults to `command` when omitted by the consumer.
*/
detectCommand?: string | null;
/**
* Optional shell snippet that can install or expose the adapter command in a
* fresh remote runtime. It should be idempotent.
*/
installCommand?: string | null;
}
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
@@ -406,6 +424,11 @@ export interface ServerAdapterModule {
* rather than reading config.paperclipRuntimeSkills.
*/
requiresMaterializedRuntimeSkills?: boolean;
/**
* Optional: describe how this adapter's runtime command should be launched
* and provisioned in fresh remote environments such as sandboxes.
*/
getRuntimeCommandSpec?: (config: Record<string, unknown>) => AdapterRuntimeCommandSpec | null;
}
// ---------------------------------------------------------------------------
@@ -0,0 +1,61 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
describe("workspace restore merge", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("preserves sibling files when sequential stale-baseline restores create the same nested directory tree", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-"));
cleanupDirs.push(rootDir);
const targetDir = path.join(rootDir, "target");
const sourceADir = path.join(rootDir, "source-a");
const sourceBDir = path.join(rootDir, "source-b");
await mkdir(targetDir, { recursive: true });
await mkdir(path.join(sourceADir, "manual-qa", "environment-matrix", "ssh"), { recursive: true });
await mkdir(path.join(sourceBDir, "manual-qa", "environment-matrix", "ssh"), { recursive: true });
const baseline = await captureDirectorySnapshot(targetDir, { exclude: [] });
await writeFile(
path.join(sourceADir, "manual-qa", "environment-matrix", "ssh", "claude_local.md"),
"ssh claude\n",
"utf8",
);
await writeFile(
path.join(sourceBDir, "manual-qa", "environment-matrix", "ssh", "codex_local.md"),
"ssh codex\n",
"utf8",
);
await mergeDirectoryWithBaseline({
baseline,
sourceDir: sourceADir,
targetDir,
});
await mergeDirectoryWithBaseline({
baseline,
sourceDir: sourceBDir,
targetDir,
});
await expect(
readFile(path.join(targetDir, "manual-qa", "environment-matrix", "ssh", "claude_local.md"), "utf8"),
).resolves.toBe("ssh claude\n");
await expect(
readFile(path.join(targetDir, "manual-qa", "environment-matrix", "ssh", "codex_local.md"), "utf8"),
).resolves.toBe("ssh codex\n");
});
});
@@ -0,0 +1,257 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { constants as fsConstants, promises as fs } from "node:fs";
import path from "node:path";
type SnapshotEntry =
| { kind: "dir" }
| { kind: "file"; mode: number; hash: string }
| { kind: "symlink"; target: string };
export interface DirectorySnapshot {
exclude: string[];
entries: Map<string, SnapshotEntry>;
}
function isRelativePathOrDescendant(relative: string, candidate: string): boolean {
return relative === candidate || relative.startsWith(`${candidate}/`);
}
function shouldExclude(relative: string, exclude: readonly string[]): boolean {
return exclude.some((candidate) => isRelativePathOrDescendant(relative, candidate));
}
async function hashFile(filePath: string): Promise<string> {
return await new Promise((resolve, reject) => {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
async function walkDirectory(
root: string,
exclude: readonly string[],
relative = "",
out: Map<string, SnapshotEntry> = new Map(),
): Promise<Map<string, SnapshotEntry>> {
const current = relative ? path.join(root, relative) : root;
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
if (shouldExclude(nextRelative, exclude)) continue;
const fullPath = path.join(root, nextRelative);
const stats = await fs.lstat(fullPath);
if (stats.isDirectory()) {
out.set(nextRelative, { kind: "dir" });
await walkDirectory(root, exclude, nextRelative, out);
continue;
}
if (stats.isSymbolicLink()) {
out.set(nextRelative, {
kind: "symlink",
target: await fs.readlink(fullPath),
});
continue;
}
out.set(nextRelative, {
kind: "file",
mode: stats.mode,
hash: await hashFile(fullPath),
});
}
return out;
}
async function readSnapshotEntry(root: string, relative: string): Promise<SnapshotEntry | null> {
const fullPath = path.join(root, relative);
let stats;
try {
stats = await fs.lstat(fullPath);
} catch {
return null;
}
if (stats.isDirectory()) return { kind: "dir" };
if (stats.isSymbolicLink()) {
return {
kind: "symlink",
target: await fs.readlink(fullPath),
};
}
return {
kind: "file",
mode: stats.mode,
hash: await hashFile(fullPath),
};
}
function entriesMatch(left: SnapshotEntry | null | undefined, right: SnapshotEntry | null | undefined): boolean {
if (!left || !right) return false;
if (left.kind !== right.kind) return false;
if (left.kind === "dir") return true;
if (left.kind === "symlink" && right.kind === "symlink") {
return left.target === right.target;
}
if (left.kind === "file" && right.kind === "file") {
return left.mode === right.mode && left.hash === right.hash;
}
return false;
}
async function isHolderAlive(lockDir: string): Promise<boolean> {
try {
const raw = await fs.readFile(path.join(lockDir, "owner.json"), "utf8");
const owner = JSON.parse(raw) as { pid?: unknown };
const pid = typeof owner.pid === "number" && Number.isFinite(owner.pid) && owner.pid > 0 ? owner.pid : null;
if (pid === null) {
// Owner record is unparseable / missing pid — treat as stale.
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
} catch {
// owner.json missing or unreadable — treat as stale.
return false;
}
}
async function acquireDirectoryMergeLock(lockDir: string): Promise<() => Promise<void>> {
const deadline = Date.now() + 30_000;
while (true) {
try {
await fs.mkdir(lockDir);
await fs.writeFile(
path.join(lockDir, "owner.json"),
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
"utf8",
);
return async () => {
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
};
} catch (error) {
const code = error && typeof error === "object" ? (error as { code?: unknown }).code : null;
if (code !== "EEXIST") throw error;
// Stale-lock detection: if the owner PID is dead (SIGKILL / OOM / crash),
// the lockDir would otherwise persist forever and stall restores. Mirror
// the materializePaperclipSkillCopy lock pattern — remove and retry.
if (!(await isHolderAlive(lockDir))) {
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
continue;
}
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for workspace restore lock at ${lockDir}`);
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
}
export async function withDirectoryMergeLock<T>(
targetDir: string,
fn: () => Promise<T>,
): Promise<T> {
const releaseLock = await acquireDirectoryMergeLock(`${targetDir}.paperclip-restore.lock`);
try {
return await fn();
} finally {
await releaseLock();
}
}
async function copySnapshotEntry(sourceDir: string, targetDir: string, relative: string, entry: SnapshotEntry): Promise<void> {
const sourcePath = path.join(sourceDir, relative);
const targetPath = path.join(targetDir, relative);
if (entry.kind === "dir") {
const existing = await fs.lstat(targetPath).catch(() => null);
if (existing?.isDirectory()) {
return;
}
if (existing) {
await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
}
await fs.mkdir(targetPath, { recursive: true });
return;
}
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
if (entry.kind === "symlink") {
await fs.symlink(entry.target, targetPath);
return;
}
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => {
await fs.copyFile(sourcePath, targetPath);
});
await fs.chmod(targetPath, entry.mode);
}
export async function captureDirectorySnapshot(
rootDir: string,
options: { exclude?: string[] } = {},
): Promise<DirectorySnapshot> {
const exclude = [...new Set(options.exclude ?? [])];
return {
exclude,
entries: await walkDirectory(rootDir, exclude),
};
}
export async function mergeDirectoryWithBaseline(input: {
baseline: DirectorySnapshot;
sourceDir: string;
targetDir: string;
beforeApply?: () => Promise<void>;
}): Promise<void> {
const source = await captureDirectorySnapshot(input.sourceDir, { exclude: input.baseline.exclude });
await withDirectoryMergeLock(input.targetDir, async () => {
await input.beforeApply?.();
const current = await captureDirectorySnapshot(input.targetDir, { exclude: input.baseline.exclude });
const deletedLeafEntries = [...input.baseline.entries.entries()]
.filter(([relative, entry]) => entry.kind !== "dir" && !source.entries.has(relative))
.sort(([left], [right]) => right.length - left.length);
for (const [relative, baselineEntry] of deletedLeafEntries) {
if (!entriesMatch(current.entries.get(relative), baselineEntry)) continue;
await fs.rm(path.join(input.targetDir, relative), { recursive: true, force: true }).catch(() => undefined);
}
const deletedDirs = [...input.baseline.entries.entries()]
.filter(([relative, entry]) => entry.kind === "dir" && !source.entries.has(relative))
.sort(([left], [right]) => right.length - left.length);
for (const [relative] of deletedDirs) {
await fs.rmdir(path.join(input.targetDir, relative)).catch(() => undefined);
}
const changedSourceEntries = [...source.entries.entries()]
.filter(([relative, entry]) => !entriesMatch(input.baseline.entries.get(relative), entry))
.sort(([left], [right]) => left.localeCompare(right));
for (const [relative, entry] of changedSourceEntries) {
await copySnapshotEntry(input.sourceDir, input.targetDir, relative, entry);
}
});
}
export async function directoryEntryMatchesBaseline(
rootDir: string,
relative: string,
baselineEntry: SnapshotEntry,
): Promise<boolean> {
return entriesMatch(await readSnapshotEntry(rootDir, relative), baselineEntry);
}
+10 -1
View File
@@ -1,3 +1,5 @@
import type { AdapterModel } from "@paperclipai/adapter-utils";
export const type = "acpx_local";
export const label = "ACPX (local)";
@@ -6,6 +8,7 @@ export const DEFAULT_ACPX_LOCAL_MODE = "persistent";
export const DEFAULT_ACPX_LOCAL_PERMISSION_MODE = "approve-all";
export const DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS = "deny";
export const DEFAULT_ACPX_LOCAL_TIMEOUT_SEC = 0;
export const DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS = 0;
export const acpxAgentOptions = [
{ id: "claude", label: "Claude via ACPX" },
@@ -13,6 +16,8 @@ export const acpxAgentOptions = [
{ id: "custom", label: "Custom ACP command" },
] as const;
export const models: AdapterModel[] = [];
export const agentConfigurationDoc = `# acpx_local agent configuration
Adapter: acpx_local
@@ -30,7 +35,7 @@ Don't use when:
Core fields:
- agent (string, optional): claude, codex, or custom. Defaults to claude.
- agentCommand (string, optional): custom ACP command when agent=custom, or an override for a built-in ACP agent command.
- mode (string, optional): persistent or oneshot. Defaults to persistent.
- mode (string, optional): persistent or oneshot. Defaults to persistent. Paperclip keeps session state persistent and may close the live process between runs.
- cwd (string, optional): default absolute working directory fallback for the agent process.
- permissionMode (string, optional): defaults to approve-all, meaning ACPX permission requests are auto-approved.
- nonInteractivePermissions (string, optional): fallback behavior when ACPX cannot ask interactively. Supported values are deny and fail.
@@ -38,7 +43,11 @@ Core fields:
- instructionsFilePath (string, optional): absolute path to a markdown instructions file used by Paperclip prompt construction.
- promptTemplate (string, optional): run prompt template.
- bootstrapPromptTemplate (string, optional): first-run bootstrap prompt template.
- model (string, optional): requested ACP model. Claude and Codex ACP agents both receive this through ACP session config.
- effort/modelReasoningEffort (string, optional): requested thinking effort. Claude uses effort; Codex uses modelReasoningEffort/reasoning_effort.
- fastMode (boolean, optional): for ACPX Codex, request Codex fast mode through ACP session config.
- timeoutSec (number, optional): run timeout in seconds. Defaults to 0, meaning no adapter timeout.
- warmHandleIdleMs (number, optional): live ACPX process idle window after a successful persistent run. Defaults to 0, meaning Paperclip shuts the process down after each run while retaining ACPX session state.
- env (object, optional): KEY=VALUE environment variables or secret bindings.
Dependency decision:
@@ -1,10 +1,9 @@
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
import {
DEFAULT_ACPX_LOCAL_AGENT,
DEFAULT_ACPX_LOCAL_MODE,
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
acpxAgentOptions,
} from "../index.js";
@@ -26,27 +25,6 @@ export function getConfigSchema(): AdapterConfigSchema {
type: "text",
hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.",
},
{
key: "mode",
label: "Session mode",
type: "select",
default: DEFAULT_ACPX_LOCAL_MODE,
options: [
{ value: "persistent", label: "Persistent" },
{ value: "oneshot", label: "One shot" },
],
},
{
key: "permissionMode",
label: "Permission mode",
type: "select",
default: DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
options: [
{ value: "approve-all", label: "Approve all" },
{ value: "default", label: "Approve reads" },
],
hint: "Defaults to maximum permissions. Approve reads grants read-only requests and asks for approval on writes.",
},
{
key: "nonInteractivePermissions",
label: "Non-interactive permissions",
@@ -56,6 +34,7 @@ export function getConfigSchema(): AdapterConfigSchema {
{ value: "deny", label: "Deny" },
{ value: "fail", label: "Fail" },
],
hint: "Fallback if the ACP agent asks for input outside an interactive session. Paperclip still auto-approves permissions by default.",
},
{
key: "cwd",
@@ -70,20 +49,12 @@ export function getConfigSchema(): AdapterConfigSchema {
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
},
{
key: "instructionsFilePath",
label: "Instructions file",
type: "text",
hint: "Optional absolute path to markdown instructions injected into the run prompt.",
},
{
key: "promptTemplate",
label: "Prompt template",
type: "textarea",
},
{
key: "bootstrapPromptTemplate",
label: "Bootstrap prompt template",
type: "textarea",
key: "fastMode",
label: "Codex fast mode",
type: "toggle",
default: false,
hint: "Only applies when ACP agent is Codex. Requests Codex Fast mode through ACP session config.",
meta: { visibleWhen: { key: "agent", values: ["codex"] } },
},
{
key: "timeoutSec",
@@ -91,6 +62,13 @@ export function getConfigSchema(): AdapterConfigSchema {
type: "number",
default: DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
},
{
key: "warmHandleIdleMs",
label: "Warm process idle ms",
type: "number",
default: DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
hint: "Defaults to 0, which closes the ACPX process after each run while retaining persistent session state.",
},
{
key: "env",
label: "Environment JSON",
@@ -56,7 +56,13 @@ function buildRuntime() {
};
}
async function runExecutor(config: Record<string, unknown>) {
async function runExecutor(
config: Record<string, unknown>,
options: {
context?: Record<string, unknown>;
executionTransport?: Record<string, unknown>;
} = {},
) {
const runtimeOptions: Record<string, unknown>[] = [];
const meta: Record<string, unknown>[] = [];
const logs: Array<{ stream: string; text: string }> = [];
@@ -73,12 +79,13 @@ async function runExecutor(config: Record<string, unknown>) {
id: "agent-1",
companyId: "company-1",
},
runtime: {},
config,
context: {},
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
runtime: {},
config,
context: options.context ?? {},
executionTransport: options.executionTransport,
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
onMeta: async (payload: unknown) => {
meta.push(payload as Record<string, unknown>);
},
@@ -257,6 +264,57 @@ describe("acpx_local runtime skill isolation", () => {
expect(env).not.toContain("old-key");
});
it("shapes ACPX wrapper workspace env for remote execution identities", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const workspaceDir = path.join(root, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await runExecutor(
{
agentCommand: "node ./fake-acp.js",
stateDir,
},
{
context: {
paperclipWorkspace: {
cwd: workspaceDir,
source: "project_primary",
strategy: "git_worktree",
workspaceId: "workspace-1",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
branchName: "feature/remote-acpx",
worktreePath: workspaceDir,
},
},
executionTransport: {
remoteExecution: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/remote/workspace",
remoteCwd: "/remote/workspace",
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
},
);
const wrappers = await fs.readdir(path.join(stateDir, "wrappers"));
const envPath = path.join(
stateDir,
"wrappers",
wrappers.find((name) => name.endsWith(".env"))!,
);
const env = await fs.readFile(envPath, "utf8");
expect(env).toContain("PAPERCLIP_WORKSPACE_CWD='/remote/workspace'");
expect(env).not.toContain("PAPERCLIP_WORKSPACE_WORKTREE_PATH=");
});
it("cleans aged credential wrapper scripts across ACPX agent changes", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
@@ -18,9 +18,13 @@ import {
materializePaperclipSkillCopy,
parseObject,
readPaperclipRuntimeSkillEntries,
readPaperclipIssueWorkModeFromContext,
renderPaperclipWakePrompt,
renderTemplate,
resolvePaperclipInstanceRootForAdapter,
resolvePaperclipDesiredSkillNames,
rewriteWorkspaceCwdEnvVarsForExecution,
shapePaperclipWorkspaceEnvForExecution,
stringifyPaperclipWakePayload,
type PaperclipSkillEntry,
} from "@paperclipai/adapter-utils/server-utils";
@@ -44,10 +48,10 @@ import {
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
} from "../index.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_WARM_HANDLE_IDLE_MS = 15 * 60 * 1000;
const WRAPPER_CLEANUP_RETENTION_MS = 15 * 60 * 1000;
const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json";
@@ -58,6 +62,7 @@ interface RuntimeCacheEntry {
handle: AcpRuntimeHandle;
fingerprint: string;
lastUsedAt: number;
cleanupTimer?: NodeJS.Timeout;
}
interface ExecuteDeps {
@@ -78,6 +83,9 @@ interface AcpxPreparedRuntime {
stateDir: string;
permissionMode: "approve-all" | "approve-reads" | "deny-all";
nonInteractivePermissions: "deny" | "fail";
requestedModel: string;
requestedThinkingEffort: string;
fastMode: boolean;
timeoutSec: number;
sessionKey: string;
fingerprint: string;
@@ -108,7 +116,10 @@ function shortHash(value: unknown): string {
function defaultPaperclipInstanceDir(): string {
const home = process.env.PAPERCLIP_HOME?.trim() || path.join(os.homedir(), ".paperclip");
const instanceId = process.env.PAPERCLIP_INSTANCE_ID?.trim() || "default";
return path.join(home, "instances", instanceId);
return resolvePaperclipInstanceRootForAdapter({
homeDir: home,
instanceId,
});
}
function defaultStateDir(companyId: string, agentId: string): string {
@@ -503,6 +514,15 @@ function normalizeNonInteractivePermissions(config: Record<string, unknown>): "d
: "deny";
}
function normalizeRequestedThinkingEffort(config: Record<string, unknown>): string {
return (
asString(config.modelReasoningEffort, "") ||
asString(config.reasoningEffort, "") ||
asString(config.thinkingEffort, "") ||
asString(config.effort, "")
).trim();
}
function isCompatibleSession(
params: Record<string, unknown>,
runtime: Pick<AcpxPreparedRuntime, "fingerprint" | "sessionKey" | "cwd" | "mode" | "acpxAgent" | "remoteExecutionIdentity">,
@@ -533,6 +553,9 @@ function buildSessionParams(input: {
mode: prepared.mode,
stateDir: prepared.stateDir,
configFingerprint: prepared.fingerprint,
...(prepared.requestedModel ? { model: prepared.requestedModel } : {}),
...(prepared.requestedThinkingEffort ? { thinkingEffort: prepared.requestedThinkingEffort } : {}),
...(prepared.fastMode ? { fastMode: true } : {}),
skills: prepared.skillsIdentity,
...(prepared.workspaceId ? { workspaceId: prepared.workspaceId } : {}),
...(prepared.workspaceRepoUrl ? { repoUrl: prepared.workspaceRepoUrl } : {}),
@@ -622,12 +645,31 @@ async function buildRuntime(input: {
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const executionTarget = readAdapterExecutionTarget({
executionTarget: input.ctx.executionTarget,
legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution,
});
const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget);
const effectiveExecutionCwd =
remoteExecutionIdentity && typeof remoteExecutionIdentity.remoteCwd === "string"
? remoteExecutionIdentity.remoteCwd
: cwd;
const executionTargetIsRemote = remoteExecutionIdentity !== null;
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
workspaceCwd: effectiveWorkspaceCwd,
workspaceWorktreePath,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const acpxAgent = normalizeAgent(config);
const mode = normalizeMode(config);
const permissionMode = normalizePermissionMode(config);
const nonInteractivePermissions = normalizeNonInteractivePermissions(config);
const requestedModel = asString(config.model, "").trim();
const requestedThinkingEffort = normalizeRequestedThinkingEffort(config);
const fastMode = acpxAgent === "codex" && config.fastMode === true;
const timeoutSec = asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC);
const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id));
await fs.mkdir(stateDir, { recursive: true });
@@ -651,7 +693,9 @@ async function buildRuntime(input: {
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
@@ -659,17 +703,23 @@ async function buildRuntime(input: {
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
applyPaperclipWorkspaceEnv(env, {
workspaceCwd: effectiveWorkspaceCwd,
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
workspaceSource,
workspaceStrategy,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceBranch,
workspaceWorktreePath,
workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath,
agentHome,
});
for (const [key, value] of Object.entries(envConfig)) {
const shapedEnvConfig = rewriteWorkspaceCwdEnvVarsForExecution({
env: envConfig,
workspaceCwd: effectiveWorkspaceCwd,
executionCwd: shapedWorkspaceEnv.workspaceCwd,
executionTargetIsRemote,
});
for (const [key, value] of Object.entries(shapedEnvConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) env.PAPERCLIP_API_KEY = authToken;
@@ -718,11 +768,6 @@ async function buildRuntime(input: {
const wrapperPath = wrapper?.wrapperPath ?? null;
const overrides = wrapperPath ? { [acpxAgent]: wrapperPath } : undefined;
const agentRegistry = createAgentRegistry({ overrides });
const executionTarget = readAdapterExecutionTarget({
executionTarget: input.ctx.executionTarget,
legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution,
});
const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget);
const fingerprint = shortHash({
acpxAgent,
agentCommand: agentCommand ?? acpxAgent,
@@ -730,6 +775,9 @@ async function buildRuntime(input: {
mode,
permissionMode,
nonInteractivePermissions,
requestedModel,
requestedThinkingEffort,
fastMode,
remoteExecutionIdentity,
skillsIdentity,
skillPromptInstructions,
@@ -755,13 +803,16 @@ async function buildRuntime(input: {
stateDir,
permissionMode,
nonInteractivePermissions,
requestedModel,
requestedThinkingEffort,
fastMode,
timeoutSec,
sessionKey,
fingerprint,
agentCommand,
agentRegistry,
remoteExecutionIdentity,
skillPromptInstructions,
skillPromptInstructions,
skillsIdentity: {
...skillsIdentity,
commandNotes: skillCommandNotes,
@@ -769,6 +820,51 @@ async function buildRuntime(input: {
};
}
function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> {
const options: Array<{ key: string; value: string }> = [];
if (prepared.requestedModel) options.push({ key: "model", value: prepared.requestedModel });
if (prepared.requestedThinkingEffort) {
options.push({
key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort",
value: prepared.requestedThinkingEffort,
});
}
if (prepared.fastMode) {
options.push(
{ key: "service_tier", value: "fast" },
{ key: "features.fast_mode", value: "true" },
);
}
return options;
}
async function applySessionConfigOptions(input: {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
prepared: AcpxPreparedRuntime;
onLog: AdapterExecutionContext["onLog"];
}) {
const options = sessionConfigOptions(input.prepared);
if (options.length === 0) return;
if (!input.runtime.setConfigOption) {
const message =
"ACPX runtime does not expose session config controls; upgrade ACPX or remove configured model, effort, and fast mode overrides.";
await input.onLog("stderr", `[paperclip] ${message}\n`);
throw new Error(message);
}
for (const option of options) {
await input.runtime.setConfigOption({
handle: input.handle,
key: option.key,
value: option.value,
});
await input.onLog(
"stdout",
`[paperclip] Applied ACPX ${input.prepared.acpxAgent} config ${option.key}=${option.value}\n`,
);
}
}
async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean): Promise<{
prompt: string;
promptMetrics: Record<string, number>;
@@ -940,20 +1036,77 @@ async function cleanupIdleHandles(input: {
now: number;
idleMs: number;
}) {
if (input.idleMs <= 0) return;
const stale: Array<[string, RuntimeCacheEntry]> = [];
for (const entry of input.handles.entries()) {
if (input.now - entry[1].lastUsedAt >= input.idleMs) stale.push(entry);
}
for (const [key, entry] of stale) {
input.handles.delete(key);
await entry.runtime.close({
handle: entry.handle,
await closeWarmHandle({
handles: input.handles,
key,
entry,
reason: "paperclip idle cleanup",
discardPersistentState: false,
}).catch(() => {});
});
}
}
function clearWarmHandleTimer(entry: RuntimeCacheEntry) {
if (!entry.cleanupTimer) return;
clearTimeout(entry.cleanupTimer);
entry.cleanupTimer = undefined;
}
async function closeWarmHandle(input: {
handles: Map<string, RuntimeCacheEntry>;
key: string;
entry: RuntimeCacheEntry;
reason: string;
discardPersistentState?: boolean;
}) {
if (input.handles.get(input.key) === input.entry) {
input.handles.delete(input.key);
}
clearWarmHandleTimer(input.entry);
await input.entry.runtime.close({
handle: input.entry.handle,
reason: input.reason,
discardPersistentState: input.discardPersistentState ?? false,
}).catch(() => {});
}
function scheduleIdleHandleCleanup(input: {
handles: Map<string, RuntimeCacheEntry>;
key: string;
entry: RuntimeCacheEntry;
idleMs: number;
now: () => number;
}) {
clearWarmHandleTimer(input.entry);
if (input.idleMs <= 0) return;
const delayMs = Math.max(1, input.entry.lastUsedAt + input.idleMs - input.now());
input.entry.cleanupTimer = setTimeout(() => {
void (async () => {
const current = input.handles.get(input.key);
if (current !== input.entry) return;
const idleForMs = input.now() - input.entry.lastUsedAt;
if (idleForMs < input.idleMs) {
scheduleIdleHandleCleanup(input);
return;
}
await closeWarmHandle({
handles: input.handles,
key: input.key,
entry: input.entry,
reason: "paperclip idle cleanup",
});
})();
}, delayMs);
input.entry.cleanupTimer.unref?.();
}
function warmHandleMatches(
entry: RuntimeCacheEntry | undefined,
runtime: AcpRuntime,
@@ -969,7 +1122,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const prepared = await buildRuntime({ ctx });
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_WARM_HANDLE_IDLE_MS);
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS);
await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs });
const previousParams = parseObject(ctx.runtime.sessionParams);
@@ -985,6 +1138,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
};
const runtime = cached?.runtime ?? createRuntime(runtimeOptions);
if (cached) clearWarmHandleTimer(cached);
if (!canResume && asString(previousParams.runtimeSessionName, "")) {
await ctx.onLog(
"stdout",
@@ -1033,7 +1187,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
errorMessage: message,
...classified,
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
clearSession,
resultJson: { phase: "ensure_session" },
summary: message,
@@ -1048,12 +1202,52 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
errorMessage: "ACPX did not return a runtime session handle.",
errorCode: "acpx_runtime_error",
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
resultJson: { phase: "ensure_session" },
summary: "ACPX did not return a runtime session handle.",
};
}
const sessionHandle = handle;
try {
await applySessionConfigOptions({
runtime,
handle: sessionHandle,
prepared,
onLog: ctx.onLog,
});
} catch (err) {
const classified = classifyError(err);
const message = err instanceof Error ? err.message : String(err);
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
await runtime.close({
handle: sessionHandle,
reason: "paperclip config cleanup",
discardPersistentState: false,
}).catch(() => {});
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
clearWarmHandleTimer(existing);
warmHandles.delete(prepared.sessionKey);
}
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: message,
...classified,
provider: "acpx",
model: prepared.requestedModel || null,
clearSession,
resultJson: {
phase: "configure_session",
agent: prepared.acpxAgent,
requestedModel: prepared.requestedModel || null,
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
fastMode: prepared.fastMode,
},
summary: message,
};
}
const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession);
const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
await emitAcpxLog(ctx, {
@@ -1065,6 +1259,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
runtimeSessionName: sessionHandle.runtimeSessionName,
mode: prepared.mode,
permissionMode: prepared.permissionMode,
model: prepared.requestedModel || null,
thinkingEffort: prepared.requestedThinkingEffort || null,
fastMode: prepared.fastMode,
});
if (ctx.onMeta) {
await ctx.onMeta({
@@ -1074,6 +1271,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
commandNotes: [
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
...(prepared.requestedModel ? [`Requested ACPX model: ${prepared.requestedModel}.`] : []),
...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []),
...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []),
...(Array.isArray(prepared.skillsIdentity.commandNotes)
? prepared.skillsIdentity.commandNotes.filter((note): note is string => typeof note === "string")
: []),
@@ -1119,15 +1319,23 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
const terminal = await turn.result;
if (timeout) clearTimeout(timeout);
if (terminal.status === "failed" || terminal.status === "cancelled" || timedOut) {
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
warmHandles.delete(prepared.sessionKey);
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
await closeWarmHandle({
handles: warmHandles,
key: prepared.sessionKey,
entry: existing,
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
discardPersistentState: terminal.status === "cancelled" || timedOut,
});
} else {
await runtime.close({
handle: sessionHandle,
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
discardPersistentState: terminal.status === "cancelled" || timedOut,
}).catch(() => {});
}
await runtime.close({
handle: sessionHandle,
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
discardPersistentState: terminal.status === "cancelled" || timedOut,
}).catch(() => {});
} else if (prepared.mode === "persistent") {
} else if (prepared.mode === "persistent" && warmIdleMs > 0) {
const existing = warmHandles.get(prepared.sessionKey);
if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) {
await runtime.close({
@@ -1136,13 +1344,37 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
discardPersistentState: false,
}).catch(() => {});
} else {
warmHandles.set(prepared.sessionKey, {
const entry: RuntimeCacheEntry = {
runtime,
handle: sessionHandle,
fingerprint: prepared.fingerprint,
lastUsedAt: now(),
};
warmHandles.set(prepared.sessionKey, entry);
scheduleIdleHandleCleanup({
handles: warmHandles,
key: prepared.sessionKey,
entry,
idleMs: warmIdleMs,
now,
});
}
} else {
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
await closeWarmHandle({
handles: warmHandles,
key: prepared.sessionKey,
entry: existing,
reason: "paperclip completed turn cleanup",
});
} else {
await runtime.close({
handle: sessionHandle,
reason: "paperclip completed turn cleanup",
discardPersistentState: false,
}).catch(() => {});
}
}
const errorMessage = timedOut
@@ -1165,7 +1397,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
billingType: "unknown",
costUsd: null,
resultJson: {
@@ -1173,6 +1405,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
stopReason: terminalStopReason,
permissionMode: prepared.permissionMode,
mode: prepared.mode,
requestedModel: prepared.requestedModel || null,
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
fastMode: prepared.fastMode,
},
summary: textParts.join("").trim() || terminalStopReason || terminal.status,
clearSession,
@@ -1188,7 +1423,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
discardPersistentState: timedOut,
}).catch(() => {});
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
clearWarmHandleTimer(existing);
warmHandles.delete(prepared.sessionKey);
}
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
@@ -1200,7 +1437,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
errorCode: timedOut ? "acpx_timeout" : classified.errorCode,
errorMeta: classified.errorMeta,
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
clearSession: clearSession || timedOut,
resultJson: { phase: "turn" },
summary: message,
@@ -5,6 +5,7 @@ import {
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
} from "../index.js";
function parseCommaArgs(value: string): string[] {
@@ -80,13 +81,15 @@ function readNumber(value: unknown, fallback: number): number {
export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const schemaValues = v.adapterSchemaValues ?? {};
const agent = String(schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT);
const ac: Record<string, unknown> = {
agent: schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT,
agent,
mode: schemaValues.mode || DEFAULT_ACPX_LOCAL_MODE,
permissionMode: schemaValues.permissionMode || DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
nonInteractivePermissions:
schemaValues.nonInteractivePermissions || DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
timeoutSec: readNumber(schemaValues.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC),
warmHandleIdleMs: readNumber(schemaValues.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS),
};
for (const key of [
@@ -105,6 +108,11 @@ export function buildAcpxLocalConfig(v: CreateConfigValues): Record<string, unkn
if (!ac.instructionsFilePath && v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (!ac.promptTemplate && v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (!ac.bootstrapPromptTemplate && v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model?.trim()) ac.model = v.model.trim();
if (v.thinkingEffort) {
ac[agent === "codex" ? "modelReasoningEffort" : "effort"] = v.thinkingEffort;
}
if (schemaValues.fastMode === true) ac.fastMode = true;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
@@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "claude_local";
export const label = "Claude Code (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code";
export const models = [
{ id: "claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
@@ -3,8 +3,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
const SEEDED_SHARED_FILES = [
".credentials.json",
"credentials.json",
@@ -92,11 +92,14 @@ export function resolveManagedClaudeConfigSeedDir(
env: NodeJS.ProcessEnv,
companyId?: string,
): string {
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
env,
});
return companyId
? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-config-seed")
: path.resolve(paperclipHome, "instances", instanceId, "claude-config-seed");
? path.resolve(instanceRoot, "companies", companyId, "claude-config-seed")
: path.resolve(instanceRoot, "claude-config-seed");
}
export async function prepareClaudeConfigSeed(
@@ -10,6 +10,7 @@ const {
prepareWorkspaceForSshExecution,
restoreWorkspaceFromSshExecution,
syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({
exitCode: 0,
@@ -26,9 +27,17 @@ const {
})),
ensureCommandResolvable: vi.fn(async () => undefined),
resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: claude"),
prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
syncDirectoryToSsh: vi.fn(async () => undefined),
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
env: {
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
PAPERCLIP_API_KEY: "bridge-token",
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
},
stop: async () => {},
})),
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
@@ -55,6 +64,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
};
});
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,
};
});
import { execute } from "./execute.js";
describe("claude remote execution", () => {
@@ -73,8 +92,11 @@ describe("claude remote execution", () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
const instructionsPath = path.join(rootDir, "instructions.md");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
await mkdir(workspaceDir, { recursive: true });
await mkdir(alternateWorkspaceDir, { recursive: true });
await writeFile(instructionsPath, "Use the remote workspace.\n", "utf8");
await execute({
@@ -95,12 +117,37 @@ describe("claude remote execution", () => {
config: {
command: "claude",
instructionsFilePath: instructionsPath,
env: {
QA_PROJECT_WORKSPACE_CWD: workspaceDir,
RANDOM_WORKSPACE_CWD: workspaceDir,
OTHER_ENV: workspaceDir,
},
},
context: {
paperclipWorkspace: {
cwd: workspaceDir,
source: "project_primary",
strategy: "git_worktree",
workspaceId: "workspace-1",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
branchName: "feature/remote-claude",
worktreePath: workspaceDir,
},
paperclipWorkspaces: [
{
workspaceId: "workspace-1",
cwd: workspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
cwd: alternateWorkspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
],
},
executionTransport: {
remoteExecution: {
@@ -112,7 +159,6 @@ describe("claude remote execution", () => {
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
paperclipApiUrl: "http://198.51.100.10:3102",
},
},
onLog: async () => {},
@@ -121,11 +167,11 @@ describe("claude remote execution", () => {
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(expect.objectContaining({
localDir: workspaceDir,
remoteDir: "/remote/workspace",
remoteDir: managedRemoteWorkspace,
}));
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
remoteDir: "/remote/workspace/.paperclip-runtime/claude/skills",
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/claude/skills`,
followSymlinks: true,
}));
expect(runChildProcess).toHaveBeenCalledTimes(1);
@@ -133,15 +179,37 @@ describe("claude remote execution", () => {
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[2]).toContain("--append-system-prompt-file");
expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills/agent-instructions.md");
expect(call?.[2]).toContain(
`${managedRemoteWorkspace}/.paperclip-runtime/claude/skills/agent-instructions.md`,
);
expect(call?.[2]).toContain("--add-dir");
expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills");
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102");
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(call?.[2]).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/claude/skills`);
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
expect(call?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined();
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
{
workspaceId: "workspace-1",
cwd: managedRemoteWorkspace,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
]);
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.QA_PROJECT_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
expect(call?.[3].env.RANDOM_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
expect(call?.[3].env.OTHER_ENV).toBe(workspaceDir);
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({
localDir: workspaceDir,
remoteDir: "/remote/workspace",
remoteDir: managedRemoteWorkspace,
}));
});
@@ -202,6 +270,7 @@ describe("claude remote execution", () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-match-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace";
await mkdir(workspaceDir, { recursive: true });
await execute({
@@ -217,13 +286,13 @@ describe("claude remote execution", () => {
sessionId: "session-123",
sessionParams: {
sessionId: "session-123",
cwd: "/remote/workspace",
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
remoteCwd: managedRemoteWorkspace,
},
},
sessionDisplayId: "session-123",
@@ -5,16 +5,18 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip
import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
import {
adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd,
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetTimeoutSec,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
@@ -30,12 +32,16 @@ import {
applyPaperclipWorkspaceEnv,
buildPaperclipEnv,
readPaperclipRuntimeSkillEntries,
readPaperclipIssueWorkModeFromContext,
joinPromptSections,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensurePathInEnv,
refreshPaperclipWorkspaceEnvForExecution,
renderTemplate,
renderPaperclipWakePrompt,
rewriteWorkspaceCwdEnvVarsForExecution,
shapePaperclipWorkspaceEnvForExecution,
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
} from "@paperclipai/adapter-utils/server-utils";
@@ -53,6 +59,8 @@ import { prepareClaudeConfigSeed } from "./claude-config.js";
import { resolveClaudeDesiredSkillNames } from "./skills.js";
import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
import { buildClaudeExecutionPermissionArgs } from "./permissions.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -61,8 +69,10 @@ interface ClaudeExecutionInput {
agent: AdapterExecutionContext["agent"];
config: Record<string, unknown>;
context: Record<string, unknown>;
runtimeCommandSpec?: AdapterExecutionContext["runtimeCommandSpec"];
executionTarget?: ReturnType<typeof readAdapterExecutionTarget>;
authToken?: string;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}
interface ClaudeRuntimeConfig {
@@ -112,7 +122,8 @@ function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscri
}
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
const { runId, agent, config, context, executionTarget, authToken } = input;
const { runId, agent, config, context, runtimeCommandSpec, executionTarget, authToken } = input;
const onLog = input.onLog ?? (async () => {});
const command = asString(config.command, "claude");
const workspaceContext = parseObject(context.paperclipWorkspace);
@@ -145,6 +156,15 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
workspaceCwd: effectiveWorkspaceCwd,
workspaceWorktreePath,
workspaceHints,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const envConfig = parseObject(config.env);
@@ -177,10 +197,14 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) {
env.PAPERCLIP_TASK_ID = wakeTaskId;
}
if (issueWorkMode) {
env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
}
if (wakeReason) {
env.PAPERCLIP_WAKE_REASON = wakeReason;
}
@@ -200,18 +224,18 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
}
applyPaperclipWorkspaceEnv(env, {
workspaceCwd: effectiveWorkspaceCwd,
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
workspaceSource,
workspaceStrategy,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceBranch,
workspaceWorktreePath,
workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath,
agentHome,
});
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
}
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
@@ -222,12 +246,13 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) {
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
}
for (const [key, value] of Object.entries(envConfig)) {
const shapedEnvConfig = rewriteWorkspaceCwdEnvVarsForExecution({
env: envConfig,
workspaceCwd: effectiveWorkspaceCwd,
executionCwd: shapedWorkspaceEnv.workspaceCwd,
executionTargetIsRemote,
});
for (const [key, value] of Object.entries(shapedEnvConfig)) {
if (typeof value === "string") env[key] = value;
}
@@ -235,8 +260,31 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
env.PAPERCLIP_API_KEY = authToken;
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
executionTarget,
asNumber(config.timeoutSec, 0),
);
const graceSec = asNumber(config.graceSec, 20);
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId,
target: executionTarget,
installCommand: runtimeCommandSpec?.installCommand,
detectCommand: runtimeCommandSpec?.detectCommand,
cwd,
env: runtimeEnv,
timeoutSec,
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
installCommand: SANDBOX_INSTALL_COMMAND,
timeoutSec,
});
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
@@ -244,8 +292,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
@@ -311,6 +357,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
});
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const executionTargetIsSandbox = executionTarget?.kind === "remote" && executionTarget.transport === "sandbox";
const promptTemplate = asString(
config.promptTemplate,
@@ -322,6 +369,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const configEnv = parseObject(config.env);
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceStrategy = asString(workspaceContext.strategy, "");
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
const agentHome = asString(workspaceContext.agentHome, "") || null;
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const hasExplicitClaudeConfigDir =
typeof configEnv.CLAUDE_CONFIG_DIR === "string" && configEnv.CLAUDE_CONFIG_DIR.trim().length > 0;
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
@@ -331,8 +393,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agent,
config,
context,
runtimeCommandSpec: ctx.runtimeCommandSpec,
executionTarget,
authToken,
onLog,
});
const {
command,
@@ -348,7 +412,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
extraArgs,
} = runtimeConfig;
let loggedEnv = initialLoggedEnv;
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const terminalResultCleanupGraceMs = Math.max(
0,
asNumber(config.terminalResultCleanupGraceMs, 5_000),
@@ -402,9 +466,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Syncing workspace and Claude runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
return await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
adapterKey: "claude",
timeoutSec,
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [
{
key: "skills",
@@ -422,6 +490,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
})()
: null;
if (preparedExecutionTargetRuntime?.workspaceRemoteDir) {
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir;
}
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig: configEnv,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceStrategy,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceBranch,
workspaceWorktreePath,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
const restoreRemoteWorkspace = preparedExecutionTargetRuntime
? () => preparedExecutionTargetRuntime.restoreWorkspace()
: null;
@@ -469,12 +557,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
target: runtimeExecutionTarget,
runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir,
adapterKey: "claude",
timeoutSec,
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
@@ -503,7 +592,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
runtimeSessionId.length > 0 &&
hasMatchingPromptBundle &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const sessionId = canResumeSession ? runtimeSessionId : null;
if (
executionTargetIsRemote &&
@@ -576,7 +665,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
) => {
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
args.push(...buildClaudeExecutionPermissionArgs({
dangerouslySkipPermissions,
targetIsSandbox: executionTargetIsSandbox,
}));
if (chrome) args.push("--chrome");
// For Bedrock: only pass --model when the ID is a Bedrock-native identifier
// (e.g. "us.anthropic.*" or ARN). Anthropic-style IDs like "claude-opus-4-6" are invalid
@@ -620,6 +712,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!resumeSessionId) {
commandNotes.push(`Using stable Claude prompt bundle ${promptBundle.bundleKey}.`);
}
if (dangerouslySkipPermissions && executionTargetIsSandbox) {
commandNotes.push(
"Using a broad --allowedTools whitelist for sandbox execution because Claude rejects --dangerously-skip-permissions under root/sudo.",
);
}
if (attemptInstructionsFilePath && !resumeSessionId) {
commandNotes.push(
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
@@ -639,7 +736,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
}
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
cwd,
env,
stdin: prompt,
@@ -760,7 +857,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
promptBundleKey: promptBundle.bundleKey,
...(executionTargetIsRemote
? {
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
}
: {}),
...(workspaceId ? { workspaceId } : {}),
@@ -777,6 +874,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const transientUpstream =
failed &&
!loginMeta.requiresLogin &&
!clearSessionForMaxTurns &&
isClaudeTransientUpstreamError({
parsed,
stdout: proc.stdout,
@@ -793,11 +891,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: null;
const resolvedErrorCode = loginMeta.requiresLogin
? "claude_auth_required"
: failed && clearSessionForMaxTurns
? "max_turns_exhausted"
: transientUpstream
? "claude_transient_upstream"
: null;
const mergedResultJson: Record<string, unknown> = {
...parsed,
...(failed && clearSessionForMaxTurns ? { stopReason: "max_turns_exhausted" } : {}),
...(transientUpstream ? { errorFamily: "transient_upstream" } : {}),
...(transientRetryNotBefore ? { retryNotBefore: transientRetryNotBefore.toISOString() } : {}),
...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}),
@@ -170,11 +170,19 @@ export function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null |
const subtype = asString(parsed.subtype, "").trim().toLowerCase();
if (subtype === "error_max_turns") return true;
const stopReason = asString(parsed.stop_reason, "").trim().toLowerCase();
if (stopReason === "max_turns") return true;
const structuredStopReasons = [
parsed.stop_reason,
parsed.stopReason,
parsed.error_code,
parsed.errorCode,
].map((value) => asString(value, "").trim().toLowerCase());
const resultText = asString(parsed.result, "").trim();
return /max(?:imum)?\s+turns?/i.test(resultText);
return structuredStopReasons.some((reason) =>
reason === "max_turns" ||
reason === "max_turns_exhausted" ||
reason === "turn_limit" ||
reason === "turn_limit_exhausted",
);
}
export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean {
@@ -0,0 +1,43 @@
// Explicit allowlist of Claude Code tools we permit when running inside a
// sandbox. We use this instead of `--dangerously-skip-permissions` for sandbox
// targets because the permission-approval prompts can't be answered by a
// human inside a non-interactive sandbox, but blanket-allowing every tool
// would defeat the point of having a separate sandbox code path.
//
// Maintenance: this list must be reviewed when Claude Code releases a new
// tool. The canonical list of built-in tools is documented at
// https://docs.claude.com/en/docs/claude-code/built-in-tools — when a tool
// is added there, decide whether it should be allowed in sandbox runs and
// either add it here or document the deliberate exclusion. Omitting a tool
// silently disables it inside sandboxes, which can look like the tool is
// "broken" rather than intentionally gated.
const SANDBOX_ALLOWED_TOOLS =
"Task AskUserQuestion Bash(*) CronCreate CronDelete CronList Edit " +
"EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Glob Grep Monitor " +
"NotebookEdit PushNotification Read RemoteTrigger ScheduleWakeup Skill " +
"TaskOutput TaskStop TodoWrite ToolSearch WebFetch WebSearch Write";
export function buildClaudeProbePermissionArgs(input: {
dangerouslySkipPermissions: boolean;
targetIsSandbox: boolean;
}): string[] {
if (!input.dangerouslySkipPermissions) return [];
// For sandbox targets, mirror the execution path: pass `--allowedTools`
// with the curated allowlist instead of dropping the flag entirely. The
// hello probe is a one-shot prompt that should never trigger a tool, but
// if a future probe prompt does, we don't want Claude CLI to stall on an
// interactive permission prompt that no human can answer.
if (input.targetIsSandbox) return ["--allowedTools", SANDBOX_ALLOWED_TOOLS];
return ["--dangerously-skip-permissions"];
}
export function buildClaudeExecutionPermissionArgs(input: {
dangerouslySkipPermissions: boolean;
targetIsSandbox: boolean;
}): string[] {
if (!input.dangerouslySkipPermissions) return [];
if (input.targetIsSandbox) {
return ["--allowedTools", SANDBOX_ALLOWED_TOOLS];
}
return ["--dangerously-skip-permissions"];
}
@@ -1,12 +1,13 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createHash, type Hash } from "node:crypto";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { ensurePaperclipSkillSymlink, type PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
import {
ensurePaperclipSkillSymlink,
resolvePaperclipInstanceRootForAdapter,
type PaperclipSkillEntry,
} from "@paperclipai/adapter-utils/server-utils";
type SkillEntry = PaperclipSkillEntry;
@@ -25,12 +26,13 @@ function resolveManagedClaudePromptCacheRoot(
env: NodeJS.ProcessEnv,
companyId: string,
): string {
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
env,
});
return path.resolve(
paperclipHome,
"instances",
instanceId,
instanceRoot,
"companies",
companyId,
"claude-prompt-cache",
@@ -14,6 +14,7 @@ import {
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
@@ -21,6 +22,8 @@ import {
import path from "node:path";
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
import { isBedrockModelId } from "./models.js";
import { buildClaudeProbePermissionArgs } from "./permissions.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@@ -62,6 +65,7 @@ export async function testEnvironment(
const command = asString(config.command, "claude");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
@@ -102,6 +106,15 @@ export async function testEnvironment(
if (typeof value === "string") env[key] = value;
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const installCheck = await maybeRunSandboxInstallCommand({
runId,
target,
adapterKey: "claude",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
env,
});
if (installCheck) checks.push(installCheck);
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
@@ -189,7 +202,7 @@ export async function testEnvironment(
})();
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
args.push(...buildClaudeProbePermissionArgs({ dangerouslySkipPermissions, targetIsSandbox }));
if (chrome) args.push("--chrome");
// For Bedrock: only pass --model when the ID is a Bedrock-native identifier.
if (model && (!hasBedrock || isBedrockModelId(model))) {
@@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "codex_local";
export const label = "Codex (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @openai/codex";
export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.4"] as const;
@@ -67,4 +67,22 @@ describe("buildCodexExecArgs", () => {
"-",
]);
});
it("adds --skip-git-repo-check when requested", () => {
const result = buildCodexExecArgs(
{
model: "gpt-5.3-codex",
},
{ skipGitRepoCheck: true },
);
expect(result.args).toEqual([
"exec",
"--json",
"--skip-git-repo-check",
"--model",
"gpt-5.3-codex",
"-",
]);
});
});
@@ -30,7 +30,10 @@ function formatFastModeSupportedModels(): string {
export function buildCodexExecArgs(
config: unknown,
options: { resumeSessionId?: string | null } = {},
options: {
resumeSessionId?: string | null;
skipGitRepoCheck?: boolean;
} = {},
): BuildCodexExecArgsResult {
const record = asRecord(config);
const model = asString(record.model, "").trim();
@@ -48,6 +51,7 @@ export function buildCodexExecArgs(
const extraArgs = readExtraArgs(record);
const args = ["exec", "--json"];
if (options.skipGitRepoCheck) args.push("--skip-git-repo-check");
if (search) args.unshift("--search");
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
if (model) args.push("--model", model);
@@ -2,11 +2,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
const SYMLINKED_SHARED_FILES = ["auth.json"] as const;
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
function nonEmpty(value: string | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
@@ -31,11 +31,14 @@ export function resolveManagedCodexHomeDir(
env: NodeJS.ProcessEnv,
companyId?: string,
): string {
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
env,
});
return companyId
? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home")
: path.resolve(paperclipHome, "instances", instanceId, "codex-home");
? path.resolve(instanceRoot, "companies", companyId, "codex-home")
: path.resolve(instanceRoot, "codex-home");
}
async function ensureParentDir(target: string): Promise<void> {
@@ -71,33 +74,71 @@ async function ensureCopiedFile(target: string, source: string): Promise<void> {
await fs.copyFile(source, target);
}
/**
* Writes an `auth.json` containing only `OPENAI_API_KEY` so the codex CLI can
* authenticate via API key. Overwrites any existing file or symlink at that
* path. Required because the codex CLI (>= 0.122) ignores the `OPENAI_API_KEY`
* environment variable and only reads credentials from `$CODEX_HOME/auth.json`.
*/
export async function writeApiKeyAuthJson(home: string, apiKey: string): Promise<void> {
await fs.mkdir(home, { recursive: true });
const target = path.join(home, "auth.json");
await fs.rm(target, { force: true });
await fs.writeFile(target, JSON.stringify({ OPENAI_API_KEY: apiKey }), { mode: 0o600 });
}
export async function prepareManagedCodexHome(
env: NodeJS.ProcessEnv,
onLog: AdapterExecutionContext["onLog"],
companyId?: string,
options: { apiKey?: string | null } = {},
): Promise<string> {
const targetHome = resolveManagedCodexHomeDir(env, companyId);
const apiKey = nonEmpty(options.apiKey ?? undefined);
const sourceHome = resolveSharedCodexHomeDir(env);
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
const seedFromShared = path.resolve(sourceHome) !== path.resolve(targetHome);
await fs.mkdir(targetHome, { recursive: true });
for (const name of SYMLINKED_SHARED_FILES) {
const source = path.join(sourceHome, name);
if (!(await pathExists(source))) continue;
await ensureSymlink(path.join(targetHome, name), source);
// If a previous run wrote an apikey-mode auth.json (regular file) and this
// run has no apiKey, remove it so the chatgpt-mode symlink can be restored.
// Without this cleanup, ensureSymlink bails on a non-symlink and Codex keeps
// authenticating with the stale key after it is removed from configuration.
if (!apiKey && seedFromShared) {
const authPath = path.join(targetHome, "auth.json");
const existing = await fs.lstat(authPath).catch(() => null);
if (existing && !existing.isSymbolicLink()) {
await fs.rm(authPath, { force: true });
}
}
for (const name of COPIED_SHARED_FILES) {
const source = path.join(sourceHome, name);
if (!(await pathExists(source))) continue;
await ensureCopiedFile(path.join(targetHome, name), source);
if (seedFromShared) {
for (const name of SYMLINKED_SHARED_FILES) {
const source = path.join(sourceHome, name);
if (!(await pathExists(source))) continue;
await ensureSymlink(path.join(targetHome, name), source);
}
for (const name of COPIED_SHARED_FILES) {
const source = path.join(sourceHome, name);
if (!(await pathExists(source))) continue;
await ensureCopiedFile(path.join(targetHome, name), source);
}
await onLog(
"stdout",
`[paperclip] Using ${isWorktreeMode(env) ? "worktree-isolated" : "Paperclip-managed"} Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
);
}
if (apiKey) {
await writeApiKeyAuthJson(targetHome, apiKey);
await onLog(
"stdout",
`[paperclip] Wrote API-key auth.json into Codex home "${targetHome}" from configured OPENAI_API_KEY.\n`,
);
}
await onLog(
"stdout",
`[paperclip] Using ${isWorktreeMode(env) ? "worktree-isolated" : "Paperclip-managed"} Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
);
return targetHome;
}
@@ -10,6 +10,7 @@ const {
prepareWorkspaceForSshExecution,
restoreWorkspaceFromSshExecution,
syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({
exitCode: 1,
@@ -22,9 +23,17 @@ const {
})),
ensureCommandResolvable: vi.fn(async () => undefined),
resolveCommandForLogs: vi.fn(async () => "/usr/bin/codex"),
prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
syncDirectoryToSsh: vi.fn(async () => undefined),
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
env: {
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
PAPERCLIP_API_KEY: "bridge-token",
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
},
stop: async () => {},
})),
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
@@ -51,6 +60,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
};
});
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,
};
});
import { execute } from "./execute.js";
describe("codex remote execution", () => {
@@ -70,10 +89,13 @@ describe("codex remote execution", () => {
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const codexHomeDir = path.join(rootDir, "codex-home");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
await mkdir(workspaceDir, { recursive: true });
await mkdir(codexHomeDir, { recursive: true });
await writeFile(path.join(rootDir, "instructions.md"), "Use the remote workspace.\n", "utf8");
await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8");
const alternateWorkspaceDir = path.join(rootDir, "alternate-workspace");
await mkdir(alternateWorkspaceDir, { recursive: true });
await execute({
runId: "run-1",
@@ -100,7 +122,27 @@ describe("codex remote execution", () => {
paperclipWorkspace: {
cwd: workspaceDir,
source: "project_primary",
strategy: "git_worktree",
workspaceId: "workspace-1",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
branchName: "feature/remote-codex",
worktreePath: workspaceDir,
},
paperclipWorkspaces: [
{
workspaceId: "workspace-1",
cwd: workspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
cwd: alternateWorkspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
],
},
executionTransport: {
remoteExecution: {
@@ -120,12 +162,12 @@ describe("codex remote execution", () => {
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(expect.objectContaining({
localDir: workspaceDir,
remoteDir: "/remote/workspace",
remoteDir: managedRemoteWorkspace,
}));
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
localDir: codexHomeDir,
remoteDir: "/remote/workspace/.paperclip-runtime/codex/home",
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/codex/home`,
followSymlinks: true,
}));
@@ -133,12 +175,31 @@ describe("codex remote execution", () => {
const call = runChildProcess.mock.calls[0] as unknown as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home");
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(call?.[2]).not.toContain("--skip-git-repo-check");
expect(call?.[3].env.CODEX_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/codex/home`);
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
expect(call?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined();
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
{
workspaceId: "workspace-1",
cwd: managedRemoteWorkspace,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
]);
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({
localDir: workspaceDir,
remoteDir: "/remote/workspace",
remoteDir: managedRemoteWorkspace,
}));
});
@@ -210,6 +271,7 @@ describe("codex remote execution", () => {
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const codexHomeDir = path.join(rootDir, "codex-home");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace";
await mkdir(workspaceDir, { recursive: true });
await mkdir(codexHomeDir, { recursive: true });
await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8");
@@ -227,13 +289,13 @@ describe("codex remote execution", () => {
sessionId: "session-123",
sessionParams: {
sessionId: "session-123",
cwd: "/remote/workspace",
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
remoteCwd: managedRemoteWorkspace,
},
},
sessionDisplayId: "session-123",
@@ -282,6 +344,7 @@ describe("codex remote execution", () => {
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const codexHomeDir = path.join(rootDir, "codex-home");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-target/workspace";
await mkdir(workspaceDir, { recursive: true });
await mkdir(codexHomeDir, { recursive: true });
await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8");
@@ -299,13 +362,13 @@ describe("codex remote execution", () => {
sessionId: "session-123",
sessionParams: {
sessionId: "session-123",
cwd: "/remote/workspace",
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
remoteCwd: managedRemoteWorkspace,
},
},
sessionDisplayId: "session-123",
@@ -353,7 +416,7 @@ describe("codex remote execution", () => {
"session-123",
"-",
]);
expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home");
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(call?.[3].env.CODEX_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/codex/home`);
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
});
});
@@ -4,15 +4,17 @@ import { fileURLToPath } from "node:url";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd,
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetTimeoutSec,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
startAdapterExecutionTargetPaperclipBridge,
@@ -21,13 +23,14 @@ import {
asString,
asNumber,
parseObject,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
refreshPaperclipWorkspaceEnvForExecution,
readPaperclipRuntimeSkillEntries,
readPaperclipIssueWorkModeFromContext,
resolvePaperclipDesiredSkillNames,
renderTemplate,
renderPaperclipWakePrompt,
@@ -44,6 +47,7 @@ import {
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
import { resolveCodexDesiredSkillNames } from "./skills.js";
import { buildCodexExecArgs } from "./codex-args.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const CODEX_ROLLOUT_NOISE_RE =
@@ -331,8 +335,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const configuredOpenAiApiKey =
typeof envConfig.OPENAI_API_KEY === "string" && envConfig.OPENAI_API_KEY.trim().length > 0
? envConfig.OPENAI_API_KEY.trim()
: null;
const preparedManagedCodexHome =
configuredCodexHome ? null : await prepareManagedCodexHome(process.env, onLog, agent.companyId);
configuredCodexHome
? null
: await prepareManagedCodexHome(process.env, onLog, agent.companyId, {
apiKey: configuredOpenAiApiKey,
});
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
await fs.mkdir(effectiveCodexHome, { recursive: true });
@@ -347,7 +359,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
desiredSkillNames,
},
);
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
executionTarget,
asNumber(config.timeoutSec, 0),
);
const graceSec = asNumber(config.graceSec, 20);
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const preparedExecutionTargetRuntime = executionTargetIsRemote
? await (async () => {
await onLog(
@@ -355,9 +372,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Syncing workspace and CODEX_HOME to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
return await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
adapterKey: "codex",
timeoutSec,
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [
{
key: "home",
@@ -368,6 +389,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
})()
: null;
if (preparedExecutionTargetRuntime?.workspaceRemoteDir) {
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir;
}
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
const executionTargetIsSandbox =
runtimeExecutionTarget?.kind === "remote" && runtimeExecutionTarget.transport === "sandbox";
const restoreRemoteWorkspace = preparedExecutionTargetRuntime
? () => preparedExecutionTargetRuntime.restoreWorkspace()
: null;
@@ -404,9 +431,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) {
env.PAPERCLIP_TASK_ID = wakeTaskId;
}
if (issueWorkMode) {
env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
}
if (wakeReason) {
env.PAPERCLIP_WAKE_REASON = wakeReason;
}
@@ -425,7 +456,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (wakePayloadJson) {
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
}
applyPaperclipWorkspaceEnv(env, {
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceStrategy,
@@ -434,11 +467,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
workspaceRepoRef,
workspaceBranch,
workspaceWorktreePath,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
@@ -448,23 +481,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) {
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
}
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
env.CODEX_HOME = remoteCodexHome ?? effectiveCodexHome;
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
target: runtimeExecutionTarget,
runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir,
adapterKey: "codex",
timeoutSec,
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
@@ -478,7 +505,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
),
);
const billingType = resolveCodexBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv(effectiveEnv)).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId,
target: executionTarget,
installCommand: ctx.runtimeCommandSpec?.installCommand,
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
cwd,
env: runtimeEnv,
timeoutSec,
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
@@ -487,9 +529,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
@@ -497,7 +536,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const codexTransientFallbackMode = readCodexTransientFallbackMode(context);
const forceSaferInvocation = fallbackModeUsesSaferInvocation(codexTransientFallbackMode);
const forceFreshSession = fallbackModeUsesFreshSession(codexTransientFallbackMode);
@@ -614,6 +653,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
return notes;
})();
if (executionTargetIsSandbox) {
commandNotes.push(
"Added --skip-git-repo-check for sandbox execution because Codex requires an explicit trust bypass in headless remote workspaces.",
);
}
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
@@ -636,7 +680,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const runAttempt = async (resumeSessionId: string | null) => {
const execArgs = buildCodexExecArgs(
forceSaferInvocation ? { ...config, fastMode: false } : config,
{ resumeSessionId },
{
resumeSessionId,
skipGitRepoCheck: executionTargetIsSandbox,
},
);
const args = execArgs.args;
const commandNotesWithFastMode =
@@ -660,7 +707,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
}
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
cwd,
env,
stdin: prompt,
@@ -713,7 +760,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
cwd: effectiveExecutionCwd,
...(executionTargetIsRemote
? {
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
}
: {}),
...(workspaceId ? { workspaceId } : {}),
@@ -0,0 +1,194 @@
import fs from "node:fs/promises";
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
const {
ensureAdapterExecutionTargetDirectory,
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
prepareAdapterExecutionTargetRuntime,
prepareManagedCodexHome,
restoreWorkspace,
} = vi.hoisted(() => {
const restoreWorkspace = vi.fn(async () => {});
return {
ensureAdapterExecutionTargetDirectory: vi.fn(async () => {}),
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}),
maybeRunSandboxInstallCommand: vi.fn(async () => null),
runAdapterExecutionTargetProcess: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
"{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}",
"{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"hello\"}}",
"{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":1,\"cached_input_tokens\":0,\"output_tokens\":1}}",
].join("\n"),
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
})),
describeAdapterExecutionTarget: vi.fn(() => "QA SSH"),
resolveAdapterExecutionTargetCwd: vi.fn((target, configuredCwd, fallbackCwd) => {
if (typeof configuredCwd === "string" && configuredCwd.trim().length > 0) return configuredCwd;
if (target && typeof target === "object" && "remoteCwd" in target && typeof target.remoteCwd === "string") {
return target.remoteCwd;
}
return fallbackCwd;
}),
prepareAdapterExecutionTargetRuntime: vi.fn(async () => ({
target: null,
workspaceRemoteDir: "/remote/workspace/.paperclip-runtime/runs/test/workspace",
runtimeRootDir: "/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/codex",
assetDirs: {
home: "/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/codex/home",
},
restoreWorkspace,
})),
prepareManagedCodexHome: vi.fn(async () => "/tmp/paperclip-managed-codex-home"),
restoreWorkspace,
};
});
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,
ensureAdapterExecutionTargetDirectory,
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
prepareAdapterExecutionTargetRuntime,
};
});
vi.mock("./codex-home.js", async () => {
const actual = await vi.importActual<typeof import("./codex-home.js")>("./codex-home.js");
return {
...actual,
prepareManagedCodexHome,
};
});
import { testEnvironment } from "./test.js";
describe("codex remote environment diagnostics", () => {
afterEach(() => {
vi.clearAllMocks();
delete process.env.OPENAI_API_KEY;
});
it("stages managed CODEX_HOME in an isolated runtime dir and keeps the probe cwd on the original remote workspace", async () => {
const remoteTarget: AdapterExecutionTarget = {
kind: "remote",
transport: "ssh",
remoteCwd: "/remote/workspace",
spec: {
host: "127.0.0.1",
port: 22,
username: "agent",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
remoteCwd: "/remote/workspace",
remoteWorkspacePath: "/remote/workspace",
strictHostKeyChecking: false,
},
};
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: {
command: "codex",
},
executionTarget: remoteTarget,
environmentName: "QA SSH",
});
expect(result.status).toBe("pass");
expect(result.checks.some((check) => check.code === "codex_hello_probe_passed")).toBe(true);
expect(prepareManagedCodexHome).toHaveBeenCalledTimes(1);
expect(prepareAdapterExecutionTargetRuntime).toHaveBeenCalledTimes(1);
const runtimeCalls = prepareAdapterExecutionTargetRuntime.mock.calls as unknown as Array<[
{
workspaceLocalDir: string;
target?: { remoteCwd?: string };
workspaceRemoteDir?: string;
},
]>;
const runtimeInput = runtimeCalls[0]?.[0];
expect(runtimeInput?.workspaceLocalDir).toContain(`${os.tmpdir()}/paperclip-codex-envtest-`);
expect(runtimeInput?.workspaceLocalDir).not.toBe("/remote/workspace");
expect(await fs.stat(runtimeInput!.workspaceLocalDir).catch(() => null)).toBeNull();
expect(runtimeInput?.target?.remoteCwd).toBe("/remote/workspace");
// `workspaceRemoteDir` is the base path passed to the runtime; the
// helper's per-run subdirectory is appended internally inside
// `prepareRemoteManagedRuntime`. Pre-building a per-run prefix here
// would double-nest the run id in the final path.
expect(runtimeInput?.workspaceRemoteDir).toBe("/remote/workspace");
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const probeCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as
| [string, { kind: string; remoteCwd: string }, string, string[], { cwd: string; env: Record<string, string> }]
| undefined;
expect(probeCall?.[1]).toMatchObject({
kind: "remote",
remoteCwd: "/remote/workspace",
});
expect(probeCall?.[4]).toMatchObject({
cwd: "/remote/workspace",
env: expect.objectContaining({
CODEX_HOME: "/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/codex/home",
}),
});
expect(restoreWorkspace).toHaveBeenCalledTimes(1);
});
it("avoids /tmp CODEX_HOME for remote API-key hello probes", async () => {
const remoteTarget: AdapterExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "cloudflare",
remoteCwd: "/remote/workspace",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
};
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: {
command: "codex",
env: {
OPENAI_API_KEY: "sk-test",
},
},
executionTarget: remoteTarget,
environmentName: "QA Cloudflare",
});
expect(result.status).toBe("pass");
const probeCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as
| [string, AdapterExecutionTarget, string, string[], { cwd: string; env: Record<string, string> }]
| undefined;
expect(probeCall?.[4].env.CODEX_HOME).toContain("/remote/workspace/.paperclip-runtime/codex/probe-home-codex-envtest-");
expect(probeCall?.[4].env.CODEX_HOME?.startsWith("/tmp/")).toBe(false);
expect(probeCall?.[3]).toContain("--skip-git-repo-check");
});
});
+200 -53
View File
@@ -11,14 +11,20 @@ import {
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
prepareAdapterExecutionTargetRuntime,
} from "@paperclipai/adapter-utils/execution-target";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { parseCodexJsonl } from "./parse.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
import { buildCodexExecArgs } from "./codex-args.js";
import { prepareManagedCodexHome } from "./codex-home.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@@ -55,6 +61,99 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin
const CODEX_AUTH_REQUIRED_RE =
/(?:not\s+logged\s+in|login\s+required|authentication\s+required|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|api[_\s-]?key.*required|please\s+run\s+`?codex\s+login`?)/i;
async function prepareCodexHelloProbe(input: {
runId: string;
companyId: string;
target: AdapterEnvironmentTestContext["executionTarget"] | null;
targetIsRemote: boolean;
cwd: string;
command: string;
args: string[];
env: Record<string, string>;
probeApiKey: string | null;
}): Promise<{
command: string;
args: string[];
env: Record<string, string>;
cleanup: () => Promise<void>;
}> {
let preparedRuntime: Awaited<ReturnType<typeof prepareAdapterExecutionTargetRuntime>> | null = null;
let preparedRuntimeWorkspaceLocalDir: string | null = null;
const cleanup = async () => {
await preparedRuntime?.restoreWorkspace().catch(() => {});
if (preparedRuntimeWorkspaceLocalDir) {
await fs.rm(preparedRuntimeWorkspaceLocalDir, { recursive: true, force: true }).catch(() => {});
}
};
if (input.targetIsRemote && !input.probeApiKey) {
const managedHome = await prepareManagedCodexHome(process.env, async () => {}, input.companyId, {
apiKey: null,
});
preparedRuntimeWorkspaceLocalDir = await fs.mkdtemp(
path.join(os.tmpdir(), `paperclip-codex-envtest-${input.runId}-`),
);
preparedRuntime = await prepareAdapterExecutionTargetRuntime({
runId: input.runId,
target: input.target,
adapterKey: "codex",
workspaceLocalDir: preparedRuntimeWorkspaceLocalDir,
// Pass `input.cwd` as the base (not a pre-built per-run subdir).
// `prepareRemoteManagedRuntime` itself appends
// `.paperclip-runtime/runs/<runId>/workspace` to whatever it gets, so
// pre-building a per-run path here would double-nest the run ID.
workspaceRemoteDir: input.cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: input.command,
assets: [
{
key: "home",
localDir: managedHome,
followSymlinks: true,
},
],
});
return {
command: input.command,
args: input.args,
env: preparedRuntime.assetDirs.home
? { ...input.env, CODEX_HOME: preparedRuntime.assetDirs.home }
: { ...input.env },
cleanup,
};
}
if (input.probeApiKey) {
const probeHome = input.targetIsRemote
? path.posix.join(input.cwd, ".paperclip-runtime", "codex", `probe-home-${input.runId}`)
: path.join(os.tmpdir(), `paperclip-codex-probe-${input.runId}`);
return {
command: "sh",
args: [
"-c",
'set -e; mkdir -p "$CODEX_HOME"; umask 077; printf "%s" "$_PAPERCLIP_CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json"; unset _PAPERCLIP_CODEX_AUTH_JSON; trap \'rm -rf "$CODEX_HOME"\' EXIT INT TERM; "$0" "$@"',
input.command,
...input.args,
],
env: {
...input.env,
CODEX_HOME: probeHome,
_PAPERCLIP_CODEX_AUTH_JSON: JSON.stringify({ OPENAI_API_KEY: input.probeApiKey }),
},
cleanup,
};
}
return {
command: input.command,
args: input.args,
env: { ...input.env },
cleanup,
};
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
@@ -63,6 +162,7 @@ export async function testEnvironment(
const command = asString(config.command, "codex");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
@@ -103,6 +203,15 @@ export async function testEnvironment(
if (typeof value === "string") env[key] = value;
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const installCheck = await maybeRunSandboxInstallCommand({
runId,
target,
adapterKey: "codex",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
env,
});
if (installCheck) checks.push(installCheck);
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
@@ -163,7 +272,10 @@ export async function testEnvironment(
hint: "Use the `codex` CLI command to run the automatic login and installation probe.",
});
} else {
const execArgs = buildCodexExecArgs({ ...config, fastMode: false });
const execArgs = buildCodexExecArgs(
{ ...config, fastMode: false },
{ skipGitRepoCheck: targetIsSandbox },
);
const args = execArgs.args;
if (execArgs.fastModeIgnoredReason) {
checks.push({
@@ -173,64 +285,99 @@ export async function testEnvironment(
hint: "Switch the agent model to GPT-5.4 or enter a manual model ID to enable Codex Fast mode.",
});
}
if (targetIsSandbox) {
checks.push({
code: "codex_git_repo_check_skipped",
level: "info",
message: "Added --skip-git-repo-check for sandbox hello probes.",
hint: "Codex requires an explicit trust bypass in headless remote sandbox workspaces.",
});
}
const probe = await runAdapterExecutionTargetProcess(
// Codex CLI (>= 0.122) ignores the OPENAI_API_KEY env var and only reads
// credentials from $CODEX_HOME/auth.json. When we have a key available,
// wrap the probe with a shell that materializes a per-run auth.json so
// the CLI can authenticate. The key content is passed via env (not on
// the command line) to avoid leaking it into process listings.
const probeApiKey = isNonEmpty(configOpenAiKey)
? configOpenAiKey
: isNonEmpty(hostOpenAiKey)
? hostOpenAiKey
: null;
const preparedProbe = await prepareCodexHelloProbe({
runId,
companyId: ctx.companyId,
target,
targetIsRemote,
cwd,
command,
args,
{
cwd,
env,
timeoutSec: 45,
graceSec: 5,
stdin: "Respond with hello.",
onLog: async () => {},
},
);
const parsed = parseCodexJsonl(probe.stdout);
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
env,
probeApiKey,
});
try {
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
preparedProbe.command,
preparedProbe.args,
{
cwd,
env: preparedProbe.env,
timeoutSec: 45,
graceSec: 5,
stdin: "Respond with hello.",
onLog: async () => {},
},
);
const parsed = parseCodexJsonl(probe.stdout);
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
if (probe.timedOut) {
checks.push({
code: "codex_hello_probe_timed_out",
level: "warn",
message: "Codex hello probe timed out.",
hint: "Retry the probe. If this persists, verify Codex can run `Respond with hello` from this directory manually.",
});
} else if ((probe.exitCode ?? 1) === 0) {
const summary = parsed.summary.trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
code: hasHello ? "codex_hello_probe_passed" : "codex_hello_probe_unexpected_output",
level: hasHello ? "info" : "warn",
message: hasHello
? "Codex hello probe succeeded."
: "Codex probe ran but did not return `hello` as expected.",
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
...(hasHello
? {}
: {
hint: "Try the probe manually (`codex exec --json -` then prompt: Respond with hello) to inspect full output.",
}),
});
} else if (CODEX_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "codex_hello_probe_auth_required",
level: "warn",
message: "Codex CLI is installed, but authentication is not ready.",
...(detail ? { detail } : {}),
hint: "Configure OPENAI_API_KEY in adapter env/shell or run `codex login`, then retry the probe.",
});
} else {
checks.push({
code: "codex_hello_probe_failed",
level: "error",
message: "Codex hello probe failed.",
...(detail ? { detail } : {}),
hint: "Run `codex exec --json -` manually in this working directory and prompt `Respond with hello` to debug.",
});
if (probe.timedOut) {
checks.push({
code: "codex_hello_probe_timed_out",
level: "warn",
message: "Codex hello probe timed out.",
hint: "Retry the probe. If this persists, verify Codex can run `Respond with hello` from this directory manually.",
});
} else if ((probe.exitCode ?? 1) === 0) {
const summary = parsed.summary.trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
code: hasHello ? "codex_hello_probe_passed" : "codex_hello_probe_unexpected_output",
level: hasHello ? "info" : "warn",
message: hasHello
? "Codex hello probe succeeded."
: "Codex probe ran but did not return `hello` as expected.",
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
...(hasHello
? {}
: {
hint: "Try the probe manually (`codex exec --json -` then prompt: Respond with hello) to inspect full output.",
}),
});
} else if (CODEX_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "codex_hello_probe_auth_required",
level: "warn",
message: "Codex CLI is installed, but authentication is not ready.",
...(detail ? { detail } : {}),
hint: probeApiKey
? "OPENAI_API_KEY was provided but Codex still rejected the request. Verify the key is valid for the OpenAI Responses API (e.g. `curl -H \"Authorization: Bearer $OPENAI_API_KEY\" https://api.openai.com/v1/models`), or run `codex login` and seed `~/.codex/auth.json`."
: "Codex CLI does not read OPENAI_API_KEY from the environment; set OPENAI_API_KEY in this adapter's config (so Paperclip writes it to `$CODEX_HOME/auth.json`) or run `codex login` on the host first.",
});
} else {
checks.push({
code: "codex_hello_probe_failed",
level: "error",
message: "Codex hello probe failed.",
...(detail ? { detail } : {}),
hint: "Run `codex exec --json -` manually in this working directory and prompt `Respond with hello` to debug.",
});
}
} finally {
await preparedProbe.cleanup();
}
}
}
@@ -0,0 +1,61 @@
{
"name": "@paperclipai/adapter-cursor-cloud",
"version": "0.3.1",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/adapters/cursor-cloud"
},
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@cursor/sdk": "^1.0.12",
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}
@@ -0,0 +1,42 @@
import pc from "picocolors";
import { parseCursorCloudStdoutLine } from "../ui/parse-stdout.js";
export function printCursorCloudEvent(raw: string, _debug: boolean): void {
const entries = parseCursorCloudStdoutLine(raw, new Date().toISOString());
for (const entry of entries) {
switch (entry.kind) {
case "assistant":
console.log(pc.green(`assistant: ${entry.text}`));
break;
case "thinking":
console.log(pc.gray(`thinking: ${entry.text}`));
break;
case "user":
console.log(pc.gray(`user: ${entry.text}`));
break;
case "tool_call":
console.log(pc.yellow(`tool_call: ${entry.name}`));
break;
case "tool_result":
console.log((entry.isError ? pc.red : pc.cyan)(entry.content || "tool result"));
break;
case "result":
console.log((entry.isError ? pc.red : pc.blue)(`result: ${entry.subtype}${entry.text ? ` - ${entry.text}` : ""}`));
break;
case "stderr":
console.error(pc.red(entry.text));
break;
case "system":
console.log(pc.blue(entry.text));
break;
case "init":
console.log(pc.blue(`Cursor Cloud init (${entry.sessionId})`));
break;
case "stdout":
console.log(entry.text);
break;
default:
console.log("text" in entry ? entry.text : JSON.stringify(entry));
}
}
}
@@ -0,0 +1 @@
export { printCursorCloudEvent } from "./format-event.js";
@@ -0,0 +1,34 @@
export const type = "cursor_cloud";
export const label = "Cursor Cloud";
export const agentConfigurationDoc = `# cursor_cloud agent configuration
Adapter: cursor_cloud
Use when:
- You want Paperclip to run Cursor Cloud Agents through the official Cursor SDK
- You want durable remote Cursor agent sessions across Paperclip heartbeats
- You want Paperclip to keep task state while Cursor handles remote code execution
Core fields:
- repoUrl (string, required): Git repository URL Cursor should open
- repoStartingRef (string, optional): starting ref for the repo
- repoPullRequestUrl (string, optional): PR URL to attach the agent to
- runtimeEnvType (string, optional): cloud | pool | machine
- runtimeEnvName (string, optional): named cloud/pool/machine target
- workOnCurrentBranch (boolean, optional): continue work on current branch
- autoCreatePR (boolean, optional): let Cursor auto-create a PR
- skipReviewerRequest (boolean, optional): suppress reviewer request on auto-created PRs
- instructionsFilePath (string, optional): agent instructions file prepended to the prompt
- promptTemplate (string, optional): heartbeat prompt template
- bootstrapPromptTemplate (string, optional): first-run-only bootstrap prompt template
- model (string, optional): Cursor model id; omit to use the account default
- env.CURSOR_API_KEY (string, required): Cursor API key
- env.* (optional): additional env vars injected into the cloud agent shell
Notes:
- Paperclip reuses the durable Cursor agent across heartbeats when the repo/runtime identity still matches.
- Each Paperclip heartbeat maps to a Cursor run on that durable agent.
- Paperclip injects PAPERCLIP_* runtime env vars into the cloud agent shell through Cursor SDK cloud envVars.
- Paperclip remains the source of truth for issue/task state; Cursor provides the remote execution surface.
`;
@@ -0,0 +1,348 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import { execute } from "./execute.js";
type MockRunOptions = {
id?: string;
agentId?: string;
status?: string;
waitResult?: Record<string, unknown>;
streamMessages?: unknown[];
streamError?: Error | null;
};
type MockAgentOptions = {
agentId?: string;
sendRun?: ReturnType<typeof createMockRun>;
};
const { createMock, resumeMock, getRunMock } = vi.hoisted(() => ({
createMock: vi.fn(),
resumeMock: vi.fn(),
getRunMock: vi.fn(),
}));
vi.mock("@cursor/sdk", () => ({
Agent: {
create: createMock,
resume: resumeMock,
getRun: getRunMock,
},
}));
function createMockRun(options: MockRunOptions = {}) {
const runId = options.id ?? "run-123";
const agentId = options.agentId ?? "agent-123";
const status = options.status ?? "finished";
const waitResult = options.waitResult ?? {
id: runId,
status,
result: "Done\nWith detail",
model: { id: "gpt-5.4" },
durationMs: 1234,
};
const streamMessages = options.streamMessages ?? [];
const streamError = options.streamError ?? null;
return {
id: runId,
agentId,
status,
result: typeof waitResult.result === "string" ? waitResult.result : null,
model: waitResult.model ?? null,
durationMs: waitResult.durationMs ?? null,
git: waitResult.git ?? null,
supports(capability: string) {
return capability === "stream" || capability === "wait";
},
async *stream() {
for (const message of streamMessages) yield message;
if (streamError) throw streamError;
},
async wait() {
return waitResult;
},
};
}
function createMockSdkAgent(options: MockAgentOptions = {}) {
const sendRun = options.sendRun ?? createMockRun();
return {
agentId: options.agentId ?? sendRun.agentId,
send: vi.fn(async () => sendRun),
[Symbol.asyncDispose]: vi.fn(async () => {}),
};
}
function createContext(
overrides: Partial<AdapterExecutionContext> = {},
): AdapterExecutionContext & {
logs: Array<{ stream: "stdout" | "stderr"; chunk: string }>;
meta: Record<string, unknown>[];
} {
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
const meta: Record<string, unknown>[] = [];
const agent = overrides.agent ?? {
id: "agent-1",
companyId: "company-1",
name: "Cursor Cloud Agent",
adapterType: "cursor_cloud",
adapterConfig: {},
};
const runtime = overrides.runtime ?? {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
};
const config = overrides.config ?? {
env: {
CURSOR_API_KEY: "cursor-secret",
EXTRA_FLAG: "1",
},
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoStartingRef: "main",
runtimeEnvType: "cloud",
promptTemplate: "Do the work for {{agent.name}}",
model: "gpt-5.4",
};
const context = overrides.context ?? {
taskId: "issue-1",
issueId: "issue-1",
wakeReason: "issue_commented",
};
const base: AdapterExecutionContext = {
runId: "run-heartbeat-1",
agent,
runtime,
config,
context,
authToken: "paperclip-run-jwt",
onLog: async (stream, chunk) => {
logs.push({ stream, chunk });
},
onMeta: async (entry) => {
meta.push(entry as unknown as Record<string, unknown>);
},
};
return {
...base,
...overrides,
logs,
meta,
};
}
describe("cursor_cloud execute", () => {
beforeEach(() => {
createMock.mockReset();
resumeMock.mockReset();
getRunMock.mockReset();
});
it("creates a fresh Cursor agent and injects Paperclip env without CURSOR_API_KEY", async () => {
const run = createMockRun({
agentId: "agent-fresh",
streamMessages: [
{
type: "assistant",
message: {
content: [{ type: "text", text: "Working" }],
},
},
],
});
const sdkAgent = createMockSdkAgent({ agentId: "agent-fresh", sendRun: run });
createMock.mockResolvedValue(sdkAgent);
const ctx = createContext();
const result = await execute(ctx);
expect(createMock).toHaveBeenCalledTimes(1);
expect(resumeMock).not.toHaveBeenCalled();
expect(getRunMock).not.toHaveBeenCalled();
expect(createMock.mock.calls[0]?.[0]).toMatchObject({
apiKey: "cursor-secret",
name: "Paperclip Cursor Cloud Agent",
model: { id: "gpt-5.4" },
cloud: {
env: { type: "cloud" },
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
},
});
expect(createMock.mock.calls[0]?.[0]?.cloud?.envVars).toMatchObject({
EXTRA_FLAG: "1",
PAPERCLIP_RUN_ID: "run-heartbeat-1",
PAPERCLIP_TASK_ID: "issue-1",
PAPERCLIP_WAKE_REASON: "issue_commented",
PAPERCLIP_API_KEY: "paperclip-run-jwt",
});
expect(createMock.mock.calls[0]?.[0]?.cloud?.envVars).not.toHaveProperty("CURSOR_API_KEY");
expect(result).toMatchObject({
exitCode: 0,
errorMessage: null,
sessionId: "agent-fresh",
model: "gpt-5.4",
summary: "Done",
sessionParams: {
cursorAgentId: "agent-fresh",
latestRunId: "run-123",
runtime: "cloud",
envType: "cloud",
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
},
});
expect(ctx.logs.map((entry) => entry.chunk)).toEqual(
expect.arrayContaining([
expect.stringContaining('"type":"cursor_cloud.init"'),
expect.stringContaining('"type":"cursor_cloud.message"'),
expect.stringContaining('"type":"cursor_cloud.result"'),
]),
);
});
it("resumes a matching saved session when no active run can be reattached", async () => {
getRunMock.mockResolvedValue(createMockRun({ status: "finished" }));
const resumedRun = createMockRun({ id: "run-resumed", agentId: "agent-resumed" });
const sdkAgent = createMockSdkAgent({ agentId: "agent-resumed", sendRun: resumedRun });
resumeMock.mockResolvedValue(sdkAgent);
const ctx = createContext({
runtime: {
sessionId: null,
sessionDisplayId: "agent-previous",
taskKey: null,
sessionParams: {
cursorAgentId: "agent-previous",
latestRunId: "run-previous",
runtime: "cloud",
envType: "cloud",
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
},
},
});
const result = await execute(ctx);
expect(getRunMock).toHaveBeenCalledWith("run-previous", {
runtime: "cloud",
agentId: "agent-previous",
apiKey: "cursor-secret",
});
expect(resumeMock).toHaveBeenCalledTimes(1);
expect(createMock).not.toHaveBeenCalled();
expect(sdkAgent.send).toHaveBeenCalledTimes(1);
expect(result.sessionId).toBe("agent-resumed");
});
it("reattaches to an active run, drains it, then sends the heartbeat as a follow-up", async () => {
const attachedRun = createMockRun({
id: "run-attached",
agentId: "agent-attached",
status: "running",
waitResult: {
id: "run-attached",
status: "finished",
result: "Prior result",
model: { id: "gpt-5.4" },
},
streamMessages: [
{
type: "status",
status: "running",
message: "Still working",
},
],
});
getRunMock.mockResolvedValue(attachedRun);
const followUpRun = createMockRun({
id: "run-followup",
agentId: "agent-attached",
waitResult: {
id: "run-followup",
status: "finished",
result: "Follow-up result",
model: { id: "gpt-5.4" },
},
});
const sdkAgent = createMockSdkAgent({ agentId: "agent-attached", sendRun: followUpRun });
resumeMock.mockResolvedValue(sdkAgent);
const ctx = createContext({
runtime: {
sessionId: null,
sessionDisplayId: "agent-attached",
taskKey: null,
sessionParams: {
cursorAgentId: "agent-attached",
latestRunId: "run-attached",
runtime: "cloud",
envType: "cloud",
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
},
},
});
const result = await execute(ctx);
expect(getRunMock).toHaveBeenCalledTimes(1);
expect(createMock).not.toHaveBeenCalled();
expect(resumeMock).toHaveBeenCalledTimes(1);
expect(sdkAgent.send).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
exitCode: 0,
sessionId: "agent-attached",
summary: "Follow-up result",
resultJson: {
cursorRunId: "run-followup",
},
});
const logChunks = ctx.logs.map((entry) => entry.chunk);
expect(logChunks).toEqual(
expect.arrayContaining([
expect.stringContaining("Reattached to existing Cursor run run-attached."),
expect.stringContaining("Prior Cursor run run-attached finished"),
expect.stringContaining("Started Cursor run run-followup."),
expect.stringContaining('"runId":"run-attached"'),
expect.stringContaining('"runId":"run-followup"'),
]),
);
expect(ctx.meta[0]?.context).toMatchObject({
cursorCloud: {
canReuseSession: true,
repoUrl: "https://github.com/paperclipai/paperclip.git",
},
});
});
it("maps non-finished Cursor results to failing Paperclip runs", async () => {
const cancelledRun = createMockRun({
id: "run-cancelled",
agentId: "agent-cancelled",
status: "cancelled",
waitResult: {
id: "run-cancelled",
status: "cancelled",
result: "",
model: { id: "gpt-5.4" },
},
});
const sdkAgent = createMockSdkAgent({ agentId: "agent-cancelled", sendRun: cancelledRun });
createMock.mockResolvedValue(sdkAgent);
const ctx = createContext();
const result = await execute(ctx);
expect(result).toMatchObject({
exitCode: 1,
errorMessage: "Cursor run cancelled",
sessionId: "agent-cancelled",
resultJson: {
status: "cancelled",
cursorAgentId: "agent-cancelled",
cursorRunId: "run-cancelled",
},
});
});
});
@@ -0,0 +1,607 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
Agent,
type AgentOptions,
type ModelSelection,
type Run,
type RunResult,
type SDKAgent,
type SDKMessage,
} from "@cursor/sdk";
import type { AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta } from "@paperclipai/adapter-utils";
import {
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
asBoolean,
asString,
buildPaperclipEnv,
joinPromptSections,
parseObject,
readPaperclipIssueWorkModeFromContext,
renderPaperclipWakePrompt,
renderTemplate,
stringifyPaperclipWakePayload,
} from "@paperclipai/adapter-utils/server-utils";
type CursorCloudSession = {
cursorAgentId: string;
latestRunId?: string;
runtime: "cloud";
envType?: "cloud" | "pool" | "machine";
envName?: string;
repos: Array<{ url: string; startingRef?: string; prUrl?: string }>;
};
type CursorCloudEvent =
| { type: "cursor_cloud.init"; sessionId: string; agentId: string; runId?: string; model?: string }
| { type: "cursor_cloud.status"; status: string; message?: string }
| { type: "cursor_cloud.message"; message: SDKMessage }
| {
type: "cursor_cloud.result";
status: string;
result?: string;
model?: string;
durationMs?: number;
git?: unknown;
error?: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asStringEnvMap(value: unknown): Record<string, string> {
const parsed = parseObject(value);
const env: Record<string, string> = {};
for (const [key, entry] of Object.entries(parsed)) {
if (typeof entry === "string") {
env[key] = entry;
} else if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) {
const rec = entry as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") env[key] = rec.value;
}
}
return env;
}
function normalizeEnvType(raw: string): "cloud" | "pool" | "machine" {
const value = raw.trim().toLowerCase();
if (value === "pool" || value === "machine") return value;
return "cloud";
}
function trimNullable(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function toModelSelection(rawModel: string): ModelSelection | undefined {
const model = rawModel.trim();
return model ? { id: model } : undefined;
}
function toSummary(result: RunResult): string | null {
const direct = trimNullable(result.result);
if (direct) return firstNonEmptyLine(direct);
return null;
}
function formatRunError(err: unknown): string {
if (err instanceof Error && err.message.trim().length > 0) return err.message.trim();
return String(err);
}
function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record<string, string>): Record<string, string> {
const { runId, agent, context, authToken } = ctx;
const env: Record<string, string> = {
...configEnv,
...buildPaperclipEnv(agent),
PAPERCLIP_RUN_ID: runId,
};
const wakeTaskId = trimNullable(context.taskId) ?? trimNullable(context.issueId);
const wakeReason = trimNullable(context.wakeReason);
const wakeCommentId = trimNullable(context.wakeCommentId) ?? trimNullable(context.commentId);
const approvalId = trimNullable(context.approvalId);
const approvalStatus = trimNullable(context.approvalStatus);
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
if (!trimNullable(env.PAPERCLIP_API_KEY) && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const workspace = parseObject(context.paperclipWorkspace);
const workspaceMappings: Array<[string, unknown]> = [
["PAPERCLIP_WORKSPACE_CWD", workspace.cwd],
["PAPERCLIP_WORKSPACE_SOURCE", workspace.source],
["PAPERCLIP_WORKSPACE_ID", workspace.workspaceId],
["PAPERCLIP_WORKSPACE_REPO_URL", workspace.repoUrl],
["PAPERCLIP_WORKSPACE_REPO_REF", workspace.repoRef],
["PAPERCLIP_WORKSPACE_BRANCH", workspace.branch],
["PAPERCLIP_WORKSPACE_WORKTREE_PATH", workspace.worktreePath],
["AGENT_HOME", workspace.agentHome],
];
for (const [key, value] of workspaceMappings) {
const normalized = trimNullable(value);
if (normalized) env[key] = normalized;
}
delete env.CURSOR_API_KEY;
return env;
}
async function buildInstructionsPrefix(
config: Record<string, unknown>,
onLog: AdapterExecutionContext["onLog"],
): Promise<{ prefix: string; notes: string[]; chars: number }> {
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
if (!instructionsFilePath) {
return { prefix: "", notes: [], chars: 0 };
}
try {
const contents = await fs.readFile(instructionsFilePath, "utf8");
const instructionsDir = `${path.dirname(instructionsFilePath)}/`;
const prefix = `${contents.trim()}\n\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsDir}.\n`;
return {
prefix,
chars: prefix.length,
notes: [
`Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
],
};
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
);
return {
prefix: "",
chars: 0,
notes: [
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
],
};
}
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
const keys = Object.keys(env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort();
if (keys.length === 0) return "";
return [
"Paperclip runtime note:",
`The following PAPERCLIP_* environment variables are available in the cloud agent shell: ${keys.join(", ")}`,
"Use them directly instead of assuming they are absent.",
].join("\n");
}
function readSession(params: Record<string, unknown> | null): CursorCloudSession | null {
if (!params) return null;
const record = asRecord(params);
if (!record) return null;
const cursorAgentId =
trimNullable(record.cursorAgentId) ??
trimNullable(record.agentId) ??
trimNullable(record.sessionId);
if (!cursorAgentId) return null;
const latestRunId = trimNullable(record.latestRunId) ?? trimNullable(record.runId) ?? undefined;
const envType = trimNullable(record.envType);
const envName = trimNullable(record.envName);
const reposValue = Array.isArray(record.repos) ? record.repos : [];
const repos = reposValue
.map((entry) => asRecord(entry))
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
.map((entry) => ({
url: asString(entry.url, "").trim(),
startingRef: trimNullable(entry.startingRef) ?? undefined,
prUrl: trimNullable(entry.prUrl) ?? undefined,
}))
.filter((entry) => entry.url.length > 0);
return {
cursorAgentId,
...(latestRunId ? { latestRunId } : {}),
runtime: "cloud",
...(envType ? { envType: normalizeEnvType(envType) } : {}),
...(envName ? { envName } : {}),
repos,
};
}
function sessionMatches(
session: CursorCloudSession | null,
envType: "cloud" | "pool" | "machine",
envName: string | null,
repos: Array<{ url: string; startingRef?: string; prUrl?: string }>,
): boolean {
if (!session) return false;
if ((session.envType ?? "cloud") !== envType) return false;
if ((session.envName ?? null) !== envName) return false;
if (session.repos.length !== repos.length) return false;
return session.repos.every((repo, index) => {
const next = repos[index];
return repo.url === next.url
&& (repo.startingRef ?? null) === (next.startingRef ?? null)
&& (repo.prUrl ?? null) === (next.prUrl ?? null);
});
}
function buildAgentOptions(input: {
apiKey: string;
name: string;
model?: ModelSelection;
envType: "cloud" | "pool" | "machine";
envName: string | null;
repos: Array<{ url: string; startingRef?: string; prUrl?: string }>;
workOnCurrentBranch: boolean;
autoCreatePR: boolean;
skipReviewerRequest: boolean;
envVars: Record<string, string>;
}): AgentOptions {
return {
apiKey: input.apiKey,
name: input.name,
...(input.model ? { model: input.model } : {}),
cloud: {
env: {
type: input.envType,
...(input.envName ? { name: input.envName } : {}),
},
repos: input.repos,
workOnCurrentBranch: input.workOnCurrentBranch,
autoCreatePR: input.autoCreatePR,
skipReviewerRequest: input.skipReviewerRequest,
envVars: input.envVars,
},
};
}
function eventLine(event: CursorCloudEvent): string {
return `${JSON.stringify(event)}\n`;
}
async function emitMessage(onLog: AdapterExecutionContext["onLog"], message: SDKMessage) {
await onLog("stdout", eventLine({ type: "cursor_cloud.message", message }));
}
async function emitStatus(onLog: AdapterExecutionContext["onLog"], status: string, message?: string) {
await onLog("stdout", eventLine({ type: "cursor_cloud.status", status, ...(message ? { message } : {}) }));
}
async function streamRun(run: Run, onLog: AdapterExecutionContext["onLog"]) {
if (!run.supports("stream")) return;
for await (const message of run.stream()) {
await emitMessage(onLog, message);
}
}
async function getAttachedRun(input: {
apiKey: string;
session: CursorCloudSession | null;
}): Promise<Run | null> {
const latestRunId = input.session?.latestRunId;
const cursorAgentId = input.session?.cursorAgentId;
if (!latestRunId || !cursorAgentId) return null;
try {
const run = await Agent.getRun(latestRunId, {
runtime: "cloud",
agentId: cursorAgentId,
apiKey: input.apiKey,
});
return run.status === "running" ? run : null;
} catch {
return null;
}
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta } = ctx;
const envConfig = asStringEnvMap(config.env);
const apiKey = asString(envConfig.CURSOR_API_KEY, "").trim();
if (!apiKey) {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: "CURSOR_API_KEY is required for cursor_cloud.",
provider: "cursor",
biller: "cursor",
billingType: "api",
clearSession: false,
};
}
const workspace = parseObject(context.paperclipWorkspace);
const repoUrl =
asString(config.repoUrl, "").trim() ||
asString(workspace.repoUrl, "").trim();
if (!repoUrl) {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: "cursor_cloud requires repoUrl in adapterConfig or workspace context.",
provider: "cursor",
biller: "cursor",
billingType: "api",
clearSession: false,
};
}
const repoStartingRef =
trimNullable(config.repoStartingRef) ??
trimNullable(workspace.repoRef) ??
undefined;
const repoPullRequestUrl = trimNullable(config.repoPullRequestUrl) ?? undefined;
const envType = normalizeEnvType(asString(config.runtimeEnvType, "cloud"));
const envName = trimNullable(config.runtimeEnvName);
const workOnCurrentBranch = asBoolean(config.workOnCurrentBranch, false);
const autoCreatePR = asBoolean(config.autoCreatePR, false);
const skipReviewerRequest = asBoolean(config.skipReviewerRequest, false);
const model = toModelSelection(asString(config.model, ""));
const repos = [{
url: repoUrl,
...(repoStartingRef ? { startingRef: repoStartingRef } : {}),
...(repoPullRequestUrl ? { prUrl: repoPullRequestUrl } : {}),
}];
const remoteEnv = buildWakeEnv(ctx, envConfig);
const session = readSession(runtime.sessionParams) ?? (runtime.sessionId
? {
cursorAgentId: runtime.sessionId,
runtime: "cloud" as const,
repos,
}
: null);
const canReuseSession = sessionMatches(session, envType, envName, repos);
const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
};
const instructions = await buildInstructionsPrefix(config, onLog);
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canReuseSession });
const renderedBootstrapPrompt =
!canReuseSession && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const renderedPrompt =
canReuseSession && wakePrompt.length > 0
? ""
: renderTemplate(promptTemplate, templateData).trim();
const paperclipEnvNote = renderPaperclipEnvNote(remoteEnv);
const prompt = joinPromptSections([
instructions.prefix,
renderedBootstrapPrompt,
wakePrompt,
paperclipEnvNote,
renderedPrompt,
]);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const finalPrompt = joinPromptSections([prompt, sessionHandoffNote]);
const agentOptions = buildAgentOptions({
apiKey,
name: `Paperclip ${agent.name}`,
model,
envType,
envName,
repos,
workOnCurrentBranch,
autoCreatePR,
skipReviewerRequest,
envVars: remoteEnv,
});
const commandNotes = [
...instructions.notes,
canReuseSession
? `Reusing Cursor cloud agent session ${session?.cursorAgentId ?? "unknown"}`
: "Creating a new Cursor cloud agent session",
`Repository: ${repoUrl}${repoStartingRef ? ` @ ${repoStartingRef}` : ""}`,
`Runtime target: ${envType}${envName ? ` (${envName})` : ""}`,
];
if (onMeta) {
const meta: AdapterInvocationMeta = {
adapterType: "cursor_cloud",
command: "@cursor/sdk",
commandNotes,
prompt: finalPrompt,
promptMetrics: {
promptChars: finalPrompt.length,
instructionsChars: instructions.chars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
heartbeatPromptChars: renderedPrompt.length,
},
context: {
cursorCloud: {
envType,
envName,
repoUrl,
repoStartingRef,
repoPullRequestUrl,
canReuseSession,
},
},
};
await onMeta(meta);
}
let sdkAgent: SDKAgent | null = null;
let run: Run | null = null;
let streamError: string | null = null;
try {
const attachedRun = canReuseSession
? await getAttachedRun({ apiKey, session })
: null;
if (attachedRun) {
await emitStatus(onLog, "running", `Reattached to existing Cursor run ${attachedRun.id}.`);
await onLog("stdout", eventLine({
type: "cursor_cloud.init",
sessionId: attachedRun.agentId,
agentId: attachedRun.agentId,
runId: attachedRun.id,
...(model?.id ? { model: model.id } : {}),
}));
const priorStreamPromise = streamRun(attachedRun, onLog).catch((err) => {
streamError = formatRunError(err);
});
if (attachedRun.supports("wait")) await attachedRun.wait();
await priorStreamPromise;
streamError = null;
await emitStatus(
onLog,
"running",
`Prior Cursor run ${attachedRun.id} finished; sending heartbeat follow-up so this wake's context is not dropped.`,
);
}
sdkAgent = canReuseSession && session
? await Agent.resume(session.cursorAgentId, agentOptions)
: await Agent.create(agentOptions);
run = await sdkAgent.send(finalPrompt, {
...(model ? { model } : {}),
});
await onLog("stdout", eventLine({
type: "cursor_cloud.init",
sessionId: sdkAgent.agentId,
agentId: sdkAgent.agentId,
runId: run.id,
...(model?.id ? { model: model.id } : {}),
}));
await emitStatus(onLog, "running", `Started Cursor run ${run.id}.`);
const streamPromise = streamRun(run, onLog).catch((err) => {
streamError = formatRunError(err);
});
const result = run.supports("wait")
? await run.wait()
: {
id: run.id,
status: run.status === "running" ? "error" : run.status,
result: run.result,
model: run.model,
durationMs: run.durationMs,
git: run.git,
};
await streamPromise;
const modelId = result.model?.id ?? model?.id ?? null;
await onLog("stdout", eventLine({
type: "cursor_cloud.result",
status: result.status,
...(result.result ? { result: result.result } : {}),
...(modelId ? { model: modelId } : {}),
...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}),
...(result.git ? { git: result.git } : {}),
...(streamError ? { error: streamError } : {}),
}));
const nextSession: CursorCloudSession = {
cursorAgentId: run.agentId,
latestRunId: result.id,
runtime: "cloud",
envType,
...(envName ? { envName } : {}),
repos,
};
const isError = result.status !== "finished";
return {
exitCode: isError ? 1 : 0,
signal: null,
timedOut: false,
errorMessage: isError ? (trimNullable(result.result) ?? streamError ?? `Cursor run ${result.status}`) : null,
sessionId: run.agentId,
sessionDisplayId: run.agentId,
sessionParams: nextSession,
provider: "cursor",
biller: "cursor",
billingType: "api",
model: modelId,
costUsd: null,
summary: toSummary(result),
resultJson: {
status: result.status,
cursorAgentId: run.agentId,
cursorRunId: result.id,
envType,
envName,
repos,
...(result.result ? { result: result.result } : {}),
...(result.git ? { git: result.git } : {}),
...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}),
...(streamError ? { streamError } : {}),
},
clearSession: false,
};
} catch (err) {
const reason = formatRunError(err);
if (run) {
await onLog("stdout", eventLine({
type: "cursor_cloud.result",
status: "error",
error: reason,
}));
}
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: reason,
sessionId: session?.cursorAgentId ?? null,
sessionDisplayId: session?.cursorAgentId ?? null,
sessionParams: session,
provider: "cursor",
biller: "cursor",
billingType: "api",
costUsd: null,
clearSession: false,
resultJson: {
status: "error",
...(run ? { cursorRunId: run.id } : {}),
...(session?.cursorAgentId ? { cursorAgentId: session.cursorAgentId } : {}),
error: reason,
},
};
} finally {
if (sdkAgent) {
try {
await sdkAgent[Symbol.asyncDispose]();
} catch {
// Best effort only.
}
}
}
}
@@ -0,0 +1,70 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export { sessionCodec } from "./session.js";
import type { AdapterConfigSchema } from "@paperclipai/adapter-utils";
export function getConfigSchema(): AdapterConfigSchema {
return {
fields: [
{
key: "repoUrl",
label: "Repository URL",
type: "text",
required: true,
hint: "Git repository URL Cursor should open for this agent.",
},
{
key: "repoStartingRef",
label: "Starting ref",
type: "text",
hint: "Optional branch, tag, or SHA Cursor should start from.",
},
{
key: "repoPullRequestUrl",
label: "Pull request URL",
type: "text",
hint: "Optional PR URL when attaching the agent to an existing review branch.",
},
{
key: "runtimeEnvType",
label: "Cursor runtime",
type: "select",
default: "cloud",
options: [
{ value: "cloud", label: "Cursor hosted" },
{ value: "pool", label: "Self-hosted pool" },
{ value: "machine", label: "Named machine" },
],
hint: "Choose where Cursor should execute the remote agent.",
},
{
key: "runtimeEnvName",
label: "Runtime name",
type: "text",
hint: "Optional pool or machine name when targeting a non-default runtime.",
},
{
key: "workOnCurrentBranch",
label: "Work on current branch",
type: "toggle",
default: false,
hint: "Tell Cursor to continue on the current branch instead of making a new one.",
},
{
key: "autoCreatePR",
label: "Auto-create PR",
type: "toggle",
default: false,
hint: "Allow Cursor to automatically create a pull request for the work.",
},
{
key: "skipReviewerRequest",
label: "Skip reviewer request",
type: "toggle",
default: false,
hint: "Suppress reviewer requests on auto-created pull requests.",
},
],
};
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { sessionCodec } from "./session.js";
describe("cursorCloud sessionCodec", () => {
it("normalizes legacy and current session identifiers", () => {
expect(
sessionCodec.deserialize({
agentId: "agent-123",
runId: "run-456",
envType: "pool",
envName: "trusted",
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
}),
).toEqual({
cursorAgentId: "agent-123",
latestRunId: "run-456",
runtime: "cloud",
envType: "pool",
envName: "trusted",
repos: [{ url: "https://github.com/paperclipai/paperclip.git", startingRef: "main" }],
});
});
it("drops invalid session payloads and exposes the display id", () => {
expect(sessionCodec.deserialize({ latestRunId: "run-1" })).toBeNull();
expect(sessionCodec.getDisplayId?.({
cursorAgentId: "agent-789",
latestRunId: "run-101",
})).toBe("agent-789");
});
});
@@ -0,0 +1,61 @@
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readRepos(value: unknown): Array<{ url: string; startingRef?: string; prUrl?: string }> {
if (!Array.isArray(value)) return [];
const repos: Array<{ url: string; startingRef?: string; prUrl?: string }> = [];
for (const entry of value) {
const repo = asRecord(entry);
if (!repo) continue;
const url = readString(repo.url);
if (!url) continue;
const startingRef = readString(repo.startingRef);
const prUrl = readString(repo.prUrl);
repos.push({
url,
...(startingRef ? { startingRef } : {}),
...(prUrl ? { prUrl } : {}),
});
}
return repos;
}
function normalize(raw: unknown): Record<string, unknown> | null {
const record = asRecord(raw);
if (!record) return null;
const cursorAgentId =
readString(record.cursorAgentId) ??
readString(record.agentId) ??
readString(record.sessionId);
if (!cursorAgentId) return null;
const latestRunId = readString(record.latestRunId) ?? readString(record.runId);
const runtime = readString(record.runtime) ?? "cloud";
const envType = readString(record.envType);
const envName = readString(record.envName);
const repos = readRepos(record.repos);
return {
cursorAgentId,
...(latestRunId ? { latestRunId } : {}),
runtime,
...(envType ? { envType } : {}),
...(envName ? { envName } : {}),
...(repos.length > 0 ? { repos } : {}),
};
}
export const sessionCodec: AdapterSessionCodec = {
deserialize: normalize,
serialize: normalize,
getDisplayId(params) {
const normalized = normalize(params);
return normalized ? String(normalized.cursorAgentId) : null;
},
};
@@ -0,0 +1,118 @@
import { Cursor } from "@cursor/sdk";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function asStringEnvMap(value: unknown): Record<string, string> {
const parsed = parseObject(value);
const env: Record<string, string> = {};
for (const [key, entry] of Object.entries(parsed)) {
if (typeof entry === "string") {
env[key] = entry;
} else if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) {
const rec = entry as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") env[key] = rec.value;
}
}
return env;
}
function looksLikeRepoUrl(value: string): boolean {
return /^(https?:\/\/|git@)/i.test(value.trim());
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const env = asStringEnvMap(config.env);
const apiKey = asString(env.CURSOR_API_KEY, "").trim();
const repoUrl = asString(config.repoUrl, "").trim();
const model = asString(config.model, "").trim();
if (!apiKey) {
checks.push({
code: "cursor_cloud_api_key_missing",
level: "error",
message: "CURSOR_API_KEY is required.",
hint: "Add CURSOR_API_KEY under environment variables for this adapter.",
});
}
if (!repoUrl) {
checks.push({
code: "cursor_cloud_repo_missing",
level: "error",
message: "repoUrl is required.",
hint: "Set the repository URL Cursor should open for this agent.",
});
} else if (!looksLikeRepoUrl(repoUrl)) {
checks.push({
code: "cursor_cloud_repo_invalid",
level: "error",
message: "repoUrl must be an http(s) or git SSH repository URL.",
detail: repoUrl,
});
} else {
checks.push({
code: "cursor_cloud_repo_present",
level: "info",
message: `Repository configured: ${repoUrl}`,
});
}
if (apiKey) {
try {
const me = await Cursor.me({ apiKey });
checks.push({
code: "cursor_cloud_auth_ok",
level: "info",
message: "Cursor API key is valid.",
detail: me.userEmail ? `Authenticated as ${me.userEmail}.` : `API key: ${me.apiKeyName}`,
});
} catch (err) {
checks.push({
code: "cursor_cloud_auth_failed",
level: "error",
message: err instanceof Error ? err.message : "Failed to validate Cursor API key.",
});
}
}
if (apiKey && model) {
try {
const models = await Cursor.models.list({ apiKey });
const match = models.find((entry) => entry.id === model);
checks.push({
code: match ? "cursor_cloud_model_ok" : "cursor_cloud_model_unknown",
level: match ? "info" : "warn",
message: match
? `Model "${model}" is available to the authenticated Cursor account.`
: `Model "${model}" was not found in the authenticated Cursor model list.`,
});
} catch (err) {
checks.push({
code: "cursor_cloud_model_probe_failed",
level: "warn",
message: err instanceof Error ? err.message : "Failed to validate model availability.",
});
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildCursorCloudConfig } from "./build-config.js";
function makeValues(overrides: Partial<CreateConfigValues> = {}): CreateConfigValues {
return {
adapterType: "cursor_cloud",
cwd: "",
instructionsFilePath: "",
promptTemplate: "",
model: "",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: false,
search: false,
fastMode: false,
dangerouslyBypassSandbox: false,
command: "",
args: "",
extraArgs: "",
envVars: "",
envBindings: {},
url: "",
bootstrapPrompt: "",
payloadTemplateJson: "",
workspaceStrategyType: "project_primary",
workspaceBaseRef: "",
workspaceBranchTemplate: "",
worktreeParentDir: "",
runtimeServicesJson: "",
maxTurnsPerRun: 1000,
heartbeatEnabled: false,
intervalSec: 300,
adapterSchemaValues: {},
...overrides,
};
}
describe("buildCursorCloudConfig", () => {
it("persists schema values and top-level prompt fields", () => {
const config = buildCursorCloudConfig(
makeValues({
instructionsFilePath: ".cursor/AGENTS.md",
promptTemplate: "hello {{agent.name}}",
bootstrapPrompt: "bootstrap",
model: "gpt-5.4",
adapterSchemaValues: {
repoUrl: "https://github.com/paperclipai/paperclip.git",
runtimeEnvType: "pool",
runtimeEnvName: "trusted-workers",
autoCreatePR: true,
},
}),
);
expect(config).toMatchObject({
instructionsFilePath: ".cursor/AGENTS.md",
promptTemplate: "hello {{agent.name}}",
bootstrapPromptTemplate: "bootstrap",
model: "gpt-5.4",
repoUrl: "https://github.com/paperclipai/paperclip.git",
runtimeEnvType: "pool",
runtimeEnvName: "trusted-workers",
autoCreatePR: true,
});
});
it("merges structured env bindings over legacy envVars text", () => {
const config = buildCursorCloudConfig(
makeValues({
envVars: ["CURSOR_API_KEY=legacy-key", "PLAIN=value", "INVALID KEY=nope"].join("\n"),
envBindings: {
CURSOR_API_KEY: { type: "secret_ref", secretId: "secret-1", version: "latest" },
STRUCTURED_ONLY: "from-binding",
},
}),
);
expect(config.env).toEqual({
CURSOR_API_KEY: { type: "secret_ref", secretId: "secret-1", version: "latest" },
PLAIN: { type: "plain", value: "value" },
STRUCTURED_ONLY: { type: "plain", value: "from-binding" },
});
});
});
@@ -0,0 +1,67 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildCursorCloudConfig(values: CreateConfigValues): Record<string, unknown> {
const config: Record<string, unknown> = {
...(values.adapterSchemaValues ?? {}),
};
if (values.instructionsFilePath) config.instructionsFilePath = values.instructionsFilePath;
if (values.promptTemplate) config.promptTemplate = values.promptTemplate;
if (values.bootstrapPrompt) config.bootstrapPromptTemplate = values.bootstrapPrompt;
if (values.model?.trim()) config.model = values.model.trim();
const env = parseEnvBindings(values.envBindings);
const legacy = parseEnvVars(values.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (Object.keys(env).length > 0) {
config.env = env;
}
return config;
}
@@ -0,0 +1,2 @@
export { buildCursorCloudConfig } from "./build-config.js";
export { parseCursorCloudStdoutLine } from "./parse-stdout.js";
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { parseCursorCloudStdoutLine } from "./parse-stdout.js";
const ts = "2026-05-10T05:10:00.000Z";
describe("parseCursorCloudStdoutLine", () => {
it("parses init and status events", () => {
expect(
parseCursorCloudStdoutLine(
JSON.stringify({ type: "cursor_cloud.init", sessionId: "agent-123", model: "gpt-5.4" }),
ts,
),
).toEqual([{ kind: "init", ts, sessionId: "agent-123", model: "gpt-5.4" }]);
expect(
parseCursorCloudStdoutLine(
JSON.stringify({ type: "cursor_cloud.status", status: "running", message: "Reattached" }),
ts,
),
).toEqual([{ kind: "system", ts, text: "running: Reattached" }]);
});
it("parses assistant text and tool lifecycle SDK messages", () => {
const assistantLine = JSON.stringify({
type: "cursor_cloud.message",
message: {
type: "assistant",
message: {
content: [
{ type: "text", text: "Working on it." },
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "README.md" } },
],
},
},
});
expect(parseCursorCloudStdoutLine(assistantLine, ts)).toEqual([
{ kind: "assistant", ts, text: "Working on it." },
{ kind: "tool_call", ts, name: "read_file", toolUseId: "tool-1", input: { path: "README.md" } },
]);
const toolStartLine = JSON.stringify({
type: "cursor_cloud.message",
message: {
type: "tool_call",
id: "call-1",
name: "bash",
status: "running",
args: { command: "pwd" },
},
});
expect(parseCursorCloudStdoutLine(toolStartLine, ts)).toEqual([
{ kind: "tool_call", ts, name: "bash", toolUseId: "call-1", input: { command: "pwd" } },
]);
const toolEndLine = JSON.stringify({
type: "cursor_cloud.message",
message: {
type: "tool_call",
id: "call-1",
name: "bash",
status: "completed",
result: { stdout: "/repo" },
},
});
expect(parseCursorCloudStdoutLine(toolEndLine, ts)).toEqual([
{
kind: "tool_result",
ts,
toolUseId: "call-1",
toolName: "bash",
content: JSON.stringify({ stdout: "/repo" }, null, 2),
isError: false,
},
]);
});
it("parses standalone tool_result SDK messages", () => {
const line = JSON.stringify({
type: "cursor_cloud.message",
message: {
type: "tool_result",
call_id: "call-9",
name: "read_file",
result: { contents: "file body" },
},
});
expect(parseCursorCloudStdoutLine(line, ts)).toEqual([
{
kind: "tool_result",
ts,
toolUseId: "call-9",
toolName: "read_file",
content: JSON.stringify({ contents: "file body" }, null, 2),
isError: false,
},
]);
const errorLine = JSON.stringify({
type: "cursor_cloud.message",
message: {
type: "tool_result",
call_id: "call-10",
name: "bash",
is_error: true,
content: "exit 1",
},
});
expect(parseCursorCloudStdoutLine(errorLine, ts)).toEqual([
{
kind: "tool_result",
ts,
toolUseId: "call-10",
toolName: "bash",
content: "exit 1",
isError: true,
},
]);
});
it("parses result events and preserves unknown lines as stdout", () => {
expect(
parseCursorCloudStdoutLine(
JSON.stringify({ type: "cursor_cloud.result", status: "finished", result: "Done", model: "gpt-5.4" }),
ts,
),
).toEqual([
{
kind: "result",
ts,
text: "Done",
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
costUsd: 0,
subtype: "finished",
isError: false,
errors: [],
},
]);
expect(parseCursorCloudStdoutLine("plain text", ts)).toEqual([{ kind: "stdout", ts, text: "plain text" }]);
});
});
@@ -0,0 +1,186 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function parseAssistantMessage(message: Record<string, unknown>, ts: string): TranscriptEntry[] {
const content = Array.isArray(message.content) ? message.content : [];
const entries: TranscriptEntry[] = [];
for (const partRaw of content) {
const part = asRecord(partRaw);
if (!part) continue;
const type = asString(part.type).trim();
if (type === "text") {
const text = asString(part.text).trim();
if (text) entries.push({ kind: "assistant", ts, text });
continue;
}
if (type === "tool_use") {
entries.push({
kind: "tool_call",
ts,
name: asString(part.name, "tool"),
toolUseId: asString(part.id) || undefined,
input: part.input ?? {},
});
}
}
return entries;
}
function parseSdkMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
const message = asRecord(messageRaw);
if (!message) return [];
const type = asString(message.type);
if (type === "assistant") {
const body = asRecord(message.message);
return body ? parseAssistantMessage(body, ts) : [];
}
if (type === "user") {
const body = asRecord(message.message);
const content = Array.isArray(body?.content) ? body.content : [];
const text = content
.map((entry) => asRecord(entry))
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
.map((entry) => asString(entry.text).trim())
.filter(Boolean)
.join("\n");
return text ? [{ kind: "user", ts, text }] : [];
}
if (type === "thinking") {
const text = asString(message.text).trim();
return text ? [{ kind: "thinking", ts, text }] : [];
}
if (type === "tool_call") {
const toolUseId = asString(message.call_id, asString(message.id, "tool_call"));
const status = asString(message.status).toLowerCase();
if (status === "running") {
return [{
kind: "tool_call",
ts,
name: asString(message.name, "tool"),
toolUseId,
input: message.args ?? {},
}];
}
if (status === "completed" || status === "error") {
return [{
kind: "tool_result",
ts,
toolUseId,
toolName: asString(message.name, "tool"),
content: stringifyUnknown(message.result ?? message.args ?? {}),
isError: status === "error",
}];
}
return [];
}
if (type === "tool_result") {
const toolUseId = asString(message.call_id, asString(message.id, "tool_result"));
const isError =
message.is_error === true ||
asString(message.status).toLowerCase() === "error";
return [{
kind: "tool_result",
ts,
toolUseId,
toolName: asString(message.name, "tool"),
content: stringifyUnknown(message.result ?? message.content ?? message.output ?? {}),
isError,
}];
}
if (type === "status") {
const status = asString(message.status);
const statusMessage = asString(message.message);
return [{
kind: "system",
ts,
text: `status: ${status}${statusMessage ? ` - ${statusMessage}` : ""}`,
}];
}
if (type === "task") {
const text = asString(message.text).trim();
return text ? [{ kind: "system", ts, text }] : [];
}
return [];
}
export function parseCursorCloudStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
}
const type = asString(parsed.type);
if (type === "cursor_cloud.init") {
const sessionId = asString(parsed.sessionId, asString(parsed.agentId));
return [{
kind: "init",
ts,
model: asString(parsed.model, "cursor_cloud"),
sessionId,
}];
}
if (type === "cursor_cloud.status") {
return [{
kind: "system",
ts,
text: `${asString(parsed.status, "status")}${parsed.message ? `: ${asString(parsed.message)}` : ""}`,
}];
}
if (type === "cursor_cloud.message") {
return parseSdkMessage(parsed.message, ts);
}
if (type === "cursor_cloud.result") {
const status = asString(parsed.status, "error");
return [{
kind: "result",
ts,
text: asString(parsed.result),
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
costUsd: 0,
subtype: status,
isError: status !== "finished",
errors: parsed.error ? [asString(parsed.error)] : [],
}];
}
return [{ kind: "stdout", ts, text: line }];
}
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}
+10 -1
View File
@@ -3,6 +3,15 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "cursor";
export const label = "Cursor CLI (local)";
// Cursor CLI is not distributed as an npm package — the official install
// path is the upstream installer script at cursor.com/install. Other adapters
// in this repo prefer `npm install -g <pkg>` which is content-addressed by the
// registry; cursor must use `curl | bash` until upstream publishes a registry
// artifact. Pinning a commit/version here would require shipping our own
// mirror of the installer; revisit if Cursor adds an npm/release-asset
// equivalent.
export const SANDBOX_INSTALL_COMMAND = "curl https://cursor.com/install -fsS | bash";
export const DEFAULT_CURSOR_LOCAL_MODEL = "auto";
const CURSOR_FALLBACK_MODEL_IDS = [
@@ -95,5 +104,5 @@ Notes:
- Sessions are resumed with --resume when stored session cwd matches current cwd.
- Paperclip auto-injects local skills into "~/.cursor/skills" when missing, so Cursor can discover "$paperclip" and related skills on local runs.
- Paperclip auto-adds --yolo unless one of --trust/--yolo/-f is already present in extraArgs.
- Remote sandbox runs prepend "~/.local/bin" to PATH and prefer "~/.local/bin/cursor-agent" when the default Cursor entrypoint is requested, so standard E2B-style installs do not need hardcoded absolute command paths.
- Remote sandbox runs prepend "~/.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.
`;
@@ -11,6 +11,7 @@ const {
restoreWorkspaceFromSshExecution,
runSshCommand,
syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({
exitCode: 0,
@@ -27,7 +28,7 @@ const {
})),
ensureCommandResolvable: vi.fn(async () => undefined),
resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: agent"),
prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
runSshCommand: vi.fn(async () => ({
stdout: "/home/agent",
@@ -35,6 +36,14 @@ const {
exitCode: 0,
})),
syncDirectoryToSsh: vi.fn(async () => undefined),
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
env: {
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
PAPERCLIP_API_KEY: "bridge-token",
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
},
stop: async () => {},
})),
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
@@ -62,6 +71,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
};
});
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,
};
});
import { execute } from "./execute.js";
describe("cursor remote execution", () => {
@@ -80,8 +99,11 @@ describe("cursor remote execution", () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
await mkdir(workspaceDir, { recursive: true });
await mkdir(alternateWorkspaceDir, { recursive: true });
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
const result = await execute({
runId: "run-1",
agent: {
@@ -105,6 +127,20 @@ describe("cursor remote execution", () => {
cwd: workspaceDir,
source: "project_primary",
},
paperclipWorkspaces: [
{
workspaceId: "workspace-1",
cwd: workspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
cwd: alternateWorkspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
],
},
executionTransport: {
remoteExecution: {
@@ -116,7 +152,6 @@ describe("cursor remote execution", () => {
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
paperclipApiUrl: "http://198.51.100.10:3102",
},
},
onLog: async () => {},
@@ -124,20 +159,19 @@ describe("cursor remote execution", () => {
expect(result.sessionParams).toMatchObject({
sessionId: "cursor-session-1",
cwd: "/remote/workspace",
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
paperclipApiUrl: "http://198.51.100.10:3102",
remoteCwd: managedRemoteWorkspace,
},
});
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
remoteDir: "/remote/workspace/.paperclip-runtime/cursor/skills",
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/cursor/skills`,
followSymlinks: true,
}));
expect(runSshCommand).toHaveBeenCalledWith(
@@ -149,9 +183,25 @@ describe("cursor remote execution", () => {
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[2]).toContain("--workspace");
expect(call?.[2]).toContain("/remote/workspace");
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102");
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(call?.[2]).toContain(managedRemoteWorkspace);
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
{
workspaceId: "workspace-1",
cwd: managedRemoteWorkspace,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
]);
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
});
@@ -161,6 +211,7 @@ describe("cursor remote execution", () => {
const workspaceDir = path.join(rootDir, "workspace");
await mkdir(workspaceDir, { recursive: true });
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace";
await execute({
runId: "run-ssh-resume",
agent: {
@@ -174,13 +225,13 @@ describe("cursor remote execution", () => {
sessionId: "session-123",
sessionParams: {
sessionId: "session-123",
cwd: "/remote/workspace",
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
remoteCwd: managedRemoteWorkspace,
},
},
sessionDisplayId: "session-123",
@@ -0,0 +1,352 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { execute } from "./execute.js";
type PrepareCursorSandboxCommandInput = {
runId: string;
target: AdapterExecutionTarget | null | undefined;
command: string;
cwd: string;
env: Record<string, string>;
remoteSystemHomeDirHint?: string | null;
timeoutSec: number;
graceSec: number;
};
type PrepareCursorSandboxCommandResult = {
command: string;
env: Record<string, string>;
remoteSystemHomeDir: string | null;
addedPathEntry: string | null;
preferredCommandPath: string | null;
};
const {
setPrepareCursorSandboxCommand,
} = vi.hoisted(() => {
const setPrepareCursorSandboxCommand = vi.fn<
(input: PrepareCursorSandboxCommandInput) => Promise<PrepareCursorSandboxCommandResult>
>();
return { setPrepareCursorSandboxCommand };
});
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
startAdapterExecutionTargetPaperclipBridge: async () => null,
};
});
vi.mock("./remote-command.js", async () => {
const actual = await vi.importActual<typeof import("./remote-command.js")>("./remote-command.js");
return {
...actual,
prepareCursorSandboxCommand: async (input: Parameters<typeof actual.prepareCursorSandboxCommand>[0]) => {
return setPrepareCursorSandboxCommand(input);
},
};
});
function buildFakeAgentScript(captureDir: string): string {
return `#!/bin/sh
cat > ${JSON.stringify(path.join(captureDir, "prompt.txt"))}
printf '%s' "$0" > ${JSON.stringify(path.join(captureDir, "command.txt"))}
printf '%s' "$PATH" > ${JSON.stringify(path.join(captureDir, "path.txt"))}
printf '%s\\n' '{"type":"system","subtype":"init","session_id":"cursor-session-fresh-1","model":"auto"}'
printf '%s\\n' '{"type":"assistant","message":{"content":[{"type":"output_text","text":"hello"}]}}'
printf '%s\\n' '{"type":"result","subtype":"success","session_id":"cursor-session-fresh-1","result":"ok"}'
`;
}
function buildInstallSimulationCommand(commandPath: string, captureDir: string): string {
return [
`mkdir -p ${JSON.stringify(path.dirname(commandPath))}`,
`mkdir -p ${JSON.stringify(captureDir)}`,
`cat > ${JSON.stringify(commandPath)} <<'EOF'`,
buildFakeAgentScript(captureDir),
"EOF",
`chmod +x ${JSON.stringify(commandPath)}`,
].join("\n");
}
function createFreshLeaseSandboxRunner(options: {
homeDir: string;
installCommandPath: string;
captureDir: string;
}) {
let counter = 0;
const installCommands: string[] = [];
const systemPath = [
"/usr/local/bin",
"/opt/homebrew/bin",
"/usr/local/sbin",
"/usr/bin",
"/bin",
"/usr/sbin",
"/sbin",
].join(path.delimiter);
return {
installCommands,
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}) => {
counter += 1;
const args = [...(input.args ?? [])];
if (args[1] === SANDBOX_INSTALL_COMMAND) {
installCommands.push(args[1]);
args[1] = buildInstallSimulationCommand(options.installCommandPath, options.captureDir);
}
const inheritedPath = input.env?.PATH ?? systemPath;
const pathWithLocalBin = `${path.join(options.homeDir, ".local", "bin")}${path.delimiter}${inheritedPath}`;
const env = {
...(input.env ?? {}),
HOME: input.env?.HOME ?? options.homeDir,
PATH: pathWithLocalBin,
};
return await runChildProcess(`cursor-fresh-lease-${counter}`, input.command, args, {
cwd: input.cwd ?? process.cwd(),
env,
stdin: input.stdin,
timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)),
graceSec: 5,
onLog: input.onLog ?? (async () => {}),
onSpawn: input.onSpawn
? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt })
: undefined,
});
},
};
}
describe("cursor execute", () => {
it("installs the default agent command on a fresh sandbox lease before execution", async () => {
setPrepareCursorSandboxCommand.mockReset();
setPrepareCursorSandboxCommand.mockImplementation(async (input) => {
const actual = await vi.importActual<typeof import("./remote-command.js")>("./remote-command.js");
return actual.prepareCursorSandboxCommand(input);
});
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-fresh-lease-"));
const homeDir = path.join(root, "home");
const workspace = path.join(root, "workspace");
const remoteWorkspace = path.join(root, "remote-workspace");
const captureDir = path.join(root, "capture");
const agentPath = path.join(homeDir, ".local", "bin", "agent");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(remoteWorkspace, { recursive: true });
const runner = createFreshLeaseSandboxRunner({
homeDir,
installCommandPath: agentPath,
captureDir,
});
const previousHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const result = await execute({
runId: "run-fresh-lease-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Cursor Coder",
adapterType: "cursor",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
executionTarget: {
kind: "remote",
transport: "sandbox",
remoteCwd: remoteWorkspace,
runner,
timeoutMs: 30_000,
},
config: {
command: "agent",
cwd: workspace,
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
expect(runner.installCommands).toEqual([SANDBOX_INSTALL_COMMAND]);
const command = await fs.readFile(path.join(captureDir, "command.txt"), "utf8");
const runtimePath = await fs.readFile(path.join(captureDir, "path.txt"), "utf8");
const prompt = await fs.readFile(path.join(captureDir, "prompt.txt"), "utf8");
expect(command).toBe(agentPath);
expect(runtimePath.split(path.delimiter)).toContain(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 });
}
});
});
@@ -5,17 +5,19 @@ import { fileURLToPath } from "node:url";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd,
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
readAdapterExecutionTargetHomeDir,
resolveAdapterExecutionTargetTimeoutSec,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
@@ -26,13 +28,14 @@ import {
asNumber,
asStringArray,
parseObject,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
refreshPaperclipWorkspaceEnvForExecution,
readPaperclipRuntimeSkillEntries,
readPaperclipIssueWorkModeFromContext,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
@@ -41,7 +44,7 @@ import {
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
joinPromptSections,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
import { prepareCursorSandboxCommand } from "./remote-command.js";
import { normalizeCursorStreamLine } from "../shared/stream.js";
@@ -222,6 +225,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
@@ -260,9 +264,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) {
env.PAPERCLIP_TASK_ID = wakeTaskId;
}
if (issueWorkMode) {
env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
}
if (wakeReason) {
env.PAPERCLIP_WAKE_REASON = wakeReason;
}
@@ -281,33 +289,43 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (wakePayloadJson) {
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
}
applyPaperclipWorkspaceEnv(env, {
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
}
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) {
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
}
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const timeoutSec = asNumber(config.timeoutSec, 0);
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
executionTarget,
asNumber(config.timeoutSec, 0),
);
const graceSec = asNumber(config.graceSec, 20);
// Probe the sandbox before the managed-home override so we discover
// cursor-agent from the real system HOME (e.g. ~/.local/bin/cursor-agent).
// The managed HOME set later is for runtime isolation, not for finding the CLI.
const sandboxCommand = await prepareCursorSandboxCommand({
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId,
target: executionTarget,
installCommand: ctx.runtimeCommandSpec?.installCommand,
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
cwd,
env,
timeoutSec,
graceSec,
onLog,
});
// Probe the sandbox before the managed-home override so we discover the
// installer-managed agent symlinks from the real system HOME (for example
// ~/.local/bin/agent). The managed HOME set later is for runtime isolation,
// not for finding the CLI.
const initialSandboxCommand = await prepareCursorSandboxCommand({
runId,
target: executionTarget,
command,
@@ -316,22 +334,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
timeoutSec,
graceSec,
});
command = sandboxCommand.command;
env = sandboxCommand.env;
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveCursorBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const sandboxSystemHomeDir = initialSandboxCommand.remoteSystemHomeDir;
command = initialSandboxCommand.command;
env = initialSandboxCommand.env;
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
@@ -339,7 +344,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return asStringArray(config.args);
})();
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
let localSkillsDir: string | null = null;
let remoteRuntimeRootDir: string | null = null;
@@ -353,9 +357,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Syncing workspace and Cursor runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
adapterKey: "cursor",
timeoutSec,
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [{
key: "skills",
localDir: localSkillsDir,
@@ -363,6 +371,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}],
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
@@ -394,12 +416,47 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
throw error;
}
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
const finalSandboxCommand = executionTarget?.kind === "remote" && executionTarget.transport === "sandbox"
? await prepareCursorSandboxCommand({
runId,
target: executionTarget,
command,
cwd,
env,
remoteSystemHomeDirHint: sandboxSystemHomeDir,
timeoutSec,
graceSec,
})
: null;
if (finalSandboxCommand) {
command = finalSandboxCommand.command;
env = finalSandboxCommand.env;
}
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveCursorBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
installCommand: SANDBOX_INSTALL_COMMAND,
timeoutSec,
});
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: runtimeExecutionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "cursor",
timeoutSec,
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
@@ -420,7 +477,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const sessionId = canResumeSession ? runtimeSessionId : null;
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
await onLog(
@@ -460,11 +517,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
notes.push("Auto-added --yolo to bypass interactive prompts.");
}
notes.push("Prompt is piped to Cursor via stdin.");
if (sandboxCommand.addedPathEntry) {
const sandboxCommand = finalSandboxCommand ?? initialSandboxCommand;
if (sandboxCommand?.addedPathEntry) {
notes.push(`Remote sandbox runs prepend ${sandboxCommand.addedPathEntry} to PATH.`);
}
if (sandboxCommand.preferredCommandPath) {
notes.push(`Remote sandbox runs prefer ${sandboxCommand.preferredCommandPath} when using the default Cursor entrypoint.`);
if (sandboxCommand?.preferredCommandPath) {
notes.push(
`Remote sandbox runs prefer ${sandboxCommand.preferredCommandPath} when using the default Cursor entrypoint.`,
);
}
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
@@ -567,7 +627,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
};
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
cwd,
env,
timeoutSec,
@@ -625,7 +685,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
...(executionTargetIsRemote
? {
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
}
: {}),
} as Record<string, unknown>)
@@ -0,0 +1,137 @@
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("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 () => {
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(":").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"));
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
@@ -6,6 +6,14 @@ import {
import { ensurePathInEnv } from "@paperclipai/adapter-utils/server-utils";
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 {
return command.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
@@ -22,6 +30,32 @@ function prependPosixPathEntry(pathValue: string, entry: string): string {
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[] {
const basename = commandBasename(command);
if (!DEFAULT_CURSOR_COMMAND_BASENAMES.has(basename)) return [];
return basename === "cursor-agent"
? ["cursor-agent", "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 = {
remoteSystemHomeDir: string | null;
preferredCommandPath: string | null;
@@ -40,20 +74,60 @@ async function readSandboxCursorRuntimeInfo(input: {
command: string;
cwd: string;
env: Record<string, string>;
remoteSystemHomeDirHint?: string | null;
timeoutSec: number;
graceSec: number;
}): Promise<SandboxCursorRuntimeInfo> {
const shouldCheckPreferredCommand = isDefaultCursorCommand(input.command) && !hasPathSeparator(input.command);
const preferredBasenames =
!hasPathSeparator(input.command)
? preferredSandboxCommandBasenames(input.command)
: [];
const hintedRemoteSystemHomeDir = input.remoteSystemHomeDirHint?.trim() || null;
const homeMarker = "__PAPERCLIP_CURSOR_HOME__:";
const preferredMarker = "__PAPERCLIP_CURSOR_AGENT__:";
try {
// 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(
input.runId,
input.target,
[
`printf ${JSON.stringify(`${homeMarker}%s\\n`)} "$HOME"`,
shouldCheckPreferredCommand
? `if [ -x "$HOME/.local/bin/cursor-agent" ]; then printf ${JSON.stringify(`${preferredMarker}%s\\n`)} "$HOME/.local/bin/cursor-agent"; fi`
hintedRemoteSystemHomeDir
? `printf ${JSON.stringify(`${homeMarker}%s\\n`)} ${JSON.stringify(hintedRemoteSystemHomeDir)}`
: `printf ${JSON.stringify(`${homeMarker}%s\\n`)} "$HOME"`,
preferredProbeBranches.length > 0
? preferredProbeBranches
.map((probeBranch, index) => {
const branchKeyword = index === 0 ? "if" : "elif";
return `${branchKeyword} ${probeBranch}; then :`;
})
.join("; ") + "; fi; :"
: "",
].filter(Boolean).join("; "),
{
@@ -100,6 +174,7 @@ export async function prepareCursorSandboxCommand(input: {
command: string;
cwd: string;
env: Record<string, string>;
remoteSystemHomeDirHint?: string | null;
timeoutSec: number;
graceSec: number;
}): Promise<PreparedCursorSandboxCommand> {
@@ -119,10 +194,12 @@ export async function prepareCursorSandboxCommand(input: {
command: input.command,
cwd: input.cwd,
env: input.env,
remoteSystemHomeDirHint: input.remoteSystemHomeDirHint,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
});
const remoteSystemHomeDir = runtimeInfo.remoteSystemHomeDir;
const remoteSystemHomeDir =
runtimeInfo.remoteSystemHomeDir ?? input.remoteSystemHomeDirHint?.trim() ?? null;
if (!remoteSystemHomeDir) {
return {
@@ -134,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 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 addedPathEntry = nextPath === currentPath ? null : sandboxPathEntries[0];
if (!runtimeInfo.preferredCommandPath) {
return {
command: input.command,
env,
remoteSystemHomeDir,
addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir,
addedPathEntry,
preferredCommandPath: null,
};
}
@@ -154,7 +232,7 @@ export async function prepareCursorSandboxCommand(input: {
command: runtimeInfo.preferredCommandPath,
env,
remoteSystemHomeDir,
addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir,
addedPathEntry,
preferredCommandPath: runtimeInfo.preferredCommandPath,
};
}
@@ -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 });
}
});
});
@@ -12,6 +12,7 @@ import {
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
@@ -19,7 +20,7 @@ import {
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
import { parseCursorJsonl } from "./parse.js";
import { isDefaultCursorCommand, prepareCursorSandboxCommand } from "./remote-command.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
@@ -147,6 +148,27 @@ export async function testEnvironment(
});
command = sandboxCommand.command;
env = sandboxCommand.env;
const installCheck = await maybeRunSandboxInstallCommand({
runId,
target,
adapterKey: "cursor",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
env,
});
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 {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
@@ -208,6 +230,58 @@ export async function testEnvironment(
hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.",
});
} 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 extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
@@ -93,14 +93,14 @@ function printTextMessage(prefix: string, colorize: (text: string) => string, me
}
function printUsage(parsed: Record<string, unknown>) {
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata) ?? asRecord(parsed.stats);
const usageMetadata = asRecord(usage?.usageMetadata);
const source = usageMetadata ?? usage ?? {};
const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount)));
const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount)));
const cached = asNumber(
source.cached_input_tokens,
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, asNumber(source.cached))),
);
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
@@ -154,6 +154,21 @@ export function printGeminiStreamEvent(raw: string, _debug: boolean): void {
return;
}
// Gemini CLI v0.38+ stream-json schema:
// {"type":"message","role":"assistant"|"user","content":"...","delta":?true}
if (type === "message") {
const role = asString(parsed.role).trim().toLowerCase();
if (role === "assistant") {
printTextMessage("assistant", pc.green, parsed.content);
return;
}
if (role === "user") {
printTextMessage("user", pc.gray, parsed.content);
return;
}
return;
}
if (type === "thinking") {
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
if (text) console.log(pc.gray(`thinking: ${text}`));
@@ -190,11 +205,17 @@ export function printGeminiStreamEvent(raw: string, _debug: boolean): void {
if (type === "result") {
printUsage(parsed);
const subtype = asString(parsed.subtype, "result");
const isError = parsed.is_error === true;
const status = asString(parsed.status).toLowerCase();
const isError =
parsed.is_error === true || status === "error" || status === "failed";
const subtype = asString(parsed.subtype, status || "result");
if (subtype || isError) {
console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`));
}
if (isError) {
const text = errorText(parsed.error ?? parsed.message ?? parsed.result);
if (text) console.log(pc.red(`error: ${text}`));
}
return;
}
@@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "gemini_local";
export const label = "Gemini CLI (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @google/gemini-cli";
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
export const models = [
@@ -11,6 +11,7 @@ const {
restoreWorkspaceFromSshExecution,
runSshCommand,
syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({
exitCode: 0,
@@ -18,13 +19,12 @@ const {
timedOut: false,
stdout: [
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
JSON.stringify({ type: "assistant", message: { content: [{ type: "output_text", text: "hello" }] } }),
JSON.stringify({ type: "message", role: "assistant", content: "hello" }),
JSON.stringify({
type: "result",
subtype: "success",
status: "success",
session_id: "gemini-session-1",
usage: { promptTokenCount: 1, cachedContentTokenCount: 0, candidatesTokenCount: 1 },
result: "hello",
stats: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 },
}),
].join("\n"),
stderr: "",
@@ -33,7 +33,7 @@ const {
})),
ensureCommandResolvable: vi.fn(async () => undefined),
resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: gemini"),
prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
runSshCommand: vi.fn(async () => ({
stdout: "/home/agent",
@@ -41,6 +41,14 @@ const {
exitCode: 0,
})),
syncDirectoryToSsh: vi.fn(async () => undefined),
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
env: {
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
PAPERCLIP_API_KEY: "bridge-token",
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
},
stop: async () => {},
})),
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
@@ -68,6 +76,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
};
});
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,
};
});
import { execute } from "./execute.js";
describe("gemini remote execution", () => {
@@ -86,7 +104,10 @@ describe("gemini remote execution", () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-remote-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
await mkdir(workspaceDir, { recursive: true });
await mkdir(alternateWorkspaceDir, { recursive: true });
const result = await execute({
runId: "run-1",
@@ -111,6 +132,20 @@ describe("gemini remote execution", () => {
cwd: workspaceDir,
source: "project_primary",
},
paperclipWorkspaces: [
{
workspaceId: "workspace-1",
cwd: workspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
cwd: alternateWorkspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
],
},
executionTransport: {
remoteExecution: {
@@ -122,7 +157,6 @@ describe("gemini remote execution", () => {
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
paperclipApiUrl: "http://198.51.100.10:3102",
},
},
onLog: async () => {},
@@ -130,20 +164,19 @@ describe("gemini remote execution", () => {
expect(result.sessionParams).toMatchObject({
sessionId: "gemini-session-1",
cwd: "/remote/workspace",
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
paperclipApiUrl: "http://198.51.100.10:3102",
remoteCwd: managedRemoteWorkspace,
},
});
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
remoteDir: "/remote/workspace/.paperclip-runtime/gemini/skills",
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/gemini/skills`,
followSymlinks: true,
}));
expect(runSshCommand).toHaveBeenCalledWith(
@@ -154,8 +187,25 @@ describe("gemini remote execution", () => {
const call = runChildProcess.mock.calls[0] as unknown as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102");
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
{
workspaceId: "workspace-1",
cwd: managedRemoteWorkspace,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
]);
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.GEMINI_CLI_TRUST_WORKSPACE).toBe("true");
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
});
@@ -163,6 +213,7 @@ describe("gemini remote execution", () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-remote-resume-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace";
await mkdir(workspaceDir, { recursive: true });
await execute({
@@ -178,13 +229,13 @@ describe("gemini remote execution", () => {
sessionId: "session-123",
sessionParams: {
sessionId: "session-123",
cwd: "/remote/workspace",
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
remoteCwd: managedRemoteWorkspace,
},
},
sessionDisplayId: "session-123",
@@ -6,17 +6,19 @@ import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd,
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
readAdapterExecutionTargetHomeDir,
resolveAdapterExecutionTargetTimeoutSec,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
@@ -27,14 +29,15 @@ import {
asNumber,
asString,
asStringArray,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensurePaperclipSkillSymlink,
joinPromptSections,
ensurePathInEnv,
refreshPaperclipWorkspaceEnvForExecution,
readPaperclipRuntimeSkillEntries,
readPaperclipIssueWorkModeFromContext,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
parseObject,
@@ -44,7 +47,7 @@ import {
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
import {
describeGeminiFailure,
detectGeminiAuthRequired,
@@ -200,6 +203,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries);
@@ -236,27 +240,30 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
applyPaperclipWorkspaceEnv(env, {
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
if (executionTargetIsRemote && typeof env.GEMINI_CLI_TRUST_WORKSPACE !== "string") {
env.GEMINI_CLI_TRUST_WORKSPACE = "true";
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
@@ -267,8 +274,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
),
);
const billingType = resolveGeminiBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv(effectiveEnv)).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
executionTarget,
asNumber(config.timeoutSec, 0),
);
const graceSec = asNumber(config.graceSec, 20);
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId,
target: executionTarget,
installCommand: ctx.runtimeCommandSpec?.installCommand,
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
cwd,
env: runtimeEnv,
timeoutSec,
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
installCommand: SANDBOX_INSTALL_COMMAND,
timeoutSec,
});
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
@@ -276,14 +306,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
resolvedCommand,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
let remoteSkillsDir: string | null = null;
let localSkillsDir: string | null = null;
@@ -298,9 +325,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Syncing workspace and Gemini runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
adapterKey: "gemini",
timeoutSec,
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [{
key: "skills",
localDir: localSkillsDir,
@@ -308,6 +339,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}],
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
@@ -339,12 +384,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
throw error;
}
}
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
target: runtimeExecutionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "gemini",
timeoutSec,
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
@@ -365,7 +412,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const sessionId = canResumeSession ? runtimeSessionId : null;
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
await onLog(
@@ -400,6 +447,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const commandNotes = (() => {
const notes: string[] = ["Prompt is passed to Gemini via --prompt for non-interactive execution."];
notes.push("Added --approval-mode yolo for unattended execution.");
if (executionTargetIsRemote) {
notes.push("Set GEMINI_CLI_TRUST_WORKSPACE=true for remote headless execution.");
}
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(
@@ -486,7 +536,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
}
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
cwd,
env,
timeoutSec,
@@ -531,7 +581,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
}
const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const structuredFailure = attempt.parsed.resultEvent
? describeGeminiFailure(attempt.parsed.resultEvent)
: null;
const fallbackErrorMessage =
parsedError ||
structuredFailure ||
stderrLine ||
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
const failed = (attempt.proc.exitCode ?? 0) !== 0;
const clearSessionForTurnLimit = isGeminiTurnLimitResult(
attempt.parsed.resultEvent,
attempt.proc.exitCode,
);
// On retry, don't fall back to old session ID — the old session was stale
const canFallbackToRuntimeSession = !isRetry;
@@ -546,28 +610,29 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
...(executionTargetIsRemote
? {
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
}
: {}),
} as Record<string, unknown>)
: null;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const structuredFailure = attempt.parsed.resultEvent
? describeGeminiFailure(attempt.parsed.resultEvent)
: null;
const fallbackErrorMessage =
parsedError ||
structuredFailure ||
stderrLine ||
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
const resultJson: Record<string, unknown> = {
...(attempt.parsed.resultEvent ?? {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
}),
...(failed && clearSessionForTurnLimit ? { stopReason: "max_turns_exhausted" } : {}),
};
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage,
errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null,
errorMessage: failed ? fallbackErrorMessage : null,
errorCode: failed && authMeta.requiresAuth
? "gemini_auth_required"
: failed && clearSessionForTurnLimit
? "max_turns_exhausted"
: null,
usage: attempt.parsed.usage,
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
@@ -577,10 +642,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
model,
billingType,
costUsd: attempt.parsed.costUsd,
resultJson: attempt.parsed.resultEvent ?? {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
resultJson,
summary: attempt.parsed.summary,
question: attempt.parsed.question,
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { parseGeminiJsonl } from "./parse.js";
describe("parseGeminiJsonl", () => {
it("collects assistant text from message events with string content", () => {
const stdout = [
'{"type":"init","session_id":"session-1"}',
'{"type":"message","role":"user","content":"Respond with hello."}',
'{"type":"message","role":"assistant","content":"hello","delta":true}',
'{"type":"result","status":"success"}',
].join("\n");
const parsed = parseGeminiJsonl(stdout);
expect(parsed.sessionId).toBe("session-1");
expect(parsed.summary).toBe("hello");
expect(parsed.errorMessage).toBeNull();
});
it("collects assistant text from message events with structured object content", () => {
const stdout = [
'{"type":"init","session_id":"session-2"}',
'{"type":"message","role":"assistant","content":{"content":[{"type":"text","text":"first part"},{"type":"text","text":"second part"}]}}',
'{"type":"result","status":"success"}',
].join("\n");
const parsed = parseGeminiJsonl(stdout);
expect(parsed.sessionId).toBe("session-2");
expect(parsed.summary).toBe("first part\n\nsecond part");
expect(parsed.errorMessage).toBeNull();
});
it("ignores non-assistant message events", () => {
const stdout = [
'{"type":"message","role":"user","content":"hidden user input"}',
'{"type":"message","role":"system","content":"hidden system note"}',
'{"type":"message","role":"assistant","content":"visible response"}',
'{"type":"result","status":"success"}',
].join("\n");
const parsed = parseGeminiJsonl(stdout);
expect(parsed.summary).toBe("visible response");
});
it("captures assistant text from gemini CLI v0.38 stream-json schema", () => {
const stdout = [
JSON.stringify({
type: "init",
timestamp: "2026-05-04T05:43:41.203Z",
session_id: "session-abc",
model: "auto-gemini-3",
}),
JSON.stringify({
type: "message",
timestamp: "2026-05-04T05:43:41.205Z",
role: "user",
content: "Respond with hello.",
}),
JSON.stringify({
type: "message",
timestamp: "2026-05-04T05:43:45.198Z",
role: "assistant",
content: "hello.",
delta: true,
}),
JSON.stringify({
type: "result",
timestamp: "2026-05-04T05:43:45.819Z",
status: "success",
stats: {
total_tokens: 9468,
input_tokens: 9095,
output_tokens: 29,
cached: 8132,
duration_ms: 4616,
},
}),
].join("\n");
const result = parseGeminiJsonl(stdout);
expect(result.summary).toBe("hello.");
expect(result.sessionId).toBe("session-abc");
expect(result.errorMessage).toBeNull();
expect(result.usage.inputTokens).toBe(9095);
expect(result.usage.outputTokens).toBe(29);
expect(result.usage.cachedInputTokens).toBe(8132);
});
it("ignores user messages and only collects assistant content", () => {
const stdout = [
JSON.stringify({ type: "message", role: "user", content: "ignore me" }),
JSON.stringify({ type: "message", role: "assistant", content: "first" }),
JSON.stringify({ type: "message", role: "assistant", content: "second" }),
].join("\n");
const result = parseGeminiJsonl(stdout);
expect(result.summary).toBe("first\n\nsecond");
});
it("preserves the legacy claude-style `assistant` event handler", () => {
const stdout = [
JSON.stringify({
type: "system",
subtype: "init",
session_id: "legacy-session",
}),
JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "legacy hello" }] },
}),
JSON.stringify({ type: "result", subtype: "success", result: "legacy hello" }),
].join("\n");
const result = parseGeminiJsonl(stdout);
expect(result.summary).toBe("legacy hello");
expect(result.sessionId).toBe("legacy-session");
});
it("flags result events with status=error", () => {
const stdout = [
JSON.stringify({
type: "result",
status: "error",
error: "boom",
}),
].join("\n");
const result = parseGeminiJsonl(stdout);
expect(result.errorMessage).toBe("boom");
});
});
@@ -64,7 +64,10 @@ function accumulateUsage(
);
target.cachedInputTokens += asNumber(
source.cached_input_tokens,
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
asNumber(
source.cachedInputTokens,
asNumber(source.cachedContentTokenCount, asNumber(source.cached, 0)),
),
);
target.outputTokens += asNumber(
source.output_tokens,
@@ -121,16 +124,34 @@ export function parseGeminiJsonl(stdout: string) {
continue;
}
// Gemini CLI v0.38+ stream-json schema emits assistant turns as:
// {"type":"message","role":"assistant","content":"...","delta":true}
// These are discrete final messages (one per assistant turn), not
// cumulative streaming tokens, so collecting all of them produces the
// expected concatenated turn-by-turn summary rather than duplicated text.
if (type === "message") {
const role = asString(event.role, "").trim().toLowerCase();
if (role === "assistant") {
messages.push(...collectMessageText(event.content));
}
continue;
}
if (type === "result") {
resultEvent = event;
accumulateUsage(usage, event.usage ?? event.usageMetadata);
accumulateUsage(usage, event.usage ?? event.usageMetadata ?? event.stats);
const resultText =
asString(event.result, "").trim() ||
asString(event.text, "").trim() ||
asString(event.response, "").trim();
if (resultText && messages.length === 0) messages.push(resultText);
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
const status = asString(event.status, "").toLowerCase();
const isError =
event.is_error === true ||
asString(event.subtype, "").toLowerCase() === "error" ||
status === "error" ||
status === "failed";
if (isError) {
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
if (text) errorMessage = text;
@@ -273,9 +294,18 @@ export function isGeminiTurnLimitResult(
if (exitCode === 53) return true;
if (!parsed) return false;
const status = asString(parsed.status, "").trim().toLowerCase();
if (status === "turn_limit" || status === "max_turns") return true;
const structuredStopReasons = [
parsed.status,
parsed.stopReason,
parsed.stop_reason,
parsed.errorCode,
parsed.error_code,
].map((value) => asString(value, "").trim().toLowerCase());
const error = asString(parsed.error, "").trim();
return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error);
return structuredStopReasons.some((reason) =>
reason === "turn_limit" ||
reason === "max_turns" ||
reason === "max_turns_exhausted" ||
reason === "turn_limit_exhausted",
);
}
@@ -14,12 +14,13 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
@@ -93,7 +94,19 @@ export async function testEnvironment(
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (targetIsRemote && typeof env.GEMINI_CLI_TRUST_WORKSPACE !== "string") {
env.GEMINI_CLI_TRUST_WORKSPACE = "true";
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const installCheck = await maybeRunSandboxInstallCommand({
runId,
target,
adapterKey: "gemini",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
env,
});
if (installCheck) checks.push(installCheck);
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
@@ -157,7 +170,7 @@ export async function testEnvironment(
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
const sandbox = asBoolean(config.sandbox, false);
const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 10));
const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 60));
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { parseGeminiStdoutLine } from "./parse-stdout.js";
const ts = "2026-05-04T05:43:45.198Z";
describe("parseGeminiStdoutLine", () => {
it("renders v0.38 message+role:assistant as an assistant transcript entry", () => {
const line = JSON.stringify({
type: "message",
role: "assistant",
content: "hello.",
delta: true,
});
const entries = parseGeminiStdoutLine(line, ts);
expect(entries).toEqual([{ kind: "assistant", ts, text: "hello." }]);
});
it("renders v0.38 message+role:user as a user transcript entry", () => {
const line = JSON.stringify({
type: "message",
role: "user",
content: "Respond with hello.",
});
const entries = parseGeminiStdoutLine(line, ts);
expect(entries).toEqual([{ kind: "user", ts, text: "Respond with hello." }]);
});
it("preserves the legacy claude-style assistant event handler", () => {
const line = JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "legacy hello" }] },
});
const entries = parseGeminiStdoutLine(line, ts);
expect(entries).toEqual([{ kind: "assistant", ts, text: "legacy hello" }]);
});
it("reads token usage from v0.38 result.stats", () => {
const line = JSON.stringify({
type: "result",
status: "success",
stats: {
total_tokens: 9468,
input_tokens: 9095,
output_tokens: 29,
cached: 8132,
},
});
const [entry] = parseGeminiStdoutLine(line, ts);
expect(entry).toMatchObject({
kind: "result",
inputTokens: 9095,
outputTokens: 29,
cachedTokens: 8132,
isError: false,
subtype: "success",
});
});
it("flags v0.38 result.status=error as an error", () => {
const line = JSON.stringify({
type: "result",
status: "error",
error: "boom",
});
const [entry] = parseGeminiStdoutLine(line, ts);
expect(entry).toMatchObject({ kind: "result", isError: true, errors: ["boom"] });
});
it("ignores message events without an actionable role", () => {
const line = JSON.stringify({ type: "message", role: "system", content: "ignored" });
expect(parseGeminiStdoutLine(line, ts)).toEqual([]);
});
});
@@ -195,7 +195,7 @@ function readSessionId(parsed: Record<string, unknown>): string {
}
function readUsage(parsed: Record<string, unknown>) {
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata) ?? asRecord(parsed.stats);
const usageMetadata = asRecord(usage?.usageMetadata);
const source = usageMetadata ?? usage ?? {};
return {
@@ -203,7 +203,7 @@ function readUsage(parsed: Record<string, unknown>) {
outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))),
cachedTokens: asNumber(
source.cached_input_tokens,
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, asNumber(source.cached))),
),
};
}
@@ -237,6 +237,19 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry
return collectTextEntries(parsed.message, ts, "user");
}
// Gemini CLI v0.38+ stream-json schema:
// {"type":"message","role":"assistant"|"user","content":"...","delta":?true}
if (type === "message") {
const role = asString(parsed.role).trim().toLowerCase();
if (role === "assistant") {
return parseAssistantMessage(parsed.content, ts);
}
if (role === "user") {
return collectTextEntries(parsed.content, ts, "user");
}
return [];
}
if (type === "thinking") {
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
return text ? [{ kind: "thinking", ts, text }] : [];
@@ -248,7 +261,10 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry
if (type === "result") {
const usage = readUsage(parsed);
const errors = parsed.is_error === true
const status = asString(parsed.status).toLowerCase();
const isError =
parsed.is_error === true || status === "error" || status === "failed";
const errors = isError
? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean)
: [];
return [{
@@ -259,8 +275,8 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry
outputTokens: usage.outputTokens,
cachedTokens: usage.cachedTokens,
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
subtype: asString(parsed.subtype, "result"),
isError: parsed.is_error === true,
subtype: asString(parsed.subtype, status || "result"),
isError,
errors,
}];
}
@@ -8,6 +8,7 @@ import {
asString,
buildPaperclipEnv,
parseObject,
readPaperclipIssueWorkModeFromContext,
renderPaperclipWakePrompt,
stringifyPaperclipWakePayload,
} from "@paperclipai/adapter-utils/server-utils";
@@ -347,6 +348,8 @@ function buildPaperclipEnvForWake(ctx: AdapterExecutionContext, wakePayload: Wak
paperclipEnv.PAPERCLIP_API_URL = paperclipApiUrlOverride;
}
if (wakePayload.taskId) paperclipEnv.PAPERCLIP_TASK_ID = wakePayload.taskId;
const issueWorkMode = readPaperclipIssueWorkModeFromContext(ctx.context);
if (issueWorkMode) paperclipEnv.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
if (wakePayload.wakeReason) paperclipEnv.PAPERCLIP_WAKE_REASON = wakePayload.wakeReason;
if (wakePayload.wakeCommentId) paperclipEnv.PAPERCLIP_WAKE_COMMENT_ID = wakePayload.wakeCommentId;
if (wakePayload.approvalId) paperclipEnv.PAPERCLIP_APPROVAL_ID = wakePayload.approvalId;
@@ -3,8 +3,39 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "opencode_local";
export const label = "OpenCode (local)";
// Use OpenCode's official installer instead of `npm install -g opencode-ai`.
// The npm package reifies four large Linux x64 prebuilt-binary subpackages
// (linux-x64, linux-x64-musl, linux-x64-baseline, linux-x64-baseline-musl) in
// parallel even though only one matches the sandbox; on bandwidth-constrained
// sandboxes (e.g. Cloudflare) that exceeded the 240s install budget. The
// official installer fetches a single arch-specific binary and adds
// `$HOME/.opencode/bin` to PATH via `~/.bashrc`, which sandbox `sh -lc`
// invocations source.
//
// Security tradeoff: this is `curl | bash` without a SHA-256 verification of
// the install script. We accept this because:
// 1. The install runs inside an isolated, ephemeral sandbox — blast radius
// is bounded to that sandbox's secrets and disk.
// 2. The prior `npm install -g opencode-ai` is also unverified code
// execution from a third-party registry; this is not strictly worse.
// 3. OpenCode does not publish per-release SHA-256 checksums in a stable
// location, and pinning a version + hash here would require manual
// version bumps on every OpenCode release.
// The `set -e` (implied by Bash's default with `-fsSL` upstream of a piped
// shell) and `curl -fsSL` give us fail-fast behavior on HTTP errors. If
// OpenCode starts publishing a stable checksum/signature, switch to fetching
// a versioned tarball + verifying the digest before exec.
export const SANDBOX_INSTALL_COMMAND = "curl -fsSL https://opencode.ai/install | bash";
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
export function isValidOpenCodeModelId(value: unknown): value is string {
if (typeof value !== "string") return false;
const trimmed = value.trim();
const slashIndex = trimmed.indexOf("/");
return Boolean(trimmed) && slashIndex > 0 && slashIndex !== trimmed.length - 1;
}
export const models: Array<{ id: string; label: string }> = [
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
{ id: "openai/gpt-5.4", label: "openai/gpt-5.4" },
@@ -11,27 +11,41 @@ const {
restoreWorkspaceFromSshExecution,
runSshCommand,
syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "step_start", sessionID: "session_123" }),
JSON.stringify({ type: "text", sessionID: "session_123", part: { text: "hello" } }),
JSON.stringify({
type: "step_finish",
sessionID: "session_123",
part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } },
}),
].join("\n"),
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
})),
runChildProcess: vi.fn(async (_runId: string, _command: string, args: string[]) => {
if (args.includes("models")) {
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: "opencode/gpt-5-nano\nopenai/gpt-4.1\n",
stderr: "",
pid: 122,
startedAt: new Date().toISOString(),
};
}
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "step_start", sessionID: "session_123" }),
JSON.stringify({ type: "text", sessionID: "session_123", part: { text: "hello" } }),
JSON.stringify({
type: "step_finish",
sessionID: "session_123",
part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } },
}),
].join("\n"),
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
};
}),
ensureCommandResolvable: vi.fn(async () => undefined),
resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: opencode"),
prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
runSshCommand: vi.fn(async () => ({
stdout: "/home/agent",
@@ -39,6 +53,14 @@ const {
exitCode: 0,
})),
syncDirectoryToSsh: vi.fn(async () => undefined),
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
env: {
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
PAPERCLIP_API_KEY: "bridge-token",
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
},
stop: async () => {},
})),
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
@@ -66,6 +88,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
};
});
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,
};
});
import { execute } from "./execute.js";
describe("opencode remote execution", () => {
@@ -84,7 +116,10 @@ describe("opencode remote execution", () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
await mkdir(workspaceDir, { recursive: true });
await mkdir(alternateWorkspaceDir, { recursive: true });
const result = await execute({
runId: "run-1",
@@ -110,6 +145,20 @@ describe("opencode remote execution", () => {
cwd: workspaceDir,
source: "project_primary",
},
paperclipWorkspaces: [
{
workspaceId: "workspace-1",
cwd: workspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
cwd: alternateWorkspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
],
},
executionTransport: {
remoteExecution: {
@@ -121,7 +170,6 @@ describe("opencode remote execution", () => {
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
paperclipApiUrl: "http://198.51.100.10:3102",
},
},
onLog: async () => {},
@@ -129,23 +177,22 @@ describe("opencode remote execution", () => {
expect(result.sessionParams).toMatchObject({
sessionId: "session_123",
cwd: "/remote/workspace",
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
paperclipApiUrl: "http://198.51.100.10:3102",
remoteCwd: managedRemoteWorkspace,
},
});
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(2);
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
remoteDir: "/remote/workspace/.paperclip-runtime/opencode/xdgConfig",
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`,
}));
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
remoteDir: "/remote/workspace/.paperclip-runtime/opencode/skills",
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/opencode/skills`,
followSymlinks: true,
}));
expect(runSshCommand).toHaveBeenCalledWith(
@@ -153,19 +200,114 @@ describe("opencode remote execution", () => {
expect.stringContaining(".claude/skills"),
expect.anything(),
);
const call = runChildProcess.mock.calls[0] as unknown as
const runCall = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("run")) as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102");
expect(call?.[3].env.XDG_CONFIG_HOME).toBe("/remote/workspace/.paperclip-runtime/opencode/xdgConfig");
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
const modelProbeCall = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("models")) as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(modelProbeCall?.[2]).toEqual(["models"]);
// The model probe runs after the runtime workspace is prepared (so XDG
// points at the managed subdirectory) but the SSH session targets the
// original target remoteCwd — the per-run subdirectory is layered
// underneath via XDG/runtime config rather than by switching the cwd.
expect(modelProbeCall?.[3].env.XDG_CONFIG_HOME).toBe(
`${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`,
);
expect(modelProbeCall?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
const call = runCall as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
{
workspaceId: "workspace-1",
cwd: managedRemoteWorkspace,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
]);
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.XDG_CONFIG_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`);
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
});
it("fails before the remote run when the configured model is unavailable on the SSH target", async () => {
runChildProcess.mockImplementationOnce(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "openai/gpt-4.1\n",
stderr: "",
pid: 456,
startedAt: new Date().toISOString(),
}));
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-model-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
await mkdir(workspaceDir, { recursive: true });
await expect(() =>
execute({
runId: "run-ssh-model-missing",
agent: {
id: "agent-1",
companyId: "company-1",
name: "OpenCode Builder",
adapterType: "opencode_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: "opencode",
model: "opencode/gpt-5-nano",
},
context: {
paperclipWorkspace: {
cwd: workspaceDir,
source: "project_primary",
},
},
executionTransport: {
remoteExecution: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/remote/workspace",
remoteCwd: "/remote/workspace",
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
onLog: async () => {},
}),
).rejects.toThrow("Configured OpenCode model is unavailable on the remote execution target");
expect(runChildProcess).toHaveBeenCalledTimes(1);
expect((runChildProcess.mock.calls[0]?.[2] as string[] | undefined) ?? []).toEqual(["models"]);
expect(startAdapterExecutionTargetPaperclipBridge).not.toHaveBeenCalled();
});
it("resumes saved OpenCode sessions for remote SSH execution only when the identity matches", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-resume-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace";
await mkdir(workspaceDir, { recursive: true });
await execute({
@@ -181,13 +323,13 @@ describe("opencode remote execution", () => {
sessionId: "session-123",
sessionParams: {
sessionId: "session-123",
cwd: "/remote/workspace",
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
remoteCwd: managedRemoteWorkspace,
},
},
sessionDisplayId: "session-123",
@@ -218,7 +360,9 @@ describe("opencode remote execution", () => {
onLog: async () => {},
});
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
const call = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("run")) as
| [string, string, string[]]
| undefined;
expect(call?.[2]).toContain("--session");
expect(call?.[2]).toContain("session-123");
});
@@ -5,17 +5,19 @@ import { fileURLToPath } from "node:url";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd,
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
readAdapterExecutionTargetHomeDir,
resolveAdapterExecutionTargetTimeoutSec,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
@@ -26,25 +28,31 @@ import {
asNumber,
asStringArray,
parseObject,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv,
joinPromptSections,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
refreshPaperclipWorkspaceEnvForExecution,
renderTemplate,
renderPaperclipWakePrompt,
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
runChildProcess,
readPaperclipRuntimeSkillEntries,
readPaperclipIssueWorkModeFromContext,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import {
ensureOpenCodeModelConfiguredAndAvailable,
parseOpenCodeModelsOutput,
requireOpenCodeModelId,
} from "./models.js";
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -68,6 +76,69 @@ function resolveOpenCodeBiller(env: Record<string, string>, provider: string | n
return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown";
}
const REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC = 20;
const REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC = 120;
async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: {
runId: string;
executionTarget: NonNullable<AdapterExecutionContext["executionTarget"]>;
command: string;
model: string;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}) {
const model = requireOpenCodeModelId(input.model);
const defaultProbeTimeoutSec =
input.executionTarget.kind === "remote" && input.executionTarget.transport === "sandbox"
? REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC
: REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC;
const probeTimeoutSec = input.timeoutSec > 0
? Math.min(input.timeoutSec, defaultProbeTimeoutSec)
: defaultProbeTimeoutSec;
const probe = await runAdapterExecutionTargetProcess(
input.runId,
input.executionTarget,
input.command,
["models"],
{
cwd: input.cwd,
env: input.env,
timeoutSec: probeTimeoutSec,
graceSec: input.graceSec,
onLog: async () => {},
},
);
if (probe.timedOut) {
throw new Error(`\`opencode models\` timed out on the remote execution target after ${probeTimeoutSec}s.`);
}
if ((probe.exitCode ?? 1) !== 0) {
const detail = firstNonEmptyLine(probe.stderr) || firstNonEmptyLine(probe.stdout);
throw new Error(
detail
? `\`opencode models\` failed on the remote execution target: ${detail}`
: "`opencode models` failed on the remote execution target.",
);
}
const models = parseOpenCodeModelsOutput(probe.stdout);
if (models.length === 0) {
throw new Error(
"OpenCode returned no models on the remote execution target. Run `opencode models` there and verify provider auth.",
);
}
if (!models.some((entry) => entry.id === model)) {
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
throw new Error(
`Configured OpenCode model is unavailable on the remote execution target: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
);
}
}
function claudeSkillsHome(): string {
return path.join(os.homedir(), ".claude", "skills");
}
@@ -155,6 +226,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries);
@@ -195,28 +267,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
applyPaperclipWorkspaceEnv(env, {
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
// Prevent OpenCode from writing an opencode.json config file into the
// project working directory (which would pollute the git repo). Model
// selection is already handled via the --model CLI flag. Set after the
@@ -234,14 +306,32 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
executionTarget,
asNumber(config.timeoutSec, 0),
);
const graceSec = asNumber(config.graceSec, 20);
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId,
target: executionTarget,
installCommand: ctx.runtimeCommandSpec?.installCommand,
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
cwd,
env: runtimeEnv,
timeoutSec,
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
installCommand: SANDBOX_INSTALL_COMMAND,
timeoutSec,
});
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
if (!executionTargetIsRemote) {
await ensureOpenCodeModelConfiguredAndAvailable({
model,
@@ -251,29 +341,30 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
}
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
let localSkillsDir: string | null = null;
let remoteRuntimeRootDir: string | null = null;
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote) {
if (executionTarget?.kind === "remote") {
localSkillsDir = await buildOpenCodeSkillsDir(config);
await onLog(
"stdout",
`[paperclip] Syncing workspace and OpenCode runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
adapterKey: "opencode",
timeoutSec,
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [
{
key: "skills",
@@ -289,6 +380,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
],
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
refreshPaperclipWorkspaceEnvForExecution({
env: preparedRuntimeConfig.env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
@@ -315,13 +420,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
{ cwd, env: preparedRuntimeConfig.env, timeoutSec, graceSec, onLog },
);
}
await ensureRemoteOpenCodeModelConfiguredAndAvailable({
runId,
executionTarget,
command,
model,
cwd,
env: preparedRuntimeConfig.env,
timeoutSec,
graceSec,
});
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
target: runtimeExecutionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "opencode",
timeoutSec,
hostApiToken: preparedRuntimeConfig.env.PAPERCLIP_API_KEY,
onLog,
});
@@ -346,7 +463,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const sessionId = canResumeSession ? runtimeSessionId : null;
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
await onLog(
@@ -456,7 +573,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
}
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
cwd,
env: preparedRuntimeConfig.env,
stdin: prompt,
@@ -502,7 +619,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
...(executionTargetIsRemote
? {
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
}
: {}),
} as Record<string, unknown>)
@@ -67,6 +67,7 @@ export {
listOpenCodeModels,
discoverOpenCodeModels,
ensureOpenCodeModelConfiguredAndAvailable,
requireOpenCodeModelId,
resetOpenCodeModelsCacheForTests,
} from "./models.js";
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
ensureOpenCodeModelConfiguredAndAvailable,
listOpenCodeModels,
requireOpenCodeModelId,
resetOpenCodeModelsCacheForTests,
} from "./models.js";
@@ -22,6 +23,19 @@ describe("openCode models", () => {
).rejects.toThrow("OpenCode requires `adapterConfig.model`");
});
it("accepts a provider/model id without running discovery", () => {
expect(requireOpenCodeModelId("openai/gpt-5.2-codex")).toBe("openai/gpt-5.2-codex");
});
it("rejects malformed provider/model ids before discovery", () => {
expect(() => requireOpenCodeModelId("gpt-5.2-codex")).toThrow(
"OpenCode requires `adapterConfig.model`",
);
expect(() => requireOpenCodeModelId("openai/")).toThrow(
"OpenCode requires `adapterConfig.model`",
);
});
it("rejects when discovery cannot run for configured model", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
await expect(
@@ -6,6 +6,7 @@ import {
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { isValidOpenCodeModelId } from "../index.js";
const MODELS_CACHE_TTL_MS = 60_000;
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
@@ -23,6 +24,14 @@ const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID", "HOME"]);
export function requireOpenCodeModelId(input: unknown): string {
const model = asString(input, "").trim();
if (!isValidOpenCodeModelId(model)) {
throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
}
return model;
}
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const deduped: AdapterModel[] = [];
@@ -50,7 +59,7 @@ function firstNonEmptyLine(text: string): string {
);
}
function parseModelsOutput(stdout: string): AdapterModel[] {
export function parseOpenCodeModelsOutput(stdout: string): AdapterModel[] {
const parsed: AdapterModel[] = [];
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim();
@@ -144,7 +153,7 @@ export async function discoverOpenCodeModels(input: {
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
}
return sortModels(parseModelsOutput(result.stdout));
return sortModels(parseOpenCodeModelsOutput(result.stdout));
}
export async function discoverOpenCodeModelsCached(input: {
@@ -172,10 +181,7 @@ export async function ensureOpenCodeModelConfiguredAndAvailable(input: {
cwd?: unknown;
env?: unknown;
}): Promise<AdapterModel[]> {
const model = asString(input.model, "").trim();
if (!model) {
throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
}
const model = requireOpenCodeModelId(input.model);
const models = await discoverOpenCodeModelsCached({
command: input.command,
@@ -34,6 +34,7 @@ async function readJsonObject(filepath: string): Promise<Record<string, unknown>
export async function prepareOpenCodeRuntimeConfig(input: {
env: Record<string, string>;
config: Record<string, unknown>;
targetIsRemote?: boolean;
}): Promise<PreparedOpenCodeRuntimeConfig> {
const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true);
if (!skipPermissions) {
@@ -44,6 +45,19 @@ export async function prepareOpenCodeRuntimeConfig(input: {
};
}
// For remote execution targets the host XDG_CONFIG_HOME path is meaningless
// (and actively harmful — it leaks a macOS-only path into the remote Linux
// env). Callers that need to ship a runtime opencode config to the remote
// box do that via prepareAdapterExecutionTargetRuntime in execute.ts; this
// host-fs helper is local-only.
if (input.targetIsRemote) {
return {
env: input.env,
notes: [],
cleanup: async () => {},
};
}
const sourceConfigDir = path.join(resolveXdgConfigHome(input.env), "opencode");
const runtimeConfigHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-config-"));
const runtimeConfigDir = path.join(runtimeConfigHome, "opencode");
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
const {
ensureAdapterExecutionTargetDirectory,
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
prepareAdapterExecutionTargetRuntime,
} = vi.hoisted(() => {
const restoreWorkspace = vi.fn(async () => {});
return {
ensureAdapterExecutionTargetDirectory: vi.fn(async () => {}),
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}),
maybeRunSandboxInstallCommand: vi.fn(async () => null),
runAdapterExecutionTargetProcess: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "step_start", sessionID: "session-1" }),
JSON.stringify({ type: "text", sessionID: "session-1", part: { text: "hello" } }),
JSON.stringify({
type: "step_finish",
sessionID: "session-1",
part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } },
}),
].join("\n"),
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
})),
describeAdapterExecutionTarget: vi.fn(() => "QA Cloudflare"),
resolveAdapterExecutionTargetCwd: vi.fn((target, configuredCwd, fallbackCwd) => {
if (typeof configuredCwd === "string" && configuredCwd.trim().length > 0) return configuredCwd;
if (target && typeof target === "object" && "remoteCwd" in target && typeof target.remoteCwd === "string") {
return target.remoteCwd;
}
return fallbackCwd;
}),
prepareAdapterExecutionTargetRuntime: vi.fn(async () => ({
target: null,
workspaceRemoteDir: "/remote/workspace/.paperclip-runtime/runs/test/workspace",
runtimeRootDir: "/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/opencode",
assetDirs: {
xdgConfig: "/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/opencode/xdgConfig",
},
restoreWorkspace,
})),
};
});
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,
ensureAdapterExecutionTargetDirectory,
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
prepareAdapterExecutionTargetRuntime,
};
});
import { testEnvironment } from "./test.js";
describe("opencode remote environment diagnostics", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("stages remote runtime config assets for sandbox hello probes", async () => {
const remoteTarget: AdapterExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "cloudflare",
remoteCwd: "/remote/workspace",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
};
const result = await testEnvironment({
companyId: "company-1",
adapterType: "opencode_local",
config: {
command: "opencode",
model: "anthropic/claude-sonnet-4-5",
},
executionTarget: remoteTarget,
environmentName: "QA Cloudflare",
});
expect(result.status).toBe("pass");
expect(prepareAdapterExecutionTargetRuntime).toHaveBeenCalledTimes(1);
const runtimeCalls = prepareAdapterExecutionTargetRuntime.mock.calls as unknown as Array<
[{ adapterKey: string; assets?: Array<{ key: string; localDir: string }> }]
>;
const runtimeInput = runtimeCalls[0]?.[0];
expect(runtimeInput?.adapterKey).toBe("opencode");
expect(runtimeInput?.assets).toEqual([
expect.objectContaining({
key: "xdgConfig",
}),
]);
const probeCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as
| [string, AdapterExecutionTarget, string, string[], { cwd: string; env: Record<string, string> }]
| undefined;
expect(probeCall?.[4].cwd).toBe("/remote/workspace/.paperclip-runtime/runs/test/workspace");
expect(probeCall?.[4].env.XDG_CONFIG_HOME).toBe(
"/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/opencode/xdgConfig",
);
});
});
@@ -1,8 +1,12 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import {
asBoolean,
asString,
@@ -12,13 +16,17 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
prepareAdapterExecutionTargetRuntime,
overrideAdapterExecutionTargetRemoteCwd,
} from "@paperclipai/adapter-utils/execution-target";
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
@@ -117,6 +125,8 @@ export async function testEnvironment(
// Prevent OpenCode from writing an opencode.json into the working directory.
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
const localRuntimeConfigHome =
preparedRuntimeConfig.notes.length > 0 ? preparedRuntimeConfig.env.XDG_CONFIG_HOME : "";
if (asBoolean(config.dangerouslySkipPermissions, true)) {
checks.push({
code: "opencode_headless_permissions_enabled",
@@ -124,7 +134,43 @@ export async function testEnvironment(
message: "Headless OpenCode external-directory permissions are auto-approved for unattended runs.",
});
}
let restoreWorkspace: (() => Promise<void>) | null = null;
// Declared outside `try` so a failure inside `prepareAdapterExecutionTargetRuntime`
// still has the path available for cleanup in `finally` — otherwise the
// `fs.mkdtemp` directory leaks on the early-throw path.
let preparedRuntimeWorkspaceLocalDir: string | null = null;
try {
let runtimeTarget: AdapterExecutionTarget | null = target ?? null;
let runtimeCwd = cwd;
if (targetIsRemote) {
preparedRuntimeWorkspaceLocalDir = await fs.mkdtemp(path.join(os.tmpdir(), `paperclip-opencode-envtest-${runId}-`));
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
runId,
target,
adapterKey: "opencode",
workspaceLocalDir: preparedRuntimeWorkspaceLocalDir,
workspaceRemoteDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: localRuntimeConfigHome
? [{
key: "xdgConfig",
localDir: localRuntimeConfigHome,
}]
: [],
});
restoreWorkspace = async () => {
await preparedExecutionTargetRuntime.restoreWorkspace().catch(() => {});
if (preparedRuntimeWorkspaceLocalDir) {
await fs.rm(preparedRuntimeWorkspaceLocalDir, { recursive: true, force: true }).catch(() => {});
}
};
runtimeCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? runtimeCwd;
runtimeTarget = overrideAdapterExecutionTargetRemoteCwd(target ?? null, runtimeCwd) ?? null;
if (localRuntimeConfigHome && preparedExecutionTargetRuntime.assetDirs.xdgConfig) {
preparedRuntimeConfig.env.XDG_CONFIG_HOME = preparedExecutionTargetRuntime.assetDirs.xdgConfig;
}
}
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env }));
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
@@ -136,8 +182,17 @@ export async function testEnvironment(
detail: command,
});
} else {
const installCheck = await maybeRunSandboxInstallCommand({
runId,
target,
adapterKey: "opencode",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
env,
});
if (installCheck) checks.push(installCheck);
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, runtimeTarget, runtimeCwd, runtimeEnv);
checks.push({
code: "opencode_command_resolvable",
level: "info",
@@ -282,11 +337,11 @@ export async function testEnvironment(
try {
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
runtimeTarget,
command,
args,
{
cwd,
cwd: runtimeCwd,
env: runtimeEnv,
timeoutSec: 60,
graceSec: 5,
@@ -358,6 +413,12 @@ export async function testEnvironment(
}
}
} finally {
await restoreWorkspace?.();
if (!restoreWorkspace && preparedRuntimeWorkspaceLocalDir) {
// Reached when `prepareAdapterExecutionTargetRuntime` threw before
// assigning `restoreWorkspace`: clean up the temp dir directly.
await fs.rm(preparedRuntimeWorkspaceLocalDir, { recursive: true, force: true }).catch(() => {});
}
await preparedRuntimeConfig.cleanup();
}
+2
View File
@@ -3,6 +3,8 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "pi_local";
export const label = "Pi (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @mariozechner/pi-coding-agent";
export const models: Array<{ id: string; label: string }> = [];
export const modelProfiles: AdapterModelProfileDefinition[] = [];
@@ -11,6 +11,7 @@ const {
restoreWorkspaceFromSshExecution,
runSshCommand,
syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({
exitCode: 0,
@@ -36,7 +37,7 @@ const {
})),
ensureCommandResolvable: vi.fn(async () => undefined),
resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: pi"),
prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
runSshCommand: vi.fn(async () => ({
stdout: "",
@@ -44,6 +45,14 @@ const {
exitCode: 0,
})),
syncDirectoryToSsh: vi.fn(async () => undefined),
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
env: {
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
PAPERCLIP_API_KEY: "bridge-token",
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
},
stop: async () => {},
})),
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
@@ -71,6 +80,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => {
};
});
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,
};
});
import { execute } from "./execute.js";
describe("pi remote execution", () => {
@@ -89,7 +108,10 @@ describe("pi remote execution", () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
await mkdir(workspaceDir, { recursive: true });
await mkdir(alternateWorkspaceDir, { recursive: true });
const result = await execute({
runId: "run-1",
@@ -115,6 +137,20 @@ describe("pi remote execution", () => {
cwd: workspaceDir,
source: "project_primary",
},
paperclipWorkspaces: [
{
workspaceId: "workspace-1",
cwd: workspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
cwd: alternateWorkspaceDir,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
],
},
executionTransport: {
remoteExecution: {
@@ -126,28 +162,26 @@ describe("pi remote execution", () => {
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
paperclipApiUrl: "http://198.51.100.10:3102",
},
},
onLog: async () => {},
});
expect(result.sessionParams).toMatchObject({
cwd: "/remote/workspace",
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
paperclipApiUrl: "http://198.51.100.10:3102",
remoteCwd: managedRemoteWorkspace,
},
});
expect(String(result.sessionId)).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/");
expect(String(result.sessionId)).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/pi/sessions/`);
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
remoteDir: "/remote/workspace/.paperclip-runtime/pi/skills",
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/pi/skills`,
followSymlinks: true,
}));
expect(runSshCommand).toHaveBeenCalledWith(
@@ -160,9 +194,25 @@ describe("pi remote execution", () => {
| undefined;
expect(call?.[2]).toContain("--session");
expect(call?.[2]).toContain("--skill");
expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/skills");
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102");
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(call?.[2]).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/pi/skills`);
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
{
workspaceId: "workspace-1",
cwd: managedRemoteWorkspace,
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "main",
},
{
workspaceId: "workspace-2",
repoUrl: "https://github.com/paperclipai/paperclip.git",
repoRef: "feature/other",
},
]);
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
});
@@ -170,8 +220,25 @@ describe("pi remote execution", () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-resume-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace";
await mkdir(workspaceDir, { recursive: true });
runSshCommand.mockImplementation(async (...args: unknown[]) => {
const command = String(args[1] ?? "");
if (command.includes("head -n 1") && command.includes("session-123.jsonl")) {
return {
stdout: `${JSON.stringify({ type: "session", cwd: managedRemoteWorkspace })}\n`,
stderr: "",
exitCode: 0,
};
}
return {
stdout: "",
stderr: "",
exitCode: 0,
};
});
await execute({
runId: "run-ssh-resume",
agent: {
@@ -181,6 +248,83 @@ describe("pi remote execution", () => {
adapterType: "pi_local",
adapterConfig: {},
},
runtime: {
sessionId: `${managedRemoteWorkspace}/.paperclip-runtime/pi/sessions/session-123.jsonl`,
sessionParams: {
sessionId: `${managedRemoteWorkspace}/.paperclip-runtime/pi/sessions/session-123.jsonl`,
cwd: managedRemoteWorkspace,
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: managedRemoteWorkspace,
},
},
sessionDisplayId: "session-123",
taskKey: null,
},
config: {
command: "pi",
model: "openai/gpt-5.4-mini",
},
context: {
paperclipWorkspace: {
cwd: workspaceDir,
source: "project_primary",
},
},
executionTransport: {
remoteExecution: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/remote/workspace",
remoteCwd: "/remote/workspace",
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
onLog: async () => {},
});
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
expect(call?.[2]).toContain("--session");
expect(call?.[2]).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/pi/sessions/session-123.jsonl`);
});
it("starts a fresh remote Pi session when the saved session header cwd points at a different workspace", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-stale-session-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
await mkdir(workspaceDir, { recursive: true });
runSshCommand.mockImplementation(async (...args: unknown[]) => {
const command = String(args[1] ?? "");
if (command.includes("head -n 1") && command.includes("session-123.jsonl")) {
return {
stdout: `${JSON.stringify({ type: "session", cwd: "/remote/old-workspace" })}\n`,
stderr: "",
exitCode: 0,
};
}
return {
stdout: "",
stderr: "",
exitCode: 0,
};
});
await execute({
runId: "run-ssh-stale-session",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Pi Builder",
adapterType: "pi_local",
adapterConfig: {},
},
runtime: {
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
sessionParams: {
@@ -222,8 +366,146 @@ describe("pi remote execution", () => {
onLog: async () => {},
});
const managedRemoteWorkspaceFresh = "/remote/workspace/.paperclip-runtime/runs/run-ssh-stale-session/workspace";
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
expect(call?.[2]).toContain("--session");
expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl");
const sessionIndex = call?.[2].indexOf("--session") ?? -1;
expect(sessionIndex).toBeGreaterThanOrEqual(0);
const usedSession = sessionIndex >= 0 ? call?.[2][sessionIndex + 1] : null;
expect(usedSession).toContain(`${managedRemoteWorkspaceFresh}/.paperclip-runtime/pi/sessions/`);
expect(usedSession).not.toBe("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl");
});
it("starts a fresh remote Pi session when the saved session header is empty or unreadable", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-empty-header-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
await mkdir(workspaceDir, { recursive: true });
runSshCommand.mockImplementation(async (...args: unknown[]) => {
const command = String(args[1] ?? "");
if (command.includes("head -n 1") && command.includes("session-123.jsonl")) {
return { stdout: "", stderr: "", exitCode: 0 };
}
return { stdout: "", stderr: "", exitCode: 0 };
});
await execute({
runId: "run-ssh-empty-header",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Pi Builder",
adapterType: "pi_local",
adapterConfig: {},
},
runtime: {
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
sessionParams: {
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
cwd: "/remote/workspace",
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
},
},
sessionDisplayId: "session-123",
taskKey: null,
},
config: { command: "pi", model: "openai/gpt-5.4-mini" },
context: {
paperclipWorkspace: { cwd: workspaceDir, source: "project_primary" },
},
executionTransport: {
remoteExecution: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/remote/workspace",
remoteCwd: "/remote/workspace",
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
onLog: async () => {},
});
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
const sessionIndex = call?.[2].indexOf("--session") ?? -1;
expect(sessionIndex).toBeGreaterThanOrEqual(0);
const usedSession = sessionIndex >= 0 ? call?.[2][sessionIndex + 1] : null;
expect(usedSession).not.toBe("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl");
});
it("starts a fresh remote Pi session when the remote head command fails", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-head-failure-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
await mkdir(workspaceDir, { recursive: true });
runSshCommand.mockImplementation(async (...args: unknown[]) => {
const command = String(args[1] ?? "");
if (command.includes("head -n 1") && command.includes("session-123.jsonl")) {
throw Object.assign(new Error("ssh: connect failed"), {
stdout: "",
stderr: "ssh: connect failed",
code: "ENOENT",
});
}
return { stdout: "", stderr: "", exitCode: 0 };
});
await execute({
runId: "run-ssh-head-failure",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Pi Builder",
adapterType: "pi_local",
adapterConfig: {},
},
runtime: {
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
sessionParams: {
sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl",
cwd: "/remote/workspace",
remoteExecution: {
transport: "ssh",
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteCwd: "/remote/workspace",
},
},
sessionDisplayId: "session-123",
taskKey: null,
},
config: { command: "pi", model: "openai/gpt-5.4-mini" },
context: {
paperclipWorkspace: { cwd: workspaceDir, source: "project_primary" },
},
executionTransport: {
remoteExecution: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/remote/workspace",
remoteCwd: "/remote/workspace",
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
onLog: async () => {},
});
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
const sessionIndex = call?.[2].indexOf("--session") ?? -1;
expect(sessionIndex).toBeGreaterThanOrEqual(0);
const usedSession = sessionIndex >= 0 ? call?.[2][sessionIndex + 1] : null;
expect(usedSession).not.toBe("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl");
});
});
+154 -24
View File
@@ -5,8 +5,8 @@ import { fileURLToPath } from "node:url";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd,
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
@@ -14,10 +14,13 @@ import {
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetFile,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetTimeoutSec,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
@@ -25,14 +28,15 @@ import {
asNumber,
asStringArray,
parseObject,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv,
joinPromptSections,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
refreshPaperclipWorkspaceEnvForExecution,
readPaperclipRuntimeSkillEntries,
readPaperclipIssueWorkModeFromContext,
resolvePaperclipDesiredSkillNames,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
@@ -41,8 +45,10 @@ import {
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
import { ensurePiModelConfiguredAndAvailable } from "./models.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -143,6 +149,68 @@ function buildRemoteSessionPath(runtimeRootDir: string, agentId: string, timesta
return path.posix.join(runtimeRootDir, "sessions", `${safeTimestamp}-${agentId}.jsonl`);
}
function normalizeExecutionCwd(candidate: string, remote: boolean): string {
return remote ? path.posix.normalize(candidate) : path.resolve(candidate);
}
function executionCwdsMatch(saved: string, current: string, remote: boolean): boolean {
return normalizeExecutionCwd(saved, remote) === normalizeExecutionCwd(current, remote);
}
function readSessionHeaderCwd(raw: string): string | null {
const headerLine = raw
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
if (!headerLine) return null;
try {
const parsed = JSON.parse(headerLine) as Record<string, unknown>;
if (parsed.type !== "session") return null;
const cwd = typeof parsed.cwd === "string" ? parsed.cwd.trim() : "";
return cwd.length > 0 ? cwd : null;
} catch {
return null;
}
}
async function readSavedSessionCwd(input: {
runId: string;
sessionPath: string;
executionTarget: ReturnType<typeof readAdapterExecutionTarget>;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<string | null> {
if (!input.sessionPath.trim()) return null;
if (!adapterExecutionTargetIsRemote(input.executionTarget)) {
try {
return readSessionHeaderCwd(await fs.readFile(input.sessionPath, "utf8"));
} catch {
return null;
}
}
try {
const sessionHeader = await runAdapterExecutionTargetShellCommand(
input.runId,
input.executionTarget,
`if [ -f ${shellQuote(input.sessionPath)} ]; then head -n 1 ${shellQuote(input.sessionPath)}; fi`,
{
cwd: input.cwd,
env: input.env,
timeoutSec: input.timeoutSec > 0 ? Math.min(input.timeoutSec, 15) : 15,
graceSec: input.graceSec,
},
);
if (sessionHeader.timedOut || (sessionHeader.exitCode ?? 0) !== 0) return null;
return readSessionHeaderCwd(sessionHeader.stdout);
} catch {
return null;
}
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const executionTarget = readAdapterExecutionTarget({
@@ -179,7 +247,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
if (!executionTargetIsRemote) {
@@ -223,29 +291,29 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
applyPaperclipWorkspaceEnv(env, {
workspaceCwd,
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
@@ -278,7 +346,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
executionTarget,
asNumber(config.timeoutSec, 0),
);
const graceSec = asNumber(config.graceSec, 20);
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId,
target: executionTarget,
installCommand: ctx.runtimeCommandSpec?.installCommand,
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
cwd,
env: runtimeEnv,
timeoutSec,
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
installCommand: SANDBOX_INSTALL_COMMAND,
timeoutSec,
});
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
@@ -295,8 +382,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
}
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
@@ -316,9 +401,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Syncing workspace and Pi runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
const preparedRemoteRuntime = await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
adapterKey: "pi",
timeoutSec,
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
assets: [
{
key: "skills",
@@ -328,6 +417,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
],
});
restoreRemoteWorkspace = () => preparedRemoteRuntime.restoreWorkspace();
effectiveExecutionCwd = preparedRemoteRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (adapterExecutionTargetUsesManagedHome(executionTarget) && preparedRemoteRuntime.runtimeRootDir) {
env.HOME = preparedRemoteRuntime.runtimeRootDir;
}
@@ -341,12 +444,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
throw error;
}
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
target: runtimeExecutionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "pi",
timeoutSec,
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
@@ -368,10 +473,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
const sessionTargetMatches = adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const sessionParamsCwdMatches =
runtimeSessionCwd.length === 0 ||
executionCwdsMatch(runtimeSessionCwd, effectiveExecutionCwd, executionTargetIsRemote);
const savedSessionCwd =
runtimeSessionId.length > 0
? await readSavedSessionCwd({
runId,
sessionPath: runtimeSessionId,
executionTarget: runtimeExecutionTarget ?? null,
cwd,
env,
timeoutSec,
graceSec,
})
: null;
const sessionHeaderCwdMatches =
runtimeSessionId.length === 0 ||
(savedSessionCwd !== null &&
executionCwdsMatch(savedSessionCwd, effectiveExecutionCwd, executionTargetIsRemote));
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
sessionTargetMatches &&
sessionParamsCwdMatches &&
sessionHeaderCwdMatches;
const sessionPath = canResumeSession
? runtimeSessionId
: executionTargetIsRemote && remoteRuntimeRootDir
@@ -379,17 +505,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: buildSessionPath(agent.id, new Date().toISOString());
if (runtimeSessionId && !canResumeSession) {
const staleSessionCwdNote =
savedSessionCwd !== null && !sessionHeaderCwdMatches
? ` Pi stored cwd "${savedSessionCwd}" in the session header, so Paperclip will start a fresh session for "${effectiveExecutionCwd}".`
: "";
await onLog(
"stdout",
executionTargetIsRemote
? `[paperclip] Pi session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`
: `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
? `[paperclip] Pi session "${runtimeSessionId}" does not match the current remote execution state and will not be resumed in "${effectiveExecutionCwd}".${staleSessionCwdNote} Starting a fresh remote session.\n`
: `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".${staleSessionCwdNote}\n`,
);
}
if (!canResumeSession) {
if (executionTargetIsRemote) {
await ensureAdapterExecutionTargetFile(runId, executionTarget, sessionPath, {
await ensureAdapterExecutionTargetFile(runId, runtimeExecutionTarget, sessionPath, {
cwd,
env,
timeoutSec: 15,
@@ -550,7 +680,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
};
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
cwd,
env: executionTargetIsRemote ? env : runtimeEnv,
timeoutSec,
@@ -599,7 +729,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
...(executionTargetIsRemote
? {
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
}
: {}),
}
@@ -13,6 +13,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
maybeRunSandboxInstallCommand,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
@@ -20,6 +21,7 @@ import {
} from "@paperclipai/adapter-utils/execution-target";
import { discoverPiModelsCached } from "./models.js";
import { parsePiJsonl } from "./parse.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@@ -134,6 +136,15 @@ export async function testEnvironment(
detail: command,
});
} else {
const installCheck = await maybeRunSandboxInstallCommand({
runId,
target,
adapterKey: "pi",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
env,
});
if (installCheck) checks.push(installCheck);
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
+1 -1
View File
@@ -45,7 +45,7 @@
},
"dependencies": {
"@paperclipai/shared": "workspace:*",
"drizzle-orm": "^0.38.4",
"drizzle-orm": "^0.45.2",
"embedded-postgres": "^18.1.0-beta.16",
"postgres": "^3.4.5"
},
+6 -30
View File
@@ -1,7 +1,11 @@
import { existsSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { formatDatabaseBackupResult, runDatabaseBackup } from "./backup-lib.js";
import {
expandHomePrefix,
resolveDefaultBackupDir,
resolvePaperclipConfigPathForInstance,
} from "@paperclipai/shared/home-paths";
type PartialConfig = {
database?: {
@@ -15,30 +19,6 @@ type PartialConfig = {
};
};
function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
function resolvePaperclipHomeDir(): string {
const envHome = process.env.PAPERCLIP_HOME?.trim();
if (envHome) return path.resolve(expandHomePrefix(envHome));
return path.resolve(os.homedir(), ".paperclip");
}
function resolvePaperclipInstanceId(): string {
const raw = process.env.PAPERCLIP_INSTANCE_ID?.trim() || "default";
if (!/^[a-zA-Z0-9_-]+$/.test(raw)) {
throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${raw}'.`);
}
return raw;
}
function resolveDefaultConfigPath(): string {
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "config.json");
}
function readConfig(configPath: string): PartialConfig | null {
if (!existsSync(configPath)) return null;
try {
@@ -72,10 +52,6 @@ function resolveConnectionString(config: PartialConfig | null): string {
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
}
function resolveDefaultBackupDir(): string {
return path.resolve(resolvePaperclipHomeDir(), "instances", resolvePaperclipInstanceId(), "data", "backups");
}
function resolveBackupDir(config: PartialConfig | null): string {
const raw = config?.database?.backup?.dir;
if (typeof raw === "string" && raw.trim().length > 0) {
@@ -89,7 +65,7 @@ function resolveRetentionDays(config: PartialConfig | null): number {
}
async function main() {
const configPath = resolveDefaultConfigPath();
const configPath = resolvePaperclipConfigPathForInstance();
const config = readConfig(configPath);
const connectionString = resolveConnectionString(config);
const backupDir = resolveBackupDir(config);
@@ -0,0 +1,7 @@
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_next_check_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_wake_requested_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_last_triggered_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_attempt_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_notes" text;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_scheduled_by" text;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issues_company_monitor_due_idx" ON "issues" USING btree ("company_id","monitor_next_check_at");
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS "plugin_managed_resources" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"plugin_id" uuid NOT NULL,
"plugin_key" text NOT NULL,
"resource_kind" text NOT NULL,
"resource_key" text NOT NULL,
"resource_id" uuid NOT NULL,
"defaults_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "plugin_managed_resources" ADD CONSTRAINT "plugin_managed_resources_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "plugin_managed_resources" ADD CONSTRAINT "plugin_managed_resources_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_company_idx" ON "plugin_managed_resources" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_plugin_idx" ON "plugin_managed_resources" USING btree ("plugin_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_resource_idx" ON "plugin_managed_resources" USING btree ("resource_kind","resource_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_managed_resources_company_plugin_resource_uq" ON "plugin_managed_resources" USING btree ("company_id","plugin_id","resource_kind","resource_key");
@@ -0,0 +1,140 @@
CREATE TABLE IF NOT EXISTS "routine_revisions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"routine_id" uuid NOT NULL,
"revision_number" integer NOT NULL,
"title" text NOT NULL,
"description" text,
"snapshot" jsonb NOT NULL,
"change_summary" text,
"restored_from_revision_id" uuid,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"created_by_run_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "latest_revision_id" uuid;--> statement-breakpoint
ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "latest_revision_number" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "routine_revisions" ADD CONSTRAINT "routine_revisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "routine_revisions" ADD CONSTRAINT "routine_revisions_routine_id_routines_id_fk" FOREIGN KEY ("routine_id") REFERENCES "public"."routines"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "routine_revisions" ADD CONSTRAINT "routine_revisions_restored_from_revision_id_routine_revisions_id_fk" FOREIGN KEY ("restored_from_revision_id") REFERENCES "public"."routine_revisions"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "routine_revisions" ADD CONSTRAINT "routine_revisions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "routine_revisions" ADD CONSTRAINT "routine_revisions_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "routine_revisions_routine_revision_uq" ON "routine_revisions" USING btree ("routine_id","revision_number");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "routine_revisions_company_routine_created_idx" ON "routine_revisions" USING btree ("company_id","routine_id","created_at");
--> statement-breakpoint
WITH inserted_revisions AS (
INSERT INTO "routine_revisions" (
"id",
"company_id",
"routine_id",
"revision_number",
"title",
"description",
"snapshot",
"change_summary",
"created_by_agent_id",
"created_by_user_id",
"created_at"
)
SELECT
gen_random_uuid(),
r."company_id",
r."id",
1,
r."title",
r."description",
jsonb_build_object(
'version', 1,
'routine', jsonb_build_object(
'id', r."id",
'companyId', r."company_id",
'projectId', r."project_id",
'goalId', r."goal_id",
'parentIssueId', r."parent_issue_id",
'title', r."title",
'description', r."description",
'assigneeAgentId', r."assignee_agent_id",
'priority', r."priority",
'status', r."status",
'concurrencyPolicy', r."concurrency_policy",
'catchUpPolicy', r."catch_up_policy",
'variables', coalesce(r."variables", '[]'::jsonb)
),
'triggers', coalesce(
(
SELECT jsonb_agg(
jsonb_build_object(
'id', rt."id",
'kind', rt."kind",
'label', rt."label",
'enabled', rt."enabled",
'cronExpression', rt."cron_expression",
'timezone', rt."timezone",
'publicId', rt."public_id",
'signingMode', rt."signing_mode",
'replayWindowSec', rt."replay_window_sec"
)
ORDER BY rt."created_at", rt."id"
)
FROM "routine_triggers" rt
WHERE rt."routine_id" = r."id"
AND rt."company_id" = r."company_id"
),
'[]'::jsonb
)
),
'Initial routine revision backfill',
r."created_by_agent_id",
r."created_by_user_id",
r."created_at"
FROM "routines" r
WHERE NOT EXISTS (
SELECT 1
FROM "routine_revisions" rr
WHERE rr."routine_id" = r."id"
AND rr."revision_number" = 1
)
RETURNING "id", "routine_id"
)
UPDATE "routines" r
SET
"latest_revision_id" = inserted_revisions."id",
"latest_revision_number" = 1
FROM inserted_revisions
WHERE r."id" = inserted_revisions."routine_id";
--> statement-breakpoint
UPDATE "routines" r
SET
"latest_revision_id" = rr."id",
"latest_revision_number" = rr."revision_number"
FROM "routine_revisions" rr
WHERE rr."routine_id" = r."id"
AND rr."revision_number" = 1
AND r."latest_revision_id" IS NULL;
@@ -0,0 +1,3 @@
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "author_type" text;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "presentation" jsonb;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "metadata" jsonb;
@@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS "documents_title_search_idx" ON "documents" USING gin ("title" gin_trgm_ops);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "documents_latest_body_search_idx" ON "documents" USING gin ("latest_body" gin_trgm_ops);
@@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;
@@ -0,0 +1 @@
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "work_mode" text DEFAULT 'standard' NOT NULL;

Some files were not shown because too many files have changed in this diff Show More