Compare commits

..

1 Commits

Author SHA1 Message Date
Dotta
02a7562198 Add v2026.428.0 release changelog
Generated via the release-changelog skill from the diff between v2026.427.0
and origin/master. Covers seven merged PRs (#4600, #4601, #4602, #4614,
#4615, #4616, #4617) and the two new additive migrations (0071, 0072).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-28 17:35:36 -05:00
79 changed files with 959 additions and 6617 deletions

View File

@@ -80,9 +80,6 @@ jobs:
- name: Run tests
run: pnpm test:run
- name: Verify release registry test coverage
run: pnpm run test:release-registry
- name: Build
run: pnpm build

View File

@@ -143,13 +143,6 @@ This keeps the default install path unchanged while allowing explicit installs w
npx paperclipai@canary onboard
```
The release script now verifies two things after a canary publish:
- the `canary` dist-tag resolves to the version that was just published
- every published internal `@paperclipai/*` dependency referenced by that manifest exists on npm
It also treats `latest -> canary` as a failure by default, because npm metadata can otherwise leave the default install path pointing at an unreleased canary dependency graph. Only pass `./scripts/release.sh canary --allow-canary-latest` when that `latest` behavior is explicitly intended.
### Stable
Stable publishes use the npm dist-tag `latest`.

View File

@@ -63,8 +63,6 @@ It:
- verifies the pushed commit
- computes the canary version for the current UTC date
- publishes under npm dist-tag `canary`
- verifies that `canary` resolves to the just-published version and that published internal dependencies exist on npm
- fails by default if npm leaves `latest` pointing at a canary; use `--allow-canary-latest` only when that state is intentional
- creates a git tag `canary/vYYYY.MDD.P-canary.N`
Users install canaries with:

View File

@@ -35,7 +35,6 @@
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
"test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts",

View File

@@ -1,128 +0,0 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { prepareCommandManagedRuntime } from "./command-managed-runtime.js";
import type { RunProcessResult } from "./server-utils.js";
const execFile = promisify(execFileCallback);
describe("command managed runtime", () => {
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("keeps the runtime overlay out of sandbox workspace sync by default", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(path.join(localWorkspaceDir, ".paperclip-runtime"), { recursive: true });
await mkdir(remoteWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
await writeFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "{\"keep\":true}\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();
const env = {
...process.env,
...input.env,
};
const command = input.command === "sh" ? "/bin/sh" : input.command;
const args = [...(input.args ?? [])];
if (input.stdin != null && input.command === "sh" && args[0] === "-lc" && typeof args[1] === "string") {
env.PAPERCLIP_TEST_STDIN = input.stdin;
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
}
try {
const result = await execFile(command, args, {
cwd: input.cwd,
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,
};
}
},
};
const prepared = await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
},
adapterKey: "claude",
workspaceLocalDir: localWorkspaceDir,
});
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
await expect(readFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
expect(calls.every((call) => call.stdin == null)).toBe(true);
await mkdir(path.join(remoteWorkspaceDir, ".paperclip-runtime"), { recursive: true });
await writeFile(path.join(remoteWorkspaceDir, "README.md"), "remote workspace\n", "utf8");
await writeFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "remote-state.json"), "{\"remote\":true}\n", "utf8");
await prepared.restoreWorkspace();
await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe("remote workspace\n");
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves
.toBe("{\"keep\":true}\n");
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "remote-state.json"), "utf8")).rejects
.toMatchObject({ code: "ENOENT" });
expect(calls.every((call) => call.stdin == null)).toBe(true);
});
});

View File

@@ -35,12 +35,6 @@ function shellQuote(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function mergeRuntimeExcludes(entries: string[] | undefined): string[] {
return [...new Set([".paperclip-runtime", ...(entries ?? [])])];
}
const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
if (Buffer.isBuffer(bytes)) return bytes;
if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
@@ -54,7 +48,7 @@ function requireSuccessfulResult(result: RunProcessResult, action: string): void
throw new Error(`${action} failed with exit code ${result.exitCode ?? "null"}${detail}`);
}
export function createCommandManagedRuntimeClient(input: {
function createCommandManagedRuntimeClient(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
timeoutMs: number;
@@ -77,39 +71,15 @@ export function createCommandManagedRuntimeClient(input: {
},
writeFile: async (remotePath, bytes) => {
const body = toBuffer(bytes).toString("base64");
const remoteDir = path.posix.dirname(remotePath);
const remoteTempPath = `${remotePath}.paperclip-upload.b64`;
await runShell(
`mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(remoteTempPath)} && : > ${shellQuote(remoteTempPath)}`,
);
for (let offset = 0; offset < body.length; offset += REMOTE_WRITE_BASE64_CHUNK_SIZE) {
const chunk = body.slice(offset, offset + REMOTE_WRITE_BASE64_CHUNK_SIZE);
await runShell(`printf '%s' ${shellQuote(chunk)} >> ${shellQuote(remoteTempPath)}`);
}
await runShell(
`base64 -d < ${shellQuote(remoteTempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(remoteTempPath)}`,
`mkdir -p ${shellQuote(path.posix.dirname(remotePath))} && base64 -d > ${shellQuote(remotePath)}`,
{ stdin: body },
);
},
readFile: async (remotePath) => {
const result = await runShell(`base64 < ${shellQuote(remotePath)}`);
return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64");
},
listFiles: async (remotePath) => {
const result = await runShell(
`if [ -d ${shellQuote(remotePath)} ]; then ` +
`for entry in ${shellQuote(remotePath)}/*; do ` +
`[ -f "$entry" ] || continue; ` +
`basename "$entry"; ` +
`done; ` +
`fi`,
);
return result.stdout
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.sort((left, right) => left.localeCompare(right));
},
remove: async (remotePath) => {
const result = await input.runner.execute({
command: "sh",
@@ -175,7 +145,7 @@ export async function prepareCommandManagedRuntime(input: {
adapterKey: input.adapterKey,
workspaceLocalDir: input.workspaceLocalDir,
workspaceRemoteDir,
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
workspaceExclude: input.workspaceExclude,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
});

View File

@@ -1,59 +1,14 @@
import { createServer } from "node:http";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetToRemoteSpec,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
type AdapterSandboxExecutionTarget,
} from "./execution-target.js";
import { runChildProcess } from "./server-utils.js";
describe("sandbox adapter execution targets", () => {
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);
}
});
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 runChildProcess(`sandbox-run-${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,
});
},
};
}
it("executes through the provider-neutral runner without a remote spec", async () => {
const runner = {
execute: vi.fn(async () => ({
@@ -103,7 +58,6 @@ describe("sandbox adapter execution targets", () => {
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd: "/workspace",
paperclipTransport: "bridge",
});
});
@@ -139,154 +93,4 @@ describe("sandbox adapter execution targets", () => {
timeoutMs: 7000,
}));
});
it("starts a localhost Paperclip bridge for sandbox targets in bridge mode", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
await mkdir(runtimeRootDir, { recursive: true });
const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = [];
const apiServer = createServer((req, res) => {
requests.push({
method: req.method ?? "GET",
url: req.url ?? "/",
auth: req.headers.authorization ?? null,
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
});
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 test API server to listen on a TCP port.");
}
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
paperclipTransport: "bridge",
runner: createLocalSandboxRunner(),
timeoutMs: 30_000,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-bridge",
target,
runtimeRootDir,
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${address.port}`,
});
try {
expect(bridge).not.toBeNull();
expect(bridge?.env.PAPERCLIP_API_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(bridge?.env.PAPERCLIP_API_KEY).not.toBe("real-run-jwt");
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
accept: "application/json",
},
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(requests).toEqual([{
method: "GET",
url: "/api/agents/me",
auth: "Bearer real-run-jwt",
runId: "run-bridge",
}]);
} 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);
const remoteCwd = path.join(rootDir, "workspace");
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
await mkdir(runtimeRootDir, { recursive: true });
const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = [];
const largeBody = "x".repeat(64);
const apiServer = createServer((req, res) => {
requests.push({
method: req.method ?? "GET",
url: req.url ?? "/",
auth: req.headers.authorization ?? null,
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
});
res.writeHead(200, {
"content-type": "application/json",
"content-length": String(Buffer.byteLength(largeBody, "utf8")),
});
res.end(largeBody);
});
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 test API server to listen on a TCP port.");
}
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
paperclipTransport: "bridge",
runner: createLocalSandboxRunner(),
timeoutMs: 30_000,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-bridge-limit",
target,
runtimeRootDir,
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${address.port}`,
maxBodyBytes: 32,
});
try {
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
accept: "application/json",
},
});
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: "Bridge response body exceeded the configured size limit of 32 bytes.",
});
expect(requests).toEqual([{
method: "GET",
url: "/api/agents/me",
auth: "Bearer real-run-jwt",
runId: "run-bridge-limit",
}]);
} finally {
await bridge?.stop();
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
}
});
});

View File

@@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import * as ssh from "./ssh.js";
import {
adapterExecutionTargetUsesManagedHome,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetShellCommand,
} from "./execution-target.js";
@@ -160,49 +159,3 @@ describe("runAdapterExecutionTargetShellCommand", () => {
})).toBe(false);
});
});
describe("resolveAdapterExecutionTargetCwd", () => {
const sshTarget = {
kind: "remote" as const,
transport: "ssh" as const,
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,
},
};
it("falls back to the remote cwd when no adapter cwd is configured", () => {
expect(resolveAdapterExecutionTargetCwd(sshTarget, "", "/Users/host/repo/server")).toBe(
"/srv/paperclip/workspace",
);
expect(resolveAdapterExecutionTargetCwd(sshTarget, " ", "/Users/host/repo/server")).toBe(
"/srv/paperclip/workspace",
);
expect(resolveAdapterExecutionTargetCwd(sshTarget, null, "/Users/host/repo/server")).toBe(
"/srv/paperclip/workspace",
);
});
it("preserves an explicit adapter cwd when one is configured", () => {
expect(
resolveAdapterExecutionTargetCwd(
sshTarget,
"/srv/paperclip/custom-agent-dir",
"/Users/host/repo/server",
),
).toBe("/srv/paperclip/custom-agent-dir");
});
it("keeps the local fallback cwd for local targets", () => {
expect(resolveAdapterExecutionTargetCwd(null, "", "/Users/host/repo/server")).toBe(
"/Users/host/repo/server",
);
});
});

View File

@@ -10,14 +10,6 @@ import {
remoteExecutionSessionMatches,
type RemoteManagedRuntimeAsset,
} from "./remote-managed-runtime.js";
import {
createCommandManagedSandboxCallbackBridgeQueueClient,
createSandboxCallbackBridgeAsset,
createSandboxCallbackBridgeToken,
DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES,
startSandboxCallbackBridgeServer,
startSandboxCallbackBridgeWorker,
} from "./sandbox-callback-bridge.js";
import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
import {
ensureCommandResolvable,
@@ -51,7 +43,6 @@ export interface AdapterSandboxExecutionTarget {
leaseId?: string | null;
remoteCwd: string;
paperclipApiUrl?: string | null;
paperclipTransport?: "direct" | "bridge";
timeoutMs?: number | null;
runner?: CommandManagedRuntimeRunner;
}
@@ -91,11 +82,6 @@ export interface AdapterExecutionTargetShellOptions {
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}
export interface AdapterExecutionTargetPaperclipBridgeHandle {
env: Record<string, string>;
stop(): Promise<void>;
}
function parseObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -110,31 +96,6 @@ function readStringMeta(parsed: Record<string, unknown>, key: string): string |
return readString(parsed[key]);
}
function resolveHostForUrl(rawHost: string): string {
const host = rawHost.trim();
if (!host || host === "0.0.0.0" || host === "::") return "localhost";
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`;
return host;
}
function resolveDefaultPaperclipApiUrl(): string {
const runtimeHost = resolveHostForUrl(
process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost",
);
// 3100 matches the default Paperclip dev server port when the runtime does not provide one.
const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100";
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 isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget {
const parsed = parseObject(value);
if (parsed.kind === "local") return true;
@@ -169,34 +130,14 @@ export function adapterExecutionTargetRemoteCwd(
return target?.kind === "remote" ? target.remoteCwd : localCwd;
}
export function resolveAdapterExecutionTargetCwd(
target: AdapterExecutionTarget | null | undefined,
configuredCwd: string | null | undefined,
localFallbackCwd: string,
): string {
if (typeof configuredCwd === "string" && configuredCwd.trim().length > 0) {
return configuredCwd;
}
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";
}
export function describeAdapterExecutionTarget(
target: AdapterExecutionTarget | null | undefined,
): string {
@@ -395,78 +336,18 @@ export async function ensureAdapterExecutionTargetFile(
);
}
/**
* Ensure a working directory exists (and is a directory) on the execution target.
*
* For local targets this delegates to the local `ensureAbsoluteDirectory` helper
* (Node fs). For remote (SSH/sandbox) targets it shells out and runs
* `mkdir -p` (when allowed) followed by a `[ -d ]` check so the result reflects
* the directory state inside the environment, not on the Paperclip host.
*
* Throws an Error with a human-readable message on failure.
*/
export async function ensureAdapterExecutionTargetDirectory(
runId: string,
target: AdapterExecutionTarget | null | undefined,
cwd: string,
options: AdapterExecutionTargetShellOptions & { createIfMissing?: boolean },
): Promise<void> {
const createIfMissing = options.createIfMissing ?? false;
if (!target || target.kind === "local") {
const { ensureAbsoluteDirectory } = await import("./server-utils.js");
await ensureAbsoluteDirectory(cwd, { createIfMissing });
return;
}
// Remote (SSH or sandbox): both expect POSIX absolute paths inside the env.
if (!cwd.startsWith("/")) {
throw new Error(`Working directory must be an absolute POSIX path on the remote target: "${cwd}"`);
}
const quoted = shellQuote(cwd);
const script = createIfMissing
? `mkdir -p ${quoted} && [ -d ${quoted} ]`
: `[ -d ${quoted} ]`;
const result = await runAdapterExecutionTargetShellCommand(runId, target, script, {
cwd: target.kind === "remote" ? target.remoteCwd : cwd,
env: options.env,
timeoutSec: options.timeoutSec ?? 15,
graceSec: options.graceSec ?? 5,
onLog: options.onLog,
});
if (result.timedOut) {
throw new Error(`Timed out checking working directory on remote target: "${cwd}"`);
}
if ((result.exitCode ?? 1) !== 0) {
const detail = (result.stderr || result.stdout || "").trim();
if (createIfMissing) {
throw new Error(
`Could not create working directory "${cwd}" on remote target${detail ? `: ${detail}` : "."}`,
);
}
throw new Error(
`Working directory does not exist on remote target: "${cwd}"${detail ? ` (${detail})` : ""}`,
);
}
}
export function adapterExecutionTargetSessionIdentity(
target: AdapterExecutionTarget | null | undefined,
): 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 } : {}),
...(target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}),
};
}
@@ -486,7 +367,6 @@ export function adapterExecutionTargetSessionMatches(
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)
);
}
@@ -519,7 +399,6 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") {
const remoteCwd = readStringMeta(parsed, "remoteCwd");
const paperclipTransport = readStringMeta(parsed, "paperclipTransport");
if (!remoteCwd) return null;
return {
kind: "remote",
@@ -529,10 +408,6 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
leaseId: readStringMeta(parsed, "leaseId"),
remoteCwd,
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl"),
paperclipTransport:
paperclipTransport === "direct" || paperclipTransport === "bridge"
? paperclipTransport
: undefined,
timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null,
};
}
@@ -639,172 +514,3 @@ export function runtimeAssetDir(
): string {
return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key);
}
function buildBridgeResponseHeaders(response: Response): Record<string, string> {
const out: Record<string, string> = {};
for (const key of ["content-type", "etag", "last-modified"]) {
const value = response.headers.get(key);
if (value && value.trim().length > 0) out[key] = value.trim();
}
return out;
}
function buildBridgeForwardUrl(baseUrl: string, request: { path: string; query: string }): URL {
const url = new URL(request.path, baseUrl);
const query = request.query.trim();
url.search = query.startsWith("?") ? query.slice(1) : query;
return url;
}
function bridgeResponseBodyLimitError(maxBodyBytes: number): Error {
return new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
}
async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: number): Promise<string> {
const rawContentLength = response.headers.get("content-length");
if (rawContentLength) {
const contentLength = Number.parseInt(rawContentLength, 10);
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
throw bridgeResponseBodyLimitError(maxBodyBytes);
}
}
if (!response.body) {
return "";
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBodyBytes) {
await reader.cancel().catch(() => undefined);
throw bridgeResponseBodyLimitError(maxBodyBytes);
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, totalBytes).toString("utf8");
}
export async function startAdapterExecutionTargetPaperclipBridge(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
runtimeRootDir: string | null | undefined;
adapterKey: string;
hostApiToken: string | null | undefined;
hostApiUrl?: string | null;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
maxBodyBytes?: number | null;
}): Promise<AdapterExecutionTargetPaperclipBridgeHandle | null> {
if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) {
return null;
}
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
return null;
}
const target = input.target;
const onLog = input.onLog ?? (async () => {});
const hostApiToken = input.hostApiToken?.trim() ?? "";
if (hostApiToken.length === 0) {
throw new Error("Sandbox bridge mode requires a host-side Paperclip API token.");
}
const runtimeRootDir =
input.runtimeRootDir?.trim().length
? input.runtimeRootDir.trim()
: path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey);
const bridgeRuntimeDir = path.posix.join(runtimeRootDir, "paperclip-bridge");
const queueDir = path.posix.join(bridgeRuntimeDir, "queue");
const assetRemoteDir = path.posix.join(bridgeRuntimeDir, "server");
const bridgeToken = createSandboxCallbackBridgeToken();
const maxBodyBytes =
typeof input.maxBodyBytes === "number" && Number.isFinite(input.maxBodyBytes) && input.maxBodyBytes > 0
? Math.trunc(input.maxBodyBytes)
: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES;
const hostApiUrl =
input.hostApiUrl?.trim() ||
process.env.PAPERCLIP_RUNTIME_API_URL?.trim() ||
process.env.PAPERCLIP_API_URL?.trim() ||
resolveDefaultPaperclipApiUrl();
await onLog(
"stdout",
`[paperclip] Starting sandbox callback bridge for ${input.adapterKey} in ${bridgeRuntimeDir}.\n`,
);
const bridgeAsset = await createSandboxCallbackBridgeAsset();
let server: Awaited<ReturnType<typeof startSandboxCallbackBridgeServer>> | null = null;
let worker: Awaited<ReturnType<typeof startSandboxCallbackBridgeWorker>> | null = null;
try {
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
runner: requireSandboxRunner(target),
remoteCwd: target.remoteCwd,
timeoutMs: target.timeoutMs,
});
worker = await startSandboxCallbackBridgeWorker({
client,
queueDir,
maxBodyBytes,
handleRequest: async (request) => {
const headers = new Headers();
for (const [key, value] of Object.entries(request.headers)) {
if (value.trim().length === 0) continue;
headers.set(key, value);
}
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),
});
return {
status: response.status,
headers: buildBridgeResponseHeaders(response),
body: await readBridgeForwardResponseBody(response, maxBodyBytes),
};
},
});
server = await startSandboxCallbackBridgeServer({
runner: requireSandboxRunner(target),
remoteCwd: target.remoteCwd,
assetRemoteDir,
queueDir,
bridgeToken,
bridgeAsset,
timeoutMs: target.timeoutMs,
maxBodyBytes,
});
} catch (error) {
await Promise.allSettled([
server?.stop(),
worker?.stop(),
bridgeAsset.cleanup(),
]);
throw error;
}
return {
env: {
PAPERCLIP_API_URL: server.baseUrl,
PAPERCLIP_API_KEY: bridgeToken,
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
},
stop: async () => {
await Promise.allSettled([
server?.stop(),
]);
await Promise.allSettled([
worker?.stop(),
bridgeAsset.cleanup(),
]);
},
};
}

View File

@@ -54,15 +54,3 @@ export {
redactTranscriptEntryPaths,
} from "./log-redaction.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.
export type {
SandboxCallbackBridgeRequest,
SandboxCallbackBridgeResponse,
SandboxCallbackBridgeAsset,
SandboxCallbackBridgeDirectories,
SandboxCallbackBridgeRouteRule,
SandboxCallbackBridgeQueueClient,
SandboxCallbackBridgeWorkerHandle,
StartedSandboxCallbackBridgeServer,
} from "./sandbox-callback-bridge.js";

View File

