mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-09 08:33:02 +02:00
Compare commits
1 Commits
e2b-plugin
...
release-v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a7562198 |
3
.github/workflows/pr.yml
vendored
3
.github/workflows/pr.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});`;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)]
|
||||
: []),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user