mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
Merge origin/master into master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
10398a933b
118 files changed
+7134
-1074
No files matched your search
@@ -80,6 +80,9 @@ 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
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ function makeCompany(overrides: Partial<Company>): Company {
|
||||
issueCounter: 1,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
|
||||
@@ -376,8 +376,11 @@ describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
||||
name: "Export Engineer",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
promptTemplate: "You verify company portability.",
|
||||
adapterConfig: {},
|
||||
instructionsBundle: {
|
||||
files: {
|
||||
"AGENTS.md": "You verify company portability.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -160,6 +160,7 @@ describe("renderCompanyImportPreview", () => {
|
||||
path: "COMPANY.md",
|
||||
name: "Source Co",
|
||||
description: null,
|
||||
attachmentMaxBytes: null,
|
||||
brandColor: null,
|
||||
logoPath: null,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
@@ -375,6 +376,7 @@ describe("import selection catalog", () => {
|
||||
path: "COMPANY.md",
|
||||
name: "Source Co",
|
||||
description: null,
|
||||
attachmentMaxBytes: null,
|
||||
brandColor: null,
|
||||
logoPath: "images/company-logo.png",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ DATABASE_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.
|
||||
DATABASE_MIGRATION_URL=postgres://postgres.[PROJECT-REF]:[PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres
|
||||
```
|
||||
|
||||
Do not patch `packages/db/src/client.ts` as a setup step. Add a configuration/env switch for postgres-js prepared statements before documenting pooled Supavisor as the supported runtime path.
|
||||
If your hosted database requires transaction-pooling-only connections, use a direct or session-pooled connection for Paperclip until runtime pooling support is documented in this guide. Do not edit database client source files as part of deployment setup.
|
||||
|
||||
### Push the schema
|
||||
|
||||
|
||||
@@ -143,6 +143,13 @@ 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,6 +63,8 @@ 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:
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
|
||||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
|
||||
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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,6 +35,12 @@ 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);
|
||||
@@ -48,7 +54,7 @@ function requireSuccessfulResult(result: RunProcessResult, action: string): void
|
||||
throw new Error(`${action} failed with exit code ${result.exitCode ?? "null"}${detail}`);
|
||||
}
|
||||
|
||||
function createCommandManagedRuntimeClient(input: {
|
||||
export function createCommandManagedRuntimeClient(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
remoteCwd: string;
|
||||
timeoutMs: number;
|
||||
@@ -71,15 +77,39 @@ 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(path.posix.dirname(remotePath))} && base64 -d > ${shellQuote(remotePath)}`,
|
||||
{ stdin: body },
|
||||
`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)}`,
|
||||
);
|
||||
},
|
||||
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",
|
||||
@@ -145,7 +175,7 @@ export async function prepareCommandManagedRuntime(input: {
|
||||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
workspaceRemoteDir,
|
||||
workspaceExclude: input.workspaceExclude,
|
||||
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
|
||||
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
||||
assets: input.assets,
|
||||
});
|
||||
|
||||
@@ -1,14 +1,59 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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 {
|
||||
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 () => ({
|
||||
@@ -58,6 +103,7 @@ describe("sandbox adapter execution targets", () => {
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd: "/workspace",
|
||||
paperclipTransport: "bridge",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,4 +139,154 @@ 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,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as ssh from "./ssh.js";
|
||||
import {
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
} from "./execution-target.js";
|
||||
|
||||
@@ -159,3 +160,49 @@ 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,6 +10,14 @@ 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,
|
||||
@@ -43,6 +51,7 @@ export interface AdapterSandboxExecutionTarget {
|
||||
leaseId?: string | null;
|
||||
remoteCwd: string;
|
||||
paperclipApiUrl?: string | null;
|
||||
paperclipTransport?: "direct" | "bridge";
|
||||
timeoutMs?: number | null;
|
||||
runner?: CommandManagedRuntimeRunner;
|
||||
}
|
||||
@@ -82,6 +91,11 @@ 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>)
|
||||
@@ -96,6 +110,31 @@ 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;
|
||||
@@ -130,14 +169,34 @@ 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 {
|
||||
@@ -336,18 +395,78 @@ 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,
|
||||
...(target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}),
|
||||
paperclipTransport,
|
||||
...(paperclipTransport === "direct" && target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,6 +486,7 @@ 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)
|
||||
);
|
||||
}
|
||||
@@ -399,6 +519,7 @@ 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",
|
||||
@@ -408,6 +529,10 @@ 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,
|
||||
};
|
||||
}
|
||||
@@ -514,3 +639,172 @@ 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,3 +54,15 @@ 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";
|
||||
@@ -0,0 +1,610 @@
|
||||
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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,822 @@
|
||||
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, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, 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,6 +73,13 @@ 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,6 +27,7 @@ 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 -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)} 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)} reset --hard ${shellQuote(input.snapshot.headCommit)} >/dev/null`,
|
||||
`git -C ${shellQuote(input.remoteDir)} clean -fdx -e .paperclip-runtime >/dev/null`,
|
||||
].join("\n");
|
||||
|
||||
@@ -216,6 +216,20 @@ 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";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
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,12 +10,15 @@ import {
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
readAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
asString,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
|
||||
import {
|
||||
parseClaudeStreamJson,
|
||||
describeClaudeFailure,
|
||||
@@ -45,6 +49,7 @@ 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";
|
||||
@@ -316,6 +321,9 @@ 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({
|
||||
@@ -334,11 +342,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
loggedEnv,
|
||||
loggedEnv: initialLoggedEnv,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
extraArgs,
|
||||
} = runtimeConfig;
|
||||
let loggedEnv = initialLoggedEnv;
|
||||
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||
const terminalResultCleanupGraceMs = Math.max(
|
||||
0,
|
||||
@@ -379,6 +388,13 @@ 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(
|
||||
@@ -395,6 +411,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
localDir: promptBundle.addDir,
|
||||
followSymlinks: true,
|
||||
},
|
||||
...(claudeConfigSeedDir
|
||||
? [{
|
||||
key: "config-seed",
|
||||
localDir: claudeConfigSeedDir,
|
||||
followSymlinks: true,
|
||||
}]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
})()
|
||||
@@ -411,6 +434,63 @@ 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 ?? "");
|
||||
@@ -766,6 +846,9 @@ 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,11 +9,15 @@ 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";
|
||||
@@ -56,10 +60,28 @@ export async function testEnvironment(
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "claude");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
|
||||
cwd,
|
||||
env: {},
|
||||
createIfMissing: true,
|
||||
});
|
||||
checks.push({
|
||||
code: "claude_cwd_valid",
|
||||
level: "info",
|
||||
@@ -81,7 +103,7 @@ export async function testEnvironment(
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "claude_command_resolvable",
|
||||
level: "info",
|
||||
@@ -96,16 +118,21 @@ 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" ||
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK === "1" ||
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK === "true" ||
|
||||
(considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "1") ||
|
||||
(considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "true") ||
|
||||
isNonEmpty(env.ANTHROPIC_BEDROCK_BASE_URL) ||
|
||||
isNonEmpty(process.env.ANTHROPIC_BEDROCK_BASE_URL);
|
||||
(considerHostEnv && isNonEmpty(process.env.ANTHROPIC_BEDROCK_BASE_URL));
|
||||
|
||||
const configApiKey = env.ANTHROPIC_API_KEY;
|
||||
const hostApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const hostApiKey = considerHostEnv ? process.env.ANTHROPIC_API_KEY : undefined;
|
||||
if (hasBedrock) {
|
||||
const source =
|
||||
env.CLAUDE_CODE_USE_BEDROCK === "1" ||
|
||||
@@ -130,7 +157,7 @@ export async function testEnvironment(
|
||||
detail: `Detected in ${source}.`,
|
||||
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
|
||||
});
|
||||
} else {
|
||||
} else if (!targetIsRemote) {
|
||||
checks.push({
|
||||
code: "claude_subscription_mode_possible",
|
||||
level: "info",
|
||||
@@ -172,8 +199,9 @@ export async function testEnvironment(
|
||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
adapterExecutionTargetRemoteCwd,
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
readAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
asString,
|
||||
@@ -369,6 +371,7 @@ 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")
|
||||
@@ -456,6 +459,19 @@ 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",
|
||||
@@ -780,6 +796,9 @@ 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,11 +6,15 @@ 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";
|
||||
@@ -57,10 +61,28 @@ export async function testEnvironment(
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "codex");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
|
||||
cwd,
|
||||
env: {},
|
||||
createIfMissing: true,
|
||||
});
|
||||
checks.push({
|
||||
code: "codex_cwd_valid",
|
||||
level: "info",
|
||||
@@ -82,7 +104,7 @@ export async function testEnvironment(
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "codex_command_resolvable",
|
||||
level: "info",
|
||||
@@ -98,7 +120,7 @@ export async function testEnvironment(
|
||||
}
|
||||
|
||||
const configOpenAiKey = env.OPENAI_API_KEY;
|
||||
const hostOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
const hostOpenAiKey = targetIsRemote ? undefined : process.env.OPENAI_API_KEY;
|
||||
if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
|
||||
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
@@ -107,7 +129,9 @@ export async function testEnvironment(
|
||||
message: "OPENAI_API_KEY is set for Codex authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
} 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.
|
||||
const codexHome = isNonEmpty(env.CODEX_HOME) ? env.CODEX_HOME : undefined;
|
||||
const codexAuth = await readCodexAuthInfo(codexHome).catch(() => null);
|
||||
if (codexAuth) {
|
||||
@@ -150,8 +174,9 @@ export async function testEnvironment(
|
||||
});
|
||||
}
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
|
||||
@@ -80,4 +80,5 @@ Notes:
|
||||
- Sessions are resumed with --resume when stored session cwd matches current cwd.
|
||||
- Paperclip auto-injects local skills into "~/.cursor/skills" when missing, so Cursor can discover "$paperclip" and related skills on local runs.
|
||||
- Paperclip auto-adds --yolo unless one of --trust/--yolo/-f is already present in extraArgs.
|
||||
- Remote sandbox runs prepend "~/.local/bin" to PATH and prefer "~/.local/bin/cursor-agent" when the default Cursor entrypoint is requested, so standard E2B-style installs do not need hardcoded absolute command paths.
|
||||
`;
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
asString,
|
||||
@@ -41,6 +43,7 @@ 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";
|
||||
|
||||
@@ -199,7 +202,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "agent");
|
||||
let command = asString(config.command, "agent");
|
||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||
const mode = normalizeMode(asString(config.mode, ""));
|
||||
|
||||
@@ -231,7 +234,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;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
let 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()) ||
|
||||
@@ -299,6 +302,22 @@ 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",
|
||||
@@ -308,14 +327,12 @@ 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);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
let 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;
|
||||
@@ -325,6 +342,8 @@ 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 {
|
||||
@@ -344,6 +363,7 @@ 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;
|
||||
@@ -374,6 +394,24 @@ 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 ?? "");
|
||||
@@ -422,6 +460,12 @@ 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(
|
||||
@@ -636,6 +680,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
return toResult(initial);
|
||||
} finally {
|
||||
if (paperclipBridge) {
|
||||
await paperclipBridge.stop();
|
||||
}
|
||||
if (restoreRemoteWorkspace) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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,16 +7,21 @@ 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"] {
|
||||
@@ -38,11 +43,6 @@ 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,11 +94,29 @@ export async function testEnvironment(
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "agent");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
|
||||
cwd,
|
||||
env: {},
|
||||
createIfMissing: true,
|
||||
});
|
||||
checks.push({
|
||||
code: "cursor_cwd_valid",
|
||||
level: "info",
|
||||
@@ -114,13 +132,24 @@ export async function testEnvironment(
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
let 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 ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "cursor_command_resolvable",
|
||||
level: "info",
|
||||
@@ -136,7 +165,7 @@ export async function testEnvironment(
|
||||
}
|
||||
|
||||
const configCursorApiKey = env.CURSOR_API_KEY;
|
||||
const hostCursorApiKey = process.env.CURSOR_API_KEY;
|
||||
const hostCursorApiKey = targetIsRemote ? undefined : process.env.CURSOR_API_KEY;
|
||||
if (isNonEmpty(configCursorApiKey) || isNonEmpty(hostCursorApiKey)) {
|
||||
const source = isNonEmpty(configCursorApiKey) ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
@@ -145,7 +174,7 @@ export async function testEnvironment(
|
||||
message: "CURSOR_API_KEY is set for Cursor authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
} else if (!targetIsRemote) {
|
||||
const cursorHome = isNonEmpty(env.CURSOR_HOME) ? env.CURSOR_HOME : undefined;
|
||||
const cursorAuth = await readCursorAuthInfo(cursorHome).catch(() => null);
|
||||
if (cursorAuth) {
|
||||
@@ -170,13 +199,13 @@ export async function testEnvironment(
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "cursor_cwd_invalid" && check.code !== "cursor_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "agent")) {
|
||||
if (!isDefaultCursorCommand(command)) {
|
||||
checks.push({
|
||||
code: "cursor_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `agent`.",
|
||||
message: "Skipped hello probe because command is not a default Cursor CLI entrypoint.",
|
||||
detail: command,
|
||||
hint: "Use the `agent` CLI command to run the automatic installation and auth probe.",
|
||||
hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||
@@ -192,8 +221,9 @@ export async function testEnvironment(
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("Respond with hello.");
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
asBoolean,
|
||||
@@ -268,7 +270,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);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
let loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
@@ -285,6 +287,8 @@ 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 {
|
||||
@@ -304,6 +308,7 @@ 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;
|
||||
@@ -334,6 +339,24 @@ 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 ?? "");
|
||||
@@ -583,6 +606,7 @@ 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,12 +9,16 @@ 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";
|
||||
@@ -48,10 +52,28 @@ export async function testEnvironment(
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "gemini");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
|
||||
cwd,
|
||||
env: {},
|
||||
createIfMissing: true,
|
||||
});
|
||||
checks.push({
|
||||
code: "gemini_cwd_valid",
|
||||
level: "info",
|
||||
@@ -73,7 +95,7 @@ export async function testEnvironment(
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "gemini_command_resolvable",
|
||||
level: "info",
|
||||
@@ -89,10 +111,10 @@ export async function testEnvironment(
|
||||
}
|
||||
|
||||
const configGeminiApiKey = env.GEMINI_API_KEY;
|
||||
const hostGeminiApiKey = process.env.GEMINI_API_KEY;
|
||||
const hostGeminiApiKey = targetIsRemote ? undefined : process.env.GEMINI_API_KEY;
|
||||
const configGoogleApiKey = env.GOOGLE_API_KEY;
|
||||
const hostGoogleApiKey = process.env.GOOGLE_API_KEY;
|
||||
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true";
|
||||
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");
|
||||
if (
|
||||
isNonEmpty(configGeminiApiKey) ||
|
||||
isNonEmpty(hostGeminiApiKey) ||
|
||||
@@ -152,8 +174,9 @@ export async function testEnvironment(
|
||||
}
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
asString,
|
||||
@@ -234,7 +236,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
|
||||
let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
@@ -259,6 +261,8 @@ 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);
|
||||
@@ -285,6 +289,7 @@ 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;
|
||||
@@ -311,6 +316,28 @@ 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 ?? "");
|
||||
@@ -538,6 +565,7 @@ 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,11 +8,15 @@ 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";
|
||||
@@ -58,10 +62,28 @@ export async function testEnvironment(
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "opencode");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
|
||||
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
|
||||
cwd,
|
||||
env: {},
|
||||
createIfMissing: false,
|
||||
});
|
||||
checks.push({
|
||||
code: "opencode_cwd_valid",
|
||||
level: "info",
|
||||
@@ -115,7 +137,7 @@ export async function testEnvironment(
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "opencode_command_resolvable",
|
||||
level: "info",
|
||||
@@ -137,7 +159,19 @@ export async function testEnvironment(
|
||||
let modelValidationPassed = false;
|
||||
const configuredModel = asString(config.model, "").trim();
|
||||
|
||||
if (canRunProbe && configuredModel) {
|
||||
// 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) {
|
||||
try {
|
||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||
if (discovered.length > 0) {
|
||||
@@ -173,7 +207,7 @@ export async function testEnvironment(
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (canRunProbe && !configuredModel) {
|
||||
} else if (!targetIsRemote && canRunProbe && !configuredModel) {
|
||||
try {
|
||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||
if (discovered.length > 0) {
|
||||
@@ -207,7 +241,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 (configuredModel && canRunProbe) {
|
||||
} else if (!targetIsRemote && configuredModel && canRunProbe) {
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: configuredModel,
|
||||
@@ -246,8 +280,9 @@ export async function testEnvironment(
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
try {
|
||||
const probe = await runChildProcess(
|
||||
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetSessionMatches,
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
describeAdapterExecutionTarget,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetFile,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
readAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
asString,
|
||||
@@ -278,7 +280,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
let loggedEnv = buildInvocationEnvForLogs(env, {
|
||||
runtimeEnv,
|
||||
includeRuntimeKeys: ["HOME"],
|
||||
resolvedCommand,
|
||||
@@ -304,6 +306,7 @@ 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 {
|
||||
@@ -338,6 +341,28 @@ 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 ?? "");
|
||||
@@ -654,6 +679,7 @@ 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,14 +6,18 @@ 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";
|
||||
|
||||
@@ -78,10 +82,28 @@ export async function testEnvironment(
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "pi");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
|
||||
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
|
||||
cwd,
|
||||
env: {},
|
||||
createIfMissing: false,
|
||||
});
|
||||
checks.push({
|
||||
code: "pi_cwd_valid",
|
||||
level: "info",
|
||||
@@ -113,7 +135,7 @@ export async function testEnvironment(
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "pi_command_resolvable",
|
||||
level: "info",
|
||||
@@ -132,7 +154,10 @@ export async function testEnvironment(
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "pi_cwd_invalid" && check.code !== "pi_command_unresolvable");
|
||||
|
||||
if (canRunProbe) {
|
||||
// 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) {
|
||||
try {
|
||||
const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv });
|
||||
if (discovered.length > 0) {
|
||||
@@ -166,6 +191,12 @@ 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 {
|
||||
@@ -218,8 +249,9 @@ export async function testEnvironment(
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
try {
|
||||
const probe = await runChildProcess(
|
||||
`pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
const probe = await runAdapterExecutionTargetProcess(
|
||||
runId,
|
||||
target,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE "companies" ADD COLUMN "attachment_max_bytes" integer DEFAULT 1073741824 NOT NULL;
|
||||
ALTER TABLE "companies" ADD COLUMN "attachment_max_bytes" integer DEFAULT 10485760 NOT NULL;
|
||||
@@ -1,4 +1,4 @@
|
||||
CREATE UNIQUE INDEX "issues_active_productivity_review_uq" ON "issues" USING btree ("company_id","origin_kind","origin_id") WHERE "issues"."origin_kind" = 'issue_productivity_review'
|
||||
and "issues"."origin_id" is not null
|
||||
and "issues"."hidden_at" is null
|
||||
and "issues"."status" not in ('done', 'cancelled');
|
||||
and "issues"."status" not in ('done', 'cancelled');
|
||||
@@ -2965,7 +2965,7 @@
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1073741824
|
||||
"default": 10485760
|
||||
},
|
||||
"require_board_approval_for_new_agents": {
|
||||
"name": "require_board_approval_for_new_agents",
|
||||
@@ -15856,4 +15856,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2965,7 +2965,7 @@
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1073741824
|
||||
"default": 10485760
|
||||
},
|
||||
"require_board_approval_for_new_agents": {
|
||||
"name": "require_board_approval_for_new_agents",
|
||||
@@ -15884,4 +15884,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const companies = pgTable(
|
||||
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
||||
attachmentMaxBytes: integer("attachment_max_bytes")
|
||||
.notNull()
|
||||
.default(1024 * 1024 * 1024),
|
||||
.default(10 * 1024 * 1024),
|
||||
requireBoardApprovalForNewAgents: boolean("require_board_approval_for_new_agents")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
@@ -14,6 +14,14 @@ 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,6 +35,10 @@ 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:
|
||||
"Paperclip secret reference for the E2B API key. Falls back to E2B_API_KEY if omitted.",
|
||||
"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.",
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "number",
|
||||
|
||||
@@ -16,7 +16,18 @@ const { MockCommandExitError, MockSandboxNotFoundError, MockTimeoutError } = vi.
|
||||
}
|
||||
}
|
||||
class MockSandboxNotFoundError extends Error {}
|
||||
class MockTimeoutError 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;
|
||||
}
|
||||
}
|
||||
return { MockCommandExitError, MockSandboxNotFoundError, MockTimeoutError };
|
||||
});
|
||||
|
||||
@@ -54,6 +65,10 @@ 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;
|
||||
@@ -228,8 +243,23 @@ describe("E2B sandbox provider plugin", () => {
|
||||
expect(sandbox.kill).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executes commands through a connected sandbox", async () => {
|
||||
it("executes commands through a connected sandbox when stdin is provided", 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?.({
|
||||
@@ -252,27 +282,91 @@ describe("E2B sandbox provider plugin", () => {
|
||||
});
|
||||
|
||||
expect(mockConnect).toHaveBeenCalledWith("sandbox-123", expect.objectContaining({ apiKey: "resolved-key" }));
|
||||
expect(sandbox.commands.run).toHaveBeenCalledWith("exec 'printf' 'hello'", expect.objectContaining({
|
||||
background: true,
|
||||
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" },
|
||||
stdin: true,
|
||||
timeoutMs: 1000,
|
||||
}));
|
||||
expect(sandbox.commands.sendStdin).toHaveBeenCalledWith(42, "input");
|
||||
expect(sandbox.commands.closeStdin).toHaveBeenCalledWith(42);
|
||||
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: "ok\n",
|
||||
stdout: "stdin\n",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("closes stdin even when sendStdin throws unexpectedly", async () => {
|
||||
it("executes non-stdin commands in foreground mode", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
const failure = new Error("send failed");
|
||||
sandbox.commands.sendStdin.mockRejectedValueOnce(failure);
|
||||
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({
|
||||
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(result).toEqual({
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdout: "foreground\n",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans up staged stdin even when writing it fails", async () => {
|
||||
const sandbox = createMockSandbox();
|
||||
const failure = new Error("write failed");
|
||||
sandbox.files.write.mockRejectedValueOnce(failure);
|
||||
mockConnect.mockResolvedValue(sandbox);
|
||||
|
||||
await expect(plugin.definition.onEnvironmentExecute?.({
|
||||
@@ -292,12 +386,103 @@ describe("E2B sandbox provider plugin", () => {
|
||||
env: { FOO: "bar" },
|
||||
stdin: "input",
|
||||
timeoutMs: 1000,
|
||||
})).rejects.toThrow("send failed");
|
||||
})).rejects.toThrow("write failed");
|
||||
|
||||
expect(sandbox.commands.closeStdin).toHaveBeenCalledWith(42);
|
||||
expect(sandbox.files.remove).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/));
|
||||
expect(sandbox.commands.sendStdin).not.toHaveBeenCalled();
|
||||
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,6 +63,34 @@ 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)}`);
|
||||
}
|
||||
@@ -316,33 +344,64 @@ const plugin = definePlugin({
|
||||
|
||||
const config = parseDriverConfig(params.config);
|
||||
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
|
||||
const started = await sandbox.commands.run(buildCommandLine(params.command, params.args), {
|
||||
background: true,
|
||||
stdin: params.stdin != null,
|
||||
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,
|
||||
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 {
|
||||
if (params.stdin != null) {
|
||||
try {
|
||||
await sandbox.commands.sendStdin(started.pid, params.stdin);
|
||||
} finally {
|
||||
await sandbox.commands.closeStdin(started.pid);
|
||||
}
|
||||
try {
|
||||
await sandbox.commands.sendStdin(started.pid, params.stdin);
|
||||
} finally {
|
||||
await sandbox.commands.closeStdin(started.pid);
|
||||
}
|
||||
const result = await started.wait();
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
exitCode: started.exitCode,
|
||||
timedOut: false,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
stdout: started.stdout,
|
||||
stderr: started.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof CommandExitError) {
|
||||
@@ -355,13 +414,7 @@ const plugin = definePlugin({
|
||||
};
|
||||
}
|
||||
if (error instanceof TimeoutError) {
|
||||
const timeoutError = error as TimeoutError;
|
||||
return {
|
||||
exitCode: null,
|
||||
timedOut: true,
|
||||
stdout: started.stdout,
|
||||
stderr: started.stderr || `${timeoutError.message}\n`,
|
||||
};
|
||||
return buildTimeoutExecuteResult(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const COMPANY_STATUSES = ["active", "paused", "archived"] as const;
|
||||
export type CompanyStatus = (typeof COMPANY_STATUSES)[number];
|
||||
|
||||
export const DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
export const DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_COMPANY_ATTACHMENT_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const;
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface Company {
|
||||
issueCounter: number;
|
||||
budgetMonthlyCents: number;
|
||||
spentMonthlyCents: number;
|
||||
attachmentMaxBytes?: number;
|
||||
attachmentMaxBytes: number;
|
||||
requireBoardApprovalForNewAgents: boolean;
|
||||
feedbackDataSharingEnabled: boolean;
|
||||
feedbackDataSharingConsentAt: Date | null;
|
||||
|
||||
@@ -132,6 +132,13 @@ 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>;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# v2026.428.0
|
||||
|
||||
> Released: 2026-04-28
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Pause and resume agents from the sidebar** — The agent sidebar now exposes per-agent edit, pause, and resume actions through a hover/focus action menu, with toast feedback and per-agent pending state. Budget-paused agents are surfaced clearly and intentionally require a non-sidebar path to resume. ([#4616](https://github.com/paperclipai/paperclip/pull/4616))
|
||||
- **Long issue threads stay smooth** — Issue chat threads now virtualize their rows, bind to a stable scroll container, preserve anchors during incremental loads, and target the absolute latest comment when jumping to the bottom. Long, busy threads scroll predictably without dragging the rest of the UI. ([#4701](https://github.com/paperclipai/paperclip/pull/4701))
|
||||
- **Productivity review surfaces stalled work** — A new productivity review service automatically opens review issues for no-comment streaks, long-active runs, and high-churn loops, and the board UI now shows a productivity review badge that deep-links to those reviews. ([#4700](https://github.com/paperclipai/paperclip/pull/4700), [#4701](https://github.com/paperclipai/paperclip/pull/4701))
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Dispatch assigned todo work during recovery sweeps** — The stranded-assignment recovery sweep now dispatches assigned `todo` issues that have no prior heartbeat run, so initial assignments can no longer sit idle. The path reuses budget hard-stops and skips paused agents, and `assignmentDispatched` is now reported in startup and scheduled recovery logs. ([#4614](https://github.com/paperclipai/paperclip/pull/4614))
|
||||
- **Safer recovery issue handling** — Failed `stranded_issue_recovery` issues are now blocked in place instead of spawning nested recovery loops, retry-failure details are redacted from recovery comments (with reviewers pointed at the linked run evidence), and `maxConcurrentRuns: 1` is honored by heartbeat concurrency normalization. ([#4600](https://github.com/paperclipai/paperclip/pull/4600))
|
||||
- **New-hire approval is opt-in by default** — Newly created or imported companies no longer require board approval for new agents unless operators explicitly enable that policy. Existing company settings are preserved. ([#4600](https://github.com/paperclipai/paperclip/pull/4600))
|
||||
- **Steadier inline selector keyboard handling** — Inline entity selectors no longer leak arrow/enter/tab/escape keys to parent shortcuts, the highlighted option commits reliably after keyboard navigation, and the empty `recentOptionIds` default avoids a stale render churn. ([#4617](https://github.com/paperclipai/paperclip/pull/4617))
|
||||
- **Per-company attachment size limits** — Companies now carry an `attachmentMaxBytes` cap (10 MB default) that flows through the shared portability contract, CLI import/export, and server attachment uploads. The process-level cap still acts as the final ceiling. ([#4700](https://github.com/paperclipai/paperclip/pull/4700))
|
||||
- **Tighter issue ownership and tree control** — Peer agents can no longer mutate issues they don't own, issue-tree pause/resume reaches dependents predictably, and stranded recovery now reuses a single active recovery origin per source issue instead of spawning duplicates. ([#4700](https://github.com/paperclipai/paperclip/pull/4700))
|
||||
- **Issue list scales with scroll** — The issues list now paginates over a server offset and loads more rows as you scroll, so long backlogs stay responsive without manual paging. ([#4701](https://github.com/paperclipai/paperclip/pull/4701))
|
||||
- **Less churn in the new-issue dialog** — Typing in the new-issue dialog no longer re-renders unrelated layout/nav surfaces, and dialog action subscriptions are split so broad shells stay stable while you draft. ([#4701](https://github.com/paperclipai/paperclip/pull/4701))
|
||||
- **Routine variables are easier to author** — The routine editor now exposes inline help for available variables and supports mentioning users, agents, and projects from the routine description. ([#4701](https://github.com/paperclipai/paperclip/pull/4701))
|
||||
|
||||
## Fixes
|
||||
|
||||
- **Manual routine runs stay visible in the runner inbox** — Manual routine runs now carry the initiating board or agent actor through dispatch. Fresh routine execution issues record the runner as `createdByUserId`, and coalesced or skipped active routine issues are touched for that user so the work stays in their inbox. ([#4615](https://github.com/paperclipai/paperclip/pull/4615))
|
||||
- **Reject stale company skill refreshes** — `companySkillService.list()` now checks that the company exists before refreshing bundled and local-path skill state, returning an explicit `404 Company not found` instead of continuing background work for deleted or missing companies. ([#4601](https://github.com/paperclipai/paperclip/pull/4601))
|
||||
- **Ignore stale stored company selections** — `CompanyProvider` now defers exposing a selected company until the loaded company list validates the stored id, clears storage when no companies are available, and avoids briefly putting downstream UI in an invalid company scope on bootstrap. ([#4602](https://github.com/paperclipai/paperclip/pull/4602))
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
Four new database migrations (`0071`–`0074`) will run automatically on startup:
|
||||
|
||||
- `0071_default_hire_approval_off` — flips the `companies.require_board_approval_for_new_agents` column default to `false`. Only affects future inserts; existing companies keep their stored values.
|
||||
- `0072_large_sandman` — adds a partial unique index that prevents duplicate active `stranded_issue_recovery` issues for the same source issue.
|
||||
- `0073_shiny_salo` — adds `companies.attachment_max_bytes` (default `10485760`, NOT NULL). Existing rows get the 10 MB default; raise the value per company if you need larger attachments.
|
||||
- `0074_striped_genesis` — adds a partial unique index that prevents duplicate active `issue_productivity_review` issues for the same source issue.
|
||||
|
||||
All migrations are additive — no existing data is modified or removed.
|
||||
|
||||
If you want new companies to keep requiring board approval for new agents, set that policy explicitly during company creation or import; the stored value still wins over the column default.
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@cryppadotta
|
||||
+32
-2
@@ -11,6 +11,7 @@ release_date=""
|
||||
dry_run=false
|
||||
skip_verify=false
|
||||
print_version_only=false
|
||||
allow_canary_latest=false
|
||||
tag_name=""
|
||||
|
||||
cleanup_on_exit=false
|
||||
@@ -18,11 +19,12 @@ cleanup_on_exit=false
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version]
|
||||
./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version] [--allow-canary-latest]
|
||||
|
||||
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
|
||||
@@ -32,6 +34,9 @@ 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.
|
||||
@@ -99,6 +104,7 @@ 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
|
||||
@@ -115,6 +121,10 @@ 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"
|
||||
|
||||
@@ -187,6 +197,11 @@ 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
|
||||
@@ -263,7 +278,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..."
|
||||
release_info "==> Step 6/7: Confirming npm package availability and dist-tag integrity..."
|
||||
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
|
||||
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
|
||||
MISSING_PUBLISHED_PACKAGES=""
|
||||
@@ -285,6 +300,21 @@ 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 ""
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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,4 +180,42 @@ 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,6 +2,7 @@ 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(
|
||||
@@ -37,6 +38,12 @@ 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");
|
||||
@@ -57,6 +64,10 @@ 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;
|
||||
};
|
||||
@@ -129,6 +140,40 @@ 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
|
||||
@@ -398,6 +443,82 @@ 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,6 +2,7 @@ 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> {
|
||||
@@ -14,6 +15,9 @@ 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(),
|
||||
@@ -43,6 +47,9 @@ type CapturePayload = {
|
||||
prompt: string;
|
||||
codexHome: string | null;
|
||||
paperclipWakePayloadJson: string | null;
|
||||
paperclipApiUrl?: string | null;
|
||||
paperclipApiKey?: string | null;
|
||||
paperclipApiBridgeMode?: string | null;
|
||||
paperclipEnvKeys: string[];
|
||||
};
|
||||
|
||||
@@ -51,6 +58,40 @@ 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-"));
|
||||
@@ -270,6 +311,80 @@ 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,6 +2,7 @@ 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> {
|
||||
@@ -27,6 +28,61 @@ 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", "");
|
||||
@@ -124,6 +180,57 @@ 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,6 +2,7 @@ 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> {
|
||||
@@ -40,6 +41,68 @@ 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;
|
||||
@@ -259,4 +322,127 @@ 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,6 +16,8 @@ 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 () => {
|
||||
@@ -52,7 +54,47 @@ 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,6 +174,13 @@ 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: {
|
||||
@@ -347,4 +354,118 @@ 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());
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.anything(), 31000);
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.anything());
|
||||
});
|
||||
|
||||
@@ -676,7 +676,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
args: ["ok"],
|
||||
cwd: "/workspace",
|
||||
env: {},
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
await environmentService(db).update(environment.id, {
|
||||
@@ -692,7 +691,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",
|
||||
@@ -700,6 +699,98 @@ 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();
|
||||
@@ -1149,7 +1240,7 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
|
||||
args: ["ok"],
|
||||
cwd: "/workspace/project",
|
||||
env: { FOO: "bar" },
|
||||
}));
|
||||
}), 31000);
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentDestroyLease", {
|
||||
driverKey: "fake-plugin",
|
||||
companyId,
|
||||
|
||||
@@ -28,6 +28,10 @@ const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCompanyService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueReferenceService = vi.hoisted(() => ({
|
||||
deleteDocumentSource: vi.fn(async () => undefined),
|
||||
diffIssueReferenceSummary: vi.fn(() => ({
|
||||
@@ -51,6 +55,7 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../services/index.js", () => ({
|
||||
projectService: () => mockProjectService,
|
||||
issueService: () => mockIssueService,
|
||||
companyService: () => mockCompanyService,
|
||||
environmentService: () => mockEnvironmentService,
|
||||
issueReferenceService: () => mockIssueReferenceService,
|
||||
logActivity: mockLogActivity,
|
||||
@@ -158,6 +163,11 @@ describe.sequential("execution environment route guards", () => {
|
||||
mockIssueService.update.mockReset();
|
||||
mockIssueService.getByIdentifier.mockReset();
|
||||
mockIssueService.assertCheckoutOwner.mockReset();
|
||||
mockCompanyService.getById.mockReset();
|
||||
mockCompanyService.getById.mockResolvedValue({
|
||||
id: "company-1",
|
||||
attachmentMaxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockIssueReferenceService.deleteDocumentSource.mockClear();
|
||||
mockIssueReferenceService.diffIssueReferenceSummary.mockClear();
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
environmentLeases,
|
||||
environments,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
@@ -309,6 +311,8 @@ 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);
|
||||
@@ -466,6 +470,48 @@ 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";
|
||||
@@ -877,6 +923,30 @@ 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);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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,4 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -114,4 +116,68 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,9 @@ function registerModuleMocks() {
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
|
||||
@@ -170,6 +170,27 @@ function makeAttachment(contentType: string, originalFilename: string) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizeIssueAttachmentMaxBytes", () => {
|
||||
it("keeps the process-level attachment cap as the final cap", async () => {
|
||||
const previous = process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
|
||||
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = "5";
|
||||
vi.resetModules();
|
||||
try {
|
||||
const { normalizeIssueAttachmentMaxBytes } = await import("../attachment-types.js");
|
||||
expect(normalizeIssueAttachmentMaxBytes(null)).toBe(5);
|
||||
expect(normalizeIssueAttachmentMaxBytes(10)).toBe(5);
|
||||
expect(normalizeIssueAttachmentMaxBytes(3)).toBe(3);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES;
|
||||
} else {
|
||||
process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES = previous;
|
||||
}
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue attachment routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
@@ -223,7 +244,7 @@ describe("issue attachment routes", () => {
|
||||
expect(res.body.contentType).toBe("application/zip");
|
||||
});
|
||||
|
||||
it("allows issue attachments larger than the legacy 10 MiB process default when company limit allows it", async () => {
|
||||
it("enforces the process-level issue attachment limit even when the company limit allows more", async () => {
|
||||
const storage = createStorageService();
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
@@ -240,8 +261,9 @@ describe("issue attachment routes", () => {
|
||||
contentType: "application/octet-stream",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(storage.__calls.putFile?.body.length).toBe(10 * 1024 * 1024 + 1);
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toBe("Attachment exceeds 10485760 bytes");
|
||||
expect(storage.__calls.putFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enforces the configured per-company issue attachment limit", async () => {
|
||||
|
||||
@@ -74,6 +74,9 @@ function registerServiceMocks() {
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
|
||||
@@ -74,6 +74,9 @@ function registerModuleMocks() {
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({ getById: vi.fn(async () => null) }),
|
||||
documentService: () => ({}),
|
||||
|
||||
@@ -108,6 +108,9 @@ vi.mock("../services/routines.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
@@ -477,7 +480,7 @@ describe.sequential("issue comment reopen routes", () => {
|
||||
));
|
||||
});
|
||||
|
||||
it("does not implicitly reopen closed issues via POST comments for agent-authored comments", async () => {
|
||||
it("rejects non-assignee agent POST comments on closed issues", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
@@ -500,11 +503,10 @@ describe.sequential("issue comment reopen routes", () => {
|
||||
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
|
||||
.send({ body: "hello" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockIssueService.update).not.toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
{ status: "todo" },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -625,7 +627,7 @@ describe.sequential("issue comment reopen routes", () => {
|
||||
));
|
||||
});
|
||||
|
||||
it("does not implicitly reopen closed issues via the PATCH comment path for agent-authored comments", async () => {
|
||||
it("rejects non-assignee agent PATCH comments on closed issues", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
@@ -652,11 +654,10 @@ describe.sequential("issue comment reopen routes", () => {
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ comment: "hello" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).not.toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({ status: "todo" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -874,7 +875,7 @@ describe.sequential("issue comment reopen routes", () => {
|
||||
.send({ body: "restart someone else's work", resume: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Agent cannot request follow-up for another agent's issue");
|
||||
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.addComment).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
|
||||
@@ -17,6 +17,9 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
|
||||
@@ -108,6 +108,9 @@ function registerModuleMocks() {
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => mockDocumentsService,
|
||||
|
||||
@@ -24,6 +24,9 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
|
||||
@@ -82,6 +82,9 @@ function registerModuleMocks() {
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
|
||||
@@ -27,6 +27,9 @@ function registerModuleMocks() {
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
@@ -70,7 +73,7 @@ function makeIssue(status: "todo" | "done") {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
status,
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1018",
|
||||
|
||||
@@ -37,6 +37,9 @@ vi.mock("../telemetry.js", () => ({
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
|
||||
@@ -27,6 +27,9 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
@@ -82,6 +85,9 @@ vi.mock("../services/index.js", () => ({
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
|
||||
@@ -90,6 +90,9 @@ function registerRouteMocks() {
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
|
||||
@@ -12,6 +12,7 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
getCommentCursor: vi.fn(),
|
||||
getComment: vi.fn(),
|
||||
listBlockerAttention: vi.fn(),
|
||||
listProductivityReviews: vi.fn(),
|
||||
listAttachments: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -91,6 +92,9 @@ const mockWorkProductService = vi.hoisted(() => ({
|
||||
const mockEnvironmentService = vi.hoisted(() => ({}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => mockDocumentsService,
|
||||
@@ -177,6 +181,7 @@ describe.sequential("issue goal context routes", () => {
|
||||
});
|
||||
mockIssueService.getComment.mockResolvedValue(null);
|
||||
mockIssueService.listBlockerAttention.mockResolvedValue(new Map());
|
||||
mockIssueService.listProductivityReviews.mockResolvedValue(new Map());
|
||||
mockIssueService.listAttachments.mockResolvedValue([]);
|
||||
mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({});
|
||||
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null);
|
||||
|
||||
@@ -2116,6 +2116,109 @@ 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://100.104.161.29:3102";
|
||||
process.env.PAPERCLIP_RUNTIME_API_URL = "http://203.0.113.42: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://100.104.161.29:3102");
|
||||
expect(env.PAPERCLIP_API_URL).toBe("http://203.0.113.42:3102");
|
||||
});
|
||||
|
||||
it("falls back to PAPERCLIP_API_URL when no runtime URL is configured", () => {
|
||||
@@ -52,6 +52,7 @@ 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";
|
||||
@@ -63,6 +64,7 @@ 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,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl } from "../runtime-api.js";
|
||||
import {
|
||||
buildRuntimeApiCandidateUrls,
|
||||
choosePrimaryRuntimeApiUrl,
|
||||
collectReachableInterfaceHosts,
|
||||
} from "../runtime-api.js";
|
||||
|
||||
describe("runtime API discovery", () => {
|
||||
it("prefers the explicit public base URL for the primary runtime URL", () => {
|
||||
@@ -56,7 +60,23 @@ describe("runtime API discovery", () => {
|
||||
"http://198.51.100.10:3102",
|
||||
"http://runtime-host.example.test:3102",
|
||||
"http://203.0.113.42:3102",
|
||||
"http://[fe80::1]: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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -74,4 +94,54 @@ 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,4 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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;
|
||||
|
||||
const {
|
||||
createAppMock,
|
||||
@@ -265,6 +271,26 @@ 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";
|
||||
|
||||
@@ -272,6 +298,10 @@ 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 () => {
|
||||
|
||||
@@ -99,7 +99,7 @@ export const MAX_ATTACHMENT_BYTES =
|
||||
|
||||
export function normalizeIssueAttachmentMaxBytes(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES;
|
||||
return Math.min(DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES, MAX_ATTACHMENT_BYTES);
|
||||
}
|
||||
return Math.min(Math.floor(value), MAX_COMPANY_ATTACHMENT_MAX_BYTES);
|
||||
return Math.min(Math.floor(value), MAX_COMPANY_ATTACHMENT_MAX_BYTES, MAX_ATTACHMENT_BYTES);
|
||||
}
|
||||
+2
-1
@@ -637,13 +637,14 @@ 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,6 +55,7 @@ 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,
|
||||
@@ -1500,6 +1501,11 @@ 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,6 +54,9 @@ 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,
|
||||
@@ -169,6 +172,111 @@ 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,
|
||||
@@ -977,6 +1085,10 @@ 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,
|
||||
@@ -987,12 +1099,32 @@ 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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -688,7 +688,6 @@ export function issueRoutes(
|
||||
details: {
|
||||
issueId: issue.id,
|
||||
status: issue.status,
|
||||
securityPrinciples: ["Complete Mediation", "Fail Securely"],
|
||||
},
|
||||
});
|
||||
return false;
|
||||
@@ -711,7 +710,6 @@ export function issueRoutes(
|
||||
holdId: activePauseHold.holdId,
|
||||
rootIssueId: activePauseHold.rootIssueId,
|
||||
mode: activePauseHold.mode,
|
||||
securityPrinciples: ["Complete Mediation", "Fail Securely", "Secure Defaults"],
|
||||
},
|
||||
});
|
||||
return false;
|
||||
@@ -945,7 +943,6 @@ export function issueRoutes(
|
||||
const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
|
||||
? Number.parseInt(rawOffset, 10)
|
||||
: null;
|
||||
const offset = parsedOffset ?? 0;
|
||||
|
||||
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
||||
@@ -971,6 +968,7 @@ export function issueRoutes(
|
||||
res.status(400).json({ error: "offset must be a non-negative integer" });
|
||||
return;
|
||||
}
|
||||
const offset = parsedOffset ?? 0;
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
status: req.query.status as string | undefined,
|
||||
|
||||
@@ -14,6 +14,14 @@ 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}]`
|
||||
@@ -68,7 +76,36 @@ 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;
|
||||
@@ -88,6 +125,7 @@ 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) {
|
||||
@@ -108,14 +146,8 @@ export function buildRuntimeApiCandidateUrls(input: {
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
for (const host of collectReachableInterfaceHosts({ networkInterfacesMap: input.networkInterfacesMap })) {
|
||||
pushCandidate(candidates, seen, formatOrigin(protocol, host, input.port));
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
|
||||
@@ -60,9 +60,7 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
||||
const paperclipApiUrl =
|
||||
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
|
||||
? input.leaseMetadata.paperclipApiUrl.trim()
|
||||
: 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;
|
||||
: null;
|
||||
|
||||
return {
|
||||
kind: "remote",
|
||||
@@ -72,6 +70,7 @@ 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,6 +114,33 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -342,6 +369,7 @@ 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" ||
|
||||
@@ -364,6 +392,10 @@ 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(
|
||||
@@ -378,6 +410,41 @@ 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,6 +36,7 @@ import {
|
||||
executePluginEnvironmentCommand,
|
||||
realizePluginEnvironmentWorkspace,
|
||||
resolvePluginSandboxProviderDriverByKey,
|
||||
resolvePluginExecuteRpcTimeoutMs,
|
||||
resumePluginEnvironmentLease,
|
||||
} from "./plugin-environment-driver.js";
|
||||
import { collectSecretRefPaths } from "./json-schema-secret-refs.js";
|
||||
@@ -151,6 +152,13 @@ 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;
|
||||
@@ -288,10 +296,63 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
|
||||
|
||||
function createSandboxEnvironmentDriver(
|
||||
db: Db,
|
||||
pluginWorkerManager?: PluginWorkerManager,
|
||||
options: {
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
pluginWorkerReadyTimeoutMs?: number;
|
||||
pluginWorkerReadyPollMs?: number;
|
||||
} = {},
|
||||
): 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;
|
||||
@@ -342,17 +403,29 @@ function createSandboxEnvironmentDriver(
|
||||
|
||||
// Check if this provider should be handled by a plugin.
|
||||
if (!isBuiltinSandboxProvider(parsed.config.provider)) {
|
||||
const pluginProvider = await resolvePluginSandboxProviderDriverByKey({
|
||||
db,
|
||||
driverKey: parsed.config.provider,
|
||||
workerManager: pluginWorkerManager,
|
||||
requireRunning: true,
|
||||
const pluginProvider = await resolveSandboxProviderPlugin({
|
||||
provider: parsed.config.provider,
|
||||
});
|
||||
if (!pluginProvider || !pluginWorkerManager) {
|
||||
if (pluginProvider.state === "missing") {
|
||||
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;
|
||||
@@ -368,7 +441,7 @@ function createSandboxEnvironmentDriver(
|
||||
|
||||
const providerLease = reusableLease?.providerLeaseId
|
||||
? await pluginWorkerManager.call(
|
||||
pluginProvider.plugin.id,
|
||||
pluginProvider.resolved.plugin.id,
|
||||
"environmentResumeLease",
|
||||
{
|
||||
driverKey: parsed.config.provider,
|
||||
@@ -385,7 +458,7 @@ function createSandboxEnvironmentDriver(
|
||||
).catch(() => null)
|
||||
: null;
|
||||
const acquiredLease = providerLease ?? await pluginWorkerManager.call(
|
||||
pluginProvider.plugin.id,
|
||||
pluginProvider.resolved.plugin.id,
|
||||
"environmentAcquireLease",
|
||||
{
|
||||
driverKey: parsed.config.provider,
|
||||
@@ -414,13 +487,13 @@ function createSandboxEnvironmentDriver(
|
||||
metadata: {
|
||||
driver: input.environment.driver,
|
||||
executionWorkspaceMode: input.executionWorkspaceMode,
|
||||
pluginId: pluginProvider.plugin.id,
|
||||
pluginKey: pluginProvider.plugin.pluginKey,
|
||||
pluginId: pluginProvider.resolved.plugin.id,
|
||||
pluginKey: pluginProvider.resolved.plugin.pluginKey,
|
||||
sandboxProviderPlugin: true,
|
||||
...sandboxConfigForLeaseMetadata(storedConfig),
|
||||
...stripSecretRefValuesFromPluginLeaseMetadata({
|
||||
metadata: acquiredLease.metadata,
|
||||
schema: pluginProvider.driver.configSchema as Record<string, unknown> | null | undefined,
|
||||
schema: pluginProvider.resolved.driver.configSchema as Record<string, unknown> | null | undefined,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -565,11 +638,12 @@ 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: stripSandboxProviderEnvelope(config as SandboxEnvironmentConfig),
|
||||
config: sanitizedConfig,
|
||||
lease: {
|
||||
providerLeaseId: input.lease.providerLeaseId,
|
||||
metadata: input.lease.metadata ?? undefined,
|
||||
@@ -581,7 +655,10 @@ 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.");
|
||||
@@ -929,6 +1006,8 @@ export function environmentRuntimeService(
|
||||
options: {
|
||||
drivers?: EnvironmentRuntimeDriver[];
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
pluginWorkerReadyTimeoutMs?: number;
|
||||
pluginWorkerReadyPollMs?: number;
|
||||
} = {},
|
||||
) {
|
||||
const environmentsSvc = environmentService(db);
|
||||
@@ -937,7 +1016,11 @@ export function environmentRuntimeService(
|
||||
const defaultDrivers = [
|
||||
createLocalEnvironmentDriver(db),
|
||||
createSshEnvironmentDriver(db),
|
||||
createSandboxEnvironmentDriver(db, options.pluginWorkerManager),
|
||||
createSandboxEnvironmentDriver(db, {
|
||||
pluginWorkerManager: options.pluginWorkerManager,
|
||||
pluginWorkerReadyTimeoutMs: options.pluginWorkerReadyTimeoutMs,
|
||||
pluginWorkerReadyPollMs: options.pluginWorkerReadyPollMs,
|
||||
}),
|
||||
...(options.pluginWorkerManager
|
||||
? [createPluginEnvironmentDriver(db, options.pluginWorkerManager)]
|
||||
: []),
|
||||
|
||||
@@ -2008,6 +2008,31 @@ 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
|
||||
@@ -2647,7 +2672,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const existing = await getRuntimeState(agent.id);
|
||||
if (existing) return existing;
|
||||
|
||||
return db
|
||||
const inserted = await db
|
||||
.insert(agentRuntimeState)
|
||||
.values({
|
||||
agentId: agent.id,
|
||||
@@ -2655,8 +2680,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
adapterType: agent.adapterType,
|
||||
stateJson: {},
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: agentRuntimeState.agentId,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
.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;
|
||||
}
|
||||
|
||||
async function setRunStatus(
|
||||
@@ -4448,6 +4483,13 @@ 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) {
|
||||
@@ -5894,22 +5936,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
await finalizeAgentStatus(run.agentId, "failed").catch(() => undefined);
|
||||
} finally {
|
||||
const latestRun = await getRun(run.id).catch(() => null);
|
||||
const releaseResult = await envOrchestrator.releaseForRun({
|
||||
heartbeatRunId: run.id,
|
||||
await releaseEnvironmentLeasesForRun({
|
||||
runId: run.id,
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
status: leaseReleaseStatusForRunStatus(latestRun?.status),
|
||||
status: 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);
|
||||
|
||||
@@ -71,6 +71,7 @@ type RestoreTreeStatusResult = TreeStatusUpdateResult & {
|
||||
const TERMINAL_ISSUE_STATUSES = new Set<IssueStatus>(["done", "cancelled"]);
|
||||
const ACTIVE_RUN_STATUSES = ["queued", "running"] as const;
|
||||
const DEFAULT_RELEASE_POLICY: IssueTreeHoldReleasePolicy = { strategy: "manual" };
|
||||
const MAX_PAUSE_HOLD_ANCESTOR_DEPTH = 100;
|
||||
export const ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS: ReadonlySet<string> = new Set([
|
||||
"issue_commented",
|
||||
"issue_reopened_via_comment",
|
||||
@@ -589,7 +590,11 @@ export function issueTreeControlService(db: Db) {
|
||||
let currentIssueId: string | null = issueId;
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (currentIssueId && !visited.has(currentIssueId)) {
|
||||
while (
|
||||
currentIssueId
|
||||
&& !visited.has(currentIssueId)
|
||||
&& visited.size < MAX_PAUSE_HOLD_ANCESTOR_DEPTH
|
||||
) {
|
||||
visited.add(currentIssueId);
|
||||
const hold = holdByRootIssueId.get(currentIssueId);
|
||||
if (hold) {
|
||||
|
||||
@@ -42,6 +42,7 @@ 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,6 +93,17 @@ 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;
|
||||
@@ -943,7 +955,8 @@ async function listIssueProductivityReviewMap(
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES),
|
||||
),
|
||||
);
|
||||
)
|
||||
.orderBy(desc(issues.createdAt), desc(issues.id));
|
||||
reviewRows.push(...rows);
|
||||
}
|
||||
|
||||
@@ -984,8 +997,9 @@ async function listIssueProductivityReviewMap(
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of reviewRows) {
|
||||
if (!row.sourceIssueId) continue;
|
||||
for (const row of reviewRows) {
|
||||
if (!row.sourceIssueId) continue;
|
||||
if (map.has(row.sourceIssueId)) continue;
|
||||
const detail = triggerByReviewIssueId.get(row.reviewIssueId);
|
||||
map.set(row.sourceIssueId, {
|
||||
reviewIssueId: row.reviewIssueId,
|
||||
@@ -2895,6 +2909,14 @@ 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);
|
||||
}
|
||||
@@ -2968,6 +2990,37 @@ 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,5 +313,31 @@ export async function executePluginEnvironmentCommand(input: {
|
||||
workerManager: input.workerManager,
|
||||
config: input.config,
|
||||
});
|
||||
return await input.workerManager.call(plugin.id, "environmentExecute", input.params);
|
||||
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;
|
||||
}
|
||||
@@ -579,7 +579,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
|
||||
originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
|
||||
originId: evidence.sourceIssue.id,
|
||||
originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id),
|
||||
requestDepth: clampIssueRequestDepth(clampIssueRequestDepth(evidence.sourceIssue.requestDepth) + 1),
|
||||
requestDepth: clampIssueRequestDepth(evidence.sourceIssue.requestDepth + 1),
|
||||
});
|
||||
} catch (error) {
|
||||
const maybe = error as { code?: string; constraint?: string; message?: string };
|
||||
|
||||
@@ -292,6 +292,8 @@ If the plan needs explicit approval before implementation, update the `plan` doc
|
||||
|
||||
When asked to convert a plan into executable Paperclip tasks — depth, assignment, dependencies, parallelization — use the companion skill `paperclip-converting-plans-to-tasks`.
|
||||
|
||||
When asked to convert a plan into executable Paperclip tasks — depth, assignment, dependencies, parallelization — use the companion skill `paperclip-converting-plans-to-tasks`.
|
||||
|
||||
Recommended API flow:
|
||||
|
||||
```bash
|
||||
|
||||
+5
-1
@@ -27,6 +27,7 @@ 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";
|
||||
@@ -64,6 +65,7 @@ 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 />} />
|
||||
@@ -99,7 +101,9 @@ function boardRoutes() {
|
||||
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
{import.meta.env.DEV ? (
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
) : null}
|
||||
<Route path="routines" element={<Routines />} />
|
||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||
|
||||
@@ -175,7 +175,10 @@ export const agentsApi = {
|
||||
testEnvironment: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
data: { adapterConfig: Record<string, unknown> },
|
||||
data: {
|
||||
adapterConfig: Record<string, unknown>;
|
||||
environmentId?: string | null;
|
||||
},
|
||||
) =>
|
||||
api.post<AdapterEnvironmentTestResult>(
|
||||
`/companies/${companyId}/adapters/${type}/test-environment`,
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user