@@ -1,610 +0,0 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { prepareCommandManagedRuntime } from "./command-managed-runtime.js";
import {
createFileSystemSandboxCallbackBridgeQueueClient,
createSandboxCallbackBridgeAsset,
createSandboxCallbackBridgeToken,
sandboxCallbackBridgeDirectories,
startSandboxCallbackBridgeServer,
startSandboxCallbackBridgeWorker,
} from "./sandbox-callback-bridge.js";
import type { RunProcessResult } from "./server-utils.js";
const execFile = promisify(execFileCallback);
describe("sandbox callback bridge", () => {
const cleanupDirs: string[] = [];
const cleanupFns: Array<() => Promise<void>> = [];
function createExecRunner() {
return {
execute: async (input: {
command: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
stdin?: string;
timeoutMs?: number;
}): Promise<RunProcessResult> => {
const startedAt = new Date().toISOString();
const env = {
...process.env,
...input.env,
};
const command = input.command === "sh" ? "/bin/sh" : input.command;
const args = [...(input.args ?? [])];
if (input.stdin != null && input.command === "sh" && args[0] === "-lc" && typeof args[1] === "string") {
env.PAPERCLIP_TEST_STDIN = input.stdin;
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
}
try {
const result = await execFile(command, args, {
cwd: input.cwd,
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,
};
}
},
};
}
async function waitForJsonFile(directory: string, timeoutMs = 2_000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const entries = await readdir(directory).catch(() => []);
const match = entries.find((entry) => entry.endsWith(".json"));
if (match) return match;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for a JSON file in ${directory}.`);
}
afterEach(async () => {
while (cleanupFns.length > 0) {
const cleanup = cleanupFns.pop();
if (!cleanup) continue;
await cleanup().catch(() => undefined);
}
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("round-trips localhost bridge requests over the sandbox queue without forwarding the bridge token", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-runtime-"));
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 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 seenRequests: Array<{
method: string;
path: string;
query: string;
headers: Record<string, string>;
body: string;
}> = [];
const worker = await startSandboxCallbackBridgeWorker({
client: createFileSystemSandboxCallbackBridgeQueueClient(),
queueDir,
authorizeRequest: async (request) =>
request.path === "/api/agents/me" ? null : `Route not allowed: ${request.method} ${request.path}`,
handleRequest: async (request) => {
seenRequests.push({
method: request.method,
path: request.path,
query: request.query,
headers: request.headers,
body: request.body,
});
return {
status: 200,
headers: {
"content-type": "application/json",
etag: '"bridge-rev-1"',
"last-modified": "Tue, 01 Apr 2025 00:00:00 GMT",
},
body: JSON.stringify({
ok: true,
method: request.method,
path: request.path,
}),
};
},
});
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 okResponse = await fetch(`${bridge.baseUrl}/api/agents/me?view=compact`, {
headers: {
authorization: `Bearer ${bridgeToken}`,
accept: "application/json",
"if-none-match": '"client-cache-key"',
"x-paperclip-run-id": "run-bridge-1",
"x-bridge-debug": "drop-me",
},
});
expect(okResponse.status).toBe(200);
expect(okResponse.headers.get("content-type")).toContain("application/json");
expect(okResponse.headers.get("etag")).toBe('"bridge-rev-1"');
expect(okResponse.headers.get("last-modified")).toBe("Tue, 01 Apr 2025 00:00:00 GMT");
await expect(okResponse.json()).resolves.toMatchObject({
ok: true,
method: "GET",
path: "/api/agents/me",
});
const deniedResponse = await fetch(`${bridge.baseUrl}/api/issues/issue-1`, {
method: "PATCH",
headers: {
authorization: `Bearer ${bridgeToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ status: "in_progress" }),
});
expect(deniedResponse.status).toBe(403);
await expect(deniedResponse.json()).resolves.toMatchObject({
error: "Route not allowed: PATCH /api/issues/issue-1",
});
const unauthorizedResponse = await fetch(`${bridge.baseUrl}/api/agents/me`, {
headers: {
authorization: "Bearer wrong-token",
},
});
expect(unauthorizedResponse.status).toBe(401);
await expect(unauthorizedResponse.json()).resolves.toMatchObject({
error: "Invalid bridge token.",
});
expect(seenRequests).toHaveLength(1);
expect(seenRequests[0]).toMatchObject({
method: "GET",
path: "/api/agents/me",
query: "?view=compact",
body: "",
headers: {
accept: "application/json",
"if-none-match": '"client-cache-key"',
},
});
expect(seenRequests[0]?.headers.authorization).toBeUndefined();
expect(seenRequests[0]?.headers["x-paperclip-run-id"]).toBeUndefined();
});
it("denies non-allowlisted requests by default", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-default-policy-"));
cleanupDirs.push(rootDir);
const queueDir = path.posix.join(rootDir, "queue");
const directories = sandboxCallbackBridgeDirectories(queueDir);
let handled = 0;
const worker = await startSandboxCallbackBridgeWorker({
client: createFileSystemSandboxCallbackBridgeQueueClient(),
queueDir,
handleRequest: async () => {
handled += 1;
return {
status: 200,
body: "should not happen",
};
},
});
await writeFile(
path.posix.join(directories.requestsDir, "req-1.json"),
`${JSON.stringify({
id: "req-1",
method: "DELETE",
path: "/api/secrets",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
await worker.stop({ drainTimeoutMs: 1_000 });
const response = JSON.parse(
await readFile(path.posix.join(directories.responsesDir, "req-1.json"), "utf8"),
) as { status: number; body: string };
expect(handled).toBe(0);
expect(response.status).toBe(403);
expect(JSON.parse(response.body)).toEqual({
error: "Route not allowed: DELETE /api/secrets",
});
});
it("drains already-queued requests on stop", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-drain-"));
cleanupDirs.push(rootDir);
const queueDir = path.posix.join(rootDir, "queue");
const directories = sandboxCallbackBridgeDirectories(queueDir);
const processed: string[] = [];
const worker = await startSandboxCallbackBridgeWorker({
client: createFileSystemSandboxCallbackBridgeQueueClient(),
queueDir,
authorizeRequest: async () => null,
handleRequest: async (request) => {
processed.push(request.id);
await new Promise((resolve) => setTimeout(resolve, 25));
return {
status: 200,
body: request.id,
};
},
});
await writeFile(
path.posix.join(directories.requestsDir, "req-a.json"),
`${JSON.stringify({
id: "req-a",
method: "GET",
path: "/api/agents/me",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
await writeFile(
path.posix.join(directories.requestsDir, "req-b.json"),
`${JSON.stringify({
id: "req-b",
method: "GET",
path: "/api/agents/me",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
await worker.stop({ drainTimeoutMs: 1_000 });
expect(processed).toEqual(["req-a", "req-b"]);
await expect(readFile(path.posix.join(directories.responsesDir, "req-a.json"), "utf8")).resolves.toContain("\"req-a\"");
await expect(readFile(path.posix.join(directories.responsesDir, "req-b.json"), "utf8")).resolves.toContain("\"req-b\"");
});
it("writes fast 503 responses for queued requests that miss the drain deadline", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-drain-timeout-"));
cleanupDirs.push(rootDir);
const queueDir = path.posix.join(rootDir, "queue");
const directories = sandboxCallbackBridgeDirectories(queueDir);
const processed: string[] = [];
const worker = await startSandboxCallbackBridgeWorker({
client: createFileSystemSandboxCallbackBridgeQueueClient(),
queueDir,
authorizeRequest: async () => null,
handleRequest: async (request) => {
processed.push(request.id);
await new Promise((resolve) => setTimeout(resolve, 100));
return {
status: 200,
body: request.id,
};
},
});
await writeFile(
path.posix.join(directories.requestsDir, "req-a.json"),
`${JSON.stringify({
id: "req-a",
method: "GET",
path: "/api/agents/me",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
await writeFile(
path.posix.join(directories.requestsDir, "req-b.json"),
`${JSON.stringify({
id: "req-b",
method: "GET",
path: "/api/agents/me",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
for (let attempt = 0; attempt < 50 && processed.length === 0; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
await worker.stop({ drainTimeoutMs: 10 });
expect(processed).toEqual(["req-a"]);
await expect(readFile(path.posix.join(directories.responsesDir, "req-a.json"), "utf8")).resolves.toContain("\"req-a\"");
await expect(readFile(path.posix.join(directories.responsesDir, "req-b.json"), "utf8")).resolves.toContain(
"Bridge worker stopped before request could be handled.",
);
});
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);
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 guard 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 bridge = await startSandboxCallbackBridgeServer({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: prepared.assetDirs.bridge,
queueDir,
bridgeToken,
timeoutMs: 30_000,
maxQueueDepth: 1,
});
cleanupFns.push(async () => {
await bridge.stop();
});
await writeFile(
path.posix.join(directories.requestsDir, "existing.json"),
`${JSON.stringify({
id: "existing",
method: "GET",
path: "/api/agents/me",
query: "",
headers: {},
body: "",
createdAt: new Date().toISOString(),
})}\n`,
"utf8",
);
const queueFullResponse = await fetch(`${bridge.baseUrl}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridgeToken}`,
},
});
expect(queueFullResponse.status).toBe(503);
await expect(queueFullResponse.json()).resolves.toEqual({
error: "Bridge request queue is full.",
});
await rm(path.posix.join(directories.requestsDir, "existing.json"), { force: true });
const nonJsonResponse = await fetch(`${bridge.baseUrl}/api/issues/issue-1/comments`, {
method: "POST",
headers: {
authorization: `Bearer ${bridgeToken}`,
"content-type": "text/plain",
},
body: "not json",
});
expect(nonJsonResponse.status).toBe(415);
await expect(nonJsonResponse.json()).resolves.toEqual({
error: "Bridge only accepts JSON request bodies.",
});
});
it("returns a 502 when the host response times out", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-timeout-"));
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 timeout 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 bridgeToken = createSandboxCallbackBridgeToken();
const bridge = await startSandboxCallbackBridgeServer({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: prepared.assetDirs.bridge,
queueDir,
bridgeToken,
timeoutMs: 30_000,
pollIntervalMs: 10,
responseTimeoutMs: 75,
});
cleanupFns.push(async () => {
await bridge.stop();
});
const response = await fetch(`${bridge.baseUrl}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridgeToken}`,
},
});
expect(response.status).toBe(502);
await expect(response.json()).resolves.toEqual({
error: "Timed out waiting for host bridge response.",
});
});
it("returns a 502 for malformed host response files", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-malformed-response-"));
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 malformed response 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 bridge = await startSandboxCallbackBridgeServer({
runner,
remoteCwd: remoteWorkspaceDir,
assetRemoteDir: prepared.assetDirs.bridge,
queueDir,
bridgeToken,
timeoutMs: 30_000,
pollIntervalMs: 10,
responseTimeoutMs: 1_000,
});
cleanupFns.push(async () => {
await bridge.stop();
});
const responsePromise = fetch(`${bridge.baseUrl}/api/agents/me`, {
headers: {
authorization: `Bearer ${bridgeToken}`,
},
});
const requestFile = await waitForJsonFile(directories.requestsDir);
await writeFile(
path.posix.join(directories.responsesDir, requestFile),
'{"status":200,"headers":{"content-type":"application/json"},"body"',
"utf8",
);
const response = await responsePromise;
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
error: expect.stringMatching(/JSON|Unexpected|Unterminated/i),
});
});
});

View File

@@ -1,822 +0,0 @@
import { 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 type { RunProcessResult } from "./server-utils.js";
const DEFAULT_BRIDGE_TOKEN_BYTES = 24;
const DEFAULT_BRIDGE_POLL_INTERVAL_MS = 100;
const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 30_000;
const DEFAULT_BRIDGE_STOP_TIMEOUT_MS = 2_000;
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";
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES = DEFAULT_BRIDGE_MAX_BODY_BYTES;
export interface SandboxCallbackBridgeRouteRule {
method: string;
path: RegExp;
}
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCallbackBridgeRouteRule[] = [
{ method: "GET", path: /^\/api\/agents\/me$/ },
{ 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: "PATCH", path: /^\/api\/issues\/[^/]+$/ },
] as const;
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [
"accept",
"content-type",
"if-match",
"if-none-match",
] as const;
export interface SandboxCallbackBridgeRequest {
id: string;
method: string;
path: string;
query: string;
headers: Record<string, string>;
/**
* UTF-8 body contents. The bridge rejects non-JSON request bodies; binary
* payloads are intentionally out of scope for this queue protocol.
*/
body: string;
createdAt: string;
}
export interface SandboxCallbackBridgeResponse {
id: string;
status: number;
headers: Record<string, string>;
body: string;
completedAt: string;
}
export interface SandboxCallbackBridgeAsset {
localDir: string;
entrypoint: string;
cleanup(): Promise<void>;
}
export interface SandboxCallbackBridgeDirectories {
rootDir: string;
requestsDir: string;
responsesDir: string;
logsDir: string;
readyFile: string;
pidFile: string;
logFile: string;
}
export interface SandboxCallbackBridgeQueueClient {
makeDir(remotePath: string): Promise<void>;
listJsonFiles(remotePath: string): Promise<string[]>;
readTextFile(remotePath: string): Promise<string>;
writeTextFile(remotePath: string, body: string): Promise<void>;
rename(fromPath: string, toPath: string): Promise<void>;
remove(remotePath: string): Promise<void>;
}
export interface SandboxCallbackBridgeWorkerHandle {
stop(options?: { drainTimeoutMs?: number }): Promise<void>;
}
export interface StartedSandboxCallbackBridgeServer {
baseUrl: string;
host: string;
port: number;
pid: number;
directories: SandboxCallbackBridgeDirectories;
stop(): Promise<void>;
}
function shellQuote(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function normalizeMethod(value: string | null | undefined): string {
return typeof value === "string" && value.trim().length > 0 ? value.trim().toUpperCase() : "GET";
}
function normalizeTimeoutMs(value: number | null | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
}
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
if (Buffer.isBuffer(bytes)) return bytes;
if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
function buildRunnerFailureMessage(action: string, result: RunProcessResult): string {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
const detail = stderr || stdout;
if (result.timedOut) {
return `${action} timed out${detail ? `: ${detail}` : ""}`;
}
return `${action} failed with exit code ${result.exitCode ?? "null"}${detail ? `: ${detail}` : ""}`;
}
async function runShell(
runner: CommandManagedRuntimeRunner,
cwd: string,
script: string,
timeoutMs: number,
): Promise<RunProcessResult> {
return await runner.execute({
command: "sh",
args: ["-lc", script],
cwd,
timeoutMs,
});
}
function requireSuccessfulResult(action: string, result: RunProcessResult): RunProcessResult {
if (!result.timedOut && result.exitCode === 0) return result;
throw new Error(buildRunnerFailureMessage(action, result));
}
function base64Chunks(body: string): string[] {
const out: string[] = [];
for (let offset = 0; offset < body.length; offset += REMOTE_WRITE_BASE64_CHUNK_SIZE) {
out.push(body.slice(offset, offset + REMOTE_WRITE_BASE64_CHUNK_SIZE));
}
return out;
}
export function createSandboxCallbackBridgeToken(bytes = DEFAULT_BRIDGE_TOKEN_BYTES): string {
return randomBytes(bytes).toString("base64url");
}
export function authorizeSandboxCallbackBridgeRequestWithRoutes(
request: Pick<SandboxCallbackBridgeRequest, "method" | "path">,
routes: readonly SandboxCallbackBridgeRouteRule[] = DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST,
): string | null {
const method = normalizeMethod(request.method);
return routes.some((route) => route.method === method && route.path.test(request.path))
? null
: `Route not allowed: ${method} ${request.path}`;
}
export function sanitizeSandboxCallbackBridgeHeaders(
headers: Record<string, string>,
allowlist: readonly string[] = DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST,
): Record<string, string> {
const allowed = new Set(allowlist.map((header) => header.toLowerCase()));
return Object.fromEntries(
Object.entries(headers).filter(([key]) => allowed.has(key.toLowerCase())),
);
}
export function sandboxCallbackBridgeDirectories(rootDir: string): SandboxCallbackBridgeDirectories {
return {
rootDir,
requestsDir: path.posix.join(rootDir, "requests"),
responsesDir: path.posix.join(rootDir, "responses"),
logsDir: path.posix.join(rootDir, "logs"),
readyFile: path.posix.join(rootDir, "ready.json"),
pidFile: path.posix.join(rootDir, "server.pid"),
logFile: path.posix.join(rootDir, "logs", "bridge.log"),
};
}
export function buildSandboxCallbackBridgeEnv(input: {
queueDir: string;
bridgeToken: string;
host?: string;
port?: number | null;
pollIntervalMs?: number | null;
responseTimeoutMs?: number | null;
maxQueueDepth?: number | null;
maxBodyBytes?: number | null;
}): Record<string, string> {
return {
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
PAPERCLIP_BRIDGE_QUEUE_DIR: input.queueDir,
PAPERCLIP_BRIDGE_TOKEN: input.bridgeToken,
PAPERCLIP_BRIDGE_HOST: input.host?.trim() || "127.0.0.1",
PAPERCLIP_BRIDGE_PORT: String(input.port && input.port > 0 ? Math.trunc(input.port) : 0),
PAPERCLIP_BRIDGE_POLL_INTERVAL_MS: String(
normalizeTimeoutMs(input.pollIntervalMs, DEFAULT_BRIDGE_POLL_INTERVAL_MS),
),
PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS: String(
normalizeTimeoutMs(input.responseTimeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS),
),
PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH: String(
normalizeTimeoutMs(input.maxQueueDepth, DEFAULT_BRIDGE_MAX_QUEUE_DEPTH),
),
PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(
normalizeTimeoutMs(input.maxBodyBytes, DEFAULT_BRIDGE_MAX_BODY_BYTES),
),
};
}
export async function createSandboxCallbackBridgeAsset(): Promise<SandboxCallbackBridgeAsset> {
const localDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-asset-"));
const entrypoint = path.join(localDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
await fs.writeFile(entrypoint, getSandboxCallbackBridgeServerSource(), "utf8");
return {
localDir,
entrypoint,
cleanup: async () => {
await fs.rm(localDir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
export function createFileSystemSandboxCallbackBridgeQueueClient(): SandboxCallbackBridgeQueueClient {
return {
makeDir: async (remotePath) => {
await fs.mkdir(remotePath, { recursive: true });
},
listJsonFiles: async (remotePath) => {
const entries = await fs.readdir(remotePath, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
},
readTextFile: async (remotePath) => await fs.readFile(remotePath, "utf8"),
writeTextFile: async (remotePath, body) => {
await fs.mkdir(path.posix.dirname(remotePath), { recursive: true });
await fs.writeFile(remotePath, body, "utf8");
},
rename: async (fromPath, toPath) => {
await fs.mkdir(path.posix.dirname(toPath), { recursive: true });
await fs.rename(fromPath, toPath);
},
remove: async (remotePath) => {
await fs.rm(remotePath, { recursive: true, force: true }).catch(() => undefined);
},
};
}
export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
timeoutMs?: number | null;
}): SandboxCallbackBridgeQueueClient {
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
const runChecked = async (action: string, script: string) =>
requireSuccessfulResult(action, await runShell(input.runner, input.remoteCwd, script, timeoutMs));
return {
makeDir: async (remotePath) => {
await runChecked(`mkdir ${remotePath}`, `mkdir -p ${shellQuote(remotePath)}`);
},
listJsonFiles: async (remotePath) => {
const result = await runShell(
input.runner,
input.remoteCwd,
[
`if [ -d ${shellQuote(remotePath)} ]; then`,
` for file in ${shellQuote(remotePath)}/*.json; do`,
` [ -f "$file" ] || continue`,
" basename \"$file\"",
" done",
"fi",
].join("\n"),
timeoutMs,
);
requireSuccessfulResult(`list ${remotePath}`, result);
return result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.sort((left, right) => left.localeCompare(right));
},
readTextFile: async (remotePath) => {
const result = await runChecked(`read ${remotePath}`, `base64 < ${shellQuote(remotePath)}`);
return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64").toString("utf8");
},
writeTextFile: async (remotePath, body) => {
const remoteDir = path.posix.dirname(remotePath);
const tempPath = `${remotePath}.paperclip-upload.b64`;
await runChecked(
`prepare upload ${remotePath}`,
`mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(tempPath)} && : > ${shellQuote(tempPath)}`,
);
const base64Body = toBuffer(Buffer.from(body, "utf8")).toString("base64");
for (const chunk of base64Chunks(base64Body)) {
await runChecked(
`append upload chunk ${remotePath}`,
`printf '%s' ${shellQuote(chunk)} >> ${shellQuote(tempPath)}`,
);
}
await runChecked(
`finalize upload ${remotePath}`,
`base64 -d < ${shellQuote(tempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(tempPath)}`,
);
},
rename: async (fromPath, toPath) => {
await runChecked(
`rename ${fromPath}`,
`mkdir -p ${shellQuote(path.posix.dirname(toPath))} && mv ${shellQuote(fromPath)} ${shellQuote(toPath)}`,
);
},
remove: async (remotePath) => {
await runChecked(`remove ${remotePath}`, `rm -rf ${shellQuote(remotePath)}`);
},
};
}
async function writeBridgeResponse(
client: SandboxCallbackBridgeQueueClient,
responsePath: string,
response: SandboxCallbackBridgeResponse,
) {
const tempPath = `${responsePath}.tmp`;
await client.writeTextFile(tempPath, `${JSON.stringify(response)}\n`);
await client.rename(tempPath, responsePath);
}
export async function startSandboxCallbackBridgeWorker(input: {
client: SandboxCallbackBridgeQueueClient;
queueDir: string;
pollIntervalMs?: number | null;
authorizeRequest?: (request: SandboxCallbackBridgeRequest) => string | null | Promise<string | null>;
handleRequest: (request: SandboxCallbackBridgeRequest) => Promise<{
status: number;
headers?: Record<string, string>;
body?: string;
}>;
maxBodyBytes?: number | null;
}): Promise<SandboxCallbackBridgeWorkerHandle> {
const pollIntervalMs = normalizeTimeoutMs(input.pollIntervalMs, DEFAULT_BRIDGE_POLL_INTERVAL_MS);
const maxBodyBytes = normalizeTimeoutMs(input.maxBodyBytes, DEFAULT_BRIDGE_MAX_BODY_BYTES);
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
await input.client.makeDir(directories.rootDir);
await input.client.makeDir(directories.requestsDir);
await input.client.makeDir(directories.responsesDir);
await input.client.makeDir(directories.logsDir);
let stopping = false;
let inFlight = 0;
let settled = false;
let stopDeadline = Number.POSITIVE_INFINITY;
let settleResolve: (() => void) | null = null;
const settledPromise = new Promise<void>((resolve) => {
settleResolve = resolve;
});
const authorizeRequest = input.authorizeRequest ??
((request: SandboxCallbackBridgeRequest) => authorizeSandboxCallbackBridgeRequestWithRoutes(request));
const processRequestFile = async (fileName: string) => {
const requestPath = path.posix.join(directories.requestsDir, fileName);
const responsePath = path.posix.join(directories.responsesDir, fileName);
const raw = await input.client.readTextFile(requestPath);
let request: SandboxCallbackBridgeRequest;
try {
request = JSON.parse(raw) as SandboxCallbackBridgeRequest;
} catch {
const requestId = fileName.replace(/\.json$/i, "") || randomUUID();
await writeBridgeResponse(input.client, responsePath, {
id: requestId,
status: 400,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: "Invalid bridge request payload." }),
completedAt: new Date().toISOString(),
});
await input.client.remove(requestPath);
return;
}
const denialReason = await authorizeRequest(request);
if (denialReason) {
await writeBridgeResponse(input.client, responsePath, {
id: request.id,
status: 403,
headers: { "content-type": "application/json" },
body: JSON.stringify({ error: denialReason }),
completedAt: new Date().toISOString(),
});
await input.client.remove(requestPath);
return;
}
try {
const result = await input.handleRequest(request);
const responseBody = result.body ?? "";
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, {
id: request.id,
status: result.status,
headers: result.headers ?? {},
body: responseBody,
completedAt: new Date().toISOString(),
});
} catch (error) {
console.warn(
`[paperclip] sandbox callback bridge handler failed for ${request.id}: ${error instanceof Error ? error.message : String(error)}`,
);
await writeBridgeResponse(input.client, responsePath, {
id: request.id,
status: 502,
headers: { "content-type": "application/json" },
body: JSON.stringify({
error: error instanceof Error ? error.message : String(error),
}),
completedAt: new Date().toISOString(),
});
} finally {
await input.client.remove(requestPath);
}
};
const failPendingRequests = async (message: string) => {
const fileNames = await input.client.listJsonFiles(directories.requestsDir).catch(() => []);
for (const fileName of fileNames) {
const requestPath = path.posix.join(directories.requestsDir, fileName);
const responsePath = path.posix.join(directories.responsesDir, fileName);
const requestId = fileName.replace(/\.json$/i, "") || randomUUID();
try {
const raw = await input.client.readTextFile(requestPath);
const parsed = JSON.parse(raw) as Partial<SandboxCallbackBridgeRequest>;
await writeBridgeResponse(input.client, 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(),
});
} catch (error) {
console.warn(
`[paperclip] sandbox callback bridge failed to abort pending request ${requestId}: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
await input.client.remove(requestPath).catch(() => undefined);
}
}
};
const loop = (async () => {
try {
while (true) {
const fileNames = await input.client.listJsonFiles(directories.requestsDir);
if (fileNames.length === 0) {
if (stopping) {
break;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
continue;
}
for (const fileName of fileNames) {
if (stopping && Date.now() >= stopDeadline) break;
inFlight += 1;
try {
await processRequestFile(fileName);
} finally {
inFlight -= 1;
}
}
if (stopping && Date.now() >= stopDeadline) {
break;
}
}
} finally {
settled = true;
if (settleResolve) {
settleResolve();
}
}
})();
void loop;
return {
stop: async (options = {}) => {
stopping = true;
const drainMs = normalizeTimeoutMs(options.drainTimeoutMs, DEFAULT_BRIDGE_STOP_TIMEOUT_MS);
stopDeadline = Date.now() + drainMs;
if (!settled) {
await Promise.race([
settledPromise,
new Promise<void>((resolve) => setTimeout(resolve, drainMs)),
]);
}
await failPendingRequests("Bridge worker stopped before request could be handled.");
},
};
}
export async function startSandboxCallbackBridgeServer(input: {
runner: CommandManagedRuntimeRunner;
remoteCwd: string;
assetRemoteDir: string;
queueDir: string;
bridgeToken: string;
bridgeAsset?: SandboxCallbackBridgeAsset | null;
host?: string;
port?: number | null;
pollIntervalMs?: number | null;
responseTimeoutMs?: number | null;
timeoutMs?: number | null;
nodeCommand?: string;
maxQueueDepth?: number | null;
maxBodyBytes?: number | null;
}): Promise<StartedSandboxCallbackBridgeServer> {
const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS);
const directories = sandboxCallbackBridgeDirectories(input.queueDir);
const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT);
if (input.bridgeAsset) {
const assetClient = createCommandManagedSandboxCallbackBridgeQueueClient({
runner: input.runner,
remoteCwd: input.remoteCwd,
timeoutMs,
});
await assetClient.makeDir(input.assetRemoteDir);
const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8");
await assetClient.writeTextFile(remoteEntrypoint, entrypointSource);
}
const env = buildSandboxCallbackBridgeEnv({
queueDir: input.queueDir,
bridgeToken: input.bridgeToken,
host: input.host,
port: input.port,
pollIntervalMs: input.pollIntervalMs,
responseTimeoutMs: input.responseTimeoutMs,
maxQueueDepth: input.maxQueueDepth,
maxBodyBytes: input.maxBodyBytes,
});
const nodeCommand = input.nodeCommand?.trim() || "node";
const startResult = await input.runner.execute({
command: "sh",
args: [
"-lc",
[
`mkdir -p ${shellQuote(directories.requestsDir)} ${shellQuote(directories.responsesDir)} ${shellQuote(directories.logsDir)}`,
`rm -f ${shellQuote(directories.readyFile)} ${shellQuote(directories.pidFile)}`,
`nohup env ${Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} ` +
`${shellQuote(nodeCommand)} ${shellQuote(remoteEntrypoint)} ` +
`>> ${shellQuote(directories.logFile)} 2>&1 < /dev/null &`,
"pid=$!",
`printf '%s\\n' \"$pid\" > ${shellQuote(directories.pidFile)}`,
"printf '{\"pid\":%s}\\n' \"$pid\"",
].join("\n"),
],
cwd: input.remoteCwd,
timeoutMs,
});
requireSuccessfulResult("start sandbox callback bridge", startResult);
const readyResult = await runShell(
input.runner,
input.remoteCwd,
[
"i=0",
`while [ \"$i\" -lt 200 ]; do`,
` if [ -s ${shellQuote(directories.readyFile)} ]; then`,
` cat ${shellQuote(directories.readyFile)}`,
" exit 0",
" fi",
` if [ -s ${shellQuote(directories.logFile)} ] && ! kill -0 \"$(cat ${shellQuote(directories.pidFile)} 2>/dev/null)\" 2>/dev/null; then`,
` cat ${shellQuote(directories.logFile)} >&2`,
" exit 1",
" fi",
" i=$((i + 1))",
" sleep 0.05",
"done",
`echo "Timed out waiting for bridge readiness." >&2`,
`if [ -s ${shellQuote(directories.logFile)} ]; then cat ${shellQuote(directories.logFile)} >&2; fi`,
"exit 1",
].join("\n"),
timeoutMs,
);
requireSuccessfulResult("wait for sandbox callback bridge readiness", readyResult);
let readyData: { host?: string; port?: number; baseUrl?: string; pid?: number };
try {
readyData = JSON.parse(readyResult.stdout.trim()) as { host?: string; port?: number; baseUrl?: string; pid?: number };
} catch (error) {
throw new Error(
`Sandbox callback bridge wrote invalid readiness JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
const host = typeof readyData.host === "string" && readyData.host.trim().length > 0
? readyData.host.trim()
: "127.0.0.1";
const port = typeof readyData.port === "number" && Number.isFinite(readyData.port) ? readyData.port : 0;
if (!port) {
throw new Error("Sandbox callback bridge did not report a listening port.");
}
const baseUrl =
typeof readyData.baseUrl === "string" && readyData.baseUrl.trim().length > 0
? readyData.baseUrl.trim()
: `http://${host}:${port}`;
return {
baseUrl,
host,
port,
pid: typeof readyData.pid === "number" && Number.isFinite(readyData.pid) ? readyData.pid : 0,
directories,
stop: async () => {
const stopResult = await input.runner.execute({
command: "sh",
args: [
"-lc",
[
`if [ -s ${shellQuote(directories.pidFile)} ]; then`,
` pid="$(cat ${shellQuote(directories.pidFile)})"`,
" kill \"$pid\" 2>/dev/null || true",
" i=0",
" while kill -0 \"$pid\" 2>/dev/null && [ \"$i\" -lt 40 ]; do",
" i=$((i + 1))",
" sleep 0.05",
" done",
"fi",
`rm -f ${shellQuote(directories.pidFile)} ${shellQuote(directories.readyFile)}`,
].join("\n"),
],
cwd: input.remoteCwd,
timeoutMs,
});
if (stopResult.timedOut) {
throw new Error(buildRunnerFailureMessage("stop sandbox callback bridge", stopResult));
}
},
};
}
function getSandboxCallbackBridgeServerSource(): string {
return `import { randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { promises as fs } from "node:fs";
import path from "node:path";
const queueDir = process.env.PAPERCLIP_BRIDGE_QUEUE_DIR;
const bridgeToken = process.env.PAPERCLIP_BRIDGE_TOKEN;
const host = process.env.PAPERCLIP_BRIDGE_HOST || "127.0.0.1";
const port = Number(process.env.PAPERCLIP_BRIDGE_PORT || "0");
const pollIntervalMs = Number(process.env.PAPERCLIP_BRIDGE_POLL_INTERVAL_MS || "100");
const responseTimeoutMs = Number(process.env.PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS || "30000");
const maxQueueDepth = Number(process.env.PAPERCLIP_BRIDGE_MAX_QUEUE_DEPTH || "${DEFAULT_BRIDGE_MAX_QUEUE_DEPTH}");
const maxBodyBytes = Number(process.env.PAPERCLIP_BRIDGE_MAX_BODY_BYTES || "${DEFAULT_BRIDGE_MAX_BODY_BYTES}");
const allowedHeaders = new Set(${JSON.stringify([...DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST])});
if (!queueDir || !bridgeToken) {
throw new Error("PAPERCLIP_BRIDGE_QUEUE_DIR and PAPERCLIP_BRIDGE_TOKEN are required.");
}
const requestsDir = path.posix.join(queueDir, "requests");
const responsesDir = path.posix.join(queueDir, "responses");
const logsDir = path.posix.join(queueDir, "logs");
const readyFile = path.posix.join(queueDir, "ready.json");
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeHeaders(headers) {
const out = {};
for (const [key, value] of Object.entries(headers)) {
if (value == null) continue;
const normalizedKey = key.toLowerCase();
if (!allowedHeaders.has(normalizedKey)) {
continue;
}
out[normalizedKey] = Array.isArray(value) ? value.join(", ") : String(value);
}
return out;
}
async function readBody(req) {
const chunks = [];
let totalBytes = 0;
for await (const chunk of req) {
const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
chunks.push(nextChunk);
totalBytes += nextChunk.byteLength;
if (totalBytes > maxBodyBytes) {
throw new Error("Bridge request body exceeded the configured size limit.");
}
}
return Buffer.concat(chunks).toString("utf8");
}
async function queueDepth() {
const entries = await fs.readdir(requestsDir, { withFileTypes: true }).catch(() => []);
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).length;
}
function tokensMatch(received) {
const expected = Buffer.from(bridgeToken, "utf8");
const actual = Buffer.from(typeof received === "string" ? received : "", "utf8");
if (expected.length !== actual.length) return false;
return timingSafeEqual(expected, actual);
}
async function waitForResponse(requestId) {
const responsePath = path.posix.join(responsesDir, \`\${requestId}.json\`);
const deadline = Date.now() + responseTimeoutMs;
while (Date.now() < deadline) {
const body = await fs.readFile(responsePath, "utf8").catch(() => null);
if (body != null) {
await fs.rm(responsePath, { force: true }).catch(() => undefined);
return JSON.parse(body);
}
await sleep(pollIntervalMs);
}
throw new Error("Timed out waiting for host bridge response.");
}
const server = createServer(async (req, res) => {
try {
const auth = req.headers.authorization || "";
const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
if (!tokensMatch(receivedToken)) {
res.statusCode = 401;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: "Invalid bridge token." }));
return;
}
if (await queueDepth() >= maxQueueDepth) {
res.statusCode = 503;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: "Bridge request queue is full." }));
return;
}
const url = new URL(req.url || "/", "http://127.0.0.1");
const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "";
if (req.method && req.method !== "GET" && req.method !== "HEAD" && !/json/i.test(contentType)) {
res.statusCode = 415;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: "Bridge only accepts JSON request bodies." }));
return;
}
const requestId = randomUUID();
const requestBody = await readBody(req);
const payload = {
id: requestId,
method: req.method || "GET",
path: url.pathname,
query: url.search,
headers: normalizeHeaders(req.headers),
body: requestBody,
createdAt: new Date().toISOString(),
};
const requestPath = path.posix.join(requestsDir, \`\${requestId}.json\`);
const tempPath = \`\${requestPath}.tmp\`;
await fs.writeFile(tempPath, \`\${JSON.stringify(payload)}\\n\`, "utf8");
await fs.rename(tempPath, requestPath);
const response = await waitForResponse(requestId);
res.statusCode = typeof response.status === "number" ? response.status : 200;
for (const [key, value] of Object.entries(response.headers || {})) {
if (typeof value !== "string" || key.toLowerCase() === "content-length") continue;
res.setHeader(key, value);
}
res.end(typeof response.body === "string" ? response.body : "");
} catch (error) {
res.statusCode = 502;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
}
});
async function shutdown() {
server.close(() => {
process.exit(0);
});
}
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
await fs.mkdir(requestsDir, { recursive: true });
await fs.mkdir(responsesDir, { recursive: true });
await fs.mkdir(logsDir, { recursive: true });
server.listen(port, host, async () => {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Bridge server did not expose a TCP address.");
}
const ready = {
pid: process.pid,
host,
port: address.port,
baseUrl: \`http://\${host}:\${address.port}\`,
startedAt: new Date().toISOString(),
};
const tempReadyFile = \`\${readyFile}.tmp\`;
await fs.writeFile(tempReadyFile, JSON.stringify(ready), "utf8");
await fs.rename(tempReadyFile, readyFile);
});`;
}

View File

@@ -1,4 +1,4 @@
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
@@ -73,13 +73,6 @@ describe("sandbox managed runtime", () => {
await writeFile(remotePath, Buffer.from(bytes));
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async (remotePath) => {
const entries = await readdir(remotePath, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
},
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},

View File

@@ -27,7 +27,6 @@ export interface SandboxManagedRuntimeClient {
makeDir(remotePath: string): Promise<void>;
writeFile(remotePath: string, bytes: ArrayBuffer): Promise<void>;
readFile(remotePath: string): Promise<Buffer | Uint8Array | ArrayBuffer>;
listFiles(remotePath: string): Promise<string[]>;
remove(remotePath: string): Promise<void>;
run(command: string, options: { timeoutMs: number }): Promise<void>;
}

View File

@@ -476,8 +476,8 @@ async function importGitWorkspaceToSsh(input: {
`if [ ! -d ${shellQuote(path.posix.join(input.remoteDir, ".git"))} ]; then git init ${shellQuote(input.remoteDir)} >/dev/null; fi`,
`git -C ${shellQuote(input.remoteDir)} fetch --force "$tmp_bundle" '${tempRef}:${tempRef}' >/dev/null`,
input.snapshot.branchName
? `git -C ${shellQuote(input.remoteDir)} checkout --force -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null`
: `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --force --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
? `git -C ${shellQuote(input.remoteDir)} checkout -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null`
: `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --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`,
].join("\n");

View File

@@ -216,20 +216,6 @@ export interface AdapterEnvironmentTestContext {
companyId: string;
adapterType: string;
config: Record<string, unknown>;
/**
* Optional execution target the adapter should run probes against.
*
* If omitted (or `kind === "local"`), the adapter tests on the Paperclip
* host. For SSH/sandbox targets the adapter should run command/auth probes
* inside the remote environment so the result reflects what an agent run
* would actually see at execution time.
*/
executionTarget?: AdapterExecutionTarget | null;
/**
* Friendly name of the environment being tested (when `executionTarget` is set).
* Surfaced in check messages so users see which environment the probe ran in.
*/
environmentName?: string | null;
deployment?: {
mode?: "local_trusted" | "authenticated";
exposure?: "private" | "public";

View File

@@ -1,66 +0,0 @@
import * as fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareClaudeConfigSeed } from "./claude-config.js";
describe("prepareClaudeConfigSeed", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
function createEnv(root: string, sourceDir: string): NodeJS.ProcessEnv {
return {
HOME: root,
PAPERCLIP_HOME: path.join(root, "paperclip-home"),
PAPERCLIP_INSTANCE_ID: "test-instance",
CLAUDE_CONFIG_DIR: sourceDir,
};
}
it("reuses the same snapshot path when the seeded files are unchanged", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-config-seed-"));
cleanupDirs.push(root);
const sourceDir = path.join(root, "claude-source");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "settings.json"), JSON.stringify({ theme: "light" }), "utf8");
const onLog = vi.fn(async () => {});
const env = createEnv(root, sourceDir);
const first = await prepareClaudeConfigSeed(env, onLog, "company-1");
const second = await prepareClaudeConfigSeed(env, onLog, "company-1");
expect(first).toBe(second);
await expect(fs.readFile(path.join(first, "settings.json"), "utf8"))
.resolves.toBe(JSON.stringify({ theme: "light" }));
});
it("keeps an existing snapshot intact when the seeded files change", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-config-race-"));
cleanupDirs.push(root);
const sourceDir = path.join(root, "claude-source");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "settings.json"), JSON.stringify({ theme: "light" }), "utf8");
const onLog = vi.fn(async () => {});
const env = createEnv(root, sourceDir);
const first = await prepareClaudeConfigSeed(env, onLog, "company-1");
await fs.writeFile(path.join(sourceDir, "settings.json"), JSON.stringify({ theme: "dark" }), "utf8");
const second = await prepareClaudeConfigSeed(env, onLog, "company-1");
expect(second).not.toBe(first);
await expect(fs.readFile(path.join(first, "settings.json"), "utf8"))
.resolves.toBe(JSON.stringify({ theme: "light" }));
await expect(fs.readFile(path.join(second, "settings.json"), "utf8"))
.resolves.toBe(JSON.stringify({ theme: "dark" }));
});
});

View File

@@ -1,135 +0,0 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
const SEEDED_SHARED_FILES = [
".credentials.json",
"credentials.json",
"settings.json",
"settings.local.json",
"CLAUDE.md",
] as const;
function nonEmpty(value: string | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
function isAlreadyExistsError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const code = "code" in error ? error.code : null;
return code === "EEXIST" || code === "ENOTEMPTY";
}
async function collectSeedFiles(sourceDir: string): Promise<Array<{ name: string; sourcePath: string }>> {
const files: Array<{ name: string; sourcePath: string }> = [];
for (const name of SEEDED_SHARED_FILES) {
const sourcePath = path.join(sourceDir, name);
if (!(await pathExists(sourcePath))) continue;
files.push({ name, sourcePath });
}
return files;
}
async function buildSeedSnapshotKey(files: Array<{ name: string; sourcePath: string }>): Promise<string> {
if (files.length === 0) return "empty";
const hash = createHash("sha256");
for (const file of files) {
hash.update(file.name);
hash.update("\0");
hash.update(await fs.readFile(file.sourcePath));
hash.update("\0");
}
return hash.digest("hex").slice(0, 16);
}
async function materializeSeedSnapshot(input: {
rootDir: string;
snapshotKey: string;
files: Array<{ name: string; sourcePath: string }>;
}): Promise<string> {
const targetDir = path.join(input.rootDir, input.snapshotKey);
if (await pathExists(targetDir)) {
return targetDir;
}
await fs.mkdir(input.rootDir, { recursive: true });
const stagingDir = await fs.mkdtemp(path.join(input.rootDir, ".tmp-"));
try {
for (const file of input.files) {
await fs.copyFile(file.sourcePath, path.join(stagingDir, file.name));
}
try {
await fs.rename(stagingDir, targetDir);
} catch (error) {
if (!isAlreadyExistsError(error)) {
throw error;
}
await fs.rm(stagingDir, { recursive: true, force: true });
}
} catch (error) {
await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
return targetDir;
}
export function resolveSharedClaudeConfigDir(
env: NodeJS.ProcessEnv = process.env,
): string {
const fromEnv = nonEmpty(env.CLAUDE_CONFIG_DIR);
return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".claude");
}
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;
return companyId
? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "claude-config-seed")
: path.resolve(paperclipHome, "instances", instanceId, "claude-config-seed");
}
export async function prepareClaudeConfigSeed(
env: NodeJS.ProcessEnv,
onLog: AdapterExecutionContext["onLog"],
companyId?: string,
): Promise<string> {
const sourceDir = resolveSharedClaudeConfigDir(env);
const targetRootDir = resolveManagedClaudeConfigSeedDir(env, companyId);
if (path.resolve(sourceDir) === path.resolve(targetRootDir)) {
return targetRootDir;
}
const copiedFiles = await collectSeedFiles(sourceDir);
const snapshotKey = await buildSeedSnapshotKey(copiedFiles);
const targetDir = await materializeSeedSnapshot({
rootDir: targetRootDir,
snapshotKey,
files: copiedFiles,
});
if (copiedFiles.length > 0) {
await onLog(
"stdout",
`[paperclip] Prepared Claude config seed "${targetDir}" from "${sourceDir}" (${copiedFiles.map((file) => file.name).join(", ")}).\n`,
);
} else {
await onLog(
"stdout",
`[paperclip] No local Claude config seed files were found in "${sourceDir}". Remote Claude auth may still require login.\n`,
);
}
return targetDir;
}

