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:
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user