View File

@@ -10,15 +10,12 @@ import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
@@ -39,7 +36,6 @@ import {
stringifyPaperclipWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
} from "@paperclipai/adapter-utils/server-utils";
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
import {
parseClaudeStreamJson,
describeClaudeFailure,
@@ -49,7 +45,6 @@ import {
isClaudeTransientUpstreamError,
isClaudeUnknownSessionError,
} from "./parse.js";
import { prepareClaudeConfigSeed } from "./claude-config.js";
import { resolveClaudeDesiredSkillNames } from "./skills.js";
import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
@@ -321,9 +316,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const chrome = asBoolean(config.chrome, false);
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const configEnv = parseObject(config.env);
const hasExplicitClaudeConfigDir =
typeof configEnv.CLAUDE_CONFIG_DIR === "string" && configEnv.CLAUDE_CONFIG_DIR.trim().length > 0;
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
const runtimeConfig = await buildClaudeRuntimeConfig({
@@ -342,12 +334,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
workspaceRepoUrl,
workspaceRepoRef,
env,
loggedEnv: initialLoggedEnv,
loggedEnv,
timeoutSec,
graceSec,
extraArgs,
} = runtimeConfig;
let loggedEnv = initialLoggedEnv;
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const terminalResultCleanupGraceMs = Math.max(
0,
@@ -388,13 +379,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
instructionsContents: combinedInstructionsContents,
onLog,
});
const useManagedRemoteClaudeConfig =
executionTargetIsRemote &&
adapterExecutionTargetUsesManagedHome(executionTarget) &&
!hasExplicitClaudeConfigDir;
const claudeConfigSeedDir = useManagedRemoteClaudeConfig
? await prepareClaudeConfigSeed(process.env, onLog, agent.companyId)
: null;
const preparedExecutionTargetRuntime = executionTargetIsRemote
? await (async () => {
await onLog(
@@ -411,13 +395,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
localDir: promptBundle.addDir,
followSymlinks: true,
},
...(claudeConfigSeedDir
? [{
key: "config-seed",
localDir: claudeConfigSeedDir,
followSymlinks: true,
}]
: []),
],
});
})()
@@ -434,63 +411,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? path.posix.join(effectivePromptBundleAddDir, path.basename(promptBundle.instructionsFilePath))
: promptBundle.instructionsFilePath
: undefined;
const remoteClaudeRuntimeRoot = executionTargetIsRemote
? preparedExecutionTargetRuntime?.runtimeRootDir ??
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "claude")
: null;
const remoteClaudeConfigSeedDir = claudeConfigSeedDir && remoteClaudeRuntimeRoot
? preparedExecutionTargetRuntime?.assetDirs["config-seed"] ??
path.posix.join(remoteClaudeRuntimeRoot, "config-seed")
: null;
const remoteClaudeConfigDir = useManagedRemoteClaudeConfig && remoteClaudeRuntimeRoot
? path.posix.join(remoteClaudeRuntimeRoot, "config")
: null;
if (remoteClaudeConfigDir && remoteClaudeConfigSeedDir) {
env.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
loggedEnv.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
await onLog(
"stdout",
`[paperclip] Materializing Claude auth/config into ${remoteClaudeConfigDir}.\n`,
);
await runAdapterExecutionTargetShellCommand(
runId,
executionTarget,
`mkdir -p ${shellQuote(remoteClaudeConfigDir)} && ` +
`if [ -d ${shellQuote(remoteClaudeConfigSeedDir)} ]; then ` +
`cp -R ${shellQuote(`${remoteClaudeConfigSeedDir}/.`)} ${shellQuote(remoteClaudeConfigDir)}/; ` +
`fi`,
{
cwd,
env,
timeoutSec: Math.max(timeoutSec, 15),
graceSec,
onLog,
},
);
}
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir,
adapterKey: "claude",
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
resolvedCommand,
});
if (remoteClaudeConfigDir) {
loggedEnv.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
}
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -846,9 +766,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
} finally {
if (paperclipBridge) {
await paperclipBridge.stop();
}
if (restoreRemoteWorkspace) {
await onLog(
"stdout",

View File

@@ -9,15 +9,11 @@ import {
asNumber,
asStringArray,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import path from "node:path";
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
import { isBedrockModelId } from "./models.js";
@@ -60,28 +56,10 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "claude");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "claude_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
checks.push({
code: "claude_cwd_valid",
level: "info",
@@ -103,7 +81,7 @@ export async function testEnvironment(
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "claude_command_resolvable",
level: "info",
@@ -118,21 +96,16 @@ export async function testEnvironment(
});
}
// When probing a remote target, the Paperclip host's process.env does not
// reflect what the agent will actually see at runtime. Only consider env
// vars from the adapter config in that case; the probe itself will surface
// any auth issues on the remote box.
const considerHostEnv = !targetIsRemote;
const hasBedrock =
env.CLAUDE_CODE_USE_BEDROCK === "1" ||
env.CLAUDE_CODE_USE_BEDROCK === "true" ||
(considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "1") ||
(considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "true") ||
process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
process.env.CLAUDE_CODE_USE_BEDROCK === "true" ||
isNonEmpty(env.ANTHROPIC_BEDROCK_BASE_URL) ||
(considerHostEnv && isNonEmpty(process.env.ANTHROPIC_BEDROCK_BASE_URL));
isNonEmpty(process.env.ANTHROPIC_BEDROCK_BASE_URL);
const configApiKey = env.ANTHROPIC_API_KEY;
const hostApiKey = considerHostEnv ? process.env.ANTHROPIC_API_KEY : undefined;
const hostApiKey = process.env.ANTHROPIC_API_KEY;
if (hasBedrock) {
const source =
env.CLAUDE_CODE_USE_BEDROCK === "1" ||
@@ -157,7 +130,7 @@ export async function testEnvironment(
detail: `Detected in ${source}.`,
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
});
} else if (!targetIsRemote) {
} else {
checks.push({
code: "claude_subscription_mode_possible",
level: "info",
@@ -199,9 +172,8 @@ export async function testEnvironment(
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
if (extraArgs.length > 0) args.push(...extraArgs);
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
const probe = await runChildProcess(
`claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{

View File

@@ -8,14 +8,12 @@ import {
adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
@@ -371,7 +369,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const restoreRemoteWorkspace = preparedExecutionTargetRuntime
? () => preparedExecutionTargetRuntime.restoreWorkspace()
: null;
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
const remoteCodexHome = executionTargetIsRemote
? preparedExecutionTargetRuntime?.assetDirs.home ??
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "codex", "home")
@@ -459,19 +456,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir,
adapterKey: "codex",
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
}
}
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
@@ -796,9 +780,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toResult(initial, false, false);
} finally {
if (paperclipBridge) {
await paperclipBridge.stop();
}
if (restoreRemoteWorkspace) {
await onLog(
"stdout",

View File

@@ -6,15 +6,11 @@ import type {
import {
asString,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import path from "node:path";
import { parseCodexJsonl } from "./parse.js";
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
@@ -61,28 +57,10 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "codex");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "codex_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
checks.push({
code: "codex_cwd_valid",
level: "info",
@@ -104,7 +82,7 @@ export async function testEnvironment(
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "codex_command_resolvable",
level: "info",
@@ -120,7 +98,7 @@ export async function testEnvironment(
}
const configOpenAiKey = env.OPENAI_API_KEY;
const hostOpenAiKey = targetIsRemote ? undefined : process.env.OPENAI_API_KEY;
const hostOpenAiKey = process.env.OPENAI_API_KEY;
if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
checks.push({
@@ -129,9 +107,7 @@ export async function testEnvironment(
message: "OPENAI_API_KEY is set for Codex authentication.",
detail: `Detected in ${source}.`,
});
} else if (!targetIsRemote) {
// Local-only auth file check. On remote targets, the probe will surface
// any missing-auth errors directly from the remote `codex` invocation.
} else {
const codexHome = isNonEmpty(env.CODEX_HOME) ? env.CODEX_HOME : undefined;
const codexAuth = await readCodexAuthInfo(codexHome).catch(() => null);
if (codexAuth) {
@@ -174,9 +150,8 @@ export async function testEnvironment(
});
}
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
const probe = await runChildProcess(
`codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{

View File

@@ -80,5 +80,4 @@ 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.
`;

View File

@@ -10,7 +10,6 @@ import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
@@ -19,7 +18,6 @@ import {
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
@@ -43,7 +41,6 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
import { prepareCursorSandboxCommand } from "./remote-command.js";
import { normalizeCursorStreamLine } from "../shared/stream.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
@@ -202,7 +199,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
let command = asString(config.command, "agent");
const command = asString(config.command, "agent");
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
const mode = normalizeMode(asString(config.mode, ""));
@@ -234,7 +231,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
let env: Record<string, string> = { ...buildPaperclipEnv(agent) };
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
@@ -302,22 +299,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const timeoutSec = 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({
runId,
target: executionTarget,
command,
cwd,
env,
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",
@@ -327,12 +308,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
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;
@@ -342,8 +325,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
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) {
try {
@@ -363,7 +344,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}],
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
@@ -394,24 +374,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
throw error;
}
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "cursor",
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv: ensurePathInEnv({ ...process.env, ...env }),
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -460,12 +422,6 @@ 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) {
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 (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(
@@ -680,9 +636,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
return toResult(initial);
} finally {
if (paperclipBridge) {
await paperclipBridge.stop();
}
if (restoreRemoteWorkspace) {
await onLog(
"stdout",

View File

@@ -1,160 +0,0 @@
import path from "node:path";
import {
runAdapterExecutionTargetShellCommand,
type AdapterExecutionTarget,
} from "@paperclipai/adapter-utils/execution-target";
import { ensurePathInEnv } from "@paperclipai/adapter-utils/server-utils";
const DEFAULT_CURSOR_COMMAND_BASENAMES = new Set(["agent", "cursor-agent"]);
function commandBasename(command: string): string {
return command.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
}
function hasPathSeparator(command: string): boolean {
return command.includes("/") || command.includes("\\");
}
function prependPosixPathEntry(pathValue: string, entry: string): string {
const parts = pathValue.split(":").filter(Boolean);
if (parts.includes(entry)) return pathValue;
const cleaned = parts.join(":");
return cleaned.length > 0 ? `${entry}:${cleaned}` : entry;
}
type SandboxCursorRuntimeInfo = {
remoteSystemHomeDir: string | null;
preferredCommandPath: string | null;
};
function readMarkedValue(lines: string[], marker: string): string | null {
const matchedLine = lines.find((line) => line.startsWith(marker));
if (!matchedLine) return null;
const value = matchedLine.slice(marker.length).trim();
return value.length > 0 ? value : null;
}
async function readSandboxCursorRuntimeInfo(input: {
runId: string;
target: AdapterExecutionTarget;
command: string;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<SandboxCursorRuntimeInfo> {
const shouldCheckPreferredCommand = isDefaultCursorCommand(input.command) && !hasPathSeparator(input.command);
const homeMarker = "__PAPERCLIP_CURSOR_HOME__:";
const preferredMarker = "__PAPERCLIP_CURSOR_AGENT__:";
try {
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`
: "",
].filter(Boolean).join("; "),
{
cwd: input.cwd,
env: input.env,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
},
);
if (result.timedOut || (result.exitCode ?? 1) !== 0) {
return {
remoteSystemHomeDir: null,
preferredCommandPath: null,
};
}
const lines = result.stdout.split(/\r?\n/);
return {
remoteSystemHomeDir: readMarkedValue(lines, homeMarker),
preferredCommandPath: readMarkedValue(lines, preferredMarker),
};
} catch {
return {
remoteSystemHomeDir: null,
preferredCommandPath: null,
};
}
}
export function isDefaultCursorCommand(command: string): boolean {
return DEFAULT_CURSOR_COMMAND_BASENAMES.has(commandBasename(command));
}
export type PreparedCursorSandboxCommand = {
command: string;
env: Record<string, string>;
remoteSystemHomeDir: string | null;
addedPathEntry: string | null;
preferredCommandPath: string | null;
};
export async function prepareCursorSandboxCommand(input: {
runId: string;
target: AdapterExecutionTarget | null | undefined;
command: string;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<PreparedCursorSandboxCommand> {
if (input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
return {
command: input.command,
env: input.env,
remoteSystemHomeDir: null,
addedPathEntry: null,
preferredCommandPath: null,
};
}
const runtimeInfo = await readSandboxCursorRuntimeInfo({
runId: input.runId,
target: input.target,
command: input.command,
cwd: input.cwd,
env: input.env,
timeoutSec: input.timeoutSec,
graceSec: input.graceSec,
});
const remoteSystemHomeDir = runtimeInfo.remoteSystemHomeDir;
if (!remoteSystemHomeDir) {
return {
command: input.command,
env: input.env,
remoteSystemHomeDir: null,
addedPathEntry: null,
preferredCommandPath: null,
};
}
const remoteLocalBinDir = path.posix.join(remoteSystemHomeDir, ".local", "bin");
const runtimeEnv = ensurePathInEnv(input.env);
const currentPath = runtimeEnv.PATH ?? runtimeEnv.Path ?? "";
const nextPath = prependPosixPathEntry(currentPath, remoteLocalBinDir);
const env = nextPath === currentPath ? input.env : { ...input.env, PATH: nextPath };
if (!runtimeInfo.preferredCommandPath) {
return {
command: input.command,
env,
remoteSystemHomeDir,
addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir,
preferredCommandPath: null,
};
}
return {
command: runtimeInfo.preferredCommandPath,
env,
remoteSystemHomeDir,
addedPathEntry: nextPath === currentPath ? null : remoteLocalBinDir,
preferredCommandPath: runtimeInfo.preferredCommandPath,
};
}

View File

@@ -7,21 +7,16 @@ import {
asString,
asStringArray,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
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 { parseCursorJsonl } from "./parse.js";
import { isDefaultCursorCommand, prepareCursorSandboxCommand } from "./remote-command.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
@@ -43,6 +38,11 @@ function firstNonEmptyLine(text: string): string {
);
}
function commandLooksLike(command: string, expected: string): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
@@ -94,29 +94,11 @@ export async function testEnvironment(
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
let command = asString(config.command, "agent");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "cursor_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
const command = asString(config.command, "agent");
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
checks.push({
code: "cursor_cwd_valid",
level: "info",
@@ -132,24 +114,13 @@ export async function testEnvironment(
}
const envConfig = parseObject(config.env);
let env: Record<string, string> = {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const sandboxCommand = await prepareCursorSandboxCommand({
runId,
target,
command,
cwd,
env,
timeoutSec: 45,
graceSec: 5,
});
command = sandboxCommand.command;
env = sandboxCommand.env;
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "cursor_command_resolvable",
level: "info",
@@ -165,7 +136,7 @@ export async function testEnvironment(
}
const configCursorApiKey = env.CURSOR_API_KEY;
const hostCursorApiKey = targetIsRemote ? undefined : process.env.CURSOR_API_KEY;
const hostCursorApiKey = process.env.CURSOR_API_KEY;
if (isNonEmpty(configCursorApiKey) || isNonEmpty(hostCursorApiKey)) {
const source = isNonEmpty(configCursorApiKey) ? "adapter config env" : "server environment";
checks.push({
@@ -174,7 +145,7 @@ export async function testEnvironment(
message: "CURSOR_API_KEY is set for Cursor authentication.",
detail: `Detected in ${source}.`,
});
} else if (!targetIsRemote) {
} else {
const cursorHome = isNonEmpty(env.CURSOR_HOME) ? env.CURSOR_HOME : undefined;
const cursorAuth = await readCursorAuthInfo(cursorHome).catch(() => null);
if (cursorAuth) {
@@ -199,13 +170,13 @@ export async function testEnvironment(
const canRunProbe =
checks.every((check) => check.code !== "cursor_cwd_invalid" && check.code !== "cursor_command_unresolvable");
if (canRunProbe) {
if (!isDefaultCursorCommand(command)) {
if (!commandLooksLike(command, "agent")) {
checks.push({
code: "cursor_hello_probe_skipped_custom_command",
level: "info",
message: "Skipped hello probe because command is not a default Cursor CLI entrypoint.",
message: "Skipped hello probe because command is not `agent`.",
detail: command,
hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.",
hint: "Use the `agent` CLI command to run the automatic installation and auth probe.",
});
} else {
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
@@ -221,9 +192,8 @@ export async function testEnvironment(
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("Respond with hello.");
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
const probe = await runChildProcess(
`cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{

View File

@@ -11,7 +11,6 @@ import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
@@ -20,7 +19,6 @@ import {
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asBoolean,
@@ -270,7 +268,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
@@ -287,8 +285,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
let remoteSkillsDir: string | null = null;
let localSkillsDir: string | null = null;
let remoteRuntimeRootDir: string | null = null;
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote) {
try {
@@ -308,7 +304,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}],
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
@@ -339,24 +334,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
throw error;
}
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "gemini",
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv: ensurePathInEnv({ ...process.env, ...env }),
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -606,7 +583,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toResult(initial);
} finally {
await Promise.all([
paperclipBridge?.stop(),
restoreRemoteWorkspace?.(),
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
]);

View File

@@ -9,16 +9,12 @@ import {
asNumber,
asString,
asStringArray,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
parseObject,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js";
@@ -52,28 +48,10 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "gemini");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "gemini_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
checks.push({
code: "gemini_cwd_valid",
level: "info",
@@ -95,7 +73,7 @@ export async function testEnvironment(
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "gemini_command_resolvable",
level: "info",
@@ -111,10 +89,10 @@ export async function testEnvironment(
}
const configGeminiApiKey = env.GEMINI_API_KEY;
const hostGeminiApiKey = targetIsRemote ? undefined : process.env.GEMINI_API_KEY;
const hostGeminiApiKey = process.env.GEMINI_API_KEY;
const configGoogleApiKey = env.GOOGLE_API_KEY;
const hostGoogleApiKey = targetIsRemote ? undefined : process.env.GOOGLE_API_KEY;
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || (!targetIsRemote && process.env.GOOGLE_GENAI_USE_GCA === "true");
const hostGoogleApiKey = process.env.GOOGLE_API_KEY;
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true";
if (
isNonEmpty(configGeminiApiKey) ||
isNonEmpty(hostGeminiApiKey) ||
@@ -174,9 +152,8 @@ export async function testEnvironment(
}
if (extraArgs.length > 0) args.push(...extraArgs);
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
const probe = await runChildProcess(
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{

View File

@@ -10,7 +10,6 @@ import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
prepareAdapterExecutionTargetRuntime,
@@ -19,7 +18,6 @@ import {
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
@@ -236,7 +234,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
const loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
@@ -261,8 +259,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
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) {
localSkillsDir = await buildOpenCodeSkillsDir(config);
@@ -289,7 +285,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
],
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
preparedRuntimeConfig.env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
@@ -316,28 +311,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "opencode",
hostApiToken: preparedRuntimeConfig.env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(preparedRuntimeConfig.env, paperclipBridge.env);
loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
runtimeEnv: Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
),
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -565,7 +538,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toResult(initial);
} finally {
await Promise.all([
paperclipBridge?.stop(),
restoreRemoteWorkspace?.(),
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
]);

View File

@@ -8,15 +8,11 @@ import {
asString,
asStringArray,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
@@ -62,28 +58,10 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "opencode");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "opencode_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: false,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
checks.push({
code: "opencode_cwd_valid",
level: "info",
@@ -137,7 +115,7 @@ export async function testEnvironment(
});
} else {
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "opencode_command_resolvable",
level: "info",
@@ -159,19 +137,7 @@ export async function testEnvironment(
let modelValidationPassed = false;
const configuredModel = asString(config.model, "").trim();
// Model discovery and validation use local child processes against
// OpenCode's `models` subcommand and JSON config; these are not yet
// wired through the execution target. When probing a remote env, skip
// discovery/validation and rely on the remote hello probe to surface
// model/auth issues directly.
if (targetIsRemote && configuredModel) {
checks.push({
code: "opencode_model_validation_skipped_remote",
level: "info",
message: `Skipped local model validation; will be validated by the hello probe inside ${targetLabel}.`,
});
modelValidationPassed = true;
} else if (canRunProbe && configuredModel) {
if (canRunProbe && configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
@@ -207,7 +173,7 @@ export async function testEnvironment(
});
}
}
} else if (!targetIsRemote && canRunProbe && !configuredModel) {
} else if (canRunProbe && !configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
@@ -241,7 +207,7 @@ export async function testEnvironment(
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
if (!configuredModel && !modelUnavailable) {
// No model configured skip model requirement if no model-related checks exist
} else if (!targetIsRemote && configuredModel && canRunProbe) {
} else if (configuredModel && canRunProbe) {
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: configuredModel,
@@ -280,9 +246,8 @@ export async function testEnvironment(
if (extraArgs.length > 0) args.push(...extraArgs);
try {
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
const probe = await runChildProcess(
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{

View File

@@ -10,7 +10,6 @@ import {
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetFile,
@@ -18,7 +17,6 @@ import {
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
@@ -280,7 +278,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
@@ -306,7 +304,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
let remoteRuntimeRootDir: string | null = null;
let localSkillsDir: string | null = null;
let remoteSkillsDir: string | null = null;
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote) {
try {
@@ -341,28 +338,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
throw error;
}
}
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: executionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "pi",
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv: Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
),
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
}
}
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
@@ -679,7 +654,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toResult(initial);
} finally {
await Promise.all([
paperclipBridge?.stop(),
restoreRemoteWorkspace?.(),
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
]);

View File

@@ -6,18 +6,14 @@ import type {
import {
asString,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import {
asStringArray,
} from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import { discoverPiModelsCached } from "./models.js";
import { parsePiJsonl } from "./parse.js";
@@ -82,28 +78,10 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "pi");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "pi_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: false,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
checks.push({
code: "pi_cwd_valid",
level: "info",
@@ -135,7 +113,7 @@ export async function testEnvironment(
});
} else {
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "pi_command_resolvable",
level: "info",
@@ -154,10 +132,7 @@ export async function testEnvironment(
const canRunProbe =
checks.every((check) => check.code !== "pi_cwd_invalid" && check.code !== "pi_command_unresolvable");
// Pi model discovery shells out to `pi --list-models` locally; when probing a
// remote target we skip discovery and let the remote hello probe surface
// model/auth issues directly.
if (!targetIsRemote && canRunProbe) {
if (canRunProbe) {
try {
const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
@@ -191,12 +166,6 @@ export async function testEnvironment(
message: "Pi requires a configured model in provider/model format.",
hint: "Set adapterConfig.model using an ID from `pi --list-models`.",
});
} else if (targetIsRemote) {
checks.push({
code: "pi_model_validation_skipped_remote",
level: "info",
message: `Skipped local model validation; will be validated by the hello probe inside ${targetLabel}.`,
});
} else if (canRunProbe) {
// Verify model is in the list
try {
@@ -249,9 +218,8 @@ export async function testEnvironment(
if (extraArgs.length > 0) args.push(...extraArgs);
try {
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
const probe = await runChildProcess(
`pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{

View File

@@ -14,14 +14,6 @@ From a Paperclip instance, install:
The host plugin installer runs `npm install` into the managed plugin directory, so package dependencies such as `e2b` are pulled in during installation.
## Configuration
Configure E2B from `Company Settings -> Environments`, not from the plugin's instance settings page.
- Put the E2B API key on the sandbox environment itself.
- When you save an environment, Paperclip stores pasted API keys as company secrets.
- `E2B_API_KEY` remains an optional host-level fallback when an environment omits the key.
## Local development
```bash

View File

@@ -35,10 +35,6 @@ declare module "e2b" {
setTimeout(timeoutMs: number): Promise<void>;
kill(): Promise<void>;
pause(): Promise<void>;
files: {
write(path: string, data: string | ArrayBuffer): Promise<unknown>;
remove(path: string): Promise<void>;
};
commands: {
run(
command: string,

View File

@@ -35,7 +35,7 @@ const manifest: PaperclipPluginManifestV1 = {
type: "string",
format: "secret-ref",
description:
"Environment-specific E2B API key. Paste a key or an existing Paperclip secret reference; saved environments store pasted values as company secrets. Falls back to E2B_API_KEY if omitted.",
"Paperclip secret reference for the E2B API key. Falls back to E2B_API_KEY if omitted.",
},
timeoutMs: {
type: "number",

View File

@@ -16,18 +16,7 @@ const { MockCommandExitError, MockSandboxNotFoundError, MockTimeoutError } = vi.
}
}
class MockSandboxNotFoundError extends Error {}
class MockTimeoutError extends Error {
stdout: string;
stderr: string;
result?: { stdout?: string; stderr?: string };
constructor(message: string, streams: { stdout?: string; stderr?: string; nested?: boolean } = {}) {
super(message);
this.stdout = streams.nested ? "" : (streams.stdout ?? "");
this.stderr = streams.nested ? "" : (streams.stderr ?? "");
this.result = streams.nested ? { stdout: streams.stdout, stderr: streams.stderr } : undefined;
}
}
class MockTimeoutError extends Error {}
return { MockCommandExitError, MockSandboxNotFoundError, MockTimeoutError };
});
@@ -65,10 +54,6 @@ function createMockSandbox(overrides: {
setTimeout: vi.fn().mockResolvedValue(undefined),
kill: vi.fn().mockResolvedValue(undefined),
pause: vi.fn().mockResolvedValue(undefined),
files: {
write: vi.fn().mockResolvedValue(undefined),
remove: vi.fn().mockResolvedValue(undefined),
},
commands: {
run: vi.fn(async (command: string, options?: { background?: boolean }) => {
if (options?.background) return handle;
@@ -243,23 +228,8 @@ describe("E2B sandbox provider plugin", () => {
expect(sandbox.kill).toHaveBeenCalled();
});
it("executes commands through a connected sandbox when stdin is provided", async () => {
it("executes commands through a connected sandbox", async () => {
const sandbox = createMockSandbox();
sandbox.commands.run.mockImplementation(async (command: string, options?: { background?: boolean }) => {
if (options?.background) return sandbox.handle;
if (command === "pwd") {
return {
exitCode: 0,
stdout: "/home/user\n",
stderr: "",
};
}
return {
exitCode: 0,
stdout: "stdin\n",
stderr: "",
};
});
mockConnect.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
@@ -282,91 +252,27 @@ describe("E2B sandbox provider plugin", () => {
});
expect(mockConnect).toHaveBeenCalledWith("sandbox-123", expect.objectContaining({ apiKey: "resolved-key" }));
expect(sandbox.files.write).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/), "input");
expect(sandbox.commands.run).toHaveBeenCalledWith(expect.stringMatching(
/^exec 'printf' 'hello' < '\/tmp\/paperclip-stdin-/,
), expect.objectContaining({
cwd: "/workspace",
envs: { FOO: "bar" },
timeoutMs: 1000,
}));
expect(sandbox.commands.run).not.toHaveBeenCalledWith(
"exec 'printf' 'hello'",
expect.objectContaining({ background: true }),
);
expect(sandbox.commands.sendStdin).not.toHaveBeenCalled();
expect(sandbox.commands.closeStdin).not.toHaveBeenCalled();
expect(sandbox.handle.wait).not.toHaveBeenCalled();
expect(sandbox.files.remove).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/));
expect(result).toEqual({
exitCode: 0,
timedOut: false,
stdout: "stdin\n",
stderr: "",
});
});
it("executes non-stdin commands in foreground mode", async () => {
const sandbox = createMockSandbox();
sandbox.commands.run.mockImplementation(async (command: string, options?: { background?: boolean }) => {
if (options?.background) return sandbox.handle;
if (command === "pwd") {
return {
exitCode: 0,
stdout: "/home/user\n",
stderr: "",
};
}
return {
exitCode: 0,
stdout: "foreground\n",
stderr: "",
};
});
mockConnect.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "e2b",
companyId: "company-1",
environmentId: "env-1",
config: {
template: "base",
apiKey: "resolved-key",
timeoutMs: 300000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/workspace",
env: { FOO: "bar" },
timeoutMs: 1000,
});
expect(sandbox.commands.run).toHaveBeenCalledWith("exec 'printf' 'hello'", expect.objectContaining({
background: true,
cwd: "/workspace",
envs: { FOO: "bar" },
stdin: true,
timeoutMs: 1000,
}));
expect(sandbox.commands.run).not.toHaveBeenCalledWith(
"exec 'printf' 'hello'",
expect.objectContaining({ background: true }),
);
expect(sandbox.commands.sendStdin).not.toHaveBeenCalled();
expect(sandbox.commands.closeStdin).not.toHaveBeenCalled();
expect(sandbox.handle.wait).not.toHaveBeenCalled();
expect(sandbox.commands.sendStdin).toHaveBeenCalledWith(42, "input");
expect(sandbox.commands.closeStdin).toHaveBeenCalledWith(42);
expect(result).toEqual({
exitCode: 0,
timedOut: false,
stdout: "foreground\n",
stdout: "ok\n",
stderr: "",
});
});
it("cleans up staged stdin even when writing it fails", async () => {
it("closes stdin even when sendStdin throws unexpectedly", async () => {
const sandbox = createMockSandbox();
const failure = new Error("write failed");
sandbox.files.write.mockRejectedValueOnce(failure);
const failure = new Error("send failed");
sandbox.commands.sendStdin.mockRejectedValueOnce(failure);
mockConnect.mockResolvedValue(sandbox);
await expect(plugin.definition.onEnvironmentExecute?.({
@@ -386,103 +292,12 @@ describe("E2B sandbox provider plugin", () => {
env: { FOO: "bar" },
stdin: "input",
timeoutMs: 1000,
})).rejects.toThrow("write failed");
})).rejects.toThrow("send failed");
expect(sandbox.files.remove).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/));
expect(sandbox.commands.sendStdin).not.toHaveBeenCalled();
expect(sandbox.commands.closeStdin).toHaveBeenCalledWith(42);
expect(sandbox.handle.wait).not.toHaveBeenCalled();
});
it("preserves partial foreground output when a non-stdin command times out", async () => {
const sandbox = createMockSandbox();
sandbox.commands.run.mockImplementation(async (command: string, options?: { background?: boolean }) => {
if (options?.background) return sandbox.handle;
if (command === "pwd") {
return {
exitCode: 0,
stdout: "/home/user\n",
stderr: "",
};
}
throw new MockTimeoutError("command timed out", {
stdout: "partial stdout\n",
stderr: "partial stderr\n",
});
});
mockConnect.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "e2b",
companyId: "company-1",
environmentId: "env-1",
config: {
template: "base",
apiKey: "resolved-key",
timeoutMs: 300000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/workspace",
env: { FOO: "bar" },
timeoutMs: 1000,
});
expect(result).toEqual({
exitCode: null,
timedOut: true,
stdout: "partial stdout\n",
stderr: "partial stderr\ncommand timed out\n",
});
});
it("preserves partial foreground output when a stdin command times out", async () => {
const sandbox = createMockSandbox();
sandbox.commands.run.mockImplementation(async (command: string, options?: { background?: boolean }) => {
if (options?.background) return sandbox.handle;
if (command === "pwd") {
return {
exitCode: 0,
stdout: "/home/user\n",
stderr: "",
};
}
throw new MockTimeoutError("command timed out", {
stdout: "stdin stdout\n",
stderr: "stdin stderr\n",
nested: true,
});
});
mockConnect.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "e2b",
companyId: "company-1",
environmentId: "env-1",
config: {
template: "base",
apiKey: "resolved-key",
timeoutMs: 300000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/workspace",
env: { FOO: "bar" },
stdin: "input",
timeoutMs: 1000,
});
expect(result).toEqual({
exitCode: null,
timedOut: true,
stdout: "stdin stdout\n",
stderr: "stdin stderr\ncommand timed out\n",
});
});
it("pauses reusable leases and kills ephemeral leases on release", async () => {
const reusable = createMockSandbox({ sandboxId: "sandbox-reusable" });
const ephemeral = createMockSandbox({ sandboxId: "sandbox-ephemeral" });

View File

@@ -63,34 +63,6 @@ function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function readTimeoutStream(error: TimeoutError, key: "stdout" | "stderr"): string {
const record = error as unknown as Record<string, unknown>;
const direct = record[key];
if (typeof direct === "string" && direct.length > 0) return direct;
const nested = (record as { result?: Record<string, unknown> }).result?.[key];
if (typeof nested === "string") return nested;
return typeof direct === "string" ? direct : "";
}
function buildTimeoutExecuteResult(error: TimeoutError): PluginEnvironmentExecuteResult {
const stdout = readTimeoutStream(error, "stdout");
const stderrOutput = readTimeoutStream(error, "stderr");
const message = error.message.trim();
const stderr = stderrOutput.length > 0
? message.length > 0 && !stderrOutput.includes(message)
? `${stderrOutput}${stderrOutput.endsWith("\n") ? "" : "\n"}${message}\n`
: stderrOutput
: message.length > 0
? `${message}\n`
: "";
return {
exitCode: null,
timedOut: true,
stdout,
stderr,
};
}
async function ensureSandboxWorkspace(sandbox: Sandbox, remoteCwd: string): Promise<void> {
await sandbox.commands.run(`mkdir -p ${shellQuote(remoteCwd)}`);
}
@@ -344,64 +316,33 @@ const plugin = definePlugin({
const config = parseDriverConfig(params.config);
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
const command = buildCommandLine(params.command, params.args);
if (params.stdin == null) {
try {
const result = await sandbox.commands.run(command, {
cwd: params.cwd,
envs: params.env,
timeoutMs: params.timeoutMs ?? config.timeoutMs,
}) as Awaited<ReturnType<Sandbox["commands"]["run"]>> & {
exitCode: number;
stdout: string;
stderr: string;
};
return {
exitCode: result.exitCode,
timedOut: false,
stdout: result.stdout,
stderr: result.stderr,
};
} catch (error) {
if (error instanceof CommandExitError) {
const commandError = error as CommandExitError;
return {
exitCode: commandError.exitCode,
timedOut: false,
stdout: commandError.stdout,
stderr: commandError.stderr,
};
}
if (error instanceof TimeoutError) {
return buildTimeoutExecuteResult(error);
}
throw error;
}
}
const started = await sandbox.commands.run(command, {
stdin: true,
const started = await sandbox.commands.run(buildCommandLine(params.command, params.args), {
background: true,
stdin: params.stdin != null,
cwd: params.cwd,
envs: params.env,
timeoutMs: params.timeoutMs ?? config.timeoutMs,
}) as Awaited<ReturnType<Sandbox["commands"]["run"]>> & {
pid: number;
exitCode: number;
stdout: string;
stderr: string;
wait(): Promise<{ exitCode: number; stdout: string; stderr: string }>;
};
try {
try {
await sandbox.commands.sendStdin(started.pid, params.stdin);
} finally {
await sandbox.commands.closeStdin(started.pid);
if (params.stdin != null) {
try {
await sandbox.commands.sendStdin(started.pid, params.stdin);
} finally {
await sandbox.commands.closeStdin(started.pid);
}
}
const result = await started.wait();
return {
exitCode: started.exitCode,
exitCode: result.exitCode,
timedOut: false,
stdout: started.stdout,
stderr: started.stderr,
stdout: result.stdout,
stderr: result.stderr,
};
} catch (error) {
if (error instanceof CommandExitError) {
@@ -414,7 +355,13 @@ const plugin = definePlugin({
};
}
if (error instanceof TimeoutError) {
return buildTimeoutExecuteResult(error);
const timeoutError = error as TimeoutError;
return {
exitCode: null,
timedOut: true,
stdout: started.stdout,
stderr: started.stderr || `${timeoutError.message}\n`,
};
}
throw error;
}

View File

@@ -132,13 +132,6 @@ export type ResetAgentSession = z.infer<typeof resetAgentSessionSchema>;
export const testAdapterEnvironmentSchema = z.object({
adapterConfig: adapterConfigSchema.optional().default({}),
/**
* Optional environment to run the adapter test inside. When omitted, the
* test runs against the local Paperclip host. When provided and the
* environment is non-local (SSH/sandbox), the test probes are executed
* inside that environment so the result reflects real agent execution.
*/
environmentId: z.string().uuid().optional().nullable(),
});
export type TestAdapterEnvironment = z.infer<typeof testAdapterEnvironmentSchema>;

View File

@@ -11,7 +11,6 @@ release_date=""
dry_run=false
skip_verify=false
print_version_only=false
allow_canary_latest=false
tag_name=""
cleanup_on_exit=false
@@ -19,12 +18,11 @@ cleanup_on_exit=false
usage() {
cat <<'EOF'
Usage:
./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version] [--allow-canary-latest]
./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version]
Examples:
./scripts/release.sh canary
./scripts/release.sh canary --date 2026-03-17 --dry-run
./scripts/release.sh canary --allow-canary-latest
./scripts/release.sh stable
./scripts/release.sh stable --date 2026-03-17 --dry-run
./scripts/release.sh stable --date 2026-03-18 --print-version
@@ -34,9 +32,6 @@ Notes:
zero-padded UTC day, and P is the same-day stable patch slot.
- Canary releases publish YYYY.MDD.P-canary.N under the npm dist-tag
"canary" and create the git tag canary/vYYYY.MDD.P-canary.N.
- Canary releases fail by default if npm leaves the "latest" dist-tag
pointing at any canary. Pass --allow-canary-latest only when that is an
intentional first-publish or migration state.
- Stable releases publish YYYY.MDD.P under the npm dist-tag "latest" and
create the git tag vYYYY.MDD.P.
- Stable release notes must already exist at releases/vYYYY.MDD.P.md.
@@ -104,7 +99,6 @@ while [ $# -gt 0 ]; do
--dry-run) dry_run=true ;;
--skip-verify) skip_verify=true ;;
--print-version) print_version_only=true ;;
--allow-canary-latest) allow_canary_latest=true ;;
-h|--help)
usage
exit 0
@@ -121,10 +115,6 @@ done
exit 1
}
if [ "$allow_canary_latest" = true ] && [ "$channel" != "canary" ]; then
release_fail "--allow-canary-latest can only be used with the canary channel."
fi
PUBLISH_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$PUBLISH_REMOTE"
@@ -197,11 +187,6 @@ release_info " Release date (UTC): $RELEASE_DATE"
release_info " Target stable version: $TARGET_STABLE_VERSION"
if [ "$channel" = "canary" ]; then
release_info " Canary version: $TARGET_PUBLISH_VERSION"
if [ "$allow_canary_latest" = true ]; then
release_info " latest dist-tag policy: allow canary"
else
release_info " latest dist-tag policy: fail if npm leaves latest on a canary"
fi
else
release_info " Stable version: $TARGET_PUBLISH_VERSION"
fi
@@ -278,7 +263,7 @@ release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 6/7: Skipping npm verification in dry-run mode..."
else
release_info "==> Step 6/7: Confirming npm package availability and dist-tag integrity..."
release_info "==> Step 6/7: Confirming npm package availability..."
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
MISSING_PUBLISHED_PACKAGES=""
@@ -300,21 +285,6 @@ else
[ -z "$MISSING_PUBLISHED_PACKAGES" ] || release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES"
release_info " ✓ Verified all versioned packages are available on npm"
verify_args=(
--channel "$channel"
--dist-tag "$DIST_TAG"
--target-version "$TARGET_PUBLISH_VERSION"
)
if [ "$allow_canary_latest" = true ]; then
verify_args+=(--allow-canary-latest)
fi
while IFS=$'\t' read -r _pkg_dir pkg_name _pkg_version; do
[ -z "$pkg_name" ] && continue
verify_args+=(--package "$pkg_name")
done <<< "$VERSIONED_PACKAGE_INFO"
node "$REPO_ROOT/scripts/verify-release-registry-state.mjs" "${verify_args[@]}"
fi
release_info ""

View File

@@ -1,292 +0,0 @@
#!/usr/bin/env node
import { pathToFileURL } from "node:url";
const CANARY_VERSION_RE = /-canary\.\d+$/;
export function isCanaryVersion(version) {
return CANARY_VERSION_RE.test(version);
}
function usage() {
process.stderr.write(
[
"Usage:",
" node scripts/verify-release-registry-state.mjs --channel <canary|stable> --dist-tag <tag> --target-version <version> --package <name> [--package <name> ...] [--allow-canary-latest]",
"",
].join("\n"),
);
}
function parseArgs(argv) {
const options = {
channel: "",
distTag: "",
targetVersion: "",
allowCanaryLatest: false,
packages: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--channel":
options.channel = argv[index + 1] ?? "";
index += 1;
break;
case "--dist-tag":
options.distTag = argv[index + 1] ?? "";
index += 1;
break;
case "--target-version":
options.targetVersion = argv[index + 1] ?? "";
index += 1;
break;
case "--package":
options.packages.push(argv[index + 1] ?? "");
index += 1;
break;
case "--allow-canary-latest":
options.allowCanaryLatest = true;
break;
case "-h":
case "--help":
usage();
process.exit(0);
default:
throw new Error(`unexpected argument: ${arg}`);
}
}
if (options.channel !== "canary" && options.channel !== "stable") {
throw new Error("--channel must be canary or stable");
}
if (!options.distTag) {
throw new Error("--dist-tag is required");
}
if (!options.targetVersion) {
throw new Error("--target-version is required");
}
if (options.packages.length === 0 || options.packages.some((name) => !name)) {
throw new Error("at least one non-empty --package value is required");
}
if (options.allowCanaryLatest && options.channel !== "canary") {
throw new Error("--allow-canary-latest only applies to canary releases");
}
return options;
}
function createRegistryUrl(packageName) {
const registry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY ?? "https://registry.npmjs.org/";
return new URL(encodeURIComponent(packageName), registry.endsWith("/") ? registry : `${registry}/`);
}
async function fetchPackageDocument(packageName, { allowMissing = false } = {}) {
const url = createRegistryUrl(packageName);
const response = await fetch(url, {
headers: {
accept: "application/vnd.npm.install-v1+json, application/json;q=0.9",
},
});
if (response.status === 404 && allowMissing) {
return null;
}
if (!response.ok) {
throw new Error(`npm registry request failed for ${packageName}: ${response.status} ${response.statusText}`);
}
return response.json();
}
export function collectInternalDependencyProblems(manifest, packageDocsByName) {
const problems = [];
const sections = [
["dependencies", manifest.dependencies ?? {}],
["optionalDependencies", manifest.optionalDependencies ?? {}],
["peerDependencies", manifest.peerDependencies ?? {}],
];
for (const [sectionName, deps] of sections) {
for (const [dependencyName, dependencyVersion] of Object.entries(deps)) {
if (!dependencyName.startsWith("@paperclipai/")) {
continue;
}
if (typeof dependencyVersion !== "string" || !dependencyVersion) {
problems.push(
`${sectionName} declares ${dependencyName} with a non-string version: ${JSON.stringify(dependencyVersion)}`,
);
continue;
}
const dependencyDoc = packageDocsByName.get(dependencyName);
if (!dependencyDoc) {
problems.push(`${sectionName} requires ${dependencyName}@${dependencyVersion}, but that package is not published`);
continue;
}
if (!(dependencyVersion in (dependencyDoc.versions ?? {}))) {
problems.push(
`${sectionName} requires ${dependencyName}@${dependencyVersion}, but npm does not expose that version`,
);
}
}
}
return problems;
}
function requireManifest(packageName, version, packageDoc, problems) {
const manifest = packageDoc.versions?.[version];
if (!manifest) {
if (problems) {
problems.push(`${packageName}: npm registry is missing manifest data for ${version}`);
}
return null;
}
return manifest;
}
export function verifyPackageRegistryState({
packageName,
packageDoc,
packageDocsByName,
channel,
distTag,
targetVersion,
allowCanaryLatest,
}) {
const problems = [];
const distTags = packageDoc["dist-tags"] ?? {};
const taggedVersion = distTags[distTag];
if (taggedVersion !== targetVersion) {
problems.push(
`${packageName}: dist-tag ${distTag} resolves to ${taggedVersion ?? "<missing>"}, expected ${targetVersion}`,
);
}
const targetManifest = requireManifest(packageName, targetVersion, packageDoc, problems);
if (targetManifest) {
for (const problem of collectInternalDependencyProblems(targetManifest, packageDocsByName)) {
problems.push(`${packageName}@${targetVersion}: ${problem}`);
}
}
if (channel === "canary") {
const latestVersion = distTags.latest;
if (latestVersion && isCanaryVersion(latestVersion) && !allowCanaryLatest) {
problems.push(
`${packageName}: latest dist-tag still resolves to canary ${latestVersion}; rerun with --allow-canary-latest only when that state is intentional`,
);
}
if (latestVersion && isCanaryVersion(latestVersion)) {
const latestManifest = requireManifest(packageName, latestVersion, packageDoc, problems);
if (latestManifest) {
for (const problem of collectInternalDependencyProblems(latestManifest, packageDocsByName)) {
problems.push(`${packageName}@${latestVersion} via latest: ${problem}`);
}
}
}
}
return problems;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const packageNames = [...new Set(options.packages)];
const packageDocsByName = new Map();
await Promise.all(
packageNames.map(async (packageName) => {
packageDocsByName.set(packageName, await fetchPackageDocument(packageName));
}),
);
const additionalInternalDeps = new Set();
for (const packageDoc of packageDocsByName.values()) {
const versionsToCheck = new Set([options.targetVersion]);
const latestVersion = packageDoc["dist-tags"]?.latest;
if (latestVersion && isCanaryVersion(latestVersion)) {
versionsToCheck.add(latestVersion);
}
for (const version of versionsToCheck) {
const manifest = packageDoc.versions?.[version];
if (!manifest) {
continue;
}
for (const deps of [
manifest.dependencies ?? {},
manifest.optionalDependencies ?? {},
manifest.peerDependencies ?? {},
]) {
for (const dependencyName of Object.keys(deps)) {
if (dependencyName.startsWith("@paperclipai/")) {
additionalInternalDeps.add(dependencyName);
}
}
}
}
}
const missingDeps = [...additionalInternalDeps].filter((dep) => !packageDocsByName.has(dep));
await Promise.all(
missingDeps.map(async (dependencyName) => {
packageDocsByName.set(
dependencyName,
await fetchPackageDocument(dependencyName, { allowMissing: true }),
);
}),
);
const problems = [];
for (const packageName of packageNames) {
process.stdout.write(` Verifying ${packageName} on dist-tag ${options.distTag}\n`);
const packageProblems = verifyPackageRegistryState({
packageName,
packageDoc: packageDocsByName.get(packageName),
packageDocsByName,
channel: options.channel,
distTag: options.distTag,
targetVersion: options.targetVersion,
allowCanaryLatest: options.allowCanaryLatest,
});
if (packageProblems.length === 0) {
process.stdout.write(` ✓ dist-tag and published internal dependencies are consistent\n`);
continue;
}
for (const problem of packageProblems) {
process.stderr.write(`${problem}\n`);
problems.push(problem);
}
}
if (problems.length > 0) {
throw new Error(`npm registry verification failed for ${problems.length} problem(s)`);
}
}
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
main().catch((error) => {
process.stderr.write(`Error: ${error.message}\n`);
process.exit(1);
});
}

View File

@@ -1,128 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
collectInternalDependencyProblems,
isCanaryVersion,
verifyPackageRegistryState,
} from "./verify-release-registry-state.mjs";
test("isCanaryVersion matches release canaries", () => {
assert.equal(isCanaryVersion("2026.427.0-canary.3"), true);
assert.equal(isCanaryVersion("2026.427.0"), false);
});
test("collectInternalDependencyProblems flags missing internal versions", () => {
const manifest = {
dependencies: {
"@paperclipai/plugin-sdk": "2026.425.0-canary.5",
e2b: "^2.19.0",
},
};
const packageDocsByName = new Map([
[
"@paperclipai/plugin-sdk",
{
versions: {
"2026.427.0-canary.3": {},
},
},
],
]);
assert.deepEqual(collectInternalDependencyProblems(manifest, packageDocsByName), [
"dependencies requires @paperclipai/plugin-sdk@2026.425.0-canary.5, but npm does not expose that version",
]);
});
test("verifyPackageRegistryState fails when canary latest is left in place by default", () => {
const packageDocsByName = new Map([
[
"@paperclipai/plugin-e2b",
{
"dist-tags": {
latest: "2026.425.0-canary.5",
canary: "2026.427.0-canary.3",
},
versions: {
"2026.425.0-canary.5": {
dependencies: {
"@paperclipai/plugin-sdk": "2026.425.0-canary.5",
},
},
"2026.427.0-canary.3": {
dependencies: {
"@paperclipai/plugin-sdk": "2026.427.0-canary.3",
},
},
},
},
],
[
"@paperclipai/plugin-sdk",
{
versions: {
"2026.427.0-canary.3": {},
},
},
],
]);
assert.deepEqual(
verifyPackageRegistryState({
packageName: "@paperclipai/plugin-e2b",
packageDoc: packageDocsByName.get("@paperclipai/plugin-e2b"),
packageDocsByName,
channel: "canary",
distTag: "canary",
targetVersion: "2026.427.0-canary.3",
allowCanaryLatest: false,
}),
[
"@paperclipai/plugin-e2b: latest dist-tag still resolves to canary 2026.425.0-canary.5; rerun with --allow-canary-latest only when that state is intentional",
"@paperclipai/plugin-e2b@2026.425.0-canary.5 via latest: dependencies requires @paperclipai/plugin-sdk@2026.425.0-canary.5, but npm does not expose that version",
],
);
});
test("verifyPackageRegistryState allows intentional canary latest but still checks dependencies", () => {
const packageDocsByName = new Map([
[
"paperclipai",
{
"dist-tags": {
latest: "2026.427.0-canary.3",
canary: "2026.427.0-canary.3",
},
versions: {
"2026.427.0-canary.3": {
dependencies: {
"@paperclipai/server": "2026.427.0-canary.3",
},
},
},
},
],
[
"@paperclipai/server",
{
versions: {
"2026.427.0-canary.3": {},
},
},
],
]);
assert.deepEqual(
verifyPackageRegistryState({
packageName: "paperclipai",
packageDoc: packageDocsByName.get("paperclipai"),
packageDocsByName,
channel: "canary",
distTag: "canary",
targetVersion: "2026.427.0-canary.3",
allowCanaryLatest: true,
}),
[],
);
});

View File

@@ -180,42 +180,4 @@ describe("claude_local environment diagnostics", () => {
expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
});
it("defaults remote probes to the environment remote cwd when adapter cwd is unset", async () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
command: process.execPath,
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "test-provider",
remoteCwd: "/srv/paperclip/workspace",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
},
environmentName: "Linux Box",
});
expect(result.checks.some((check) => check.code === "claude_cwd_valid")).toBe(true);
expect(
result.checks.some(
(check) =>
check.code === "claude_cwd_valid" &&
check.message === "Working directory is valid: /srv/paperclip/workspace",
),
).toBe(true);
expect(result.checks.some((check) => check.code === "claude_cwd_invalid")).toBe(false);
});
});

View File

@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
import { execute } from "@paperclipai/adapter-claude-local/server";
async function writeFailingClaudeCommand(
@@ -38,12 +37,6 @@ const payload = {
instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null,
skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [],
claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null,
claudeConfigEntries: process.env.CLAUDE_CONFIG_DIR && fs.existsSync(process.env.CLAUDE_CONFIG_DIR)
? fs.readdirSync(process.env.CLAUDE_CONFIG_DIR).sort()
: [],
paperclipApiUrl: process.env.PAPERCLIP_API_URL || null,
paperclipApiKey: process.env.PAPERCLIP_API_KEY || null,
paperclipApiBridgeMode: process.env.PAPERCLIP_API_BRIDGE_MODE || null,
};
if (capturePath) {
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
@@ -64,10 +57,6 @@ type CapturePayload = {
instructionsContents: string | null;
skillEntries: string[];
claudeConfigDir: string | null;
claudeConfigEntries?: string[];
paperclipApiUrl?: string | null;
paperclipApiKey?: string | null;
paperclipApiBridgeMode?: string | null;
appendedSystemPromptFilePath?: string | null;
appendedSystemPromptFileContents?: string | null;
};
@@ -140,40 +129,6 @@ async function setupExecuteEnv(
};
}
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 runChildProcess(
`sandbox-run-${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,
},
);
},
};
}
describe("claude execute", () => {
/**
* Regression tests for https://github.com/paperclipai/paperclip/issues/2848
@@ -443,82 +398,6 @@ describe("claude execute", () => {
}
});
it("injects bridge env into sandbox-managed remote runs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-"));
const localWorkspace = path.join(root, "workspace");
const remoteWorkspace = path.join(root, "sandbox-$HOME");
const binDir = path.join(root, "bin");
const commandPath = path.join(binDir, "claude");
const capturePath = path.join(remoteWorkspace, "capture.json");
const claudeRoot = path.join(root, ".claude");
const previousHome = process.env.HOME;
const previousPath = process.env.PATH;
await fs.mkdir(localWorkspace, { recursive: true });
await fs.mkdir(remoteWorkspace, { recursive: true });
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(claudeRoot, { recursive: true });
await fs.writeFile(path.join(claudeRoot, "settings.json"), JSON.stringify({ theme: "test" }), "utf8");
await writeFakeClaudeCommand(commandPath);
process.env.HOME = root;
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
try {
const result = await execute({
runId: "run-sandbox-auth",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: localWorkspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd: remoteWorkspace,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.claudeConfigDir).toBe(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "config"));
expect(capture.claudeConfigEntries).toContain("settings.json");
expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(capture.paperclipApiKey).not.toBe("run-jwt-token");
expect(capture.paperclipApiBridgeMode).toBe("queue_v1");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
await fs.rm(root, { recursive: true, force: true });
}
});
it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-"));
const workspace = path.join(root, "workspace");

View File

@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
import { execute } from "@paperclipai/adapter-codex-local/server";
async function writeFakeCodexCommand(commandPath: string): Promise<void> {
@@ -15,9 +14,6 @@ const payload = {
prompt: fs.readFileSync(0, "utf8"),
codexHome: process.env.CODEX_HOME || null,
paperclipWakePayloadJson: process.env.PAPERCLIP_WAKE_PAYLOAD_JSON || null,
paperclipApiUrl: process.env.PAPERCLIP_API_URL || null,
paperclipApiKey: process.env.PAPERCLIP_API_KEY || null,
paperclipApiBridgeMode: process.env.PAPERCLIP_API_BRIDGE_MODE || null,
paperclipEnvKeys: Object.keys(process.env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort(),
@@ -47,9 +43,6 @@ type CapturePayload = {
prompt: string;
codexHome: string | null;
paperclipWakePayloadJson: string | null;
paperclipApiUrl?: string | null;
paperclipApiKey?: string | null;
paperclipApiBridgeMode?: string | null;
paperclipEnvKeys: string[];
};
@@ -58,40 +51,6 @@ type LogEntry = {
chunk: string;
};
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 runChildProcess(
`sandbox-run-${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,
},
);
},
};
}
describe("codex execute", () => {
it("uses a Paperclip-managed CODEX_HOME outside worktree mode while preserving shared auth and config", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-default-"));
@@ -311,80 +270,6 @@ describe("codex execute", () => {
}
});
it("injects bridge env into sandbox-managed remote runs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-sandbox-"));
const localWorkspace = path.join(root, "workspace");
const remoteWorkspace = path.join(root, "sandbox");
const binDir = path.join(root, "bin");
const commandPath = path.join(binDir, "codex");
const capturePath = path.join(remoteWorkspace, "capture.json");
const previousHome = process.env.HOME;
const previousPath = process.env.PATH;
await fs.mkdir(localWorkspace, { recursive: true });
await fs.mkdir(remoteWorkspace, { recursive: true });
await fs.mkdir(binDir, { recursive: true });
await writeFakeCodexCommand(commandPath);
process.env.HOME = root;
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
try {
const result = await execute({
runId: "run-sandbox-auth",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: localWorkspace,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd: remoteWorkspace,
timeoutMs: 30_000,
runner: createLocalSandboxRunner(),
},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.codexHome).toBe(path.join(remoteWorkspace, ".paperclip-runtime", "codex", "home"));
expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(capture.paperclipApiKey).not.toBe("run-jwt-token");
expect(capture.paperclipApiBridgeMode).toBe("queue_v1");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
await fs.rm(root, { recursive: true, force: true });
}
});
it("injects structured Paperclip wake payloads into env and prompt", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-wake-"));
const workspace = path.join(root, "workspace");

View File

@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
import { testEnvironment } from "@paperclipai/adapter-cursor-local/server";
async function writeFakeAgentCommand(binDir: string, argsCapturePath: string): Promise<string> {
@@ -28,61 +27,6 @@ console.log(JSON.stringify({
return commandPath;
}
async function writeFakeCursorAgentCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const outPath = process.env.PAPERCLIP_TEST_ARGS_PATH;
if (outPath) {
fs.writeFileSync(outPath, JSON.stringify({
command: process.argv[1],
argv: process.argv.slice(2),
path: process.env.PATH || "",
}), "utf8");
}
console.log(JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
}));
console.log(JSON.stringify({
type: "result",
subtype: "success",
result: "hello",
}));
`;
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
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-sandbox-env-${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,
});
},
};
}
describe("cursor environment diagnostics", () => {
beforeEach(() => {
vi.stubEnv("CURSOR_API_KEY", "");
@@ -180,57 +124,6 @@ describe("cursor environment diagnostics", () => {
await fs.rm(root, { recursive: true, force: true });
});
it("prefers ~/.local/bin/cursor-agent for remote sandbox probes when using the default command", async () => {
const root = path.join(
os.tmpdir(),
`paperclip-cursor-sandbox-probe-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
const homeDir = path.join(root, "home");
const remoteCwd = path.join(root, "workspace");
const argsCapturePath = path.join(root, "args.json");
const cursorAgentPath = path.join(homeDir, ".local", "bin", "cursor-agent");
await fs.mkdir(remoteCwd, { recursive: true });
await writeFakeCursorAgentCommand(cursorAgentPath);
const previousHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "cursor",
executionTarget: {
kind: "remote",
transport: "sandbox",
remoteCwd,
runner: createLocalSandboxRunner(),
timeoutMs: 30_000,
},
config: {
command: "agent",
cwd: remoteCwd,
env: {
CURSOR_API_KEY: "test-key",
PAPERCLIP_TEST_ARGS_PATH: argsCapturePath,
},
},
});
expect(result.status).toBe("pass");
const capture = JSON.parse(await fs.readFile(argsCapturePath, "utf8")) as {
command: string;
argv: string[];
path: string;
};
expect(capture.command).toBe(cursorAgentPath);
expect(capture.path.split(":")[0]).toBe(path.join(homeDir, ".local", "bin"));
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
it("emits cursor_native_auth_present when cli-config.json has authInfo and CURSOR_API_KEY is unset", async () => {
const root = path.join(
os.tmpdir(),

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
import { execute } from "@paperclipai/adapter-cursor-local/server";
async function writeFakeCursorCommand(commandPath: string): Promise<void> {
@@ -41,68 +40,6 @@ console.log(JSON.stringify({
await fs.chmod(commandPath, 0o755);
}
async function writeFakeSandboxCursorAgent(commandPath: string, capturePath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const payload = {
command: process.argv[1],
argv: process.argv.slice(2),
prompt: fs.readFileSync(0, "utf8"),
path: process.env.PATH || "",
};
fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(payload), "utf8");
console.log(JSON.stringify({
type: "system",
subtype: "init",
session_id: "cursor-session-remote-1",
model: "auto",
}));
console.log(JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
}));
console.log(JSON.stringify({
type: "result",
subtype: "success",
session_id: "cursor-session-remote-1",
result: "ok",
}));
`;
await fs.mkdir(path.dirname(commandPath), { recursive: true });
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
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-sandbox-execute-${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,
});
},
};
}
type CapturePayload = {
argv: string[];
prompt: string;
@@ -322,127 +259,4 @@ describe("cursor execute", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
it("prefers ~/.local/bin/cursor-agent for remote sandbox execution when using the default command", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-sandbox-execute-"));
const homeDir = path.join(root, "home");
const workspace = path.join(root, "workspace");
const remoteWorkspace = path.join(root, "remote-workspace");
const capturePath = path.join(root, "capture.json");
const cursorAgentPath = path.join(homeDir, ".local", "bin", "cursor-agent");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(remoteWorkspace, { recursive: true });
await writeFakeSandboxCursorAgent(cursorAgentPath, capturePath);
const previousHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const result = await execute({
runId: "run-sandbox-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: createLocalSandboxRunner(),
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);
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as {
command: string;
argv: string[];
prompt: string;
path: string;
};
expect(capture.command).toBe(cursorAgentPath);
expect(capture.path.split(":")[0]).toBe(path.join(homeDir, ".local", "bin"));
expect(capture.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("keeps explicit command overrides for remote sandbox execution", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-sandbox-explicit-"));
const homeDir = path.join(root, "home");
const workspace = path.join(root, "workspace");
const remoteWorkspace = path.join(root, "remote-workspace");
const capturePath = path.join(root, "capture.json");
const cursorAgentPath = path.join(homeDir, ".local", "bin", "cursor-agent");
const customCommandPath = path.join(root, "bin", "custom-cursor");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(remoteWorkspace, { recursive: true });
await writeFakeSandboxCursorAgent(cursorAgentPath, path.join(root, "unused.json"));
await writeFakeSandboxCursorAgent(customCommandPath, capturePath);
const previousHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const result = await execute({
runId: "run-sandbox-2",
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: createLocalSandboxRunner(),
timeoutMs: 30_000,
},
config: {
command: customCommandPath,
cwd: workspace,
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as { command: string };
expect(capture.command).toBe(customCommandPath);
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -16,8 +16,6 @@ import {
describe("resolveEnvironmentExecutionTarget", () => {
beforeEach(() => {
mockResolveEnvironmentDriverConfigForRuntime.mockReset();
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_RUNTIME_API_URL;
});
it("uses a bounded default cwd for sandbox targets when lease metadata omits remoteCwd", async () => {
@@ -54,47 +52,7 @@ describe("resolveEnvironmentExecutionTarget", () => {
remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD,
leaseId: "lease-1",
environmentId: "env-1",
paperclipTransport: "bridge",
timeoutMs: 30_000,
});
});
it("prefers an explicit Paperclip API URL from lease metadata for sandbox targets", async () => {
process.env.PAPERCLIP_API_URL = "https://paperclip.example.test";
process.env.PAPERCLIP_RUNTIME_API_URL = "http://paperclip.example.test:3200";
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "sandbox",
config: {
provider: "fake-plugin",
reuseLease: false,
timeoutMs: 30_000,
},
});
const target = await resolveEnvironmentExecutionTarget({
db: {} as never,
companyId: "company-1",
adapterType: "codex_local",
environment: {
id: "env-1",
driver: "sandbox",
config: {
provider: "fake-plugin",
},
},
leaseId: "lease-1",
leaseMetadata: {
paperclipApiUrl: "https://paperclip.example.test",
},
lease: null,
environmentRuntime: null,
});
expect(target).toMatchObject({
kind: "remote",
transport: "sandbox",
paperclipApiUrl: "https://paperclip.example.test",
paperclipTransport: "direct",
});
});
});

View File

@@ -174,13 +174,6 @@ function makeMockRuntime(overrides: Partial<EnvironmentRuntimeService> = {}): En
return {
acquireRunLease: vi.fn(),
releaseRunLeases: vi.fn(),
execute: vi.fn().mockResolvedValue({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
}),
realizeWorkspace: vi.fn().mockResolvedValue({
cwd: "/workspace/project",
metadata: {
@@ -354,118 +347,4 @@ describe("environmentRunOrchestrator — realizeForRun", () => {
expect(result.lease).toEqual(updatedLease);
expect(result.persistedExecutionWorkspace).toEqual(updatedEw);
});
it("runs a remote provision command after workspace realization when configured", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
adapterType: "claude_local",
companyId: "company-1",
environmentId: "env-1",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: "run-1",
requestedMode: null,
source: {
kind: "project_primary",
localPath: "/workspace/project",
projectId: null,
projectWorkspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "project_primary",
branchName: null,
worktreePath: null,
},
runtimeOverlay: {
provisionCommand: "npm install -g @anthropic-ai/claude-code",
},
});
mockResolveEnvironmentExecutionTarget.mockResolvedValue({
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
remoteCwd: "/remote/workspace",
environmentId: "env-1",
leaseId: "lease-1",
});
const runtime = makeMockRuntime({
realizeWorkspace: vi.fn().mockResolvedValue({
cwd: "/remote/workspace",
metadata: {
workspaceRealization: {
version: 1,
transport: "sandbox",
remote: { path: "/remote/workspace" },
},
},
}),
});
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
await orchestrator.realizeForRun(makeRealizeInput({
environment: makeEnvironment("sandbox"),
}));
expect(runtime.execute).toHaveBeenCalledOnce();
expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({
environment: expect.objectContaining({ driver: "sandbox" }),
lease: expect.objectContaining({ id: "lease-1" }),
command: "bash",
args: ["-lc", "npm install -g @anthropic-ai/claude-code"],
cwd: "/remote/workspace",
env: {
SHELL: "/bin/bash",
},
}));
});
it("surfaces remote provision command failures before resolving the adapter target", async () => {
mockBuildWorkspaceRealizationRequest.mockReturnValue({
version: 1,
adapterType: "claude_local",
companyId: "company-1",
environmentId: "env-1",
executionWorkspaceId: null,
issueId: null,
heartbeatRunId: "run-1",
requestedMode: null,
source: {
kind: "project_primary",
localPath: "/workspace/project",
projectId: null,
projectWorkspaceId: null,
repoUrl: null,
repoRef: null,
strategy: "project_primary",
branchName: null,
worktreePath: null,
},
runtimeOverlay: {
provisionCommand: "install-tool",
},
});
const runtime = makeMockRuntime({
execute: vi.fn().mockResolvedValue({
exitCode: 127,
signal: null,
timedOut: false,
stdout: "",
stderr: "/bin/sh: install-tool: not found\n",
}),
});
const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime });
await expect(orchestrator.realizeForRun(makeRealizeInput({
environment: makeEnvironment("sandbox"),
}))).rejects.toSatisfy(
(err: unknown) =>
err instanceof EnvironmentRunError &&
err.code === "workspace_realization_failed" &&
String(err.message).includes("install-tool: not found"),
);
expect(mockResolveEnvironmentExecutionTarget).not.toHaveBeenCalled();
});
});

View File

@@ -551,7 +551,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(executed.stdout).toBe("ok\n");
expect(released).toHaveLength(1);
expect(released[0]?.lease.status).toBe("released");
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.anything(), 31000);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.anything());
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.anything());
});
@@ -676,6 +676,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
args: ["ok"],
cwd: "/workspace",
env: {},
timeoutMs: 1000,
});
await environmentService(db).update(environment.id, {
@@ -691,7 +692,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
config: expect.objectContaining({
apiKey: "resolved-provider-key",
}),
}), 31234);
}));
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.objectContaining({
config: expect.objectContaining({
apiKey: "resolved-provider-key",
@@ -699,98 +700,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
}));
});
it("waits briefly for a ready sandbox provider plugin worker to come online", async () => {
const pluginId = randomUUID();
const { companyId, environment: baseEnvironment, runId } = await seedEnvironment();
const providerConfig = {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 1234,
reuseLease: false,
};
const environment = {
...baseEnvironment,
name: "Eventually Running Plugin Sandbox",
driver: "sandbox",
config: providerConfig,
};
await environmentService(db).update(environment.id, {
driver: "sandbox",
name: environment.name,
config: providerConfig,
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: "acme.eventually-running-sandbox-provider",
packageName: "@acme/eventually-running-sandbox-provider",
version: "1.0.0",
apiVersion: 1,
categories: ["automation"],
manifestJson: {
id: "acme.eventually-running-sandbox-provider",
apiVersion: 1,
version: "1.0.0",
displayName: "Eventually Running Sandbox Provider",
description: "Test plugin worker startup grace period",
author: "Acme",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
environmentDrivers: [
{
driverKey: "fake-plugin",
kind: "sandbox_provider",
displayName: "Fake Plugin",
configSchema: { type: "object" },
},
],
},
status: "ready",
installOrder: 1,
updatedAt: new Date(),
} as any);
let runningChecks = 0;
const workerManager = {
isRunning: vi.fn((id: string) => {
if (id !== pluginId) return false;
runningChecks += 1;
return runningChecks >= 3;
}),
call: vi.fn(async (_pluginId: string, method: string) => {
if (method === "environmentAcquireLease") {
return {
providerLeaseId: "sandbox-1",
metadata: {
provider: "fake-plugin",
image: "fake:test",
timeoutMs: 1234,
reuseLease: false,
},
};
}
throw new Error(`Unexpected plugin method: ${method}`);
}),
} as unknown as PluginWorkerManager;
const runtimeWithPlugin = environmentRuntimeService(db, {
pluginWorkerManager: workerManager,
pluginWorkerReadyTimeoutMs: 25,
pluginWorkerReadyPollMs: 1,
});
const acquired = await runtimeWithPlugin.acquireRunLease({
companyId,
environment,
issueId: null,
heartbeatRunId: runId,
persistedExecutionWorkspace: null,
});
expect(acquired.lease.providerLeaseId).toBe("sandbox-1");
expect(workerManager.isRunning).toHaveBeenCalledTimes(3);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", expect.anything());
});
it("falls back to acquire when plugin-backed sandbox lease resume throws", async () => {
const pluginId = randomUUID();
const { companyId, environment: baseEnvironment, runId } = await seedEnvironment();
@@ -1240,7 +1149,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
args: ["ok"],
cwd: "/workspace/project",
env: { FOO: "bar" },
}), 31000);
}));
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", {
driverKey: "fake-plugin",
companyId,

View File

@@ -14,8 +14,6 @@ import {
createDb,
documentRevisions,
documents,
environmentLeases,
environments,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
@@ -311,8 +309,6 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(agentRuntimeState);
await db.delete(companySkills);
await db.delete(costEvents);
await db.delete(environmentLeases);
await db.delete(environments);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
@@ -470,48 +466,6 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
return { companyId, agentId, runId, wakeupRequestId, issueId };
}
async function seedEnvironmentLeaseFixture(input: {
companyId: string;
runId: string;
issueId: string;
provider?: string;
}) {
const environmentId = randomUUID();
const leaseId = randomUUID();
const now = new Date("2026-03-19T00:00:00.000Z");
await db.insert(environments).values({
id: environmentId,
companyId: input.companyId,
name: "Local test environment",
driver: "local",
status: "active",
config: {},
metadata: null,
});
await db.insert(environmentLeases).values({
id: leaseId,
companyId: input.companyId,
environmentId,
issueId: input.issueId,
heartbeatRunId: input.runId,
status: "active",
leasePolicy: "ephemeral",
provider: input.provider ?? "local",
providerLeaseId: null,
acquiredAt: now,
lastUsedAt: now,
metadata: {
driver: "local",
},
createdAt: now,
updatedAt: now,
});
return { environmentId, leaseId };
}
async function seedStrandedIssueFixture(input: {
status: "todo" | "in_progress";
runStatus: "failed" | "timed_out" | "cancelled" | "succeeded";
@@ -923,30 +877,6 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(issue?.checkoutRunId).toBe(runId);
});
it("releases active environment leases when an orphaned run is reaped", async () => {
const { runId, issueId, companyId } = await seedRunFixture({
processPid: 999_999_999,
});
const { leaseId } = await seedEnvironmentLeaseFixture({
companyId,
runId,
issueId,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reapOrphanedRuns();
expect(result.reaped).toBe(1);
expect(result.runIds).toEqual([runId]);
const lease = await db
.select()
.from(environmentLeases)
.where(eq(environmentLeases.id, leaseId))
.then((rows) => rows[0] ?? null);
expect(lease?.status).toBe("failed");
expect(lease?.releasedAt).toBeTruthy();
});
it.skipIf(process.platform === "win32")("reaps orphaned descendant process groups when the parent pid is already gone", async () => {
const orphan = await spawnOrphanedProcessGroup();
cleanupPids.add(orphan.descendantPid);

View File

@@ -1,88 +0,0 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import {
agents,
agentRuntimeState,
agentWakeupRequests,
companies,
createDb,
heartbeatRunEvents,
heartbeatRuns,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres heartbeat runtime-state tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("heartbeat runtime state deduplication", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("heartbeat-runtime-state-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("deduplicates concurrent runtime-state creation", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const heartbeat = heartbeatService(db);
const results = await Promise.all(Array.from({ length: 12 }, () => heartbeat.getRuntimeState(agentId)));
expect(results.every((row) => row?.agentId === agentId)).toBe(true);
const rows = await db.select().from(agentRuntimeState).where(eq(agentRuntimeState.agentId, agentId));
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
agentId,
companyId,
adapterType: "codex_local",
stateJson: {},
});
});
});

View File

@@ -1,6 +1,4 @@
import os from "node:os";
import { describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { Request } from "express";
import { buildInviteOnboardingTextDocument } from "../routes/access.js";
@@ -116,68 +114,4 @@ describe("buildInviteOnboardingTextDocument", () => {
expect(text).toContain("Message from inviter");
expect(text).toContain("prioritize flaky test triage first");
});
it("includes LAN candidates when the advertised host is tailnet-only", () => {
const networkSpy = vi.spyOn(os, "networkInterfaces").mockReturnValue({
en0: [
{
address: "fe80::1",
family: "IPv6",
internal: false,
netmask: "ffff:ffff:ffff:ffff::",
cidr: "fe80::1/64",
mac: "00:00:00:00:00:00",
scopeid: 1,
},
{
address: "192.168.6.178",
family: "IPv4",
internal: false,
netmask: "255.255.252.0",
cidr: "192.168.6.178/22",
mac: "00:00:00:00:00:00",
},
],
utun0: [
{
address: "203.0.113.42",
family: "IPv4",
internal: false,
netmask: "255.255.255.255",
cidr: "203.0.113.42/32",
mac: "00:00:00:00:00:00",
},
],
});
try {
const req = buildReq("paperclip.example.test:3103");
const invite = {
id: "invite-4",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
tokenHash: "hash",
defaultsPayload: null,
expiresAt: new Date("2026-03-05T00:00:00.000Z"),
invitedByUserId: null,
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-04T00:00:00.000Z"),
updatedAt: new Date("2026-03-04T00:00:00.000Z"),
} as const;
const text = buildInviteOnboardingTextDocument(req, "token-999", invite as any, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "0.0.0.0",
allowedHostnames: ["paperclip.example.test", "203.0.113.42"],
});
expect(text).toContain("http://192.168.6.178:3103");
expect(text).not.toContain("http://[fe80::1]:3103");
} finally {
networkSpy.mockRestore();
}
});
});

View File

@@ -2116,109 +2116,6 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
mode: "operator_branch",
});
});
it("syncs reused execution workspace config when issue workspace settings are updated", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary workspace",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Issue worktree",
status: "active",
providerType: "git_worktree",
metadata: {
config: {
environmentId: "env-old",
provisionCommand: "bash ./scripts/provision-old.sh",
teardownCommand: "bash ./scripts/teardown-old.sh",
workspaceRuntime: { profile: "old" },
},
},
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Recovery issue",
status: "in_progress",
priority: "medium",
executionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
environmentId: "env-old",
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "bash ./scripts/provision-old.sh",
teardownCommand: "bash ./scripts/teardown-old.sh",
},
workspaceRuntime: { profile: "old" },
},
});
await svc.update(issueId, {
executionWorkspaceSettings: {
mode: "isolated_workspace",
environmentId: "env-new",
workspaceStrategy: {
type: "cloud_sandbox",
provisionCommand: "bash ./scripts/provision-new.sh",
teardownCommand: "bash ./scripts/teardown-new.sh",
},
workspaceRuntime: { profile: "new" },
},
});
const workspace = await db
.select({ metadata: executionWorkspaces.metadata })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId))
.then((rows) => rows[0] ?? null);
expect(workspace?.metadata).toEqual({
config: {
environmentId: "env-new",
provisionCommand: "bash ./scripts/provision-new.sh",
teardownCommand: "bash ./scripts/teardown-new.sh",
cleanupCommand: null,
workspaceRuntime: { profile: "new" },
desiredState: null,
serviceStates: null,
},
});
});
});
describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => {

View File

@@ -30,14 +30,14 @@ afterEach(() => {
describe("buildPaperclipEnv", () => {
it("prefers an explicit PAPERCLIP_RUNTIME_API_URL", () => {
process.env.PAPERCLIP_RUNTIME_API_URL = "http://203.0.113.42:3102";
process.env.PAPERCLIP_RUNTIME_API_URL = "http://100.104.161.29:3102";
process.env.PAPERCLIP_API_URL = "http://localhost:4100";
process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1";
process.env.PAPERCLIP_LISTEN_PORT = "3101";
const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" });
expect(env.PAPERCLIP_API_URL).toBe("http://203.0.113.42:3102");
expect(env.PAPERCLIP_API_URL).toBe("http://100.104.161.29:3102");
});
it("falls back to PAPERCLIP_API_URL when no runtime URL is configured", () => {
@@ -52,7 +52,6 @@ describe("buildPaperclipEnv", () => {
});
it("uses runtime listen host/port when explicit URL is not set", () => {
delete process.env.PAPERCLIP_RUNTIME_API_URL;
delete process.env.PAPERCLIP_API_URL;
process.env.PAPERCLIP_LISTEN_HOST = "0.0.0.0";
process.env.PAPERCLIP_LISTEN_PORT = "3101";
@@ -64,7 +63,6 @@ describe("buildPaperclipEnv", () => {
});
it("formats IPv6 hosts safely in fallback URL generation", () => {
delete process.env.PAPERCLIP_RUNTIME_API_URL;
delete process.env.PAPERCLIP_API_URL;
process.env.PAPERCLIP_LISTEN_HOST = "::1";
process.env.PAPERCLIP_LISTEN_PORT = "3101";

View File

@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
buildRuntimeApiCandidateUrls,
choosePrimaryRuntimeApiUrl,
collectReachableInterfaceHosts,
} from "../runtime-api.js";
import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl } from "../runtime-api.js";
describe("runtime API discovery", () => {
it("prefers the explicit public base URL for the primary runtime URL", () => {
@@ -60,23 +56,7 @@ describe("runtime API discovery", () => {
"http://198.51.100.10:3102",
"http://runtime-host.example.test:3102",
"http://203.0.113.42:3102",
]);
});
it("tries the preferred API URL before derived callback candidates", () => {
expect(
buildRuntimeApiCandidateUrls({
preferredApiUrl: "https://agent-entry.example.test/base/path",
authPublicBaseUrl: "https://paperclip.example.test/app",
allowedHostnames: ["198.51.100.10"],
bindHost: "0.0.0.0",
port: 3102,
networkInterfacesMap: {},
}),
).toEqual([
"https://agent-entry.example.test",
"https://paperclip.example.test",
"https://198.51.100.10:3102",
"http://[fe80::1]:3102",
]);
});
@@ -94,54 +74,4 @@ describe("runtime API discovery", () => {
"http://host.docker.internal:3102",
]);
});
it("prefers usable interface hosts and skips link-local addresses", () => {
expect(
collectReachableInterfaceHosts({
networkInterfacesMap: {
en0: [
{
address: "fe80::1",
family: "IPv6",
internal: false,
netmask: "ffff:ffff:ffff:ffff::",
cidr: "fe80::1/64",
mac: "00:00:00:00:00:00",
scopeid: 1,
},
{
address: "192.168.6.178",
family: "IPv4",
internal: false,
netmask: "255.255.252.0",
cidr: "192.168.6.178/22",
mac: "00:00:00:00:00:00",
},
{
address: "fd7a:115c:a1e0::8a3a:a11d",
family: "IPv6",
internal: false,
netmask: "ffff:ffff:ffff::",
cidr: "fd7a:115c:a1e0::8a3a:a11d/48",
mac: "00:00:00:00:00:00",
scopeid: 0,
},
],
en1: [
{
address: "169.254.10.20",
family: "IPv4",
internal: false,
netmask: "255.255.0.0",
cidr: "169.254.10.20/16",
mac: "00:00:00:00:00:00",
},
],
},
}),
).toEqual([
"192.168.6.178",
"fd7a:115c:a1e0::8a3a:a11d",
]);
});
});

View File

@@ -1,10 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_PAPERCLIP_API_URL = process.env.PAPERCLIP_API_URL;
const ORIGINAL_PAPERCLIP_RUNTIME_API_URL = process.env.PAPERCLIP_RUNTIME_API_URL;
const ORIGINAL_PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
const ORIGINAL_PAPERCLIP_LISTEN_HOST = process.env.PAPERCLIP_LISTEN_HOST;
const ORIGINAL_PAPERCLIP_LISTEN_PORT = process.env.PAPERCLIP_LISTEN_PORT;
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
createAppMock,
@@ -271,26 +265,6 @@ describe("startServer PAPERCLIP_API_URL handling", () => {
delete process.env.PAPERCLIP_API_URL;
});
afterEach(() => {
if (ORIGINAL_PAPERCLIP_API_URL === undefined) delete process.env.PAPERCLIP_API_URL;
else process.env.PAPERCLIP_API_URL = ORIGINAL_PAPERCLIP_API_URL;
if (ORIGINAL_PAPERCLIP_RUNTIME_API_URL === undefined) delete process.env.PAPERCLIP_RUNTIME_API_URL;
else process.env.PAPERCLIP_RUNTIME_API_URL = ORIGINAL_PAPERCLIP_RUNTIME_API_URL;
if (ORIGINAL_PAPERCLIP_RUNTIME_API_CANDIDATES_JSON === undefined) {
delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
} else {
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = ORIGINAL_PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
}
if (ORIGINAL_PAPERCLIP_LISTEN_HOST === undefined) delete process.env.PAPERCLIP_LISTEN_HOST;
else process.env.PAPERCLIP_LISTEN_HOST = ORIGINAL_PAPERCLIP_LISTEN_HOST;
if (ORIGINAL_PAPERCLIP_LISTEN_PORT === undefined) delete process.env.PAPERCLIP_LISTEN_PORT;
else process.env.PAPERCLIP_LISTEN_PORT = ORIGINAL_PAPERCLIP_LISTEN_PORT;
});
it("uses the externally set PAPERCLIP_API_URL when provided", async () => {
process.env.PAPERCLIP_API_URL = "http://custom-api:3100";
@@ -298,10 +272,6 @@ describe("startServer PAPERCLIP_API_URL handling", () => {
expect(started.apiUrl).toBe("http://custom-api:3100");
expect(process.env.PAPERCLIP_API_URL).toBe("http://custom-api:3100");
expect(JSON.parse(process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]")).toEqual(
expect.arrayContaining(["http://custom-api:3100"]),
);
expect(JSON.parse(process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]")[0]).toBe("http://custom-api:3100");
});
it("falls back to host-based URL when PAPERCLIP_API_URL is not set", async () => {

View File

@@ -637,14 +637,13 @@ export async function startServer(): Promise<StartedServer> {
bindHost: runtimeListenHost,
port: listenPort,
});
const configuredApiUrl = process.env.PAPERCLIP_API_URL?.trim() || runtimeApiUrl;
const runtimeApiCandidates = buildRuntimeApiCandidateUrls({
preferredApiUrl: configuredApiUrl,
authPublicBaseUrl: config.authPublicBaseUrl ?? null,
allowedHostnames: config.allowedHostnames,
bindHost: runtimeListenHost,
port: listenPort,
});
const configuredApiUrl = process.env.PAPERCLIP_API_URL?.trim() || runtimeApiUrl;
process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
process.env.PAPERCLIP_RUNTIME_API_URL = runtimeApiUrl;

View File

@@ -55,7 +55,6 @@ import {
} from "../errors.js";
import { logger } from "../middleware/logger.js";
import { validate } from "../middleware/validate.js";
import { collectReachableInterfaceHosts } from "../runtime-api.js";
import {
accessService,
agentService,
@@ -1501,11 +1500,6 @@ function buildOnboardingConnectionCandidates(input: {
candidates.add(`${protocol}//host.docker.internal${port}`);
}
for (const host of collectReachableInterfaceHosts()) {
const formattedHost = host.includes(":") && !host.startsWith("[") && !host.endsWith("]") ? `[${host}]` : host;
candidates.add(`${protocol}//${formattedHost}${port}`);
}
return Array.from(candidates);
}

View File

@@ -54,9 +54,6 @@ import {
} from "./workspace-command-authz.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
import { secretService } from "../services/secrets.js";
import {
detectAdapterModel,
@@ -172,111 +169,6 @@ export function agentRoutes(
});
}
/**
* Resolve the execution target the adapter should run its test probes against.
*
* - No environmentId / local environment → returns a local target so the
* adapter probes the Paperclip host (legacy behavior).
* - SSH environment → builds an SSH execution target from the environment
* config so the adapter probes the remote box. No lease is required:
* the SSH spec is fully derived from the saved environment config.
* - Sandbox / plugin environments → currently fall back to local probing
* with a warning check, since lifting a temporary sandbox lease for an
* ad-hoc test invocation is out of scope for this iteration.
*/
async function resolveAdapterTestExecutionContext(input: {
companyId: string;
adapterType: string;
environmentId: string | null;
}): Promise<{
executionTarget: AdapterExecutionTarget | null;
environmentName: string | null;
fallbackChecks: AdapterEnvironmentCheck[];
}> {
if (!input.environmentId) {
return { executionTarget: null, environmentName: null, fallbackChecks: [] };
}
const environment = await environmentsSvc.getById(input.environmentId);
if (!environment || environment.companyId !== input.companyId) {
return {
executionTarget: null,
environmentName: null,
fallbackChecks: [
{
code: "environment_not_found",
level: "warn",
message: "Selected environment was not found. Falling back to a local probe.",
},
],
};
}
if (environment.driver === "local") {
return { executionTarget: null, environmentName: environment.name, fallbackChecks: [] };
}
if (environment.driver === "ssh") {
try {
const target = await resolveEnvironmentExecutionTarget({
db,
companyId: input.companyId,
adapterType: input.adapterType,
environment: {
id: environment.id,
driver: environment.driver,
config: environment.config ?? null,
},
leaseMetadata: null,
});
if (target) {
return { executionTarget: target, environmentName: environment.name, fallbackChecks: [] };
}
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_unavailable",
level: "warn",
message:
`Could not resolve an execution target for environment "${environment.name}". Falling back to a local probe.`,
},
],
};
} catch (err) {
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_failed",
level: "warn",
message:
`Could not connect to environment "${environment.name}" to run the test. Falling back to a local probe.`,
detail: err instanceof Error ? err.message : String(err),
},
],
};
}
}
// sandbox / plugin / other drivers: not yet supported for ad-hoc adapter tests.
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_driver_not_supported_for_test",
level: "warn",
message:
`Adapter testing inside ${environment.driver} environments is not yet supported. Falling back to a local probe; results may not reflect runs in "${environment.name}".`,
hint: "Run a real heartbeat in the environment to verify end-to-end behavior.",
},
],
};
}
async function getCurrentUserRedactionOptions() {
return {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
@@ -1085,10 +977,6 @@ export function agentRoutes(
const inputAdapterConfig =
(req.body?.adapterConfig ?? {}) as Record<string, unknown>;
const requestedEnvironmentId =
typeof req.body?.environmentId === "string" && req.body.environmentId.trim().length > 0
? (req.body.environmentId as string)
: null;
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
companyId,
inputAdapterConfig,
@@ -1099,32 +987,12 @@ export function agentRoutes(
normalizedAdapterConfig,
);
const { executionTarget, environmentName, fallbackChecks } =
await resolveAdapterTestExecutionContext({
companyId,
adapterType: type,
environmentId: requestedEnvironmentId,
});
const result = await adapter.testEnvironment({
companyId,
adapterType: type,
config: runtimeAdapterConfig,
executionTarget,
environmentName,
});
if (fallbackChecks.length > 0) {
const checks = [...fallbackChecks, ...result.checks];
const status: typeof result.status = checks.some((c) => c.level === "error")
? "fail"
: checks.some((c) => c.level === "warn")
? "warn"
: result.status;
res.json({ ...result, checks, status });
return;
}
res.json(result);
},
);

View File

@@ -14,14 +14,6 @@ function isWildcardHost(host: string): boolean {
return normalized === "0.0.0.0" || normalized === "::";
}
function isLinkLocalHost(host: string): boolean {
const normalized = normalizeHost(host).toLowerCase();
if (normalized.startsWith("169.254.")) return true;
// IPv6 link-local block is fe80::/10 (fe80:: through febf::)
if (/^fe[89ab][0-9a-f]:/.test(normalized)) return true;
return false;
}
function formatOrigin(protocol: string, host: string, port: number): string {
const normalizedHost = host.includes(":") && !host.startsWith("[") && !host.endsWith("]")
? `[${host}]`
@@ -76,36 +68,7 @@ export function choosePrimaryRuntimeApiUrl(input: {
return formatOrigin("http:", "localhost", input.port);
}
export function collectReachableInterfaceHosts(input: {
networkInterfacesMap?: NodeJS.Dict<os.NetworkInterfaceInfo[]>;
} = {}): string[] {
const interfaces = input.networkInterfacesMap ?? os.networkInterfaces();
const rankedHosts: Array<{ host: string; rank: number; index: number }> = [];
const seen = new Set<string>();
let index = 0;
for (const entries of Object.values(interfaces)) {
for (const entry of entries ?? []) {
if (entry.internal) continue;
const host = normalizeHost(entry.address);
if (!host || isLoopbackHost(host) || isWildcardHost(host) || isLinkLocalHost(host)) continue;
if (seen.has(host)) continue;
seen.add(host);
rankedHosts.push({
host,
rank: entry.family === "IPv4" ? 0 : 1,
index: index++,
});
}
}
return rankedHosts
.sort((left, right) => left.rank - right.rank || left.index - right.index)
.map((entry) => entry.host);
}
export function buildRuntimeApiCandidateUrls(input: {
preferredApiUrl?: string | null;
authPublicBaseUrl?: string | null;
allowedHostnames: string[];
bindHost: string;
@@ -125,7 +88,6 @@ export function buildRuntimeApiCandidateUrls(input: {
})();
const protocol = explicitOrigin ? new URL(explicitOrigin).protocol : "http:";
pushCandidate(candidates, seen, input.preferredApiUrl);
pushCandidate(candidates, seen, explicitOrigin);
for (const rawHost of input.allowedHostnames) {
@@ -146,8 +108,14 @@ export function buildRuntimeApiCandidateUrls(input: {
}
}
for (const host of collectReachableInterfaceHosts({ networkInterfacesMap: input.networkInterfacesMap })) {
pushCandidate(candidates, seen, formatOrigin(protocol, host, input.port));
const interfaces = input.networkInterfacesMap ?? os.networkInterfaces();
for (const entries of Object.values(interfaces)) {
for (const entry of entries ?? []) {
if (entry.internal) continue;
const host = normalizeHost(entry.address);
if (!host || isLoopbackHost(host) || isWildcardHost(host)) continue;
pushCandidate(candidates, seen, formatOrigin(protocol, host, input.port));
}
}
if (candidates.length === 0) {

View File

@@ -60,7 +60,9 @@ export async function resolveEnvironmentExecutionTarget(input: {
const paperclipApiUrl =
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
? input.leaseMetadata.paperclipApiUrl.trim()
: null;
: typeof process.env.PAPERCLIP_RUNTIME_API_URL === "string" && process.env.PAPERCLIP_RUNTIME_API_URL.trim().length > 0
? process.env.PAPERCLIP_RUNTIME_API_URL.trim()
: null;
return {
kind: "remote",
@@ -70,7 +72,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null,
paperclipApiUrl,
paperclipTransport: paperclipApiUrl ? "direct" : "bridge",
timeoutMs,
runner: input.environmentRuntime && input.lease
? {

View File

@@ -114,33 +114,6 @@ export interface EnvironmentReleaseResult {
errors: Array<{ leaseId: string; error: unknown }>;
}
function firstNonEmptyLine(text: string | null | undefined): string | null {
if (!text) return null;
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (line) return line;
}
return null;
}
function formatProvisionFailureDetail(result: {
exitCode: number | null;
signal?: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
}): string {
if (result.timedOut) {
return "provision command timed out";
}
const signal = typeof result.signal === "string" && result.signal.trim().length > 0
? ` (signal ${result.signal.trim()})`
: "";
const detail = firstNonEmptyLine(result.stderr) ?? firstNonEmptyLine(result.stdout);
const status = `exit code ${result.exitCode ?? "null"}${signal}`;
return detail ? `${status}: ${detail}` : status;
}
// ---------------------------------------------------------------------------
// Service factory
// ---------------------------------------------------------------------------
@@ -369,7 +342,6 @@ export function environmentRunOrchestrator(
// Step 2: Realize workspace in the environment via the runtime driver
let workspaceRealization: Record<string, unknown> = {};
let realizedWorkspaceCwd: string | null = null;
if (
environment.driver === "local" ||
environment.driver === "ssh" ||
@@ -392,10 +364,6 @@ export function environmentRunOrchestrator(
},
},
});
realizedWorkspaceCwd =
typeof workspaceRealizationResult.cwd === "string" && workspaceRealizationResult.cwd.trim().length > 0
? workspaceRealizationResult.cwd.trim()
: null;
workspaceRealization = parseObject(workspaceRealizationResult.metadata?.workspaceRealization);
} catch (err) {
throw new EnvironmentRunError(
@@ -410,41 +378,6 @@ export function environmentRunOrchestrator(
}
}
const provisionCommand = workspaceRealizationRequest.runtimeOverlay.provisionCommand?.trim() ?? "";
const realizedCwd =
realizedWorkspaceCwd ??
(typeof lease.metadata?.remoteCwd === "string" && lease.metadata.remoteCwd.trim().length > 0
? lease.metadata.remoteCwd.trim()
: executionWorkspace.cwd);
if (provisionCommand && environment.driver !== "local") {
try {
const provisionResult = await environmentRuntime.execute({
environment,
lease,
command: "bash",
args: ["-lc", provisionCommand],
cwd: realizedCwd,
env: {
SHELL: "/bin/bash",
},
timeoutMs: 300_000,
});
if (provisionResult.exitCode !== 0 || provisionResult.timedOut) {
throw new Error(formatProvisionFailureDetail(provisionResult));
}
} catch (err) {
throw new EnvironmentRunError(
"workspace_realization_failed",
`Failed to provision workspace for environment "${environment.name}" (${environment.driver}): ${err instanceof Error ? err.message : String(err)}`,
{
environmentId: environment.id,
driver: environment.driver,
cause: err,
},
);
}
}
// Step 3: Persist realization metadata on lease and execution workspace
if (Object.keys(workspaceRealization).length > 0) {
const nextLeaseMetadata = {

View File

@@ -36,7 +36,6 @@ import {
executePluginEnvironmentCommand,
realizePluginEnvironmentWorkspace,
resolvePluginSandboxProviderDriverByKey,
resolvePluginExecuteRpcTimeoutMs,
resumePluginEnvironmentLease,
} from "./plugin-environment-driver.js";
import { collectSecretRefPaths } from "./json-schema-secret-refs.js";
@@ -152,13 +151,6 @@ export interface EnvironmentRuntimeLeaseRecord {
leaseContext: ReturnType<typeof buildEnvironmentLeaseContext>;
}
const DEFAULT_PLUGIN_SANDBOX_WORKER_READY_TIMEOUT_MS = 5_000;
const DEFAULT_PLUGIN_SANDBOX_WORKER_READY_POLL_MS = 100;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getLeaseDriverKey(lease: Pick<EnvironmentLease, "metadata">, environment: Pick<Environment, "driver">): string {
const leaseDriver = typeof lease.metadata?.driver === "string" ? lease.metadata.driver : null;
return leaseDriver ?? environment.driver;
@@ -296,63 +288,10 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
function createSandboxEnvironmentDriver(
db: Db,
options: {
pluginWorkerManager?: PluginWorkerManager;
pluginWorkerReadyTimeoutMs?: number;
pluginWorkerReadyPollMs?: number;
} = {},
pluginWorkerManager?: PluginWorkerManager,
): EnvironmentRuntimeDriver {
const pluginWorkerManager = options.pluginWorkerManager;
const pluginWorkerReadyTimeoutMs = options.pluginWorkerReadyTimeoutMs ?? DEFAULT_PLUGIN_SANDBOX_WORKER_READY_TIMEOUT_MS;
const pluginWorkerReadyPollMs = options.pluginWorkerReadyPollMs ?? DEFAULT_PLUGIN_SANDBOX_WORKER_READY_POLL_MS;
const environmentsSvc = environmentService(db);
async function resolveSandboxProviderPlugin(input: { provider: string }) {
const running = await resolvePluginSandboxProviderDriverByKey({
db,
driverKey: input.provider,
workerManager: pluginWorkerManager,
requireRunning: true,
});
if (running) {
return { state: "running" as const, resolved: running };
}
const installed = await resolvePluginSandboxProviderDriverByKey({
db,
driverKey: input.provider,
workerManager: pluginWorkerManager,
requireRunning: false,
});
if (!installed) {
return { state: "missing" as const, resolved: null };
}
if (installed.plugin.status !== "ready") {
return { state: "not_ready" as const, resolved: installed };
}
if (!pluginWorkerManager) {
return { state: "worker_unavailable" as const, resolved: installed };
}
const deadline = Date.now() + Math.max(0, pluginWorkerReadyTimeoutMs);
while (Date.now() < deadline) {
const retried = await resolvePluginSandboxProviderDriverByKey({
db,
driverKey: input.provider,
workerManager: pluginWorkerManager,
requireRunning: true,
});
if (retried) {
return { state: "running" as const, resolved: retried };
}
await delay(Math.max(1, pluginWorkerReadyPollMs));
}
return { state: "worker_unavailable" as const, resolved: installed };
}
async function resolvePluginSandboxRuntimeConfig(input: {
environment: Environment;
lease: EnvironmentLease;
@@ -403,29 +342,17 @@ function createSandboxEnvironmentDriver(
// Check if this provider should be handled by a plugin.
if (!isBuiltinSandboxProvider(parsed.config.provider)) {
const pluginProvider = await resolveSandboxProviderPlugin({
provider: parsed.config.provider,
const pluginProvider = await resolvePluginSandboxProviderDriverByKey({
db,
driverKey: parsed.config.provider,
workerManager: pluginWorkerManager,
requireRunning: true,
});
if (pluginProvider.state === "missing") {
if (!pluginProvider || !pluginWorkerManager) {
throw new Error(
`Sandbox provider "${parsed.config.provider}" is not registered as a built-in provider and no matching plugin is available.`,
);
}
if (pluginProvider.state === "not_ready") {
throw new Error(
`Sandbox provider "${parsed.config.provider}" is installed via plugin "${pluginProvider.resolved.plugin.pluginKey}", but that plugin is currently ${pluginProvider.resolved.plugin.status}.`,
);
}
if (pluginProvider.state === "worker_unavailable") {
throw new Error(
`Sandbox provider "${parsed.config.provider}" is installed via plugin "${pluginProvider.resolved.plugin.pluginKey}", but its worker is not running.`,
);
}
if (!pluginWorkerManager) {
throw new Error(
`Sandbox provider "${parsed.config.provider}" is installed, but sandbox plugin workers are unavailable in this server process.`,
);
}
const workerConfig = stripSandboxProviderEnvelope(parsed.config);
const storedConfig = storedParsed.config;
@@ -441,7 +368,7 @@ function createSandboxEnvironmentDriver(
const providerLease = reusableLease?.providerLeaseId
? await pluginWorkerManager.call(
pluginProvider.resolved.plugin.id,
pluginProvider.plugin.id,
"environmentResumeLease",
{
driverKey: parsed.config.provider,
@@ -458,7 +385,7 @@ function createSandboxEnvironmentDriver(
).catch(() => null)
: null;
const acquiredLease = providerLease ?? await pluginWorkerManager.call(
pluginProvider.resolved.plugin.id,
pluginProvider.plugin.id,
"environmentAcquireLease",
{
driverKey: parsed.config.provider,
@@ -487,13 +414,13 @@ function createSandboxEnvironmentDriver(
metadata: {
driver: input.environment.driver,
executionWorkspaceMode: input.executionWorkspaceMode,
pluginId: pluginProvider.resolved.plugin.id,
pluginKey: pluginProvider.resolved.plugin.pluginKey,
pluginId: pluginProvider.plugin.id,
pluginKey: pluginProvider.plugin.pluginKey,
sandboxProviderPlugin: true,
...sandboxConfigForLeaseMetadata(storedConfig),
...stripSecretRefValuesFromPluginLeaseMetadata({
metadata: acquiredLease.metadata,
schema: pluginProvider.resolved.driver.configSchema as Record<string, unknown> | null | undefined,
schema: pluginProvider.driver.configSchema as Record<string, unknown> | null | undefined,
}),
},
});
@@ -638,12 +565,11 @@ function createSandboxEnvironmentDriver(
lease: input.lease,
provider: providerKey,
});
const sanitizedConfig = stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig);
return await pluginWorkerManager.call(pluginId, "environmentExecute", {
driverKey: providerKey,
companyId: input.lease.companyId,
environmentId: input.environment.id,
config: sanitizedConfig,
config: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig),
lease: {
providerLeaseId: input.lease.providerLeaseId,
metadata: input.lease.metadata ?? undefined,
@@ -655,10 +581,7 @@ function createSandboxEnvironmentDriver(
env: input.env,
stdin: input.stdin,
timeoutMs: input.timeoutMs,
}, resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: input.timeoutMs,
config: sanitizedConfig,
}));
});
}
}
throw new Error("Sandbox driver does not support direct command execution for built-in providers.");
@@ -1006,8 +929,6 @@ export function environmentRuntimeService(
options: {
drivers?: EnvironmentRuntimeDriver[];
pluginWorkerManager?: PluginWorkerManager;
pluginWorkerReadyTimeoutMs?: number;
pluginWorkerReadyPollMs?: number;
} = {},
) {
const environmentsSvc = environmentService(db);
@@ -1016,11 +937,7 @@ export function environmentRuntimeService(
const defaultDrivers = [
createLocalEnvironmentDriver(db),
createSshEnvironmentDriver(db),
createSandboxEnvironmentDriver(db, {
pluginWorkerManager: options.pluginWorkerManager,
pluginWorkerReadyTimeoutMs: options.pluginWorkerReadyTimeoutMs,
pluginWorkerReadyPollMs: options.pluginWorkerReadyPollMs,
}),
createSandboxEnvironmentDriver(db, options.pluginWorkerManager),
...(options.pluginWorkerManager
? [createPluginEnvironmentDriver(db, options.pluginWorkerManager)]
: []),

View File

@@ -2008,31 +2008,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const productivityReviews = productivityReviewService(db, { enqueueWakeup });
let unsafeTextProjectionPromise: Promise<boolean> | null = null;
async function releaseEnvironmentLeasesForRun(input: {
runId: string;
companyId: string;
agentId: string;
status: string | null | undefined;
failureReason?: string | null;
}) {
const releaseResult = await envOrchestrator.releaseForRun({
heartbeatRunId: input.runId,
companyId: input.companyId,
agentId: input.agentId,
status: leaseReleaseStatusForRunStatus(input.status),
failureReason: input.failureReason ?? undefined,
}).catch((err) => {
logger.warn({ err, runId: input.runId }, "failed to release environment leases for heartbeat run");
return null;
});
for (const releaseError of releaseResult?.errors ?? []) {
logger.warn(
{ err: releaseError.error, leaseId: releaseError.leaseId, runId: input.runId },
"failed to release environment lease for heartbeat run",
);
}
}
async function hasUnsafeTextProjectionDatabase() {
if (!unsafeTextProjectionPromise) {
unsafeTextProjectionPromise = db
@@ -2672,7 +2647,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
const existing = await getRuntimeState(agent.id);
if (existing) return existing;
const inserted = await db
return db
.insert(agentRuntimeState)
.values({
agentId: agent.id,
@@ -2680,18 +2655,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
adapterType: agent.adapterType,
stateJson: {},
})
.onConflictDoNothing({
target: agentRuntimeState.agentId,
})
.returning()
.then((rows) => rows[0] ?? null);
if (inserted) return inserted;
const ensured = await getRuntimeState(agent.id);
if (!ensured) {
throw new Error(`Failed to ensure runtime state for agent ${agent.id}`);
}
return ensured;
.then((rows) => rows[0]);
}
async function setRunStatus(
@@ -4483,13 +4448,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (!finalizedRun) finalizedRun = await getRun(run.id);
if (!finalizedRun) continue;
finalizedRun = await classifyAndPersistRunLiveness(finalizedRun, parseObject(finalizedRun.resultJson)) ?? finalizedRun;
await releaseEnvironmentLeasesForRun({
runId: finalizedRun.id,
companyId: finalizedRun.companyId,
agentId: finalizedRun.agentId,
status: finalizedRun.status,
failureReason: finalizedRun.error ?? undefined,
});
let retriedRun: typeof heartbeatRuns.$inferSelect | null = null;
if (shouldRetry) {
@@ -5936,13 +5894,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
await finalizeAgentStatus(run.agentId, "failed").catch(() => undefined);
} finally {
const latestRun = await getRun(run.id).catch(() => null);
await releaseEnvironmentLeasesForRun({
runId: run.id,
const releaseResult = await envOrchestrator.releaseForRun({
heartbeatRunId: run.id,
companyId: run.companyId,
agentId: run.agentId,
status: latestRun?.status,
status: leaseReleaseStatusForRunStatus(latestRun?.status),
failureReason: latestRun?.error ?? undefined,
}).catch((err) => {
logger.warn({ err, runId: run.id }, "failed to release environment leases for heartbeat run");
return null;
});
for (const releaseError of releaseResult?.errors ?? []) {
logger.warn(
{ err: releaseError.error, leaseId: releaseError.leaseId, runId: run.id },
"failed to release environment lease for heartbeat run",
);
}
await releaseRuntimeServicesForRun(run.id).catch(() => undefined);
activeRunExecutions.delete(run.id);
await startNextQueuedRunForAgent(run.agentId);

View File

@@ -42,7 +42,6 @@ import {
parseIssueExecutionWorkspaceSettings,
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js";
import { mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
import { instanceSettingsService } from "./instance-settings.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
@@ -92,17 +91,6 @@ function readStringFromRecord(record: unknown, key: string) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
settings: ReturnType<typeof parseIssueExecutionWorkspaceSettings>,
) {
return {
environmentId: settings?.environmentId ?? null,
provisionCommand: settings?.workspaceStrategy?.provisionCommand ?? null,
teardownCommand: settings?.workspaceStrategy?.teardownCommand ?? null,
workspaceRuntime: settings?.workspaceRuntime ?? null,
};
}
export interface IssueFilters {
status?: string;
assigneeAgentId?: string;
@@ -2902,14 +2890,6 @@ export function issueService(db: Db) {
issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId;
const nextExecutionWorkspaceId =
issueData.executionWorkspaceId !== undefined ? issueData.executionWorkspaceId : existing.executionWorkspaceId;
const nextExecutionWorkspacePreference =
issueData.executionWorkspacePreference !== undefined
? issueData.executionWorkspacePreference
: existing.executionWorkspacePreference;
const nextExecutionWorkspaceSettings =
issueData.executionWorkspaceSettings !== undefined
? parseIssueExecutionWorkspaceSettings(issueData.executionWorkspaceSettings)
: parseIssueExecutionWorkspaceSettings(existing.executionWorkspaceSettings);
if (nextProjectWorkspaceId) {
await assertValidProjectWorkspace(existing.companyId, nextProjectId, nextProjectWorkspaceId);
}
@@ -2983,37 +2963,6 @@ export function issueService(db: Db) {
tx,
);
}
if (
issueData.executionWorkspaceSettings !== undefined &&
nextExecutionWorkspaceId &&
nextExecutionWorkspacePreference === "reuse_existing"
) {
const workspace = await tx
.select({
id: executionWorkspaces.id,
metadata: executionWorkspaces.metadata,
})
.from(executionWorkspaces)
.where(
and(
eq(executionWorkspaces.id, nextExecutionWorkspaceId),
eq(executionWorkspaces.companyId, existing.companyId),
),
)
.then((rows: Array<{ id: string; metadata: unknown }>) => rows[0] ?? null);
if (workspace) {
await tx
.update(executionWorkspaces)
.set({
metadata: mergeExecutionWorkspaceConfig(
(workspace.metadata as Record<string, unknown> | null) ?? null,
buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(nextExecutionWorkspaceSettings),
),
updatedAt: new Date(),
})
.where(eq(executionWorkspaces.id, workspace.id));
}
}
const [enriched] = await withIssueLabels(tx, [updated]);
return enriched;
};

View File

@@ -313,31 +313,5 @@ export async function executePluginEnvironmentCommand(input: {
workerManager: input.workerManager,
config: input.config,
});
return await input.workerManager.call(
plugin.id,
"environmentExecute",
input.params,
resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: input.params.timeoutMs,
config: input.config.driverConfig,
}),
);
}
const RPC_OVERHEAD_BUFFER_MS = 30_000;
export function resolvePluginExecuteRpcTimeoutMs(input: {
requestedTimeoutMs?: number;
config: Record<string, unknown>;
}): number | undefined {
let baseMs: number | undefined;
if (Number.isFinite(input.requestedTimeoutMs) && (input.requestedTimeoutMs ?? 0) > 0) {
baseMs = Math.trunc(input.requestedTimeoutMs!);
} else {
const configTimeoutMs = typeof input.config.timeoutMs === "number" ? input.config.timeoutMs : null;
if (configTimeoutMs && Number.isFinite(configTimeoutMs) && configTimeoutMs > 0) {
baseMs = Math.trunc(configTimeoutMs);
}
}
return baseMs != null ? baseMs + RPC_OVERHEAD_BUFFER_MS : undefined;
return await input.workerManager.call(plugin.id, "environmentExecute", input.params);
}

View File

@@ -27,7 +27,6 @@ import { Costs } from "./pages/Costs";
import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
import { CompanyAccess } from "./pages/CompanyAccess";
import { CompanyInvites } from "./pages/CompanyInvites";
import { CompanySkills } from "./pages/CompanySkills";
@@ -65,7 +64,6 @@ function boardRoutes() {
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/access" element={<CompanyAccess />} />
<Route path="company/settings/invites" element={<CompanyInvites />} />
<Route path="company/export/*" element={<CompanyExport />} />

View File

@@ -175,10 +175,7 @@ export const agentsApi = {
testEnvironment: (
companyId: string,
type: string,
data: {
adapterConfig: Record<string, unknown>;
environmentId?: string | null;
},
data: { adapterConfig: Record<string, unknown> },
) =>
api.post<AdapterEnvironmentTestResult>(
`/companies/${companyId}/adapters/${type}/test-environment`,

View File

@@ -70,12 +70,6 @@ type AgentConfigFormProps = {
onDirtyChange?: (dirty: boolean) => void;
onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => void) | null) => void;
onTestActionChange?: (test: (() => void) | null) => void;
onTestActionStateChange?: (state: { disabled: boolean; pending: boolean }) => void;
onTestFeedbackChange?: (feedback: {
errorMessage: string | null;
result: AdapterEnvironmentTestResult | null;
}) => void;
hideInlineSave?: boolean;
showAdapterTypeField?: boolean;
showAdapterTestEnvironmentButton?: boolean;
@@ -182,9 +176,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const cards = props.sectionLayout === "cards";
const showAdapterTypeField = props.showAdapterTypeField ?? true;
const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true;
const showInlineAdapterTestEnvironmentButton =
showAdapterTestEnvironmentButton && !props.onTestActionChange;
const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange;
const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true;
const hideInstructionsFile = props.hideInstructionsFile ?? false;
const { selectedCompanyId } = useCompany();
@@ -407,62 +398,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
if (!selectedCompanyId) {
throw new Error("Select a company to test adapter environment");
}
const selectedEnvironmentId = isCreate
? val!.defaultEnvironmentId ?? null
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? null);
return agentsApi.testEnvironment(selectedCompanyId, adapterType, {
adapterConfig: buildAdapterConfigForTest(),
environmentId:
typeof selectedEnvironmentId === "string" && selectedEnvironmentId.length > 0
? selectedEnvironmentId
: null,
});
},
});
const testEnvironmentDisabled = testEnvironment.isPending || !selectedCompanyId;
const triggerTestEnvironment = useCallback(() => {
if (testEnvironmentDisabled) return;
testEnvironment.mutate();
}, [testEnvironment.mutate, testEnvironmentDisabled]);
useEffect(() => {
if (!showAdapterTestEnvironmentButton || !props.onTestActionChange) return;
props.onTestActionChange(triggerTestEnvironment);
return () => {
props.onTestActionChange?.(null);
};
}, [showAdapterTestEnvironmentButton, props.onTestActionChange, triggerTestEnvironment]);
useEffect(() => {
if (!showAdapterTestEnvironmentButton || !props.onTestActionStateChange) return;
props.onTestActionStateChange({
disabled: testEnvironmentDisabled,
pending: testEnvironment.isPending,
});
return () => {
props.onTestActionStateChange?.({ disabled: true, pending: false });
};
}, [
showAdapterTestEnvironmentButton,
props.onTestActionStateChange,
testEnvironmentDisabled,
testEnvironment.isPending,
]);
useEffect(() => {
if (!props.onTestFeedbackChange) return;
props.onTestFeedbackChange({
errorMessage: testEnvironment.error instanceof Error
? testEnvironment.error.message
: testEnvironment.error
? "Environment test failed"
: null,
result: testEnvironment.data ?? null,
});
return () => {
props.onTestFeedbackChange?.({ errorMessage: null, result: null });
};
}, [props.onTestFeedbackChange, testEnvironment.data, testEnvironment.error]);
// Current model for display
const currentModelId = isCreate
@@ -678,16 +618,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? <h3 className="text-sm font-medium">Adapter</h3>
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
}
{showInlineAdapterTestEnvironmentButton && (
{showAdapterTestEnvironmentButton && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={triggerTestEnvironment}
disabled={testEnvironmentDisabled}
onClick={() => testEnvironment.mutate()}
disabled={testEnvironment.isPending || !selectedCompanyId}
>
{testEnvironment.isPending ? "Testing..." : "Test"}
{testEnvironment.isPending ? "Testing..." : "Test environment"}
</Button>
)}
</div>
@@ -747,7 +687,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
)}
{showInlineAdapterTestEnvironmentFeedback && testEnvironment.error && (
{testEnvironment.error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{testEnvironment.error instanceof Error
? testEnvironment.error.message
@@ -755,7 +695,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div>
)}
{showInlineAdapterTestEnvironmentFeedback && testEnvironment.data && (
{testEnvironment.data && (
<AdapterEnvironmentResult result={testEnvironment.data} />
)}
@@ -1107,7 +1047,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
);
}
export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) {
function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) {
const statusLabel =
result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed";
const statusClass =

View File

@@ -105,7 +105,6 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("Paperclip");
expect(container.textContent).toContain("Company Settings");
expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Environments");
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Invites");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
@@ -115,13 +114,6 @@ describe("CompanySettingsSidebar", () => {
end: true,
}),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/environments",
label: "Environments",
end: true,
}),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/access",

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { ChevronLeft, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react";
import { ChevronLeft, MailPlus, Settings, Shield, SlidersHorizontal } from "lucide-react";
import { sidebarBadgesApi } from "@/api/sidebarBadges";
import { ApiError } from "@/api/client";
import { Link } from "@/lib/router";
@@ -54,12 +54,6 @@ export function CompanySettingsSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem
to="/company/settings/environments"
label="Environments"
icon={MonitorCog}
end
/>
<SidebarNavItem
to="/company/settings/access"
label="Access"

View File

@@ -58,8 +58,6 @@ describe("CompanySettingsNav", () => {
it("maps company settings routes to the expected shared tab value", () => {
expect(getCompanySettingsTab("/company/settings")).toBe("general");
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/company/settings/access")).toBe("access");
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access");
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
@@ -79,7 +77,6 @@ describe("CompanySettingsNav", () => {
value: "access",
items: [
{ value: "general", label: "General" },
{ value: "environments", label: "Environments" },
{ value: "access", label: "Access" },
{ value: "invites", label: "Invites" },
],

View File

@@ -4,7 +4,6 @@ import { useLocation, useNavigate } from "@/lib/router";
const items = [
{ value: "general", label: "General", href: "/company/settings" },
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
{ value: "access", label: "Access", href: "/company/settings/access" },
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
] as const;
@@ -12,10 +11,6 @@ const items = [
type CompanySettingsTab = (typeof items)[number]["value"];
export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
if (pathname.includes("/company/settings/environments")) {
return "environments";
}
if (pathname.includes("/company/settings/access")) {
return "access";
}

View File

@@ -1,805 +0,0 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AGENT_ADAPTER_TYPES,
getAdapterEnvironmentSupport,
type Environment,
type EnvironmentProbeResult,
type JsonSchema,
} from "@paperclipai/shared";
import { Check, Settings } from "lucide-react";
import { environmentsApi } from "@/api/environments";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { secretsApi } from "@/api/secrets";
import { Button } from "@/components/ui/button";
import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import {
Field,
ToggleField,
adapterLabels,
} from "../components/agent-config-primitives";
type EnvironmentFormState = {
name: string;
description: string;
driver: "local" | "ssh" | "sandbox";
sshHost: string;
sshPort: string;
sshUsername: string;
sshRemoteWorkspacePath: string;
sshPrivateKey: string;
sshPrivateKeySecretId: string;
sshKnownHosts: string;
sshStrictHostKeyChecking: boolean;
sandboxProvider: string;
sandboxConfig: Record<string, unknown>;
};
const ENVIRONMENT_SUPPORT_ROWS = AGENT_ADAPTER_TYPES.map((adapterType) => ({
adapterType,
support: getAdapterEnvironmentSupport(adapterType),
}));
function buildEnvironmentPayload(form: EnvironmentFormState) {
return {
name: form.name.trim(),
description: form.description.trim() || null,
driver: form.driver,
config:
form.driver === "ssh"
? {
host: form.sshHost.trim(),
port: Number.parseInt(form.sshPort || "22", 10) || 22,
username: form.sshUsername.trim(),
remoteWorkspacePath: form.sshRemoteWorkspacePath.trim(),
privateKey: form.sshPrivateKey.trim() || null,
privateKeySecretRef:
form.sshPrivateKey.trim().length > 0 || !form.sshPrivateKeySecretId
? null
: { type: "secret_ref" as const, secretId: form.sshPrivateKeySecretId, version: "latest" as const },
knownHosts: form.sshKnownHosts.trim() || null,
strictHostKeyChecking: form.sshStrictHostKeyChecking,
}
: form.driver === "sandbox"
? {
provider: form.sandboxProvider.trim(),
...form.sandboxConfig,
}
: {},
} as const;
}
function createEmptyEnvironmentForm(): EnvironmentFormState {
return {
name: "",
description: "",
driver: "ssh",
sshHost: "",
sshPort: "22",
sshUsername: "",
sshRemoteWorkspacePath: "",
sshPrivateKey: "",
sshPrivateKeySecretId: "",
sshKnownHosts: "",
sshStrictHostKeyChecking: true,
sandboxProvider: "",
sandboxConfig: {},
};
}
function readSshConfig(environment: Environment) {
const config = environment.config ?? {};
return {
host: typeof config.host === "string" ? config.host : "",
port:
typeof config.port === "number"
? String(config.port)
: typeof config.port === "string"
? config.port
: "22",
username: typeof config.username === "string" ? config.username : "",
remoteWorkspacePath:
typeof config.remoteWorkspacePath === "string" ? config.remoteWorkspacePath : "",
privateKey: "",
privateKeySecretId:
config.privateKeySecretRef &&
typeof config.privateKeySecretRef === "object" &&
!Array.isArray(config.privateKeySecretRef) &&
typeof (config.privateKeySecretRef as { secretId?: unknown }).secretId === "string"
? String((config.privateKeySecretRef as { secretId: string }).secretId)
: "",
knownHosts: typeof config.knownHosts === "string" ? config.knownHosts : "",
strictHostKeyChecking:
typeof config.strictHostKeyChecking === "boolean"
? config.strictHostKeyChecking
: true,
};
}
function readSandboxConfig(environment: Environment) {
const config = environment.config ?? {};
const { provider: rawProvider, ...providerConfig } = config;
return {
provider: typeof rawProvider === "string" && rawProvider.trim().length > 0
? rawProvider
: "fake",
config: providerConfig,
};
}
function normalizeJsonSchema(schema: unknown): JsonSchema | null {
return schema && typeof schema === "object" && !Array.isArray(schema)
? schema as JsonSchema
: null;
}
function summarizeSandboxConfig(config: Record<string, unknown>): string | null {
for (const key of ["template", "image", "region", "workspacePath"]) {
const value = config[key];
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
}
return null;
}
function SupportMark({ supported }: { supported: boolean }) {
return supported ? (
<span className="inline-flex items-center gap-1 text-green-700 dark:text-green-400">
<Check className="h-3 w-3" />
Yes
</span>
) : (
<span className="text-muted-foreground">No</span>
);
}
export function CompanyEnvironments() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [editingEnvironmentId, setEditingEnvironmentId] = useState<string | null>(null);
const [environmentForm, setEnvironmentForm] = useState<EnvironmentFormState>(createEmptyEnvironmentForm);
const [probeResults, setProbeResults] = useState<Record<string, EnvironmentProbeResult | null>>({});
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" },
{ label: "Environments" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
const { data: environments } = useQuery({
queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"],
queryFn: () => environmentsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId) && environmentsEnabled,
});
const { data: environmentCapabilities } = useQuery({
queryKey: selectedCompanyId ? ["environment-capabilities", selectedCompanyId] : ["environment-capabilities", "none"],
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
enabled: Boolean(selectedCompanyId) && environmentsEnabled,
});
const { data: secrets } = useQuery({
queryKey: selectedCompanyId ? ["company-secrets", selectedCompanyId] : ["company-secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const environmentMutation = useMutation({
mutationFn: async (form: EnvironmentFormState) => {
const body = buildEnvironmentPayload(form);
if (editingEnvironmentId) {
return await environmentsApi.update(editingEnvironmentId, body);
}
return await environmentsApi.create(selectedCompanyId!, body);
},
onSuccess: async (environment) => {
await queryClient.invalidateQueries({
queryKey: queryKeys.environments.list(selectedCompanyId!),
});
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
pushToast({
title: editingEnvironmentId ? "Environment updated" : "Environment created",
body: `${environment.name} is ready.`,
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to save environment",
body: error instanceof Error ? error.message : "Environment save failed.",
tone: "error",
});
},
});
const environmentProbeMutation = useMutation({
mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId),
onSuccess: (probe, environmentId) => {
setProbeResults((current) => ({
...current,
[environmentId]: probe,
}));
pushToast({
title: probe.ok ? "Environment probe passed" : "Environment probe failed",
body: probe.summary,
tone: probe.ok ? "success" : "error",
});
},
onError: (error, environmentId) => {
const failedEnvironment = (environments ?? []).find((environment) => environment.id === environmentId);
setProbeResults((current) => ({
...current,
[environmentId]: {
ok: false,
driver: failedEnvironment?.driver ?? "local",
summary: error instanceof Error ? error.message : "Environment probe failed.",
details: null,
},
}));
pushToast({
title: "Environment probe failed",
body: error instanceof Error ? error.message : "Environment probe failed.",
tone: "error",
});
},
});
const draftEnvironmentProbeMutation = useMutation({
mutationFn: async (form: EnvironmentFormState) => {
const body = buildEnvironmentPayload(form);
return await environmentsApi.probeConfig(selectedCompanyId!, body);
},
onSuccess: (probe) => {
pushToast({
title: probe.ok ? "Draft probe passed" : "Draft probe failed",
body: probe.summary,
tone: probe.ok ? "success" : "error",
});
},
onError: (error) => {
pushToast({
title: "Draft probe failed",
body: error instanceof Error ? error.message : "Environment probe failed.",
tone: "error",
});
},
});
useEffect(() => {
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
setProbeResults({});
}, [selectedCompanyId]);
function handleEditEnvironment(environment: Environment) {
setEditingEnvironmentId(environment.id);
if (environment.driver === "ssh") {
const ssh = readSshConfig(environment);
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "ssh",
sshHost: ssh.host,
sshPort: ssh.port,
sshUsername: ssh.username,
sshRemoteWorkspacePath: ssh.remoteWorkspacePath,
sshPrivateKey: ssh.privateKey,
sshPrivateKeySecretId: ssh.privateKeySecretId,
sshKnownHosts: ssh.knownHosts,
sshStrictHostKeyChecking: ssh.strictHostKeyChecking,
});
return;
}
if (environment.driver === "sandbox") {
const sandbox = readSandboxConfig(environment);
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "sandbox",
sandboxProvider: sandbox.provider,
sandboxConfig: sandbox.config,
});
return;
}
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "local",
});
}
function handleCancelEnvironmentEdit() {
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
}
const discoveredPluginSandboxProviders = Object.entries(environmentCapabilities?.sandboxProviders ?? {})
.filter(([provider, capability]) => provider !== "fake" && capability.supportsRunExecution)
.map(([provider, capability]) => ({
provider,
displayName: capability.displayName || provider,
description: capability.description,
configSchema: normalizeJsonSchema(capability.configSchema),
}))
.sort((left, right) => left.displayName.localeCompare(right.displayName));
const sandboxCreationEnabled = discoveredPluginSandboxProviders.length > 0;
const sandboxSupportVisible = sandboxCreationEnabled;
const pluginSandboxProviders =
environmentForm.sandboxProvider.trim().length > 0 &&
environmentForm.sandboxProvider !== "fake" &&
!discoveredPluginSandboxProviders.some((provider) => provider.provider === environmentForm.sandboxProvider)
? [
...discoveredPluginSandboxProviders,
{ provider: environmentForm.sandboxProvider, displayName: environmentForm.sandboxProvider, description: undefined, configSchema: null },
]
: discoveredPluginSandboxProviders;
const selectedSandboxProvider = pluginSandboxProviders.find(
(provider) => provider.provider === environmentForm.sandboxProvider,
) ?? null;
const selectedSandboxSchema = selectedSandboxProvider?.configSchema ?? null;
const sandboxConfigErrors =
environmentForm.driver === "sandbox" && selectedSandboxSchema
? validateJsonSchemaForm(selectedSandboxSchema as any, environmentForm.sandboxConfig)
: {};
useEffect(() => {
if (environmentForm.driver !== "sandbox") return;
if (environmentForm.sandboxProvider.trim().length > 0 && environmentForm.sandboxProvider !== "fake") return;
const firstProvider = discoveredPluginSandboxProviders[0]?.provider;
if (!firstProvider) return;
const firstSchema = discoveredPluginSandboxProviders[0]?.configSchema;
setEnvironmentForm((current) => (
current.driver !== "sandbox" || (current.sandboxProvider.trim().length > 0 && current.sandboxProvider !== "fake")
? current
: {
...current,
sandboxProvider: firstProvider,
sandboxConfig: firstSchema ? getDefaultValues(firstSchema as any) : {},
}
));
}, [discoveredPluginSandboxProviders, environmentForm.driver, environmentForm.sandboxProvider]);
const environmentFormValid =
environmentForm.name.trim().length > 0 &&
(environmentForm.driver !== "ssh" ||
(
environmentForm.sshHost.trim().length > 0 &&
environmentForm.sshUsername.trim().length > 0 &&
environmentForm.sshRemoteWorkspacePath.trim().length > 0
)) &&
(environmentForm.driver !== "sandbox" ||
environmentForm.sandboxProvider.trim().length > 0 &&
environmentForm.sandboxProvider !== "fake" &&
Object.keys(sandboxConfigErrors).length === 0);
if (!selectedCompanyId) {
return <div className="text-sm text-muted-foreground">Select a company to manage environments.</div>;
}
if (!environmentsEnabled) {
return (
<div className="max-w-3xl space-y-4">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Environments</h1>
</div>
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
Enable Environments in instance experimental settings to manage company execution targets.
</div>
</div>
);
}
return (
<div className="max-w-5xl space-y-6" data-testid="company-settings-environments-section">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Environments</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Define reusable execution targets for projects, issue workspaces, and remote-capable adapters.
</p>
</div>
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
Environment choices use the same adapter support matrix as agent defaults. SSH is always available for
remote-managed adapters, and sandbox environments appear only when a run-capable sandbox provider plugin is
installed.
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[34rem] text-left text-xs">
<caption className="sr-only">Environment support by adapter</caption>
<thead className="border-b border-border text-muted-foreground">
<tr>
<th className="py-2 pr-3 font-medium">Adapter</th>
<th className="px-3 py-2 font-medium">Local</th>
<th className="px-3 py-2 font-medium">SSH</th>
{sandboxSupportVisible ? (
<th className="px-3 py-2 font-medium">Sandbox</th>
) : null}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{(environmentCapabilities?.adapters.map((support) => ({
adapterType: support.adapterType,
support,
})) ?? ENVIRONMENT_SUPPORT_ROWS).map(({ adapterType, support }) => (
<tr key={adapterType}>
<td className="py-2 pr-3 font-medium">
{adapterLabels[adapterType] ?? adapterType}
</td>
<td className="px-3 py-2">
<SupportMark supported={support.drivers.local === "supported"} />
</td>
<td className="px-3 py-2">
<SupportMark supported={support.drivers.ssh === "supported"} />
</td>
{sandboxSupportVisible ? (
<td className="px-3 py-2">
<SupportMark
supported={discoveredPluginSandboxProviders.some((provider) =>
support.sandboxProviders[provider.provider] === "supported")}
/>
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
<div className="space-y-3">
{(environments ?? []).length === 0 ? (
<div className="text-sm text-muted-foreground">No environments saved for this company yet.</div>
) : (
(environments ?? []).map((environment) => {
const probe = probeResults[environment.id] ?? null;
const isEditing = editingEnvironmentId === environment.id;
return (
<div
key={environment.id}
className="rounded-md border border-border/70 px-3 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-sm font-medium">
{environment.name} <span className="text-muted-foreground">· {environment.driver}</span>
</div>
{environment.description ? (
<div className="text-xs text-muted-foreground">{environment.description}</div>
) : null}
{environment.driver === "ssh" ? (
<div className="text-xs text-muted-foreground">
{typeof environment.config.host === "string" ? environment.config.host : "SSH host"} ·{" "}
{typeof environment.config.username === "string" ? environment.config.username : "user"}
</div>
) : environment.driver === "sandbox" ? (
<div className="text-xs text-muted-foreground">
{(() => {
const provider =
typeof environment.config.provider === "string" ? environment.config.provider : "sandbox";
const displayName =
environmentCapabilities?.sandboxProviders?.[provider]?.displayName ?? provider;
const summary = summarizeSandboxConfig(environment.config as Record<string, unknown>);
return `${displayName} sandbox provider${summary ? ` · ${summary}` : ""}`;
})()}
</div>
) : (
<div className="text-xs text-muted-foreground">Runs on this Paperclip host.</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{environment.driver !== "local" ? (
<Button
size="sm"
variant="outline"
onClick={() => environmentProbeMutation.mutate(environment.id)}
disabled={environmentProbeMutation.isPending}
>
{environmentProbeMutation.isPending
? "Testing..."
: environment.driver === "ssh"
? "Test connection"
: "Test provider"}
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => handleEditEnvironment(environment)}
>
{isEditing ? "Editing" : "Edit"}
</Button>
</div>
</div>
{probe ? (
<div
className={
probe.ok
? "mt-3 rounded border border-green-500/30 bg-green-500/5 px-2.5 py-2 text-xs text-green-700"
: "mt-3 rounded border border-destructive/30 bg-destructive/5 px-2.5 py-2 text-xs text-destructive"
}
>
<div className="font-medium">{probe.summary}</div>
{probe.details?.error && typeof probe.details.error === "string" ? (
<div className="mt-1 font-mono text-[11px]">{probe.details.error}</div>
) : null}
</div>
) : null}
</div>
);
})
)}
</div>
<div className="border-t border-border/60 pt-4">
<div className="mb-3 text-sm font-medium">
{editingEnvironmentId ? "Edit environment" : "Add environment"}
</div>
<div className="space-y-3">
<Field label="Name" hint="Operator-facing name for this execution target.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.name}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, name: e.target.value }))}
/>
</Field>
<Field label="Description" hint="Optional note about what this machine is for.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.description}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, description: e.target.value }))}
/>
</Field>
<Field label="Driver" hint="Local runs on this host. SSH stores a remote machine target. Sandbox stores plugin-backed provider config on the shared environment seam.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.driver}
onChange={(e) =>
setEnvironmentForm((current) => ({
...current,
sandboxProvider:
e.target.value === "sandbox"
? current.sandboxProvider.trim() || discoveredPluginSandboxProviders[0]?.provider || ""
: current.sandboxProvider,
sandboxConfig:
e.target.value === "sandbox"
? (
current.sandboxProvider.trim().length > 0 && current.driver === "sandbox"
? current.sandboxConfig
: discoveredPluginSandboxProviders[0]?.configSchema
? getDefaultValues(discoveredPluginSandboxProviders[0].configSchema as any)
: {}
)
: current.sandboxConfig,
driver:
e.target.value === "local"
? "local"
: e.target.value === "sandbox"
? "sandbox"
: "ssh",
}))}
>
<option value="ssh">SSH</option>
{sandboxCreationEnabled || environmentForm.driver === "sandbox" ? (
<option value="sandbox">Sandbox</option>
) : null}
<option value="local">Local</option>
</select>
</Field>
{environmentForm.driver === "ssh" ? (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Host" hint="DNS name or IP address for the remote machine.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.sshHost}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshHost: e.target.value }))}
/>
</Field>
<Field label="Port" hint="Defaults to 22.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="number"
min={1}
max={65535}
value={environmentForm.sshPort}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPort: e.target.value }))}
/>
</Field>
<Field label="Username" hint="SSH login user.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.sshUsername}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshUsername: e.target.value }))}
/>
</Field>
<Field label="Remote workspace path" hint="Absolute path that Paperclip will verify during SSH connection tests.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
placeholder="/Users/paperclip/workspace"
value={environmentForm.sshRemoteWorkspacePath}
onChange={(e) =>
setEnvironmentForm((current) => ({ ...current, sshRemoteWorkspacePath: e.target.value }))}
/>
</Field>
<Field label="Private key" hint="Optional PEM private key. Leave blank to rely on the server's SSH agent or default keychain.">
<div className="space-y-2">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sshPrivateKeySecretId}
onChange={(e) =>
setEnvironmentForm((current) => ({
...current,
sshPrivateKeySecretId: e.target.value,
sshPrivateKey: e.target.value ? "" : current.sshPrivateKey,
}))}
>
<option value="">No saved secret</option>
{(secrets ?? []).map((secret) => (
<option key={secret.id} value={secret.id}>{secret.name}</option>
))}
</select>
<textarea
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
value={environmentForm.sshPrivateKey}
disabled={!!environmentForm.sshPrivateKeySecretId}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPrivateKey: e.target.value }))}
/>
</div>
</Field>
<Field label="Known hosts" hint="Optional known_hosts block used when strict host key checking is enabled.">
<textarea
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
value={environmentForm.sshKnownHosts}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshKnownHosts: e.target.value }))}
/>
</Field>
<div className="md:col-span-2">
<ToggleField
label="Strict host key checking"
hint="Keep this on unless you deliberately want probe-time host key acceptance disabled."
checked={environmentForm.sshStrictHostKeyChecking}
onChange={(checked) =>
setEnvironmentForm((current) => ({ ...current, sshStrictHostKeyChecking: checked }))}
/>
</div>
</div>
) : null}
{environmentForm.driver === "sandbox" ? (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Provider" hint="Installed run-capable sandbox provider plugins appear here.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sandboxProvider}
onChange={(e) => {
const nextProviderKey = e.target.value;
const nextProvider = pluginSandboxProviders.find((provider) => provider.provider === nextProviderKey) ?? null;
setEnvironmentForm((current) => ({
...current,
sandboxProvider: nextProviderKey,
sandboxConfig:
current.sandboxProvider === nextProviderKey
? current.sandboxConfig
: nextProvider?.configSchema
? getDefaultValues(nextProvider.configSchema as any)
: {},
}));
}}
>
{pluginSandboxProviders.map((provider) => (
<option key={provider.provider} value={provider.provider}>
{provider.displayName}
</option>
))}
</select>
</Field>
<div className="md:col-span-2 space-y-3">
{selectedSandboxProvider?.description ? (
<div className="text-xs text-muted-foreground">
{selectedSandboxProvider.description}
</div>
) : null}
{selectedSandboxSchema ? (
<JsonSchemaForm
schema={selectedSandboxSchema as any}
values={environmentForm.sandboxConfig}
onChange={(values) =>
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
errors={sandboxConfigErrors}
/>
) : (
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
This provider does not declare additional configuration fields.
</div>
)}
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => environmentMutation.mutate(environmentForm)}
disabled={environmentMutation.isPending || !environmentFormValid}
>
{environmentMutation.isPending
? editingEnvironmentId
? "Saving..."
: "Creating..."
: editingEnvironmentId
? "Save environment"
: "Create environment"}
</Button>
{editingEnvironmentId ? (
<Button
size="sm"
variant="ghost"
onClick={handleCancelEnvironmentEdit}
disabled={environmentMutation.isPending}
>
Cancel
</Button>
) : null}
{environmentForm.driver !== "local" ? (
<Button
size="sm"
variant="outline"
onClick={() => draftEnvironmentProbeMutation.mutate(environmentForm)}
disabled={draftEnvironmentProbeMutation.isPending || !environmentFormValid}
>
{draftEnvironmentProbeMutation.isPending ? "Testing..." : "Test draft"}
</Button>
) : null}
{environmentMutation.isError ? (
<span className="text-xs text-destructive">
{environmentMutation.error instanceof Error
? environmentMutation.error.message
: "Failed to save environment"}
</span>
) : null}
{draftEnvironmentProbeMutation.data ? (
<span className={draftEnvironmentProbeMutation.data.ok ? "text-xs text-green-600" : "text-xs text-destructive"}>
{draftEnvironmentProbeMutation.data.summary}
</span>
) : null}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES, getEnvironmentCapabilities } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompanyEnvironments } from "./CompanyEnvironments";
import { CompanySettings } from "./CompanySettings";
import { TooltipProvider } from "@/components/ui/tooltip";
const mockCompaniesApi = vi.hoisted(() => ({
@@ -105,7 +105,7 @@ async function flushReact() {
});
}
describe("CompanyEnvironments", () => {
describe("CompanySettings", () => {
let container: HTMLDivElement;
beforeEach(() => {
@@ -146,7 +146,7 @@ describe("CompanyEnvironments", () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
<CompanySettings />
</TooltipProvider>
</QueryClientProvider>,
);
@@ -212,7 +212,7 @@ describe("CompanyEnvironments", () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<CompanyEnvironments />
<CompanySettings />
</TooltipProvider>
</QueryClientProvider>,
);

View File

@@ -1,18 +1,31 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AGENT_ADAPTER_TYPES,
getAdapterEnvironmentSupport,
type Environment,
type EnvironmentProbeResult,
type JsonSchema,
} from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
import { environmentsApi } from "../api/environments";
import { instanceSettingsApi } from "../api/instanceSettings";
import { secretsApi } from "../api/secrets";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Settings, Check, Download, Upload } from "lucide-react";
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm";
import {
Field,
ToggleField,
HintIcon,
adapterLabels,
} from "../components/agent-config-primitives";
type AgentSnippetInput = {
@@ -21,6 +34,141 @@ type AgentSnippetInput = {
testResolutionUrl?: string | null;
};
type EnvironmentFormState = {
name: string;
description: string;
driver: "local" | "ssh" | "sandbox";
sshHost: string;
sshPort: string;
sshUsername: string;
sshRemoteWorkspacePath: string;
sshPrivateKey: string;
sshPrivateKeySecretId: string;
sshKnownHosts: string;
sshStrictHostKeyChecking: boolean;
sandboxProvider: string;
sandboxConfig: Record<string, unknown>;
};
const ENVIRONMENT_SUPPORT_ROWS = AGENT_ADAPTER_TYPES.map((adapterType) => ({
adapterType,
support: getAdapterEnvironmentSupport(adapterType),
}));
function buildEnvironmentPayload(form: EnvironmentFormState) {
return {
name: form.name.trim(),
description: form.description.trim() || null,
driver: form.driver,
config:
form.driver === "ssh"
? {
host: form.sshHost.trim(),
port: Number.parseInt(form.sshPort || "22", 10) || 22,
username: form.sshUsername.trim(),
remoteWorkspacePath: form.sshRemoteWorkspacePath.trim(),
privateKey: form.sshPrivateKey.trim() || null,
privateKeySecretRef:
form.sshPrivateKey.trim().length > 0 || !form.sshPrivateKeySecretId
? null
: { type: "secret_ref" as const, secretId: form.sshPrivateKeySecretId, version: "latest" as const },
knownHosts: form.sshKnownHosts.trim() || null,
strictHostKeyChecking: form.sshStrictHostKeyChecking,
}
: form.driver === "sandbox"
? {
provider: form.sandboxProvider.trim(),
...form.sandboxConfig,
}
: {},
} as const;
}
function createEmptyEnvironmentForm(): EnvironmentFormState {
return {
name: "",
description: "",
driver: "ssh",
sshHost: "",
sshPort: "22",
sshUsername: "",
sshRemoteWorkspacePath: "",
sshPrivateKey: "",
sshPrivateKeySecretId: "",
sshKnownHosts: "",
sshStrictHostKeyChecking: true,
sandboxProvider: "",
sandboxConfig: {},
};
}
function readSshConfig(environment: Environment) {
const config = environment.config ?? {};
return {
host: typeof config.host === "string" ? config.host : "",
port:
typeof config.port === "number"
? String(config.port)
: typeof config.port === "string"
? config.port
: "22",
username: typeof config.username === "string" ? config.username : "",
remoteWorkspacePath:
typeof config.remoteWorkspacePath === "string" ? config.remoteWorkspacePath : "",
privateKey: "",
privateKeySecretId:
config.privateKeySecretRef &&
typeof config.privateKeySecretRef === "object" &&
!Array.isArray(config.privateKeySecretRef) &&
typeof (config.privateKeySecretRef as { secretId?: unknown }).secretId === "string"
? String((config.privateKeySecretRef as { secretId: string }).secretId)
: "",
knownHosts: typeof config.knownHosts === "string" ? config.knownHosts : "",
strictHostKeyChecking:
typeof config.strictHostKeyChecking === "boolean"
? config.strictHostKeyChecking
: true,
};
}
function readSandboxConfig(environment: Environment) {
const config = environment.config ?? {};
const { provider: rawProvider, ...providerConfig } = config;
return {
provider: typeof rawProvider === "string" && rawProvider.trim().length > 0
? rawProvider
: "fake",
config: providerConfig,
};
}
function normalizeJsonSchema(schema: unknown): JsonSchema | null {
return schema && typeof schema === "object" && !Array.isArray(schema)
? schema as JsonSchema
: null;
}
function summarizeSandboxConfig(config: Record<string, unknown>): string | null {
for (const key of ["template", "image", "region", "workspacePath"]) {
const value = config[key];
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
}
return null;
}
function SupportMark({ supported }: { supported: boolean }) {
return supported ? (
<span className="inline-flex items-center gap-1 text-green-700 dark:text-green-400">
<Check className="h-3 w-3" />
Yes
</span>
) : (
<span className="text-muted-foreground">No</span>
);
}
export function CompanySettings() {
const {
companies,
@@ -29,6 +177,7 @@ export function CompanySettings() {
setSelectedCompanyId
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
// General settings local state
const [companyName, setCompanyName] = useState("");
@@ -36,6 +185,9 @@ export function CompanySettings() {
const [brandColor, setBrandColor] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
const [editingEnvironmentId, setEditingEnvironmentId] = useState<string | null>(null);
const [environmentForm, setEnvironmentForm] = useState<EnvironmentFormState>(createEmptyEnvironmentForm);
const [probeResults, setProbeResults] = useState<Record<string, EnvironmentProbeResult | null>>({});
// Sync local state from selected company
useEffect(() => {
@@ -51,6 +203,30 @@ export function CompanySettings() {
const [snippetCopied, setSnippetCopied] = useState(false);
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
const { data: environments } = useQuery({
queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"],
queryFn: () => environmentsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId) && environmentsEnabled,
});
const { data: environmentCapabilities } = useQuery({
queryKey: selectedCompanyId ? ["environment-capabilities", selectedCompanyId] : ["environment-capabilities", "none"],
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
enabled: Boolean(selectedCompanyId) && environmentsEnabled,
});
const { data: secrets } = useQuery({
queryKey: selectedCompanyId ? ["company-secrets", selectedCompanyId] : ["company-secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const generalDirty =
!!selectedCompany &&
(companyName !== selectedCompany.name ||
@@ -155,6 +331,90 @@ export function CompanySettings() {
}
});
const environmentMutation = useMutation({
mutationFn: async (form: EnvironmentFormState) => {
const body = buildEnvironmentPayload(form);
if (editingEnvironmentId) {
return await environmentsApi.update(editingEnvironmentId, body);
}
return await environmentsApi.create(selectedCompanyId!, body);
},
onSuccess: async (environment) => {
await queryClient.invalidateQueries({
queryKey: queryKeys.environments.list(selectedCompanyId!),
});
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
pushToast({
title: editingEnvironmentId ? "Environment updated" : "Environment created",
body: `${environment.name} is ready.`,
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to save environment",
body: error instanceof Error ? error.message : "Environment save failed.",
tone: "error",
});
},
});
const environmentProbeMutation = useMutation({
mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId),
onSuccess: (probe, environmentId) => {
setProbeResults((current) => ({
...current,
[environmentId]: probe,
}));
pushToast({
title: probe.ok ? "Environment probe passed" : "Environment probe failed",
body: probe.summary,
tone: probe.ok ? "success" : "error",
});
},
onError: (error, environmentId) => {
const failedEnvironment = (environments ?? []).find((environment) => environment.id === environmentId);
setProbeResults((current) => ({
...current,
[environmentId]: {
ok: false,
driver: failedEnvironment?.driver ?? "local",
summary: error instanceof Error ? error.message : "Environment probe failed.",
details: null,
},
}));
pushToast({
title: "Environment probe failed",
body: error instanceof Error ? error.message : "Environment probe failed.",
tone: "error",
});
},
});
const draftEnvironmentProbeMutation = useMutation({
mutationFn: async (form: EnvironmentFormState) => {
const body = buildEnvironmentPayload(form);
return await environmentsApi.probeConfig(selectedCompanyId!, body);
},
onSuccess: (probe) => {
pushToast({
title: probe.ok ? "Draft probe passed" : "Draft probe failed",
body: probe.summary,
tone: probe.ok ? "success" : "error",
});
},
onError: (error) => {
pushToast({
title: "Draft probe failed",
body: error instanceof Error ? error.message : "Environment probe failed.",
tone: "error",
});
},
});
function handleLogoFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
event.currentTarget.value = "";
@@ -172,6 +432,9 @@ export function CompanySettings() {
setInviteSnippet(null);
setSnippetCopied(false);
setSnippetCopyDelightId(0);
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
setProbeResults({});
}, [selectedCompanyId]);
const archiveMutation = useMutation({
@@ -218,6 +481,113 @@ export function CompanySettings() {
});
}
function handleEditEnvironment(environment: Environment) {
setEditingEnvironmentId(environment.id);
if (environment.driver === "ssh") {
const ssh = readSshConfig(environment);
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "ssh",
sshHost: ssh.host,
sshPort: ssh.port,
sshUsername: ssh.username,
sshRemoteWorkspacePath: ssh.remoteWorkspacePath,
sshPrivateKey: ssh.privateKey,
sshPrivateKeySecretId: ssh.privateKeySecretId,
sshKnownHosts: ssh.knownHosts,
sshStrictHostKeyChecking: ssh.strictHostKeyChecking,
});
return;
}
if (environment.driver === "sandbox") {
const sandbox = readSandboxConfig(environment);
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "sandbox",
sandboxProvider: sandbox.provider,
sandboxConfig: sandbox.config,
});
return;
}
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "local",
});
}
function handleCancelEnvironmentEdit() {
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
}
const discoveredPluginSandboxProviders = Object.entries(environmentCapabilities?.sandboxProviders ?? {})
.filter(([provider, capability]) => provider !== "fake" && capability.supportsRunExecution)
.map(([provider, capability]) => ({
provider,
displayName: capability.displayName || provider,
description: capability.description,
configSchema: normalizeJsonSchema(capability.configSchema),
}))
.sort((left, right) => left.displayName.localeCompare(right.displayName));
const sandboxCreationEnabled = discoveredPluginSandboxProviders.length > 0;
const sandboxSupportVisible = sandboxCreationEnabled;
const pluginSandboxProviders =
environmentForm.sandboxProvider.trim().length > 0 &&
environmentForm.sandboxProvider !== "fake" &&
!discoveredPluginSandboxProviders.some((provider) => provider.provider === environmentForm.sandboxProvider)
? [
...discoveredPluginSandboxProviders,
{ provider: environmentForm.sandboxProvider, displayName: environmentForm.sandboxProvider, description: undefined, configSchema: null },
]
: discoveredPluginSandboxProviders;
const selectedSandboxProvider = pluginSandboxProviders.find(
(provider) => provider.provider === environmentForm.sandboxProvider,
) ?? null;
const selectedSandboxSchema = selectedSandboxProvider?.configSchema ?? null;
const sandboxConfigErrors =
environmentForm.driver === "sandbox" && selectedSandboxSchema
? validateJsonSchemaForm(selectedSandboxSchema as any, environmentForm.sandboxConfig)
: {};
useEffect(() => {
if (environmentForm.driver !== "sandbox") return;
if (environmentForm.sandboxProvider.trim().length > 0 && environmentForm.sandboxProvider !== "fake") return;
const firstProvider = discoveredPluginSandboxProviders[0]?.provider;
if (!firstProvider) return;
const firstSchema = discoveredPluginSandboxProviders[0]?.configSchema;
setEnvironmentForm((current) => (
current.driver !== "sandbox" || (current.sandboxProvider.trim().length > 0 && current.sandboxProvider !== "fake")
? current
: {
...current,
sandboxProvider: firstProvider,
sandboxConfig: firstSchema ? getDefaultValues(firstSchema as any) : {},
}
));
}, [discoveredPluginSandboxProviders, environmentForm.driver, environmentForm.sandboxProvider]);
const environmentFormValid =
environmentForm.name.trim().length > 0 &&
(environmentForm.driver !== "ssh" ||
(
environmentForm.sshHost.trim().length > 0 &&
environmentForm.sshUsername.trim().length > 0 &&
environmentForm.sshRemoteWorkspacePath.trim().length > 0
)) &&
(environmentForm.driver !== "sandbox" ||
environmentForm.sandboxProvider.trim().length > 0 &&
environmentForm.sandboxProvider !== "fake" &&
Object.keys(sandboxConfigErrors).length === 0);
return (
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-2">
@@ -374,6 +744,388 @@ export function CompanySettings() {
</div>
)}
{environmentsEnabled ? (
<div className="space-y-4" data-testid="company-settings-environments-section">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Environments
</div>
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
Environment choices use the same adapter support matrix as agent defaults. SSH is always available for
remote-managed adapters, and sandbox environments appear only when a run-capable sandbox provider plugin is
installed.
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[34rem] text-left text-xs">
<caption className="sr-only">Environment support by adapter</caption>
<thead className="border-b border-border text-muted-foreground">
<tr>
<th className="py-2 pr-3 font-medium">Adapter</th>
<th className="px-3 py-2 font-medium">Local</th>
<th className="px-3 py-2 font-medium">SSH</th>
{sandboxSupportVisible ? (
<th className="px-3 py-2 font-medium">Sandbox</th>
) : null}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{(environmentCapabilities?.adapters.map((support) => ({
adapterType: support.adapterType,
support,
})) ?? ENVIRONMENT_SUPPORT_ROWS).map(({ adapterType, support }) => (
<tr key={adapterType}>
<td className="py-2 pr-3 font-medium">
{adapterLabels[adapterType] ?? adapterType}
</td>
<td className="px-3 py-2">
<SupportMark supported={support.drivers.local === "supported"} />
</td>
<td className="px-3 py-2">
<SupportMark supported={support.drivers.ssh === "supported"} />
</td>
{sandboxSupportVisible ? (
<td className="px-3 py-2">
<SupportMark
supported={discoveredPluginSandboxProviders.some((provider) =>
support.sandboxProviders[provider.provider] === "supported")}
/>
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
<div className="space-y-3">
{(environments ?? []).length === 0 ? (
<div className="text-sm text-muted-foreground">No environments saved for this company yet.</div>
) : (
(environments ?? []).map((environment) => {
const probe = probeResults[environment.id] ?? null;
const isEditing = editingEnvironmentId === environment.id;
return (
<div
key={environment.id}
className="rounded-md border border-border/70 px-3 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-sm font-medium">
{environment.name} <span className="text-muted-foreground">· {environment.driver}</span>
</div>
{environment.description ? (
<div className="text-xs text-muted-foreground">{environment.description}</div>
) : null}
{environment.driver === "ssh" ? (
<div className="text-xs text-muted-foreground">
{typeof environment.config.host === "string" ? environment.config.host : "SSH host"} ·{" "}
{typeof environment.config.username === "string" ? environment.config.username : "user"}
</div>
) : environment.driver === "sandbox" ? (
<div className="text-xs text-muted-foreground">
{(() => {
const provider =
typeof environment.config.provider === "string" ? environment.config.provider : "sandbox";
const displayName =
environmentCapabilities?.sandboxProviders?.[provider]?.displayName ?? provider;
const summary = summarizeSandboxConfig(environment.config as Record<string, unknown>);
return `${displayName} sandbox provider${summary ? ` · ${summary}` : ""}`;
})()}
</div>
) : (
<div className="text-xs text-muted-foreground">Runs on this Paperclip host.</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{environment.driver !== "local" ? (
<Button
size="sm"
variant="outline"
onClick={() => environmentProbeMutation.mutate(environment.id)}
disabled={environmentProbeMutation.isPending}
>
{environmentProbeMutation.isPending
? "Testing..."
: environment.driver === "ssh"
? "Test connection"
: "Test provider"}
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => handleEditEnvironment(environment)}
>
{isEditing ? "Editing" : "Edit"}
</Button>
</div>
</div>
{probe ? (
<div
className={
probe.ok
? "mt-3 rounded border border-green-500/30 bg-green-500/5 px-2.5 py-2 text-xs text-green-700"
: "mt-3 rounded border border-destructive/30 bg-destructive/5 px-2.5 py-2 text-xs text-destructive"
}
>
<div className="font-medium">{probe.summary}</div>
{probe.details?.error && typeof probe.details.error === "string" ? (
<div className="mt-1 font-mono text-[11px]">{probe.details.error}</div>
) : null}
</div>
) : null}
</div>
);
})
)}
</div>
<div className="border-t border-border/60 pt-4">
<div className="mb-3 text-sm font-medium">
{editingEnvironmentId ? "Edit environment" : "Add environment"}
</div>
<div className="space-y-3">
<Field label="Name" hint="Operator-facing name for this execution target.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.name}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, name: e.target.value }))}
/>
</Field>
<Field label="Description" hint="Optional note about what this machine is for.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.description}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, description: e.target.value }))}
/>
</Field>
<Field label="Driver" hint="Local runs on this host. SSH stores a remote machine target. Sandbox stores plugin-backed provider config on the shared environment seam.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.driver}
onChange={(e) =>
setEnvironmentForm((current) => ({
...current,
sandboxProvider:
e.target.value === "sandbox"
? current.sandboxProvider.trim() || discoveredPluginSandboxProviders[0]?.provider || ""
: current.sandboxProvider,
sandboxConfig:
e.target.value === "sandbox"
? (
current.sandboxProvider.trim().length > 0 && current.driver === "sandbox"
? current.sandboxConfig
: discoveredPluginSandboxProviders[0]?.configSchema
? getDefaultValues(discoveredPluginSandboxProviders[0].configSchema as any)
: {}
)
: current.sandboxConfig,
driver:
e.target.value === "local"
? "local"
: e.target.value === "sandbox"
? "sandbox"
: "ssh",
}))}
>
<option value="ssh">SSH</option>
{sandboxCreationEnabled || environmentForm.driver === "sandbox" ? (
<option value="sandbox">Sandbox</option>
) : null}
<option value="local">Local</option>
</select>
</Field>
{environmentForm.driver === "ssh" ? (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Host" hint="DNS name or IP address for the remote machine.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.sshHost}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshHost: e.target.value }))}
/>
</Field>
<Field label="Port" hint="Defaults to 22.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="number"
min={1}
max={65535}
value={environmentForm.sshPort}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPort: e.target.value }))}
/>
</Field>
<Field label="Username" hint="SSH login user.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={environmentForm.sshUsername}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshUsername: e.target.value }))}
/>
</Field>
<Field label="Remote workspace path" hint="Absolute path that Paperclip will verify during SSH connection tests.">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
placeholder="/Users/paperclip/workspace"
value={environmentForm.sshRemoteWorkspacePath}
onChange={(e) =>
setEnvironmentForm((current) => ({ ...current, sshRemoteWorkspacePath: e.target.value }))}
/>
</Field>
<Field label="Private key" hint="Optional PEM private key. Leave blank to rely on the server's SSH agent or default keychain.">
<div className="space-y-2">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sshPrivateKeySecretId}
onChange={(e) =>
setEnvironmentForm((current) => ({
...current,
sshPrivateKeySecretId: e.target.value,
sshPrivateKey: e.target.value ? "" : current.sshPrivateKey,
}))}
>
<option value="">No saved secret</option>
{(secrets ?? []).map((secret) => (
<option key={secret.id} value={secret.id}>{secret.name}</option>
))}
</select>
<textarea
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
value={environmentForm.sshPrivateKey}
disabled={!!environmentForm.sshPrivateKeySecretId}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPrivateKey: e.target.value }))}
/>
</div>
</Field>
<Field label="Known hosts" hint="Optional known_hosts block used when strict host key checking is enabled.">
<textarea
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
value={environmentForm.sshKnownHosts}
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshKnownHosts: e.target.value }))}
/>
</Field>
<div className="md:col-span-2">
<ToggleField
label="Strict host key checking"
hint="Keep this on unless you deliberately want probe-time host key acceptance disabled."
checked={environmentForm.sshStrictHostKeyChecking}
onChange={(checked) =>
setEnvironmentForm((current) => ({ ...current, sshStrictHostKeyChecking: checked }))}
/>
</div>
</div>
) : null}
{environmentForm.driver === "sandbox" ? (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Provider" hint="Installed run-capable sandbox provider plugins appear here.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sandboxProvider}
onChange={(e) => {
const nextProviderKey = e.target.value;
const nextProvider = pluginSandboxProviders.find((provider) => provider.provider === nextProviderKey) ?? null;
setEnvironmentForm((current) => ({
...current,
sandboxProvider: nextProviderKey,
sandboxConfig:
current.sandboxProvider === nextProviderKey
? current.sandboxConfig
: nextProvider?.configSchema
? getDefaultValues(nextProvider.configSchema as any)
: {},
}));
}}
>
{pluginSandboxProviders.map((provider) => (
<option key={provider.provider} value={provider.provider}>
{provider.displayName}
</option>
))}
</select>
</Field>
<div className="md:col-span-2 space-y-3">
{selectedSandboxProvider?.description ? (
<div className="text-xs text-muted-foreground">
{selectedSandboxProvider.description}
</div>
) : null}
{selectedSandboxSchema ? (
<JsonSchemaForm
schema={selectedSandboxSchema as any}
values={environmentForm.sandboxConfig}
onChange={(values) =>
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
errors={sandboxConfigErrors}
/>
) : (
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
This provider does not declare additional configuration fields.
</div>
)}
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => environmentMutation.mutate(environmentForm)}
disabled={environmentMutation.isPending || !environmentFormValid}
>
{environmentMutation.isPending
? editingEnvironmentId
? "Saving..."
: "Creating..."
: editingEnvironmentId
? "Save environment"
: "Create environment"}
</Button>
{editingEnvironmentId ? (
<Button
size="sm"
variant="ghost"
onClick={handleCancelEnvironmentEdit}
disabled={environmentMutation.isPending}
>
Cancel
</Button>
) : null}
{environmentForm.driver !== "local" ? (
<Button
size="sm"
variant="outline"
onClick={() => draftEnvironmentProbeMutation.mutate(environmentForm)}
disabled={draftEnvironmentProbeMutation.isPending || !environmentFormValid}
>
{draftEnvironmentProbeMutation.isPending ? "Testing..." : "Test draft"}
</Button>
) : null}
{environmentMutation.isError ? (
<span className="text-xs text-destructive">
{environmentMutation.error instanceof Error
? environmentMutation.error.message
: "Failed to save environment"}
</span>
) : null}
{draftEnvironmentProbeMutation.data ? (
<span className={draftEnvironmentProbeMutation.data.ok ? "text-xs text-green-600" : "text-xs text-destructive"}>
{draftEnvironmentProbeMutation.data.summary}
</span>
) : null}
</div>
</div>
</div>
</div>
</div>
) : null}
{/* Hiring */}
<div className="space-y-4" data-testid="company-settings-team-section">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
@@ -6,7 +6,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { agentsApi } from "../api/agents";
import { companySkillsApi } from "../api/companySkills";
import { queryKeys } from "../lib/queryKeys";
import { AGENT_ROLES, type AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { AGENT_ROLES } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -17,11 +17,7 @@ import {
import { Shield } from "lucide-react";
import { cn, agentUrl } from "../lib/utils";
import { roleLabels } from "../components/agent-config-primitives";
import {
AgentConfigForm,
AdapterEnvironmentResult,
type CreateConfigValues,
} from "../components/AgentConfigForm";
import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm";
import { defaultCreateValues } from "../components/agent-config-defaults";
import { getUIAdapter, listUIAdapters } from "../adapters";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
@@ -70,15 +66,6 @@ export function NewAgent() {
const [selectedSkillKeys, setSelectedSkillKeys] = useState<string[]>([]);
const [roleOpen, setRoleOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [testAgentAction, setTestAgentAction] = useState<(() => void) | null>(null);
const [testAgentState, setTestAgentState] = useState({ disabled: true, pending: false });
const [testAgentFeedback, setTestAgentFeedback] = useState<{
errorMessage: string | null;
result: AdapterEnvironmentTestResult | null;
}>({
errorMessage: null,
result: null,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -205,21 +192,6 @@ export function NewAgent() {
});
}
const handleTestAgentActionChange = useCallback((fn: (() => void) | null) => {
setTestAgentAction(() => fn);
}, []);
const handleTestAgentStateChange = useCallback((state: { disabled: boolean; pending: boolean }) => {
setTestAgentState(state);
}, []);
const handleTestAgentFeedbackChange = useCallback((feedback: {
errorMessage: string | null;
result: AdapterEnvironmentTestResult | null;
}) => {
setTestAgentFeedback(feedback);
}, []);
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
@@ -296,9 +268,6 @@ export function NewAgent() {
values={configValues}
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
adapterModels={adapterModels}
onTestActionChange={handleTestAgentActionChange}
onTestActionStateChange={handleTestAgentStateChange}
onTestFeedbackChange={handleTestAgentFeedbackChange}
/>
<div className="border-t border-border px-4 py-4">
@@ -347,38 +316,17 @@ export function NewAgent() {
{formError && (
<p className="text-xs text-destructive mb-2">{formError}</p>
)}
<div className="space-y-3">
{testAgentFeedback.errorMessage && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{testAgentFeedback.errorMessage}
</div>
)}
{testAgentFeedback.result && (
<AdapterEnvironmentResult result={testAgentFeedback.result} />
)}
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={() => navigate("/agents")}>
Cancel
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={testAgentState.disabled}
onClick={() => testAgentAction?.()}
>
{testAgentState.pending ? "Testing..." : "Test Agent"}
</Button>
<Button
size="sm"
disabled={!name.trim() || createAgent.isPending}
onClick={handleSubmit}
>
{createAgent.isPending ? "Creating…" : "Create agent"}
</Button>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => navigate("/agents")}>
Cancel
</Button>
<Button
size="sm"
disabled={!name.trim() || createAgent.isPending}
onClick={handleSubmit}
>
{createAgent.isPending ? "Creating…" : "Create agent"}
</Button>
</div>
</div>
</div>

View File

@@ -1,125 +0,0 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginSettings } from "./PluginSettings";
const mockPluginsApi = vi.hoisted(() => ({
get: vi.fn(),
health: vi.fn(),
dashboard: vi.fn(),
logs: vi.fn(),
getConfig: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
vi.mock("@/api/plugins", () => ({
pluginsApi: mockPluginsApi,
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
}),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
selectedCompanyId: "company-1",
}),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
Navigate: () => null,
useParams: () => ({ companyPrefix: "PAP", pluginId: "plugin-1" }),
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: () => null,
usePluginSlots: () => ({ slots: [] }),
}));
vi.mock("@/components/PageTabBar", () => ({
PageTabBar: () => null,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("PluginSettings", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockPluginsApi.get.mockResolvedValue({
id: "plugin-1",
pluginKey: "paperclip.e2b-sandbox-provider",
packageName: "@paperclipai/plugin-e2b",
version: "0.1.0",
status: "error",
categories: ["automation"],
manifestJson: {
displayName: "E2B Sandbox Provider",
version: "0.1.0",
description: "E2B environments for Paperclip.",
author: "Paperclip",
capabilities: ["environment.drivers.register"],
environmentDrivers: [
{
driverKey: "e2b",
kind: "sandbox_provider",
displayName: "E2B Cloud Sandbox",
},
],
},
lastError: null,
});
mockPluginsApi.dashboard.mockResolvedValue(null);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("routes environment-provider plugins to company environments when they have no instance config", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<PluginSettings />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Configure this plugin from Company Environments.");
expect(container.textContent).toContain("company-scoped instead of instance-global");
const link = container.querySelector('a[href="/company/settings/environments"]');
expect(link?.textContent).toContain("Open Company Environments");
await act(async () => {
root.unmount();
});
});
});

View File

@@ -142,11 +142,6 @@ export function PluginSettings() {
: "secondary";
const pluginDescription = plugin.manifestJson.description || "No description provided.";
const pluginCapabilities = plugin.manifestJson.capabilities ?? [];
const environmentDrivers = plugin.manifestJson.environmentDrivers ?? [];
const environmentDriverNames = environmentDrivers
.map((driver) => driver.displayName?.trim() || driver.driverKey)
.filter((name, index, values) => values.indexOf(name) === index);
const driverLabel = environmentDriverNames.join(", ");
return (
<div className="space-y-6 max-w-5xl">
@@ -240,19 +235,6 @@ export function PluginSettings() {
pluginStatus={plugin.status}
supportsConfigTest={(plugin as unknown as { supportsConfigTest?: boolean }).supportsConfigTest === true}
/>
) : environmentDrivers.length > 0 ? (
<div className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-sm">
<p className="font-medium text-foreground">Configure this plugin from Company Environments.</p>
<p className="mt-1 text-muted-foreground">
{driverLabel || "This plugin"} registers environment runtime settings there so credentials stay
company-scoped instead of instance-global.
</p>
<div className="mt-3">
<Link to="/company/settings/environments">
<Button variant="outline" size="sm">Open Company Environments</Button>
</Link>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
This plugin does not require any settings.