Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27db0d3c67 | |||
| 9e30b72b27 | |||
| 7b12d907cc | |||
| d1d592d793 | |||
| 3dfb859676 |
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -23,9 +23,7 @@ jobs:
|
||||
- name: Block manual lockfile edits
|
||||
if: github.head_ref != 'chore/refresh-lockfile'
|
||||
run: |
|
||||
# Diff the PR branch against its merge base so recent base-branch commits
|
||||
# do not masquerade as changes made by the PR itself.
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
|
||||
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
|
||||
exit 1
|
||||
@@ -45,18 +43,9 @@ jobs:
|
||||
- name: Validate Dockerfile deps stage
|
||||
run: node ./scripts/check-docker-deps-stage.mjs
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Verify release package bootstrap for changed manifests
|
||||
run: |
|
||||
mapfile -t changed_paths < <(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")
|
||||
PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \
|
||||
node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}"
|
||||
|
||||
- name: Validate dependency resolution when manifests change
|
||||
run: |
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")"
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
||||
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
||||
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
@@ -85,88 +74,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck workspaces whose build scripts skip TypeScript
|
||||
run: pnpm run typecheck:build-gaps
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run general test suites
|
||||
run: pnpm test:run:general
|
||||
|
||||
- name: Verify release registry test coverage
|
||||
run: pnpm run test:release-registry
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
verify_serialized_server:
|
||||
name: Verify serialized server suites (${{ matrix.shard_label }})
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- shard_index: 0
|
||||
shard_count: 4
|
||||
shard_label: 1/4
|
||||
- shard_index: 1
|
||||
shard_count: 4
|
||||
shard_label: 2/4
|
||||
- shard_index: 2
|
||||
shard_count: 4
|
||||
shard_label: 3/4
|
||||
- shard_index: 3
|
||||
shard_count: 4
|
||||
shard_label: 4/4
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run serialized server test shard
|
||||
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
|
||||
|
||||
canary_dry_run:
|
||||
name: Canary Dry Run
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# `release.sh` always executes its Step 2/7 workspace build, even when
|
||||
# `--skip-verify` bypasses the initial verification gate.
|
||||
- name: Release canary dry run via release.sh internal build
|
||||
- name: Release canary dry run
|
||||
run: |
|
||||
git checkout -B master HEAD
|
||||
git checkout -- pnpm-lock.yaml
|
||||
@@ -195,6 +112,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Install Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
|
||||
@@ -50,9 +50,6 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
@@ -92,9 +89,6 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
@@ -145,9 +139,6 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
@@ -186,9 +177,6 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Validate release package manifest
|
||||
run: node ./scripts/release-package-map.mjs check
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
|
||||
@@ -22,10 +22,8 @@ COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||
COPY packages/mcp-server/package.json packages/mcp-server/
|
||||
COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/
|
||||
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
||||
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
|
||||
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
||||
@@ -34,7 +32,6 @@ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
||||
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
|
||||
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
|
||||
COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/
|
||||
COPY patches/ patches/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -37,10 +37,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.10.0",
|
||||
"@paperclipai/adapter-acpx-local": "workspace:*",
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-cloud": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
@@ -50,7 +48,7 @@
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/server": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-orm": "0.38.4",
|
||||
"dotenv": "^17.0.1",
|
||||
"commander": "^13.1.0",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
|
||||
@@ -244,7 +244,6 @@ describe("renderCompanyImportPreview", () => {
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
comments: [],
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
@@ -461,7 +460,6 @@ describe("import selection catalog", () => {
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
comments: [],
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -17,14 +16,13 @@ describe("home path resolution", () => {
|
||||
});
|
||||
|
||||
it("defaults to ~/.paperclip and default instance", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-home-paths-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
|
||||
const paths = describeLocalInstancePaths();
|
||||
expect(paths.homeDir).toBe(home);
|
||||
expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip"));
|
||||
expect(paths.instanceId).toBe("default");
|
||||
expect(paths.configPath).toBe(path.resolve(home, "instances", "default", "config.json"));
|
||||
expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json"));
|
||||
});
|
||||
|
||||
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
|
||||
@@ -36,7 +34,7 @@ describe("home path resolution", () => {
|
||||
});
|
||||
|
||||
it("rejects invalid instance ids", () => {
|
||||
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid PAPERCLIP_INSTANCE_ID/);
|
||||
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/);
|
||||
});
|
||||
|
||||
it("expands ~ prefixes", () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { onboard } from "../commands/onboard.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
|
||||
function createExistingConfigFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
|
||||
@@ -86,18 +85,10 @@ describe("onboard", () => {
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_BIND;
|
||||
delete process.env.PAPERCLIP_BIND_HOST;
|
||||
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||
delete process.env.HOST;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
});
|
||||
|
||||
it("preserves an existing config when rerun without flags", async () => {
|
||||
@@ -134,27 +125,6 @@ describe("onboard", () => {
|
||||
expect(raw.server.host).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("creates instance-root config and data paths for a fresh PAPERCLIP_HOME", async () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-home-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-cwd-"));
|
||||
process.chdir(cwd);
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
|
||||
await onboard({ yes: true, invokedByRun: true });
|
||||
|
||||
const instanceRoot = path.join(home, "instances", "default");
|
||||
const configPath = path.join(instanceRoot, "config.json");
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||
|
||||
expect(raw.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
|
||||
expect(raw.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups"));
|
||||
expect(raw.logging.logDir).toBe(path.join(instanceRoot, "logs"));
|
||||
expect(raw.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
|
||||
expect(raw.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
|
||||
expect(fs.existsSync(path.join(instanceRoot, ".env"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(instanceRoot, "secrets", "master.key"))).toBe(true);
|
||||
});
|
||||
|
||||
it("supports authenticated/private quickstart bind presets", async () => {
|
||||
const configPath = createFreshConfigPath();
|
||||
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scaffoldPluginProject: vi.fn((options: { outputDir: string }) => options.outputDir),
|
||||
}));
|
||||
|
||||
vi.mock("../../../packages/plugins/create-paperclip-plugin/src/index.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../../packages/plugins/create-paperclip-plugin/src/index.js")>(
|
||||
"../../../packages/plugins/create-paperclip-plugin/src/index.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
scaffoldPluginProject: mocks.scaffoldPluginProject,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildPluginInstallRequest,
|
||||
buildPluginInitNextCommands,
|
||||
buildPluginInitScaffoldOptions,
|
||||
registerPluginCommands,
|
||||
} from "../commands/client/plugin.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-plugin-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("plugin init", () => {
|
||||
beforeEach(() => {
|
||||
mocks.scaffoldPluginProject.mockClear();
|
||||
});
|
||||
|
||||
it("maps package name and flags to scaffolder options", () => {
|
||||
const cwd = path.resolve("/tmp/paperclip-cli-test");
|
||||
const options = buildPluginInitScaffoldOptions(
|
||||
"@acme/plugin-linear",
|
||||
{
|
||||
output: "plugins",
|
||||
template: "connector",
|
||||
category: "automation",
|
||||
displayName: "Linear Bridge",
|
||||
description: "Syncs Linear issues",
|
||||
author: "Acme",
|
||||
sdkPath: "../paperclip/packages/plugins/sdk",
|
||||
},
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(options).toEqual({
|
||||
pluginName: "@acme/plugin-linear",
|
||||
outputDir: path.resolve(cwd, "plugins", "plugin-linear"),
|
||||
template: "connector",
|
||||
category: "automation",
|
||||
displayName: "Linear Bridge",
|
||||
description: "Syncs Linear issues",
|
||||
author: "Acme",
|
||||
sdkPath: "../paperclip/packages/plugins/sdk",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds exact next commands using the scaffold path", () => {
|
||||
expect(buildPluginInitNextCommands("/tmp/acme plugin")).toEqual([
|
||||
"cd '/tmp/acme plugin'",
|
||||
"pnpm install",
|
||||
"pnpm dev",
|
||||
"paperclipai plugin install '/tmp/acme plugin'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers the CLI wrapper and invokes the existing scaffolder", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerPluginCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"plugin",
|
||||
"init",
|
||||
"demo-plugin",
|
||||
"--output",
|
||||
"/tmp/paperclip-init-output",
|
||||
"--template",
|
||||
"workspace",
|
||||
"--category",
|
||||
"workspace",
|
||||
"--display-name",
|
||||
"Demo Plugin",
|
||||
"--description",
|
||||
"Demo description",
|
||||
"--author",
|
||||
"Paperclip",
|
||||
"--sdk-path",
|
||||
"/repo/packages/plugins/sdk",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(mocks.scaffoldPluginProject).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.scaffoldPluginProject).toHaveBeenCalledWith({
|
||||
pluginName: "demo-plugin",
|
||||
outputDir: path.resolve("/tmp/paperclip-init-output", "demo-plugin"),
|
||||
template: "workspace",
|
||||
category: "workspace",
|
||||
displayName: "Demo Plugin",
|
||||
description: "Demo description",
|
||||
author: "Paperclip",
|
||||
sdkPath: "/repo/packages/plugins/sdk",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin install", () => {
|
||||
it("resolves an existing relative local path to an absolute local install request", () => {
|
||||
const cwd = makeTempDir();
|
||||
const pluginDir = path.join(cwd, "demo-plugin");
|
||||
fs.mkdirSync(pluginDir);
|
||||
|
||||
expect(buildPluginInstallRequest("demo-plugin", {}, { cwd })).toEqual({
|
||||
packageName: pluginDir,
|
||||
version: undefined,
|
||||
isLocalPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an absolute local path absolute and marks it as local", () => {
|
||||
const pluginDir = path.join(makeTempDir(), "demo-plugin");
|
||||
fs.mkdirSync(pluginDir);
|
||||
|
||||
expect(buildPluginInstallRequest(pluginDir, {}, { cwd: "/" })).toEqual({
|
||||
packageName: pluginDir,
|
||||
version: undefined,
|
||||
isLocalPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves npm package installs when no local path exists", () => {
|
||||
expect(
|
||||
buildPluginInstallRequest("@acme/plugin-linear", { version: "1.2.3" }, {
|
||||
cwd: makeTempDir(),
|
||||
}),
|
||||
).toEqual({
|
||||
packageName: "@acme/plugin-linear",
|
||||
version: "1.2.3",
|
||||
isLocalPath: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Agent, CompanySecret } from "@paperclipai/shared";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { secretsCheck } from "../checks/secrets-check.js";
|
||||
import {
|
||||
buildInlineMigrationSecretName,
|
||||
buildMigratedAgentEnv,
|
||||
collectInlineSecretMigrationCandidates,
|
||||
parseSecretsInclude,
|
||||
toPlainEnvValue,
|
||||
} from "../commands/client/secrets.js";
|
||||
|
||||
function agent(partial: Partial<Agent>): Agent {
|
||||
return {
|
||||
id: "agent-12345678",
|
||||
companyId: "company-1",
|
||||
name: "Coder",
|
||||
urlKey: "coder",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: {
|
||||
canCreateAgents: false,
|
||||
},
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function secret(partial: Partial<CompanySecret>): CompanySecret {
|
||||
return {
|
||||
id: "secret-1",
|
||||
companyId: "company-1",
|
||||
key: "agent_agent-12_anthropic_api_key",
|
||||
name: "agent_agent-12_anthropic_api_key",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: null,
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-26T00:00:00.000Z"),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function configWithSecretsProvider(provider: PaperclipConfig["secrets"]["provider"]): PaperclipConfig {
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-05-02T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "/tmp/paperclip/db",
|
||||
embeddedPostgresPort: 55432,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: "/tmp/paperclip/backups",
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: "/tmp/paperclip/logs",
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: "/tmp/paperclip/storage",
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider,
|
||||
strictMode: true,
|
||||
localEncrypted: {
|
||||
keyFilePath: "/tmp/paperclip/secrets/master.key",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("secrets CLI helpers", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("parses declaration include filters", () => {
|
||||
expect(parseSecretsInclude("agents,projects,tasks")).toEqual({
|
||||
company: false,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects inline sensitive env values that need migration", () => {
|
||||
const rows = collectInlineSecretMigrationCandidates(
|
||||
[
|
||||
agent({
|
||||
id: "agent-12345678",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "sk-ant-test",
|
||||
GH_TOKEN: {
|
||||
type: "plain",
|
||||
value: "ghp-test",
|
||||
},
|
||||
PATH: {
|
||||
type: "plain",
|
||||
value: "/usr/bin",
|
||||
},
|
||||
OPENAI_API_KEY: {
|
||||
type: "secret_ref",
|
||||
secretId: "secret-existing",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
secret({
|
||||
id: "secret-gh-token",
|
||||
name: buildInlineMigrationSecretName("agent-12345678", "GH_TOKEN"),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
agentId: "agent-12345678",
|
||||
agentName: "Coder",
|
||||
envKey: "ANTHROPIC_API_KEY",
|
||||
secretName: "agent_agent-12_anthropic_api_key",
|
||||
existingSecretId: null,
|
||||
},
|
||||
{
|
||||
agentId: "agent-12345678",
|
||||
agentName: "Coder",
|
||||
envKey: "GH_TOKEN",
|
||||
secretName: "agent_agent-12_gh_token",
|
||||
existingSecretId: "secret-gh-token",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds migrated env bindings without preserving secret values", () => {
|
||||
const next = buildMigratedAgentEnv(
|
||||
{
|
||||
ANTHROPIC_API_KEY: "sk-ant-test",
|
||||
NODE_ENV: {
|
||||
type: "plain",
|
||||
value: "development",
|
||||
},
|
||||
},
|
||||
new Map([["ANTHROPIC_API_KEY", "secret-1"]]),
|
||||
);
|
||||
|
||||
expect(next).toEqual({
|
||||
ANTHROPIC_API_KEY: {
|
||||
type: "secret_ref",
|
||||
secretId: "secret-1",
|
||||
version: "latest",
|
||||
},
|
||||
NODE_ENV: {
|
||||
type: "plain",
|
||||
value: "development",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(next)).not.toContain("sk-ant-test");
|
||||
});
|
||||
|
||||
it("reads only explicit plain env values", () => {
|
||||
expect(toPlainEnvValue("plain-value")).toBe("plain-value");
|
||||
expect(toPlainEnvValue({ type: "plain", value: "wrapped" })).toBe("wrapped");
|
||||
expect(toPlainEnvValue({ type: "secret_ref", secretId: "secret-1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the AWS bootstrap config required by doctor", () => {
|
||||
const result = secretsCheck(configWithSecretsProvider("aws_secrets_manager"));
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.message).toContain("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
|
||||
expect(result.repairHint).toContain("AWS SDK default credential chain");
|
||||
expect(result.repairHint).toContain("Do not store AWS root credentials");
|
||||
});
|
||||
|
||||
it("passes AWS doctor checks when non-secret provider config is present", () => {
|
||||
process.env.PAPERCLIP_SECRETS_AWS_REGION = "us-east-1";
|
||||
process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID = "prod-us-1";
|
||||
process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID =
|
||||
"arn:aws:kms:us-east-1:123456789012:key/test";
|
||||
process.env.AWS_PROFILE = "paperclip-prod";
|
||||
|
||||
const result = secretsCheck(configWithSecretsProvider("aws_secrets_manager"));
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(result.message).toContain("prod-us-1");
|
||||
expect(result.message).toContain("AWS_PROFILE/shared config");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { printAcpxStreamEvent } from "@paperclipai/adapter-acpx-local/cli";
|
||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli";
|
||||
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||
@@ -16,11 +14,6 @@ const claudeLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printClaudeStreamEvent,
|
||||
};
|
||||
|
||||
const acpxLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "acpx_local",
|
||||
formatStdoutEvent: printAcpxStreamEvent,
|
||||
};
|
||||
|
||||
const codexLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "codex_local",
|
||||
formatStdoutEvent: printCodexStreamEvent,
|
||||
@@ -41,11 +34,6 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCursorStreamEvent,
|
||||
};
|
||||
|
||||
const cursorCloudCLIAdapter: CLIAdapterModule = {
|
||||
type: "cursor_cloud",
|
||||
formatStdoutEvent: printCursorCloudEvent,
|
||||
};
|
||||
|
||||
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "gemini_local",
|
||||
formatStdoutEvent: printGeminiStreamEvent,
|
||||
@@ -58,13 +46,11 @@ const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
|
||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
[
|
||||
acpxLocalCLIAdapter,
|
||||
claudeLocalCLIAdapter,
|
||||
codexLocalCLIAdapter,
|
||||
openCodeLocalCLIAdapter,
|
||||
piLocalCLIAdapter,
|
||||
cursorLocalCLIAdapter,
|
||||
cursorCloudCLIAdapter,
|
||||
geminiLocalCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
|
||||
@@ -5,9 +5,6 @@ import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
||||
|
||||
const AWS_CREDENTIAL_SOURCE_HINT =
|
||||
"Provide AWS runtime credentials through the AWS SDK default credential chain: IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, web identity, container/instance metadata, or short-lived shell credentials";
|
||||
|
||||
function decodeMasterKey(raw: string): Buffer | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -50,16 +47,13 @@ function withStrictModeNote(
|
||||
|
||||
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
||||
const provider = config.secrets.provider;
|
||||
if (provider === "aws_secrets_manager") {
|
||||
return withStrictModeNote(awsSecretsManagerCheck(), config);
|
||||
}
|
||||
if (provider !== "local_encrypted") {
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "fail",
|
||||
message: `${provider} is configured, but this build only supports local_encrypted and aws_secrets_manager`,
|
||||
message: `${provider} is configured, but this build only supports local_encrypted`,
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section secrets` and choose local_encrypted or aws_secrets_manager",
|
||||
repairHint: "Run `paperclipai configure --section secrets` and set provider to local_encrypted",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,100 +135,12 @@ export function secretsCheck(config: PaperclipConfig, configPath?: string): Chec
|
||||
};
|
||||
}
|
||||
|
||||
const keyMode = fs.statSync(keyFilePath).mode & 0o777;
|
||||
const permissionWarning =
|
||||
(keyMode & 0o077) !== 0
|
||||
? `; key file permissions are ${keyMode.toString(8)} (run chmod 600 ${keyFilePath})`
|
||||
: "";
|
||||
|
||||
return withStrictModeNote(
|
||||
{
|
||||
name: "Secrets adapter",
|
||||
status: permissionWarning ? "warn" : "pass",
|
||||
message: `Local encrypted provider configured with key file ${keyFilePath}${permissionWarning}`,
|
||||
repairHint: permissionWarning
|
||||
? "Restrict the local encrypted secrets key file to owner read/write permissions"
|
||||
: undefined,
|
||||
status: "pass",
|
||||
message: `Local encrypted provider configured with key file ${keyFilePath}`,
|
||||
},
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
function awsSecretsManagerCheck(): CheckResult {
|
||||
const missingConfig = missingAwsSecretsManagerConfig();
|
||||
if (missingConfig.length > 0) {
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "fail",
|
||||
message: `AWS Secrets Manager provider is missing non-secret config: ${missingConfig.join(", ")}`,
|
||||
canRepair: false,
|
||||
repairHint:
|
||||
`Set ${missingConfig.join(", ")} in the Paperclip server runtime. ${AWS_CREDENTIAL_SOURCE_HINT}. Do not store AWS root credentials or long-lived IAM user keys in Paperclip secrets.`,
|
||||
};
|
||||
}
|
||||
|
||||
const staticEnvCredentials =
|
||||
process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim();
|
||||
const credentialSource = detectedAwsCredentialSources().join(", ");
|
||||
const message =
|
||||
`AWS Secrets Manager provider configured for deployment ${process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID}; ` +
|
||||
`runtime credentials source: ${credentialSource || "AWS SDK default credential chain"}`;
|
||||
|
||||
if (staticEnvCredentials) {
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "warn",
|
||||
message,
|
||||
canRepair: false,
|
||||
repairHint:
|
||||
"AWS static environment credentials are visible. Use only short-lived shell credentials locally; prefer IAM role/workload identity for hosted deployments and never store AWS access keys in Paperclip company secrets.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Secrets adapter",
|
||||
status: "pass",
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function missingAwsSecretsManagerConfig(): string[] {
|
||||
const missing: string[] = [];
|
||||
if (
|
||||
!(
|
||||
process.env.PAPERCLIP_SECRETS_AWS_REGION?.trim() ||
|
||||
process.env.AWS_REGION?.trim() ||
|
||||
process.env.AWS_DEFAULT_REGION?.trim()
|
||||
)
|
||||
) {
|
||||
missing.push("PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION");
|
||||
}
|
||||
if (!process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID?.trim()) {
|
||||
missing.push("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
|
||||
}
|
||||
if (!process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID?.trim()) {
|
||||
missing.push("PAPERCLIP_SECRETS_AWS_KMS_KEY_ID");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
function detectedAwsCredentialSources(): string[] {
|
||||
const sources: string[] = [];
|
||||
if (process.env.AWS_PROFILE?.trim()) sources.push("AWS_PROFILE/shared config");
|
||||
if (process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()) {
|
||||
sources.push("temporary AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment credentials");
|
||||
}
|
||||
if (process.env.AWS_WEB_IDENTITY_TOKEN_FILE?.trim() && process.env.AWS_ROLE_ARN?.trim()) {
|
||||
sources.push("AWS web identity token");
|
||||
}
|
||||
if (
|
||||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI?.trim() ||
|
||||
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI?.trim()
|
||||
) {
|
||||
sources.push("AWS container credentials endpoint");
|
||||
}
|
||||
if (process.env.AWS_SHARED_CREDENTIALS_FILE?.trim() || process.env.AWS_CONFIG_FILE?.trim()) {
|
||||
sources.push("custom AWS shared credentials/config file");
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { Command, Option } from "commander";
|
||||
import {
|
||||
scaffoldPluginProject,
|
||||
shellQuote,
|
||||
type ScaffoldPluginOptions,
|
||||
} from "../../../../packages/plugins/create-paperclip-plugin/src/index.js";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
@@ -45,101 +39,28 @@ interface PluginInstallOptions extends BaseClientOptions {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface PluginInstallRequest {
|
||||
packageName: string;
|
||||
version?: string;
|
||||
isLocalPath: boolean;
|
||||
}
|
||||
|
||||
interface PluginUninstallOptions extends BaseClientOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface PluginInitOptions extends BaseClientOptions {
|
||||
output?: string;
|
||||
template?: ScaffoldPluginOptions["template"];
|
||||
category?: ScaffoldPluginOptions["category"];
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
sdkPath?: string;
|
||||
}
|
||||
|
||||
interface PluginInitResult {
|
||||
outputDir: string;
|
||||
nextCommands: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function expandHomePath(packageArg: string): string {
|
||||
if (!packageArg.startsWith("~")) return packageArg;
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
}
|
||||
|
||||
function hasLocalPathSyntax(packageArg: string): boolean {
|
||||
return (
|
||||
path.isAbsolute(packageArg) ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("~") ||
|
||||
packageArg.startsWith(".\\") ||
|
||||
packageArg.startsWith("..\\")
|
||||
);
|
||||
}
|
||||
|
||||
function isExistingRelativePath(
|
||||
packageArg: string,
|
||||
cwd: string,
|
||||
pathExists: (targetPath: string) => boolean,
|
||||
): boolean {
|
||||
if (packageArg.trim() === "") return false;
|
||||
if (hasLocalPathSyntax(packageArg)) return false;
|
||||
return pathExists(path.resolve(cwd, packageArg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local path argument to an absolute path so the server can find the
|
||||
* plugin on disk regardless of where the user ran the CLI.
|
||||
*/
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean, cwd = process.cwd()): string {
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
|
||||
if (!isLocal) return packageArg;
|
||||
// Already absolute
|
||||
if (path.isAbsolute(packageArg)) return packageArg;
|
||||
if (packageArg.startsWith("~")) return expandHomePath(packageArg);
|
||||
return path.resolve(cwd, packageArg);
|
||||
}
|
||||
|
||||
export function buildPluginInstallRequest(
|
||||
packageArg: string,
|
||||
opts: Pick<PluginInstallOptions, "local" | "version"> = {},
|
||||
deps: { cwd?: string; existsSync?: (targetPath: string) => boolean } = {},
|
||||
): PluginInstallRequest {
|
||||
const cwd = deps.cwd ?? process.cwd();
|
||||
const pathExists = deps.existsSync ?? existsSync;
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
hasLocalPathSyntax(packageArg) ||
|
||||
(opts.version ? false : isExistingRelativePath(packageArg, cwd, pathExists));
|
||||
|
||||
if (isLocal && opts.version) {
|
||||
throw new Error("--version is only supported for npm package installs, not local plugin paths.");
|
||||
// Expand leading ~ to home directory
|
||||
if (packageArg.startsWith("~")) {
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
}
|
||||
|
||||
return {
|
||||
packageName: resolvePackageArg(packageArg, Boolean(isLocal), cwd),
|
||||
version: opts.version,
|
||||
isLocalPath: Boolean(isLocal),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderLocalPluginInstallHint(packagePath: string): string {
|
||||
return [
|
||||
pc.dim("Local plugin installs run trusted local code from your machine."),
|
||||
pc.dim(`Keep ${pc.cyan("pnpm dev")} running in ${packagePath}; Paperclip watches rebuilt dist output and reloads the plugin worker.`),
|
||||
].join("\n");
|
||||
return path.resolve(process.cwd(), packageArg);
|
||||
}
|
||||
|
||||
function formatPlugin(p: PluginRecord): string {
|
||||
@@ -166,58 +87,6 @@ function formatPlugin(p: PluginRecord): string {
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function packageToDirName(pluginName: string): string {
|
||||
return pluginName.replace(/^@[^/]+\//, "");
|
||||
}
|
||||
|
||||
export function buildPluginInitScaffoldOptions(
|
||||
packageName: string,
|
||||
opts: PluginInitOptions,
|
||||
cwd = process.cwd(),
|
||||
): ScaffoldPluginOptions {
|
||||
const outputRoot = path.resolve(cwd, opts.output ?? ".");
|
||||
const outputDir = path.resolve(outputRoot, packageToDirName(packageName));
|
||||
|
||||
return {
|
||||
pluginName: packageName,
|
||||
outputDir,
|
||||
template: opts.template,
|
||||
category: opts.category,
|
||||
displayName: opts.displayName,
|
||||
description: opts.description,
|
||||
author: opts.author,
|
||||
sdkPath: opts.sdkPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPluginInitNextCommands(outputDir: string): string[] {
|
||||
const quotedOutputDir = shellQuote(outputDir);
|
||||
return [
|
||||
`cd ${quotedOutputDir}`,
|
||||
"pnpm install",
|
||||
"pnpm dev",
|
||||
`paperclipai plugin install ${quotedOutputDir}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function renderPluginInitSuccess(result: PluginInitResult): string {
|
||||
return [
|
||||
pc.green(`✓ Created plugin scaffold at ${result.outputDir}`),
|
||||
"",
|
||||
"Next commands:",
|
||||
...result.nextCommands.map((command) => ` ${pc.cyan(command)}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function runPluginInitCommand(packageName: string, opts: PluginInitOptions): PluginInitResult {
|
||||
const scaffoldOptions = buildPluginInitScaffoldOptions(packageName, opts);
|
||||
const outputDir = scaffoldPluginProject(scaffoldOptions);
|
||||
return {
|
||||
outputDir,
|
||||
nextCommands: buildPluginInitNextCommands(outputDir),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -225,43 +94,6 @@ export function runPluginInitCommand(packageName: string, opts: PluginInitOption
|
||||
export function registerPluginCommands(program: Command): void {
|
||||
const plugin = program.command("plugin").description("Plugin lifecycle management");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin init <package-name>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("init <packageName>")
|
||||
.description("Scaffold a local Paperclip plugin project")
|
||||
.option("--output <dir>", "Directory to create the plugin folder in")
|
||||
.addOption(
|
||||
new Option("--template <template>", "Starter template")
|
||||
.choices(["default", "connector", "workspace", "environment"])
|
||||
.default("default"),
|
||||
)
|
||||
.addOption(
|
||||
new Option("--category <category>", "Manifest category")
|
||||
.choices(["connector", "workspace", "automation", "ui", "environment"]),
|
||||
)
|
||||
.option("--display-name <name>", "Manifest display name")
|
||||
.option("--description <description>", "Manifest description")
|
||||
.option("--author <author>", "Manifest author")
|
||||
.option("--sdk-path <path>", "Local @paperclipai/plugin-sdk package path")
|
||||
.action((packageName: string, opts: PluginInitOptions) => {
|
||||
try {
|
||||
const result = runPluginInitCommand(packageName, opts);
|
||||
|
||||
if (opts.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(renderPluginInitSuccess(result));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin list
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -315,19 +147,31 @@ export function registerPluginCommands(program: Command): void {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
|
||||
const installRequest = buildPluginInstallRequest(packageArg, opts);
|
||||
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("/") ||
|
||||
packageArg.startsWith("~");
|
||||
|
||||
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
installRequest.isLocalPath
|
||||
? `Installing plugin from local path: ${installRequest.packageName}`
|
||||
: `Installing plugin: ${installRequest.packageName}${opts.version ? `@${opts.version}` : ""}`,
|
||||
isLocal
|
||||
? `Installing plugin from local path: ${resolvedPackage}`
|
||||
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", installRequest);
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
|
||||
packageName: resolvedPackage,
|
||||
version: opts.version,
|
||||
isLocalPath: isLocal,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(installedPlugin, { json: true });
|
||||
@@ -348,10 +192,6 @@ export function registerPluginCommands(program: Command): void {
|
||||
if (installedPlugin.lastError) {
|
||||
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
|
||||
}
|
||||
|
||||
if (installRequest.isLocalPath) {
|
||||
console.log(renderLocalPluginInstallHint(installRequest.packageName));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import type {
|
||||
Agent,
|
||||
AgentEnvConfig,
|
||||
CompanyPortabilityEnvInput,
|
||||
CompanyPortabilityExportPreviewResult,
|
||||
CompanyPortabilityInclude,
|
||||
CompanySecret,
|
||||
EnvBinding,
|
||||
SecretProvider,
|
||||
SecretProviderDescriptor,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
formatInlineRecord,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface SecretListOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
interface SecretDeclarationsOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
include?: string;
|
||||
kind?: "all" | "secret" | "plain";
|
||||
}
|
||||
|
||||
interface SecretCreateOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
name?: string;
|
||||
key?: string;
|
||||
provider?: SecretProvider;
|
||||
value?: string;
|
||||
valueEnv?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SecretLinkOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
name?: string;
|
||||
key?: string;
|
||||
provider?: SecretProvider;
|
||||
externalRef?: string;
|
||||
providerVersionRef?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SecretDoctorOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
interface SecretMigrateInlineEnvOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
apply?: boolean;
|
||||
}
|
||||
|
||||
interface SecretProviderHealth {
|
||||
provider: SecretProvider;
|
||||
status: "ok" | "warn" | "error";
|
||||
message: string;
|
||||
warnings?: string[];
|
||||
backupGuidance?: string[];
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SecretProviderHealthResponse {
|
||||
providers: SecretProviderHealth[];
|
||||
}
|
||||
|
||||
export interface InlineSecretMigrationCandidate {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
envKey: string;
|
||||
secretName: string;
|
||||
existingSecretId: string | null;
|
||||
}
|
||||
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
/(^token$|[-_]?token$|api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
|
||||
const DEFAULT_DECLARATION_INCLUDE: CompanyPortabilityInclude = {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: false,
|
||||
skills: false,
|
||||
};
|
||||
|
||||
export function parseSecretsInclude(input: string | undefined): CompanyPortabilityInclude {
|
||||
if (!input?.trim()) return { ...DEFAULT_DECLARATION_INCLUDE };
|
||||
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
||||
const include = {
|
||||
company: values.includes("company"),
|
||||
agents: values.includes("agents"),
|
||||
projects: values.includes("projects"),
|
||||
issues: values.includes("issues") || values.includes("tasks"),
|
||||
skills: values.includes("skills"),
|
||||
};
|
||||
if (!Object.values(include).some(Boolean)) {
|
||||
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills");
|
||||
}
|
||||
return include;
|
||||
}
|
||||
|
||||
export function isSensitiveEnvKey(key: string): boolean {
|
||||
return SENSITIVE_ENV_KEY_RE.test(key);
|
||||
}
|
||||
|
||||
export function toPlainEnvValue(binding: unknown): string | null {
|
||||
if (typeof binding === "string") return binding;
|
||||
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) return null;
|
||||
const record = binding as Record<string, unknown>;
|
||||
if (record.type === "plain" && typeof record.value === "string") return record.value;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildInlineMigrationSecretName(agentId: string, key: string): string {
|
||||
return `agent_${agentId.slice(0, 8)}_${key.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function collectInlineSecretMigrationCandidates(
|
||||
agents: Agent[],
|
||||
existingSecrets: CompanySecret[],
|
||||
): InlineSecretMigrationCandidate[] {
|
||||
const secretByName = new Map(existingSecrets.map((secret) => [secret.name, secret]));
|
||||
const candidates: InlineSecretMigrationCandidate[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
const env = asRecord(agent.adapterConfig.env);
|
||||
if (!env) continue;
|
||||
for (const [envKey, binding] of Object.entries(env)) {
|
||||
if (!isSensitiveEnvKey(envKey)) continue;
|
||||
const plain = toPlainEnvValue(binding);
|
||||
if (plain === null || plain.trim().length === 0) continue;
|
||||
const secretName = buildInlineMigrationSecretName(agent.id, envKey);
|
||||
candidates.push({
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
envKey,
|
||||
secretName,
|
||||
existingSecretId: secretByName.get(secretName)?.id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function buildMigratedAgentEnv(
|
||||
env: Record<string, unknown>,
|
||||
secretIdByEnvKey: Map<string, string>,
|
||||
): AgentEnvConfig {
|
||||
const next: AgentEnvConfig = { ...(env as Record<string, EnvBinding>) };
|
||||
for (const [envKey, secretId] of secretIdByEnvKey) {
|
||||
next[envKey] = {
|
||||
type: "secret_ref",
|
||||
secretId,
|
||||
version: "latest",
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readValueFromOptions(opts: SecretCreateOptions): string {
|
||||
if (opts.value !== undefined && opts.valueEnv !== undefined) {
|
||||
throw new Error("Use only one of --value or --value-env.");
|
||||
}
|
||||
if (opts.valueEnv !== undefined) {
|
||||
const value = process.env[opts.valueEnv];
|
||||
if (!value) throw new Error(`Environment variable ${opts.valueEnv} is empty or unset.`);
|
||||
return value;
|
||||
}
|
||||
if (opts.value !== undefined) return opts.value;
|
||||
throw new Error("Secret value is required. Pass --value or --value-env.");
|
||||
}
|
||||
|
||||
function renderDeclaration(input: CompanyPortabilityEnvInput): Record<string, unknown> {
|
||||
const scope = input.agentSlug
|
||||
? `agent:${input.agentSlug}`
|
||||
: input.projectSlug
|
||||
? `project:${input.projectSlug}`
|
||||
: "company";
|
||||
return {
|
||||
key: input.key,
|
||||
scope,
|
||||
kind: input.kind,
|
||||
requirement: input.requirement,
|
||||
portability: input.portability,
|
||||
hasDefault: input.defaultValue !== null && input.defaultValue.length > 0,
|
||||
description: input.description,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSecret(secret: CompanySecret): Record<string, unknown> {
|
||||
return {
|
||||
id: secret.id,
|
||||
name: secret.name,
|
||||
key: secret.key,
|
||||
provider: secret.provider,
|
||||
status: secret.status,
|
||||
managedMode: secret.managedMode,
|
||||
latestVersion: secret.latestVersion,
|
||||
externalRef: secret.externalRef ? "yes" : "no",
|
||||
};
|
||||
}
|
||||
|
||||
function printProviderHealth(rows: SecretProviderHealth[], json: boolean): void {
|
||||
if (json) {
|
||||
printOutput(rows, { json: true });
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
printOutput([], { json: false });
|
||||
return;
|
||||
}
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
formatInlineRecord({
|
||||
id: row.provider,
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
}),
|
||||
);
|
||||
for (const warning of row.warnings ?? []) {
|
||||
console.log(pc.yellow(`warning=${warning}`));
|
||||
}
|
||||
const missingConfig = asStringArray(row.details?.missingConfig);
|
||||
if (missingConfig.length > 0) {
|
||||
console.log(pc.dim(`missingConfig=${missingConfig.join(",")}`));
|
||||
}
|
||||
const credentialSource = typeof row.details?.credentialSource === "string"
|
||||
? row.details.credentialSource
|
||||
: null;
|
||||
if (credentialSource) {
|
||||
console.log(pc.dim(`credentialSource=${credentialSource}`));
|
||||
}
|
||||
const detectedCredentialSources = asStringArray(row.details?.detectedCredentialSources);
|
||||
if (detectedCredentialSources.length > 0) {
|
||||
console.log(pc.dim(`detectedCredentialSources=${detectedCredentialSources.join(",")}`));
|
||||
}
|
||||
for (const guidance of row.backupGuidance ?? []) {
|
||||
console.log(pc.dim(`backup=${guidance}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
async function migrateInlineEnv(opts: SecretMigrateInlineEnvOptions): Promise<void> {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const companyId = ctx.companyId!;
|
||||
const agents = (await ctx.api.get<Agent[]>(`/api/companies/${companyId}/agents`)) ?? [];
|
||||
const secrets = (await ctx.api.get<CompanySecret[]>(`/api/companies/${companyId}/secrets`)) ?? [];
|
||||
const candidates = collectInlineSecretMigrationCandidates(agents, secrets);
|
||||
|
||||
if (!opts.apply) {
|
||||
printOutput(
|
||||
{
|
||||
apply: false,
|
||||
agentsToUpdate: new Set(candidates.map((candidate) => candidate.agentId)).size,
|
||||
secretsToCreate: candidates.filter((candidate) => !candidate.existingSecretId).length,
|
||||
secretsToRotate: candidates.filter((candidate) => candidate.existingSecretId).length,
|
||||
candidates,
|
||||
},
|
||||
{ json: ctx.json },
|
||||
);
|
||||
if (!ctx.json) {
|
||||
console.log(pc.dim("Re-run with --apply to create/rotate secrets and update agent env bindings."));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const createdOrRotated = new Map<string, string>();
|
||||
let createdSecrets = 0;
|
||||
let rotatedSecrets = 0;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const agent = agents.find((row) => row.id === candidate.agentId);
|
||||
const env = asRecord(agent?.adapterConfig.env);
|
||||
const value = env ? toPlainEnvValue(env[candidate.envKey]) : null;
|
||||
if (!value) continue;
|
||||
|
||||
if (candidate.existingSecretId) {
|
||||
await ctx.api.post(`/api/secrets/${candidate.existingSecretId}/rotate`, { value });
|
||||
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, candidate.existingSecretId);
|
||||
rotatedSecrets += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await ctx.api.post<CompanySecret>(`/api/companies/${companyId}/secrets`, {
|
||||
name: candidate.secretName,
|
||||
provider: "local_encrypted",
|
||||
value,
|
||||
description: `Migrated from agent ${candidate.agentId} env ${candidate.envKey}`,
|
||||
});
|
||||
if (!created) throw new Error(`Secret create returned no data for ${candidate.secretName}`);
|
||||
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, created.id);
|
||||
createdSecrets += 1;
|
||||
}
|
||||
|
||||
let updatedAgents = 0;
|
||||
for (const agent of agents) {
|
||||
const env = asRecord(agent.adapterConfig.env);
|
||||
if (!env) continue;
|
||||
const secretIdByEnvKey = new Map<string, string>();
|
||||
for (const [key] of Object.entries(env)) {
|
||||
const secretId = createdOrRotated.get(`${agent.id}:${key}`);
|
||||
if (secretId) secretIdByEnvKey.set(key, secretId);
|
||||
}
|
||||
if (secretIdByEnvKey.size === 0) continue;
|
||||
const adapterConfig = {
|
||||
...agent.adapterConfig,
|
||||
env: buildMigratedAgentEnv(env, secretIdByEnvKey),
|
||||
};
|
||||
await ctx.api.patch(`/api/agents/${agent.id}`, {
|
||||
adapterConfig,
|
||||
replaceAdapterConfig: true,
|
||||
});
|
||||
updatedAgents += 1;
|
||||
}
|
||||
|
||||
printOutput(
|
||||
{
|
||||
apply: true,
|
||||
updatedAgents,
|
||||
createdSecrets,
|
||||
rotatedSecrets,
|
||||
},
|
||||
{ json: ctx.json },
|
||||
);
|
||||
}
|
||||
|
||||
export function registerSecretCommands(program: Command): void {
|
||||
const secrets = program.command("secrets").description("Secret declaration and provider operations");
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("list")
|
||||
.description("List secret metadata for a company")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.action(async (opts: SecretListOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const rows = (await ctx.api.get<CompanySecret[]>(`/api/companies/${ctx.companyId}/secrets`)) ?? [];
|
||||
printOutput(ctx.json ? rows : rows.map(renderSecret), { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("declarations")
|
||||
.description("List portable env declarations emitted by company export")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents,projects")
|
||||
.option("--kind <kind>", "Filter declarations: all | secret | plain", "all")
|
||||
.action(async (opts: SecretDeclarationsOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const kind = opts.kind ?? "all";
|
||||
if (!["all", "secret", "plain"].includes(kind)) {
|
||||
throw new Error("Invalid --kind value. Use: all, secret, plain");
|
||||
}
|
||||
const preview = await ctx.api.post<CompanyPortabilityExportPreviewResult>(
|
||||
`/api/companies/${ctx.companyId}/exports/preview`,
|
||||
{ include: parseSecretsInclude(opts.include) },
|
||||
);
|
||||
const declarations = (preview?.manifest.envInputs ?? [])
|
||||
.filter((entry) => kind === "all" || entry.kind === kind);
|
||||
printOutput(ctx.json ? declarations : declarations.map(renderDeclaration), { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("create")
|
||||
.description("Create a Paperclip-managed secret")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.requiredOption("--name <name>", "Secret display name")
|
||||
.option("--key <key>", "Portable secret key")
|
||||
.option("--provider <provider>", "Secret provider id")
|
||||
.option("--value <value>", "Secret value")
|
||||
.option("--value-env <name>", "Read secret value from an environment variable")
|
||||
.option("--description <text>", "Description")
|
||||
.action(async (opts: SecretCreateOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const created = await ctx.api.post<CompanySecret>(`/api/companies/${ctx.companyId}/secrets`, {
|
||||
name: opts.name,
|
||||
key: opts.key,
|
||||
provider: opts.provider,
|
||||
value: readValueFromOptions(opts),
|
||||
description: opts.description,
|
||||
});
|
||||
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("link")
|
||||
.description("Link an external provider-owned secret without storing its value in Paperclip")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.requiredOption("--name <name>", "Secret display name")
|
||||
.requiredOption("--provider <provider>", "Secret provider id")
|
||||
.requiredOption("--external-ref <ref>", "Provider secret ARN/name/path/reference")
|
||||
.option("--key <key>", "Portable secret key")
|
||||
.option("--provider-version-ref <ref>", "Provider version id or label")
|
||||
.option("--description <text>", "Description")
|
||||
.action(async (opts: SecretLinkOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const created = await ctx.api.post<CompanySecret>(`/api/companies/${ctx.companyId}/secrets`, {
|
||||
name: opts.name,
|
||||
key: opts.key,
|
||||
provider: opts.provider,
|
||||
managedMode: "external_reference",
|
||||
externalRef: opts.externalRef,
|
||||
providerVersionRef: opts.providerVersionRef,
|
||||
description: opts.description,
|
||||
});
|
||||
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("doctor")
|
||||
.description("Run secret provider health checks through the Paperclip API")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.action(async (opts: SecretDoctorOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const health = await ctx.api.get<SecretProviderHealthResponse>(
|
||||
`/api/companies/${ctx.companyId}/secret-providers/health`,
|
||||
);
|
||||
printProviderHealth(health?.providers ?? [], ctx.json);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("providers")
|
||||
.description("List configured secret provider descriptors")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.action(async (opts: SecretDoctorOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const rows = (await ctx.api.get<SecretProviderDescriptor[]>(
|
||||
`/api/companies/${ctx.companyId}/secret-providers`,
|
||||
)) ?? [];
|
||||
printOutput(rows, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
secrets
|
||||
.command("migrate-inline-env")
|
||||
.description("Migrate inline sensitive agent env values into secret references")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--apply", "Persist changes; default is a dry run", false)
|
||||
.action(async (opts: SecretMigrateInlineEnvOptions) => {
|
||||
try {
|
||||
await migrateInlineEnv(opts);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,32 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir as resolveSharedDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir as resolveSharedDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir as resolveSharedDefaultLogsDir,
|
||||
resolveDefaultSecretsKeyFilePath as resolveSharedDefaultSecretsKeyFilePath,
|
||||
resolveDefaultStorageDir as resolveSharedDefaultStorageDir,
|
||||
resolveHomeAwarePath,
|
||||
resolvePaperclipConfigPathForInstance,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
resolvePaperclipInstanceRoot as resolveSharedPaperclipInstanceRoot,
|
||||
} from "@paperclipai/shared/home-paths";
|
||||
|
||||
export {
|
||||
expandHomePrefix,
|
||||
resolveHomeAwarePath,
|
||||
resolvePaperclipHomeDir,
|
||||
resolvePaperclipInstanceId,
|
||||
};
|
||||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export function resolvePaperclipHomeDir(): string {
|
||||
const envHome = process.env.PAPERCLIP_HOME?.trim();
|
||||
if (envHome) return path.resolve(expandHomePrefix(envHome));
|
||||
return path.resolve(os.homedir(), ".paperclip");
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceId(override?: string): string {
|
||||
const raw = override?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
|
||||
if (!INSTANCE_ID_RE.test(raw)) {
|
||||
throw new Error(
|
||||
`Invalid instance id '${raw}'. Allowed characters: letters, numbers, '_' and '-'.`,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceRoot(instanceId?: string): string {
|
||||
return resolveSharedPaperclipInstanceRoot({ instanceId });
|
||||
const id = resolvePaperclipInstanceId(instanceId);
|
||||
return path.resolve(resolvePaperclipHomeDir(), "instances", id);
|
||||
}
|
||||
|
||||
export function resolveDefaultConfigPath(instanceId?: string): string {
|
||||
return resolvePaperclipConfigPathForInstance({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "config.json");
|
||||
}
|
||||
|
||||
export function resolveDefaultContextPath(): string {
|
||||
@@ -37,23 +38,29 @@ export function resolveDefaultCliAuthPath(): string {
|
||||
}
|
||||
|
||||
export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
|
||||
return resolveSharedDefaultEmbeddedPostgresDir({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db");
|
||||
}
|
||||
|
||||
export function resolveDefaultLogsDir(instanceId?: string): string {
|
||||
return resolveSharedDefaultLogsDir({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "logs");
|
||||
}
|
||||
|
||||
export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string {
|
||||
return resolveSharedDefaultSecretsKeyFilePath({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "secrets", "master.key");
|
||||
}
|
||||
|
||||
export function resolveDefaultStorageDir(instanceId?: string): string {
|
||||
return resolveSharedDefaultStorageDir({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage");
|
||||
}
|
||||
|
||||
export function resolveDefaultBackupDir(instanceId?: string): string {
|
||||
return resolveSharedDefaultBackupDir({ instanceId });
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "backups");
|
||||
}
|
||||
|
||||
export function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function describeLocalInstancePaths(instanceId?: string) {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { registerRoutineCommands } from "./commands/routines.js";
|
||||
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
||||
import { registerSecretCommands } from "./commands/client/secrets.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||
@@ -148,7 +147,6 @@ registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerRoutineCommands(program);
|
||||
registerFeedbackCommands(program);
|
||||
registerSecretCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerEnvLabCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
|
||||
{
|
||||
value: "aws_secrets_manager" as const,
|
||||
label: "AWS Secrets Manager",
|
||||
hint: "requires runtime AWS credentials and provider env config",
|
||||
hint: "requires external adapter integration",
|
||||
},
|
||||
{
|
||||
value: "gcp_secret_manager" as const,
|
||||
@@ -84,9 +84,7 @@ export async function promptSecrets(current?: SecretsConfig): Promise<SecretsCon
|
||||
|
||||
if (provider !== "local_encrypted") {
|
||||
p.note(
|
||||
provider === "aws_secrets_manager"
|
||||
? "AWS credentials must come from the Paperclip server runtime (IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, or short-lived shell env), not from Paperclip company secrets."
|
||||
: `${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`,
|
||||
`${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`,
|
||||
"Heads up",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"outDir": "dist",
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": ["src", "../packages/shared/src", "../packages/plugins/create-paperclip-plugin/src"]
|
||||
"include": ["src", "../packages/shared/src"]
|
||||
}
|
||||
|
||||
@@ -143,32 +143,6 @@ pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
```
|
||||
|
||||
## Secrets Commands
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets list --company-id <company-id>
|
||||
pnpm paperclipai secrets declarations --company-id <company-id> [--include agents,projects] [--kind secret]
|
||||
pnpm paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
||||
pnpm paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
||||
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> [--apply]
|
||||
```
|
||||
|
||||
Secret listing and declarations never print secret values. `create` accepts
|
||||
`--value-env` so shell history does not capture the value. `link` records
|
||||
provider-owned references without copying the secret value into Paperclip.
|
||||
For AWS-backed secrets, `secrets doctor` reports missing non-secret provider
|
||||
env and the expected AWS SDK runtime credential source; do not store AWS
|
||||
bootstrap credentials in Paperclip secrets.
|
||||
|
||||
Per-company provider vaults (multiple vault instances per provider, default
|
||||
vault selection, coming-soon GCP/Vault) are configured from the board UI under
|
||||
`Company Settings → Secrets → Provider vaults` or through
|
||||
`/api/companies/{companyId}/secret-provider-configs`. There is no CLI surface
|
||||
for vault management today. See the
|
||||
[secrets deploy guide](../docs/deploy/secrets.md#provider-vaults) and
|
||||
[API reference](../docs/api/secrets.md#provider-vaults) for the contract.
|
||||
|
||||
## Approval Commands
|
||||
|
||||
```sh
|
||||
@@ -204,28 +178,7 @@ pnpm paperclipai heartbeat run --agent-id <agent-id> [--api-base http://localhos
|
||||
|
||||
## Local Storage Defaults
|
||||
|
||||
Local Paperclip data lives under the selected instance root. `PAPERCLIP_HOME` chooses the home directory and `PAPERCLIP_INSTANCE_ID` chooses the instance.
|
||||
|
||||
```text
|
||||
~/.paperclip/ # PAPERCLIP_HOME
|
||||
└── instances/
|
||||
└── default/ # instance root (PAPERCLIP_INSTANCE_ID)
|
||||
├── config.json # runtime config
|
||||
├── .env # instance env file
|
||||
├── db/ # embedded PostgreSQL data
|
||||
├── data/
|
||||
│ ├── storage/ # local_disk uploads
|
||||
│ └── backups/ # automatic DB backups
|
||||
├── logs/
|
||||
├── secrets/
|
||||
│ └── master.key # local_encrypted master key
|
||||
├── workspaces/ # default agent workspaces
|
||||
├── projects/ # project execution workspaces
|
||||
├── companies/ # per-company adapter homes (e.g. codex-home)
|
||||
└── codex-home/ # per-instance codex home (when not company-scoped)
|
||||
```
|
||||
|
||||
Default paths for the canonical install:
|
||||
Default local instance root is `~/.paperclip/instances/default`:
|
||||
|
||||
- config: `~/.paperclip/instances/default/config.json`
|
||||
- embedded db: `~/.paperclip/instances/default/db`
|
||||
|
||||
@@ -149,15 +149,7 @@ The plugin runtime tracks plugin-owned database namespaces and migrations in `pl
|
||||
|
||||
## Backups
|
||||
|
||||
Paperclip supports automatic and manual logical database backups. These dumps include
|
||||
non-system database schemas such as `public`, the Drizzle migration journal, and
|
||||
plugin-owned database schemas. See `doc/DEVELOPING.md` for the current
|
||||
`paperclipai db:backup` / `pnpm db:backup` commands and backup retention
|
||||
configuration.
|
||||
|
||||
Database backups do not include non-database instance files such as local-disk
|
||||
uploads, workspace files, or the local encrypted secrets master key. Back those paths
|
||||
up separately when you need full instance disaster recovery.
|
||||
Paperclip supports automatic and manual database backups. See `doc/DEVELOPING.md` for the current `paperclipai db:backup` / `pnpm db:backup` commands and backup retention configuration.
|
||||
|
||||
## Secret storage
|
||||
|
||||
@@ -171,8 +163,6 @@ For local/default installs, the active provider is `local_encrypted`:
|
||||
- Secret material is encrypted at rest with a local master key.
|
||||
- Default key file: `~/.paperclip/instances/default/secrets/master.key` (auto-created if missing).
|
||||
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
|
||||
- Backup/restore requires both the database metadata and the local master key file; either artifact alone is insufficient.
|
||||
- The server best-effort enforces `0600` key file permissions and provider health reports permission warnings.
|
||||
|
||||
Optional overrides:
|
||||
|
||||
@@ -194,10 +184,5 @@ pnpm paperclipai configure --section secrets
|
||||
Inline secret migration command:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
|
||||
# direct database maintenance fallback
|
||||
pnpm secrets:migrate-inline-env --apply
|
||||
```
|
||||
|
||||
Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVIDER.md).
|
||||
|
||||
@@ -157,27 +157,6 @@ See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`)
|
||||
|
||||
For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||
|
||||
## Local Instance Layout
|
||||
|
||||
Every local install keeps runtime state directly under the selected instance root:
|
||||
|
||||
```text
|
||||
~/.paperclip/instances/default/ # instance root
|
||||
config.json # runtime config
|
||||
.env # instance env file
|
||||
db/ # embedded PostgreSQL data
|
||||
data/
|
||||
storage/ # local_disk uploads
|
||||
backups/ # automatic DB backups
|
||||
logs/
|
||||
secrets/master.key # local_encrypted master key
|
||||
workspaces/<agent-id>/ # default agent workspaces
|
||||
projects/ # project execution workspaces
|
||||
companies/<company-id>/codex-home/ # per-company codex_local home
|
||||
```
|
||||
|
||||
`PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` override the home root and instance id respectively. `paperclipai onboard` echoes the resolved values in its banner (`Local home: <home> | instance: <id> | config: <path>`) so you can confirm where state will land before continuing.
|
||||
|
||||
## Database in Dev (Auto-Handled)
|
||||
|
||||
For local development, leave `DATABASE_URL` unset.
|
||||
@@ -185,7 +164,7 @@ The server will automatically use embedded PostgreSQL and persist data at:
|
||||
|
||||
- `~/.paperclip/instances/default/db`
|
||||
|
||||
Override home or instance:
|
||||
Override home and instance:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_HOME=/custom/path PAPERCLIP_INSTANCE_ID=dev pnpm paperclipai run
|
||||
@@ -301,7 +280,7 @@ paperclipai worktree init --from-data-dir ~/.paperclip
|
||||
paperclipai worktree init --force
|
||||
```
|
||||
|
||||
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install. Point `--from-config` at the instance config:
|
||||
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install:
|
||||
|
||||
```sh
|
||||
cd /path/to/paperclip/.paperclip/worktrees/PAP-884-ai-commits-component
|
||||
@@ -442,9 +421,7 @@ If you set `DATABASE_URL`, the server will use that instead of embedded PostgreS
|
||||
|
||||
## Automatic DB Backups
|
||||
|
||||
Paperclip can run automatic logical database backups on a timer. These backups cover
|
||||
non-system database schemas, including migration history and plugin-owned database
|
||||
schemas. Defaults:
|
||||
Paperclip can run automatic DB backups on a timer. Defaults:
|
||||
|
||||
- enabled
|
||||
- every 60 minutes
|
||||
@@ -472,10 +449,6 @@ Environment overrides:
|
||||
- `PAPERCLIP_DB_BACKUP_RETENTION_DAYS=<days>`
|
||||
- `PAPERCLIP_DB_BACKUP_DIR=/absolute/or/~/path`
|
||||
|
||||
DB backups are not full instance filesystem backups. For full local disaster
|
||||
recovery, also back up local storage files and the local encrypted secrets key if
|
||||
those providers are enabled.
|
||||
|
||||
## Secrets in Dev
|
||||
|
||||
Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config.
|
||||
@@ -483,7 +456,6 @@ Agent env vars now support secret references. By default, secret values are stor
|
||||
- Default local key path: `~/.paperclip/instances/default/secrets/master.key`
|
||||
- Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY`
|
||||
- Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE`
|
||||
- Back up the key file and database together; either one alone is not enough to restore local encrypted secrets.
|
||||
|
||||
Strict mode (recommended outside local trusted machines):
|
||||
|
||||
@@ -492,20 +464,12 @@ PAPERCLIP_SECRETS_STRICT_MODE=true
|
||||
```
|
||||
|
||||
When strict mode is enabled, sensitive env keys (for example `*_API_KEY`, `*_TOKEN`, `*_SECRET`) must use secret references instead of inline plain values.
|
||||
Authenticated deployments default strict mode on unless explicitly overridden.
|
||||
|
||||
CLI configuration support:
|
||||
|
||||
- `pnpm paperclipai onboard` writes a default `secrets` config section (`local_encrypted`, strict mode off, key file path set) and creates a local key file when needed.
|
||||
- `pnpm paperclipai configure --section secrets` lets you update provider/strict mode/key path and creates the local key file when needed.
|
||||
- `pnpm paperclipai doctor` validates secrets adapter configuration, can create a missing local key file with `--repair`, and reports missing AWS Secrets Manager bootstrap env when that provider is selected.
|
||||
- Provider health is available at `GET /api/companies/:companyId/secret-providers/health` and reports local key permission warnings plus backup guidance.
|
||||
|
||||
Per-company provider vaults are configured in the board UI under
|
||||
`Company Settings → Secrets → Provider vaults`, backed by
|
||||
`/api/companies/{companyId}/secret-provider-configs`. The CLI does not own
|
||||
vault lifecycle today. See `docs/deploy/secrets.md` (`Provider Vaults` section)
|
||||
for the operator model.
|
||||
- `pnpm paperclipai doctor` validates secrets adapter configuration and can create a missing local key file with `--repair`.
|
||||
|
||||
Migration helper for existing inline env secrets:
|
||||
|
||||
|
||||
@@ -143,13 +143,6 @@ This keeps the default install path unchanged while allowing explicit installs w
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
The release script now verifies two things after a canary publish:
|
||||
|
||||
- the `canary` dist-tag resolves to the version that was just published
|
||||
- every published internal `@paperclipai/*` dependency referenced by that manifest exists on npm
|
||||
|
||||
It also treats `latest -> canary` as a failure by default, because npm metadata can otherwise leave the default install path pointing at an unreleased canary dependency graph. Only pass `./scripts/release.sh canary --allow-canary-latest` when that `latest` behavior is explicitly intended.
|
||||
|
||||
### Stable
|
||||
|
||||
Stable publishes use the npm dist-tag `latest`.
|
||||
@@ -176,58 +169,6 @@ That means:
|
||||
|
||||
See [doc/RELEASE-AUTOMATION-SETUP.md](RELEASE-AUTOMATION-SETUP.md) for the GitHub/npm setup steps.
|
||||
|
||||
## Release enrollment for new public packages
|
||||
|
||||
Paperclip does not auto-publish every non-private workspace package anymore.
|
||||
CI publishing is controlled by [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json).
|
||||
|
||||
When you add a new public package:
|
||||
|
||||
1. add it to the manifest and decide whether CI should publish it immediately
|
||||
2. if CI should publish it, bootstrap the package on npm before merge
|
||||
3. if CI should not publish it yet, keep `"publishFromCi": false`
|
||||
4. only enable `"publishFromCi": true` after npm trusted publishing is configured for that package
|
||||
|
||||
PR CI now checks changed release-enabled package manifests against npm. That catches a missing first-publish bootstrap before the change reaches `master`.
|
||||
|
||||
### One-time bootstrap sequence for a new package
|
||||
|
||||
The first publish of a brand-new package still needs one human maintainer with npm write access.
|
||||
After that, trusted publishing can take over.
|
||||
|
||||
Example for `@paperclipai/adapter-acpx-local` from the repo root:
|
||||
|
||||
```bash
|
||||
# safe preview
|
||||
pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local
|
||||
|
||||
# one-time first publish from an authenticated maintainer machine
|
||||
pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local --publish --otp 123456
|
||||
```
|
||||
|
||||
The helper script:
|
||||
|
||||
- checks that the package does not already exist on npm
|
||||
- builds the target package unless `--skip-build` is passed
|
||||
- runs `npm pack --dry-run` in the package directory
|
||||
- only runs the real `npm publish --access public` when `--publish --otp <code>` is provided
|
||||
|
||||
For the real `--publish` step, the maintainer machine must already be authenticated to npm.
|
||||
If `npm whoami` returns `401`, first run `npm logout --registry=https://registry.npmjs.org/` to clear any stale local auth, then run `npm login` or `npm adduser` locally as an npm org member, and finally rerun the helper.
|
||||
That local human auth is fine for the one-time bootstrap publish; we just do not want the same auth model inside CI.
|
||||
The helper now requires `--otp <code>` up front for `--publish`, so it fails before the real publish attempt if the one-time password is missing.
|
||||
|
||||
After that first publish succeeds:
|
||||
|
||||
1. open `https://www.npmjs.com/package/@paperclipai/adapter-acpx-local`
|
||||
2. go to `Settings` → `Trusted publishing`
|
||||
3. add repository `paperclipai/paperclip`
|
||||
4. set workflow filename to `release.yml`
|
||||
5. optionally go to `Settings` → `Publishing access` and enable `Require two-factor authentication and disallow tokens`
|
||||
6. keep `publishFromCi: true` in [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json)
|
||||
|
||||
Once those steps are done, future canary and stable publishes for that package are automated through GitHub OIDC. The manual step is only the first package creation on npm.
|
||||
|
||||
## Rollback model
|
||||
|
||||
Rollback does not unpublish anything.
|
||||
|
||||
@@ -67,27 +67,6 @@ Why:
|
||||
- the single `release.yml` workflow handles both canary and stable publishing
|
||||
- GitHub environments `npm-canary` and `npm-stable` still enforce different approval rules on the GitHub side
|
||||
|
||||
### 2.2.1. Newly added public packages need a bootstrap phase
|
||||
|
||||
Trusted publishing is configured on the npm package itself, not at the repo scope.
|
||||
That means a brand-new public package must not be auto-enrolled into CI publishing until its npm package exists and its trusted publisher has been configured.
|
||||
|
||||
Repo policy:
|
||||
|
||||
1. add every non-private package to [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json)
|
||||
2. set `"publishFromCi": true` only when CI is expected to publish that package
|
||||
3. if the package is not ready for CI publishing yet, keep `"publishFromCi": false`
|
||||
4. complete the package bootstrap before merging any PR that changes a release-enabled new package
|
||||
|
||||
Bootstrap sequence for a new package:
|
||||
|
||||
1. publish the package once from a trusted maintainer machine using normal npm auth
|
||||
2. open that package on npm and add the `paperclipai/paperclip` trusted publisher for `.github/workflows/release.yml`
|
||||
3. rerun or dry-run the release flow as needed to confirm CI publishing now works
|
||||
4. only then enable `"publishFromCi": true`
|
||||
|
||||
PR CI enforces this by checking changed release-enabled package manifests against npm. That keeps `master` canary publishing healthy while preserving the no-long-lived-token model for normal CI releases.
|
||||
|
||||
### 2.3. Verify trusted publishing before removing old auth
|
||||
|
||||
After the workflows are live:
|
||||
|
||||
@@ -63,8 +63,6 @@ It:
|
||||
- verifies the pushed commit
|
||||
- computes the canary version for the current UTC date
|
||||
- publishes under npm dist-tag `canary`
|
||||
- verifies that `canary` resolves to the just-published version and that published internal dependencies exist on npm
|
||||
- fails by default if npm leaves `latest` pointing at a canary; use `--allow-canary-latest` only when that state is intentional
|
||||
- creates a git tag `canary/vYYYY.MDD.P-canary.N`
|
||||
|
||||
Users install canaries with:
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
# AWS Secrets Manager Provider
|
||||
|
||||
Operational contract for the hosted `aws_secrets_manager` secret provider used by Paperclip Cloud.
|
||||
|
||||
## Scope
|
||||
|
||||
- Hosted provider for Paperclip-managed secrets when Paperclip Cloud runs on AWS.
|
||||
- Source of truth for secret values is AWS Secrets Manager, not Postgres.
|
||||
- Paperclip stores only metadata needed for ownership, bindings, version selection, audit, and runtime resolution.
|
||||
- AWS provider bootstrap credentials are deployment/runtime credentials, not Paperclip-managed company secrets.
|
||||
- Remote import for existing AWS secrets is metadata-only. Preview/import uses
|
||||
AWS inventory metadata and creates Paperclip external references; it does not
|
||||
copy plaintext into Paperclip.
|
||||
- Per-company AWS provider vaults (named instances of `aws_secrets_manager`
|
||||
with their own region, namespace, prefix, KMS key id, and tags) are managed
|
||||
in the board UI under `Company Settings → Secrets → Provider vaults`. See
|
||||
[Provider Vaults](../docs/deploy/secrets.md#provider-vaults) for the operator
|
||||
model and [Provider Vaults API](../docs/api/secrets.md#provider-vaults) for
|
||||
the routes. The bootstrap trust model in this document still applies — vault
|
||||
config carries non-sensitive routing metadata only, never AWS credentials.
|
||||
|
||||
## Bootstrap Trust Model
|
||||
|
||||
The AWS provider has a chicken-and-egg boundary: Paperclip cannot use
|
||||
`company_secrets` to unlock the AWS provider that stores those secrets. The
|
||||
initial AWS trust must exist before the Paperclip server starts.
|
||||
|
||||
Allowed bootstrap locations:
|
||||
|
||||
- Infrastructure IAM or workload identity attached to the Paperclip server
|
||||
runtime.
|
||||
- Process environment or orchestrator secret store used to start the Paperclip
|
||||
server.
|
||||
- Local AWS SDK sources such as `AWS_PROFILE`, AWS SSO/shared config, web
|
||||
identity, container metadata, or instance metadata.
|
||||
- Short-lived shell credentials for local development only.
|
||||
|
||||
Do not ask operators to paste AWS root credentials or long-lived IAM user access
|
||||
keys into the Paperclip board UI. Do not store those bootstrap keys in
|
||||
`company_secrets`.
|
||||
|
||||
## Paperclip Cloud Bootstrap
|
||||
|
||||
Paperclip Cloud must provision the AWS backing resources before any board user
|
||||
can create AWS-backed company secrets:
|
||||
|
||||
1. Create or select the deployment KMS key.
|
||||
2. Create the Paperclip server runtime role for the deployment.
|
||||
3. Attach a minimum IAM policy scoped to the deployment Secrets Manager prefix
|
||||
and the configured KMS key.
|
||||
4. Configure the server runtime with the non-secret provider environment
|
||||
variables below.
|
||||
5. Run `paperclipai doctor` or the provider health endpoint from the deployed
|
||||
runtime and confirm that the provider reports the expected region, prefix,
|
||||
deployment id, KMS setting, and AWS SDK credential source.
|
||||
|
||||
Once this is in place, the board UI can create Paperclip-managed AWS secrets and
|
||||
Paperclip will write them under the deployment/company namespace.
|
||||
|
||||
## Self-Hosted And Local Bootstrap
|
||||
|
||||
Self-hosted AWS deployments should use the AWS SDK default credential provider
|
||||
chain. Preferred sources are role-based:
|
||||
|
||||
- EC2 instance profile.
|
||||
- ECS task role.
|
||||
- EKS IRSA or another OIDC web identity role.
|
||||
- AWS SSO/shared config via `AWS_PROFILE`.
|
||||
|
||||
Local development can use:
|
||||
|
||||
```sh
|
||||
aws sso login --profile paperclip-dev
|
||||
AWS_PROFILE=paperclip-dev \
|
||||
PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager \
|
||||
PAPERCLIP_SECRETS_AWS_REGION=us-east-1 \
|
||||
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID=dev-local \
|
||||
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/abcd-... \
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Temporary `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` environment credentials
|
||||
are acceptable only as a local break-glass or short-lived test source. They
|
||||
should not be written to Paperclip config, committed to `.env` files, stored in
|
||||
`company_secrets`, or used as the default Paperclip Cloud bootstrap path.
|
||||
|
||||
## Deployment Config
|
||||
|
||||
Required environment variables:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager
|
||||
PAPERCLIP_SECRETS_AWS_REGION=us-east-1
|
||||
PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID=prod-us-1
|
||||
PAPERCLIP_SECRETS_AWS_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/abcd-...
|
||||
```
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_SECRETS_AWS_PREFIX=paperclip
|
||||
PAPERCLIP_SECRETS_AWS_ENVIRONMENT=production
|
||||
PAPERCLIP_SECRETS_AWS_PROVIDER_OWNER=paperclip
|
||||
PAPERCLIP_SECRETS_AWS_ENDPOINT=
|
||||
PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS=30
|
||||
```
|
||||
|
||||
Naming convention for Paperclip-managed secrets:
|
||||
|
||||
```text
|
||||
paperclip/{deploymentId}/{companyId}/{secretKey}
|
||||
```
|
||||
|
||||
Tag set for Paperclip-managed secrets:
|
||||
|
||||
- `paperclip:managed-by=paperclip`
|
||||
- `paperclip:provider-owner=<owner tag>`
|
||||
- `paperclip:deployment-id=<deployment id>`
|
||||
- `paperclip:company-id=<company id>`
|
||||
- `paperclip:secret-key=<secret key>`
|
||||
- `paperclip:environment=<environment tag>`
|
||||
|
||||
## IAM And KMS Assumptions
|
||||
|
||||
Launch posture:
|
||||
|
||||
- One Paperclip app role per deployment.
|
||||
- One deployment-scoped KMS key per deployment at launch.
|
||||
- Future per-company KMS keys remain compatible because Paperclip stores provider refs and version metadata separately from values.
|
||||
|
||||
Minimum IAM boundary:
|
||||
|
||||
- Allow `secretsmanager:CreateSecret`, `PutSecretValue`, `GetSecretValue`, and `DeleteSecret`.
|
||||
- Scope resources to the deployment prefix:
|
||||
|
||||
```text
|
||||
arn:aws:secretsmanager:<region>:<account-id>:secret:paperclip/<deployment-id>/*
|
||||
```
|
||||
|
||||
- Allow `kms:Encrypt`, `kms:Decrypt`, `kms:GenerateDataKey`, and `kms:DescribeKey` for the configured deployment CMK.
|
||||
- Deny wildcard access outside the deployment prefix.
|
||||
- Prefer workload identity / role-based auth. Do not store AWS credentials inline in Paperclip config.
|
||||
|
||||
Example minimum policy shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PaperclipDeploymentSecrets",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"secretsmanager:CreateSecret",
|
||||
"secretsmanager:PutSecretValue",
|
||||
"secretsmanager:GetSecretValue",
|
||||
"secretsmanager:DeleteSecret"
|
||||
],
|
||||
"Resource": "arn:aws:secretsmanager:<region>:<account-id>:secret:paperclip/<deployment-id>/*"
|
||||
},
|
||||
{
|
||||
"Sid": "PaperclipDeploymentKms",
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"kms:Encrypt",
|
||||
"kms:Decrypt",
|
||||
"kms:GenerateDataKey",
|
||||
"kms:DescribeKey"
|
||||
],
|
||||
"Resource": "arn:aws:kms:<region>:<account-id>:key/<key-id>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Operational expectation:
|
||||
|
||||
- Paperclip-managed secrets may be deleted only by Paperclip or an operator with equivalent break-glass access.
|
||||
- External references may resolve through Paperclip runtime, but Paperclip should not delete the external secret resource.
|
||||
|
||||
## Remote Import Inventory IAM
|
||||
|
||||
Remote import preview needs one additional AWS permission:
|
||||
|
||||
```json
|
||||
{
|
||||
"Sid": "PaperclipRemoteSecretInventory",
|
||||
"Effect": "Allow",
|
||||
"Action": "secretsmanager:ListSecrets",
|
||||
"Resource": "*"
|
||||
}
|
||||
```
|
||||
|
||||
This is intentionally separate from the managed create/rotate/delete policy.
|
||||
AWS treats `ListSecrets` as an account/Region inventory action; do not document
|
||||
secret ARNs, names, tags, or AWS request filters as an IAM boundary for it. Use
|
||||
`Resource: "*"` and decide whether inventory exposure is acceptable for the AWS
|
||||
account and Region behind each provider vault.
|
||||
|
||||
Remote import preview/import must not call:
|
||||
|
||||
- `secretsmanager:GetSecretValue`
|
||||
- `secretsmanager:BatchGetSecretValue`
|
||||
- `kms:Decrypt`
|
||||
|
||||
Those permissions are only needed later when a bound runtime resolves an
|
||||
imported external reference. For imported refs, scope read permissions to the
|
||||
operator-approved external prefixes that Paperclip is allowed to consume:
|
||||
|
||||
```json
|
||||
{
|
||||
"Sid": "PaperclipResolveImportedExternalReferences",
|
||||
"Effect": "Allow",
|
||||
"Action": "secretsmanager:GetSecretValue",
|
||||
"Resource": [
|
||||
"arn:aws:secretsmanager:<region>:<account-id>:secret:<approved-external-prefix>/*"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If selected external secrets use customer-managed KMS keys, also grant
|
||||
`kms:Decrypt` and `kms:DescribeKey` on those keys. Keep managed write/delete
|
||||
permissions scoped to `paperclip/<deployment-id>/*`; do not broaden them for
|
||||
remote import.
|
||||
|
||||
Safe scoping guidance:
|
||||
|
||||
- Prefer one Paperclip runtime role per environment/account.
|
||||
- Point provider vaults at the intended AWS account and Region instead of a
|
||||
broad central admin role.
|
||||
- Enable `ListSecrets` only in accounts where inventory exposure is acceptable.
|
||||
- Keep preview/import board-only; agent API keys must not call these routes.
|
||||
- Treat AWS tag/name filters as search UX only, not permission enforcement.
|
||||
|
||||
Paperclip also blocks importing refs under its own managed namespace as
|
||||
external references. Use the Paperclip-managed flow for
|
||||
`paperclip/{deploymentId}/{companyId}/{secretKey}` resources.
|
||||
|
||||
## Existing AWS Secrets
|
||||
|
||||
V1 keeps existing AWS Secrets Manager entries as **linked external references**, not adopted
|
||||
Paperclip-managed resources.
|
||||
|
||||
Use the Paperclip-managed flow when Paperclip should create and rotate the value. The AWS
|
||||
secret name is derived from deployment and company scope:
|
||||
|
||||
```text
|
||||
paperclip/{deploymentId}/{companyId}/{secretKey}
|
||||
```
|
||||
|
||||
Use the external-reference flow when the secret already exists at an operator-owned path such
|
||||
as:
|
||||
|
||||
```text
|
||||
/paperclip-bench/anthropic_api_key
|
||||
```
|
||||
|
||||
In that mode Paperclip stores only the path or ARN, resolves it at runtime, and records
|
||||
redacted access events. Operators rotate the actual value in AWS. Update the Paperclip
|
||||
reference only when the AWS path, ARN, or pinned provider version changes.
|
||||
|
||||
Paperclip does not currently offer an "adopt existing AWS secret" flow that takes over future
|
||||
`PutSecretValue` writes for an arbitrary existing secret. Adding that later requires explicit
|
||||
confirmation UX, scope validation, expected Paperclip tags, and security/cloud-ops review.
|
||||
|
||||
## Data Custody
|
||||
|
||||
- Paperclip stores `externalRef`, `providerVersionRef`, provider id, fingerprint hash, status, and binding metadata.
|
||||
- Paperclip does not store AWS secret plaintext in `company_secret_versions.material`.
|
||||
- Runtime resolution fetches the value from AWS only when a bound consumer needs it.
|
||||
|
||||
## Rotation Runbook
|
||||
|
||||
Manual Paperclip-managed rotation:
|
||||
|
||||
1. Write the new value through the Paperclip secret rotate flow.
|
||||
2. Paperclip creates a new AWS secret version with `PutSecretValue`.
|
||||
3. Paperclip records the new `providerVersionRef` in `company_secret_versions`.
|
||||
4. Re-run or restart affected workloads that consume `latest`, or pin consumers to a specific Paperclip version before rollout when you need staged release safety.
|
||||
|
||||
Guidance:
|
||||
|
||||
- Prefer pinned Paperclip secret versions for risky rollouts.
|
||||
- Treat provider-native automatic rotation as a later enhancement; current V1 flow is explicit create-new-version plus controlled rollout.
|
||||
|
||||
## Backup And Restore Runbook
|
||||
|
||||
What must survive:
|
||||
|
||||
- Paperclip database metadata for secret ownership, bindings, status, and provider version refs.
|
||||
- AWS Secrets Manager namespace under the configured deployment prefix.
|
||||
- The configured KMS key and its decrypt permissions.
|
||||
|
||||
Restore checklist:
|
||||
|
||||
1. Restore Paperclip database metadata.
|
||||
2. Confirm the same AWS Secrets Manager namespace still exists.
|
||||
3. Confirm the Paperclip runtime role can call `GetSecretValue` on the restored prefix.
|
||||
4. Confirm the role still has decrypt access to the CMK referenced by `PAPERCLIP_SECRETS_AWS_KMS_KEY_ID`.
|
||||
5. Run the live smoke below or a targeted runtime secret resolution test.
|
||||
|
||||
## Provider Outage Runbook
|
||||
|
||||
Symptoms:
|
||||
|
||||
- Secret create/rotate/resolve operations fail with AWS provider errors.
|
||||
- Agent runs fail before adapter invocation on required secret resolution.
|
||||
- Remote import preview fails to list AWS inventory.
|
||||
|
||||
Immediate actions:
|
||||
|
||||
1. Confirm AWS regional health and Secrets Manager availability.
|
||||
2. Confirm the runtime role still has `GetSecretValue` and KMS decrypt permissions.
|
||||
3. Check for accidental prefix, region, deployment id, or KMS key config drift.
|
||||
4. Retry a single resolution after AWS service health is green.
|
||||
5. If outage persists, pause high-risk runs that require secret access rather than churning retries.
|
||||
|
||||
Remote import-specific actions:
|
||||
|
||||
- Missing list permission: add `secretsmanager:ListSecrets` with
|
||||
`Resource: "*"` only when inventory import is approved for that vault's
|
||||
AWS account and Region.
|
||||
- Throttling: narrow the search, wait briefly, and retry with backoff. Avoid
|
||||
full-account enumeration.
|
||||
- Invalid or stale cursor: refresh the preview and discard the old
|
||||
`NextToken`.
|
||||
- Large account: load pages intentionally, keep one in-flight preview request
|
||||
per vault/search, and do not run background full-account crawls.
|
||||
- Runtime read failure after import: verify `GetSecretValue` and KMS decrypt
|
||||
on the selected external secret. Visibility in `ListSecrets` does not prove
|
||||
read permission.
|
||||
|
||||
## Incident Response Runbook
|
||||
|
||||
Potential incidents:
|
||||
|
||||
- Cross-company access caused by IAM scoping drift.
|
||||
- KMS policy drift causing decrypt failures or over-broad access.
|
||||
- Suspected secret exposure in logs, transcripts, or downstream agent output.
|
||||
|
||||
Response steps:
|
||||
|
||||
1. Stop or pause affected Paperclip runs.
|
||||
2. Audit recent Paperclip secret access events for impacted secret ids and consumers.
|
||||
3. Audit AWS CloudTrail for `ListSecrets`, `GetSecretValue`,
|
||||
`PutSecretValue`, and `DeleteSecret` calls on the relevant vault account,
|
||||
Region, deployment prefix, and approved external prefixes.
|
||||
4. Rotate impacted secrets in AWS through Paperclip-managed versioning.
|
||||
5. Re-scope IAM and KMS policies before resuming normal traffic.
|
||||
6. If a value may have reached an agent transcript or external system, treat it as exposed and rotate immediately.
|
||||
|
||||
## Optional Live Smoke
|
||||
|
||||
This is safe to skip locally. Run it only against a dedicated AWS test namespace.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- AWS credentials or workload identity with the deployment-scoped IAM permissions above.
|
||||
- `PAPERCLIP_SECRETS_PROVIDER=aws_secrets_manager`
|
||||
- The required `PAPERCLIP_SECRETS_AWS_*` environment variables set.
|
||||
|
||||
Suggested smoke:
|
||||
|
||||
1. Create a test secret through the Paperclip board or API under a throwaway company.
|
||||
2. Confirm the resulting AWS secret name matches `paperclip/{deploymentId}/{companyId}/{secretKey}`.
|
||||
3. Rotate the secret once and confirm a new `providerVersionRef` appears in Paperclip metadata.
|
||||
4. Resolve the secret through a bound runtime path, not by adding a general-purpose reveal endpoint.
|
||||
5. Delete the throwaway secret and confirm AWS schedules deletion with the configured recovery window.
|
||||
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
|
||||
| Visibility | Full visibility to board and all agents in same company |
|
||||
| Communication | Tasks + comments only (no separate chat system) |
|
||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
|
||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise create visible recovery issues or require human escalation (see `doc/execution-semantics.md`) |
|
||||
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
|
||||
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
|
||||
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
|
||||
@@ -150,7 +150,7 @@ Invariant: every business record belongs to exactly one company.
|
||||
- `capabilities` text null
|
||||
- `adapter_type` text; built-ins include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, and `openclaw_gateway`
|
||||
- `adapter_config` jsonb not null
|
||||
- `runtime_config` jsonb not null default `{}`; may include Paperclip runtime policy such as `modelProfiles.cheap.adapterConfig` for an optional low-cost model lane that does not change the primary adapter config
|
||||
- `runtime_config` jsonb not null default `{}`
|
||||
- `default_environment_id` uuid fk `environments.id` null
|
||||
- `context_mode` enum: `thin | fat` default `thin`
|
||||
- `budget_monthly_cents` int not null default 0
|
||||
@@ -434,10 +434,9 @@ Side effects:
|
||||
V1 non-terminal liveness rule:
|
||||
|
||||
- agent-owned `todo`, `in_progress`, `in_review`, and `blocked` issues must have a live execution path, an explicit waiting path, or an explicit recovery path
|
||||
- `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery action owns the next action
|
||||
- `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery issue owns the next action
|
||||
- a blocked chain is covered only when each unresolved leaf issue is live or explicitly waiting
|
||||
- when Paperclip cannot safely infer the next action, it surfaces the problem through visible blocked/recovery work instead of silently completing or reassigning work
|
||||
- explicit recovery actions are the liveness primitive; source-scoped actions are the default form, issue-backed recovery is a fallback for independent repair work or safety boundaries, and comments alone are evidence rather than a healthy liveness path
|
||||
|
||||
Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and non-terminal liveness semantics are documented in `doc/execution-semantics.md`.
|
||||
|
||||
@@ -677,7 +676,7 @@ Per-agent schedule fields in `adapter_config`:
|
||||
|
||||
- `enabled` boolean
|
||||
- `intervalSec` integer (minimum 30)
|
||||
- `maxConcurrentRuns` integer; new agents default to `20`; scheduler clamps configured values to `1..50`
|
||||
- `maxConcurrentRuns` integer; new agents default to `5`
|
||||
|
||||
Scheduler must skip invocation when:
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -67,15 +67,13 @@ This is the right state for:
|
||||
|
||||
- waiting on another issue
|
||||
- waiting on a human decision
|
||||
- waiting on an external dependency or system when Paperclip does not own a scheduled re-check
|
||||
- waiting on an external dependency or system
|
||||
- work that automatic recovery could not safely continue
|
||||
|
||||
### `in_review`
|
||||
|
||||
Execution work is paused because the next move belongs to a reviewer or approver, not the current executor.
|
||||
|
||||
An external review service can also be a valid review path when the issue keeps an agent assignee and has an active one-shot monitor that will wake that assignee to check the service later.
|
||||
|
||||
### `done`
|
||||
|
||||
The work is complete and terminal.
|
||||
@@ -156,7 +154,7 @@ If a parent is truly waiting on a child, model that with blockers. Do not rely o
|
||||
|
||||
For agent-owned, non-terminal issues, Paperclip should never leave work in a state where nobody is responsible for the next move and nothing will wake or surface it.
|
||||
|
||||
This is a visibility contract, not an auto-completion contract. If Paperclip cannot safely infer the next action, it should surface the ambiguity with a blocked state, a visible notice, or an explicit recovery action. It must not silently mark work done from prose comments or guess that a dependency is complete.
|
||||
This is a visibility contract, not an auto-completion contract. If Paperclip cannot safely infer the next action, it should surface the ambiguity with a blocked state, a visible comment, or an explicit recovery issue. It must not silently mark work done from prose comments or guess that a dependency is complete.
|
||||
|
||||
An issue is healthy when the product can answer "what moves this forward next?" without requiring a human to reconstruct intent from the whole thread. An issue is stalled when it is non-terminal but has no live execution path, no explicit waiting path, and no recovery path.
|
||||
|
||||
@@ -166,35 +164,9 @@ The valid action-path primitives are:
|
||||
- a queued wake or continuation that can be delivered to the responsible agent
|
||||
- a typed execution-policy participant, such as `executionState.currentParticipant`
|
||||
- a pending issue-thread interaction or linked approval that is waiting for a specific responder
|
||||
- a one-shot issue monitor (`executionPolicy.monitor.nextCheckAt`) that will wake the assignee for a future check
|
||||
- a human owner via `assigneeUserId`
|
||||
- a first-class blocker chain whose unresolved leaf issues are themselves healthy
|
||||
- an open explicit recovery action that names the owner and action needed to restore liveness
|
||||
|
||||
### Explicit recovery actions
|
||||
|
||||
An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item.
|
||||
|
||||
A valid recovery action must name:
|
||||
|
||||
- the source issue and company
|
||||
- the recovery kind and idempotency fingerprint
|
||||
- the recovery owner, plus previous or return owner when ownership may temporarily shift
|
||||
- the cause, bounded evidence, and next action
|
||||
- the wake, monitor, timeout, retry, or escalation policy that will move the action forward
|
||||
- the resolution outcome when closed, such as restored, delegated, false positive, blocked, escalated, or cancelled
|
||||
|
||||
A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: restore a wake path, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue.
|
||||
|
||||
Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include:
|
||||
|
||||
- long or cross-agent repair work with its own assignee, subtasks, or blockers
|
||||
- real delegated follow-up that should block the source issue as a first-class dependency
|
||||
- active-run watchdog work that must observe a still-running source process without interfering with it
|
||||
- recovery that needs separate review, approval, security handling, or escalation ownership
|
||||
- cases where source issue ownership cannot be changed or restored safely
|
||||
|
||||
A comment or system notice can be evidence for a recovery action, but it is not a recovery action by itself. Comment-only recovery is not a healthy liveness path because it does not define a typed owner, wake or monitor policy, retry bound, timeout, escalation path, or resolution outcome.
|
||||
- an open explicit recovery issue that names the owner and action needed to restore liveness
|
||||
|
||||
### Agent-assigned `todo`
|
||||
|
||||
@@ -208,16 +180,6 @@ A healthy dispatch state means at least one of these is true:
|
||||
|
||||
An assigned `todo` issue is stalled when dispatch was interrupted, no wake remains queued or running, and no recovery path has been opened.
|
||||
|
||||
### Agent-assigned `backlog`
|
||||
|
||||
This is parked state, not dispatch state.
|
||||
|
||||
Assigning an issue normally implies executable intent. When create APIs receive an assignee and no explicit status, Paperclip defaults the issue to `todo` so the assignee has a wake path instead of silently inheriting the unassigned `backlog` default.
|
||||
|
||||
An explicit assigned `backlog` issue remains valid when the creator is deliberately parking the work. It must not wake the assignee just because it has an assignee. Paperclip should make that choice visible in activity and UI so operators can distinguish intentional parking from a missed handoff.
|
||||
|
||||
An assigned `backlog` issue becomes a liveness problem when another issue is blocked on it and there is no explicit waiting path such as a human owner, active run, queued wake, pending interaction or approval, monitor, or open recovery action. In that case the blocked parent should surface "blocked by parked work" rather than treating the dependency chain as healthy.
|
||||
|
||||
### Agent-assigned `in_progress`
|
||||
|
||||
This is active-work state.
|
||||
@@ -226,8 +188,7 @@ A healthy active-work state means at least one of these is true:
|
||||
|
||||
- there is an active run for the issue
|
||||
- there is already a queued continuation wake
|
||||
- there is an active one-shot monitor that will wake the assignee for a future check
|
||||
- there is an open explicit recovery action for the lost execution path
|
||||
- there is an open explicit recovery issue for the lost execution path
|
||||
|
||||
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract.
|
||||
|
||||
@@ -241,34 +202,11 @@ A healthy `in_review` issue has at least one valid action path:
|
||||
- a pending issue-thread interaction or linked approval waiting for a named responder
|
||||
- a human owner via `assigneeUserId`
|
||||
- an active run or queued wake that is expected to process the review state
|
||||
- an active one-shot monitor for an external service or async review loop that the assignee owns
|
||||
- an open explicit recovery action for an ambiguous review handoff
|
||||
- an open explicit recovery issue for an ambiguous review handoff
|
||||
|
||||
Agent-assigned `in_review` with no typed participant is only healthy when one of the other paths exists. Assignment to the same agent that produced the handoff is not, by itself, a review path.
|
||||
|
||||
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery action. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
|
||||
|
||||
### Issue monitors
|
||||
|
||||
An issue monitor is a one-shot deferred action path for agent-owned issues in `in_progress` or `in_review`.
|
||||
|
||||
Use a monitor when the current assignee owns a future check against an async system or external service. Examples include Greptile review loops, GitHub checks, Vercel deployments, or provider jobs where the agent should come back later and decide what happens next.
|
||||
|
||||
Monitor policy lives under `executionPolicy.monitor` and includes:
|
||||
|
||||
- `nextCheckAt`: when Paperclip should wake the assignee
|
||||
- `notes`: non-secret instructions for what the assignee should check
|
||||
- `serviceName`: optional non-secret external-service context
|
||||
- `externalRef`: optional external-service reference input; Paperclip treats it as secret-adjacent, redacts it before persistence/visibility, and omits it from activity and wake payloads
|
||||
- `timeoutAt`, `maxAttempts`, and `recoveryPolicy`: optional recovery hints for bounded waits
|
||||
|
||||
Monitors are not recurring intervals. When a monitor fires, Paperclip clears the scheduled monitor and queues an `issue_monitor_due` wake for the assignee. If the external service is still pending, the assignee must explicitly re-arm the monitor with a new `nextCheckAt`. If the issue moves to `done`, `cancelled`, an invalid status, or a human/unassigned owner, the monitor is cleared.
|
||||
|
||||
Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials.
|
||||
|
||||
Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible issue-backed recovery work, and `escalate_to_board` records a board-visible escalation comment/activity.
|
||||
|
||||
Use `blocked` instead of a monitor when no Paperclip assignee owns a responsible polling path. In that case, name the external owner/action or create first-class recovery/blocker work.
|
||||
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active run, no queued wake, and no explicit recovery issue. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
|
||||
|
||||
### `blocked`
|
||||
|
||||
@@ -277,12 +215,12 @@ This is explicit waiting state.
|
||||
A healthy `blocked` issue has an explicit waiting path:
|
||||
|
||||
- first-class blockers exist, and each unresolved leaf has a valid action path under this contract
|
||||
- the issue has an explicit recovery action that itself has a live or waiting path
|
||||
- the issue is blocked on an explicit recovery issue that itself has a live or waiting path
|
||||
- the issue is waiting on a pending interaction, linked approval, human owner, or clearly named external owner/action
|
||||
|
||||
A blocker chain is covered only when its unresolved leaf is live or explicitly waiting. An intermediate `blocked` issue does not make the chain healthy by itself.
|
||||
|
||||
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery action. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
|
||||
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery issue. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
|
||||
|
||||
## 8. Crash and Restart Recovery
|
||||
|
||||
@@ -302,7 +240,7 @@ Example:
|
||||
Recovery rule:
|
||||
|
||||
- if the latest issue-linked run failed/timed out/cancelled and no live execution path remains, Paperclip queues one automatic assignment recovery wake
|
||||
- if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and opens or updates an explicit recovery action when a bounded owner/action is known; the visible comment is evidence, not the recovery path by itself
|
||||
- if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
|
||||
|
||||
This is a dispatch recovery, not a continuation recovery.
|
||||
|
||||
@@ -318,7 +256,7 @@ Example:
|
||||
Recovery rule:
|
||||
|
||||
- Paperclip queues one automatic continuation wake
|
||||
- if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and opens or updates an explicit recovery action when a bounded owner/action is known; the visible comment is evidence, not the recovery path by itself
|
||||
- if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
|
||||
|
||||
This is an active-work continuity recovery.
|
||||
|
||||
@@ -331,7 +269,7 @@ On startup and on the periodic recovery loop, Paperclip now does four things in
|
||||
1. reap orphaned `running` runs
|
||||
2. resume persisted `queued` runs
|
||||
3. reconcile stranded assigned work
|
||||
4. scan silent active runs and create or update explicit watchdog recovery actions
|
||||
4. scan silent active runs and create or update explicit watchdog review issues
|
||||
|
||||
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output.
|
||||
|
||||
@@ -344,11 +282,11 @@ The recovery service owns this contract:
|
||||
- classify active-run output silence as `ok`, `suspicious`, `critical`, `snoozed`, or `not_applicable`
|
||||
- collect bounded evidence from run logs, recent run events, child issues, and blockers
|
||||
- preserve redaction and truncation before evidence is written to issue descriptions
|
||||
- create at most one open watchdog recovery action per run; issue-backed implementations use `stale_active_run_evaluation` issues
|
||||
- create at most one open `stale_active_run_evaluation` issue per run
|
||||
- honor active snooze decisions before creating more review work
|
||||
- build the `outputSilence` summary shown by live-run and active-run API responses
|
||||
|
||||
Suspicious silence creates a medium-priority watchdog recovery action for the selected recovery owner. Critical silence raises that recovery action to high priority and, when issue-backed evaluation is needed for correctness, blocks the source issue on the explicit evaluation task without cancelling the active process.
|
||||
Suspicious silence creates a medium-priority review issue for the selected recovery owner. Critical silence raises that review issue to high priority and blocks the source issue on the explicit evaluation task without cancelling the active process.
|
||||
|
||||
Watchdog decisions are explicit operator/recovery-owner decisions:
|
||||
|
||||
@@ -358,7 +296,7 @@ Watchdog decisions are explicit operator/recovery-owner decisions:
|
||||
|
||||
Operators should prefer `snooze` for known time-bounded quiet periods. `continue` is only a short acknowledgement of the current evidence; if the run remains silent after the re-arm window, the periodic watchdog scan can create or update review work again.
|
||||
|
||||
The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot.
|
||||
The board can record watchdog decisions. The assigned owner of the watchdog evaluation issue can also record them. Other agents cannot.
|
||||
|
||||
## 11. Auto-Recover vs Explicit Recovery vs Human Escalation
|
||||
|
||||
@@ -376,9 +314,9 @@ Examples:
|
||||
|
||||
Auto-recovery preserves the existing owner. It does not choose a replacement agent.
|
||||
|
||||
### Explicit Recovery Action
|
||||
### Explicit Recovery Issue
|
||||
|
||||
Paperclip opens an explicit recovery action when the system can identify a problem but cannot safely complete the work itself.
|
||||
Paperclip creates an explicit recovery issue when the system can identify a problem but cannot safely complete the work itself.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -386,11 +324,9 @@ Examples:
|
||||
- a dependency graph has an invalid/uninvokable owner, unassigned blocker, or invalid review participant
|
||||
- an active run is silent past the watchdog threshold
|
||||
|
||||
The recovery action stays source-scoped by default. The source issue should show the recovery owner, cause, evidence, next action, and wake or monitor policy in its own thread/detail surface.
|
||||
The source issue remains visible and blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, or record the reason it is a false positive.
|
||||
|
||||
Create an issue-backed recovery action only when a separate issue is the right execution object. In that fallback form, the source issue remains visible and is blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, delegate real follow-up work, or record the reason the signal is a false positive.
|
||||
|
||||
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery actions automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface.
|
||||
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery issues automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface.
|
||||
|
||||
### Human Escalation
|
||||
|
||||
@@ -418,7 +354,7 @@ The recovery model is intentionally conservative:
|
||||
|
||||
- preserve ownership
|
||||
- retry once when the control plane lost execution continuity
|
||||
- open an explicit recovery action when the system can identify a bounded recovery owner/action
|
||||
- create explicit recovery work when the system can identify a bounded recovery owner/action
|
||||
- escalate visibly when the system cannot safely keep going
|
||||
|
||||
## 13. Practical Interpretation
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# Plugin Secret Refs: Company Scope Reintroduction Plan
|
||||
|
||||
Date: 2026-04-26
|
||||
Status: follow-up after fail-closed mitigation
|
||||
Related issue: PAP-2394
|
||||
|
||||
## Current state
|
||||
|
||||
`PAP-2394` now fails closed:
|
||||
|
||||
- `POST /api/plugins/:pluginId/config` rejects any config containing plugin secret refs.
|
||||
- `ctx.secrets.resolve()` is disabled for plugin workers.
|
||||
|
||||
This removes the release-blocking cross-company exposure path, but it also disables plugin secret-ref support until the runtime carries company scope end to end.
|
||||
|
||||
## Vulnerability summary
|
||||
|
||||
The original design mixed an instance-global config store with company-scoped secret bindings:
|
||||
|
||||
- [server/src/routes/plugins.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/server/src/routes/plugins.ts:1898) saved one global plugin config row, then wrote bindings into `company_secret_bindings` grouped by each referenced secret's owning company.
|
||||
- [packages/db/src/schema/plugin_config.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/db/src/schema/plugin_config.ts:15) stored one config row per plugin, with no company dimension.
|
||||
- [packages/db/src/schema/company_secret_bindings.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/db/src/schema/company_secret_bindings.ts:5) already modeled bindings as company-scoped.
|
||||
- [server/src/services/plugin-secrets-handler.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/server/src/services/plugin-secrets-handler.ts:212) resolved by `pluginId` + secret UUID, with no active company context from the bridge call.
|
||||
- [packages/plugins/sdk/src/worker-rpc-host.ts](/Users/dotta/paperclip/.paperclip/worktrees/PAP-2339-secrets-make-a-plan/packages/plugins/sdk/src/worker-rpc-host.ts:384) exposed `ctx.config.get()` and `ctx.secrets.resolve()` without a company parameter.
|
||||
|
||||
This violated Least Privilege, Complete Mediation, and Secure Defaults.
|
||||
|
||||
## Recommended end state
|
||||
|
||||
Re-enable plugin secret refs only after both of these are true:
|
||||
|
||||
1. Plugin config reads/writes are company-scoped.
|
||||
2. Runtime secret resolution carries explicit company context and enforces it at resolution time.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### 1. Make plugin config company-scoped
|
||||
|
||||
- Add `company_id` to `plugin_config`, with a unique index on `(plugin_id, company_id)`.
|
||||
- Update registry helpers to require `companyId` for `getConfig`, `upsertConfig`, `patchConfig`, and `deleteConfig`.
|
||||
- Update plugin config routes to require `companyId` and call `assertCompanyAccess(req, companyId)`.
|
||||
- Keep instance-global plugin lifecycle state separate from company-scoped plugin config.
|
||||
|
||||
### 2. Propagate company context through the worker runtime
|
||||
|
||||
- Extend the SDK so `ctx.config.get()` and `ctx.secrets.resolve()` can receive or derive `companyId`.
|
||||
- Introduce worker request context storage for handlers that already run with company scope:
|
||||
- `getData`
|
||||
- `performAction`
|
||||
- scoped API routes
|
||||
- tool executions
|
||||
- environment driver calls
|
||||
- Fail closed when plugin code tries to read company-scoped config or secrets outside an active company context.
|
||||
|
||||
### 3. Rebind secrets by `(companyId, pluginId, configPath)`
|
||||
|
||||
- On config save, validate every referenced secret belongs to the authorized company.
|
||||
- Store bindings only for that company.
|
||||
- Resolve secrets only by the current company-scoped binding, never by bare plugin ID plus UUID.
|
||||
- Treat stale bindings as invalid and remove them on config replacement.
|
||||
|
||||
### 4. Prevent cross-company config disclosure
|
||||
|
||||
- When returning config to the UI, only materialize the selected company's secret refs.
|
||||
- Never expose another company's secret UUIDs through the global plugin config surface.
|
||||
|
||||
## Required regression coverage
|
||||
|
||||
- Company A board user cannot save plugin config that references a Company B secret.
|
||||
- Company A plugin execution cannot resolve a Company B secret even if the same plugin is configured for Company B.
|
||||
- Company-scoped config reads only return the selected company's secret bindings.
|
||||
- Config replacement removes stale bindings for the same `(companyId, pluginId)` target.
|
||||
- Runtime calls without company context fail closed.
|
||||
|
||||
## Migration notes
|
||||
|
||||
- Existing `plugin_config` rows need a migration strategy before re-enable.
|
||||
- Safest default: do not auto-assume a company for historical secret refs.
|
||||
- Prefer one of:
|
||||
- explicit admin migration per company, or
|
||||
- import existing rows as non-secret config only and require re-entry of secret refs.
|
||||
|
||||
## Release posture
|
||||
|
||||
- Keep plugin secret refs disabled until all steps above land.
|
||||
- Do not restore the feature behind a soft warning; the insecure path must remain unavailable by default.
|
||||
@@ -1,135 +0,0 @@
|
||||
# LLM Wiki Paperclip Asset And Work-Product Security Gate
|
||||
|
||||
Status: accepted Phase 5 policy
|
||||
Date: 2026-05-06
|
||||
Owner: Security engineering
|
||||
Scope: Paperclip-derived ingestion into the LLM Wiki before any asset or work-product content indexing ships
|
||||
|
||||
## Decision
|
||||
|
||||
Phase 5 remains **fail-closed** for Paperclip assets and work products.
|
||||
|
||||
- Paperclip-derived **text extraction is allowed only** for issue titles/descriptions, issue comments, and issue documents.
|
||||
- Paperclip **assets/attachments** and **issue work products** are **metadata-only** in Phase 5.
|
||||
- **Linked summaries** and **content extraction** for assets/work products are **not approved** in Phase 5.
|
||||
- No implementation may fetch `/api/assets/:id/content`, dereference a work-product `url`, scrape preview pages, or embed binary/blob content into source bundles or source snapshots.
|
||||
|
||||
This keeps the secure path easier than the insecure one and avoids broadening the wiki into a second content-distribution channel.
|
||||
|
||||
## Allowed Source Kinds
|
||||
|
||||
These source kinds may contribute body text to Paperclip-derived source bundles:
|
||||
|
||||
| Source kind | Allowed body fields | Reason |
|
||||
| --- | --- | --- |
|
||||
| Issue | `title`, `description`, identifier/status metadata | First-party Paperclip text under company ACL |
|
||||
| Comment | `body` | First-party Paperclip text under company ACL |
|
||||
| Document | `body`, `title`, `key`, revision metadata | First-party Paperclip text under company ACL |
|
||||
|
||||
## Assets And Work Products
|
||||
|
||||
### Assets / attachments
|
||||
|
||||
Allowed in Phase 5:
|
||||
|
||||
- metadata-only references built from allowlisted structured fields already stored in Paperclip
|
||||
- recommended fields: `issueId`, `issueCommentId`, `attachmentId`, `assetId`, `originalFilename`, `contentType`, `byteSize`, `sha256`, `createdAt`, `createdByAgentId`, `createdByUserId`
|
||||
|
||||
Disallowed in Phase 5:
|
||||
|
||||
- fetching asset bytes from `/api/assets/:id/content`
|
||||
- parsing any blob body, including `text/plain`, `text/markdown`, `application/json`, images, SVG, PDFs, archives, or office formats
|
||||
- storing `contentPath` in wiki source bundles or source snapshots
|
||||
- model summarization of attachment bodies
|
||||
|
||||
### Work products
|
||||
|
||||
Allowed in Phase 5:
|
||||
|
||||
- metadata-only references built from allowlisted structured fields already stored in Paperclip
|
||||
- recommended fields: `issueId`, `workProductId`, `type`, `provider`, `title`, `status`, `reviewState`, `healthStatus`, `externalId`, `isPrimary`, `createdAt`, `updatedAt`
|
||||
- optional boolean/derived metadata such as `hasUrl: true`
|
||||
|
||||
Disallowed in Phase 5:
|
||||
|
||||
- fetching or crawling the work-product `url`
|
||||
- scraping preview pages, artifacts, pull requests, branches, commits, or custom provider targets through the wiki ingestion path
|
||||
- storing raw `url` values in wiki source bundles or source snapshots
|
||||
- model-authored linked summaries derived from off-record content
|
||||
|
||||
## MIME Allowlists And Size Caps
|
||||
|
||||
No MIME allowlist is approved for asset content extraction in Phase 5 because **no asset body extraction is approved at all**.
|
||||
|
||||
- Every asset MIME type is treated as opaque for Paperclip-derived indexing.
|
||||
- Existing upload limits remain storage concerns, not ingestion approvals.
|
||||
- Work-product destinations are also opaque regardless of MIME type or size.
|
||||
|
||||
Any future issue that wants blob parsing must define:
|
||||
|
||||
- a positive MIME allowlist
|
||||
- per-type parser strategy
|
||||
- per-source size caps
|
||||
- sandbox/isolation requirements
|
||||
- prompt-injection handling
|
||||
- regression tests for refusal paths
|
||||
|
||||
## Redaction Rules
|
||||
|
||||
Metadata-only means **structured facts only**, not capability-bearing links.
|
||||
|
||||
- Do not persist `contentPath` for assets.
|
||||
- Do not persist raw work-product `url` values.
|
||||
- Do not persist query strings, fragments, signed URL tokens, or userinfo.
|
||||
- Prefer stable identifiers (`assetId`, `workProductId`, `externalId`) over links.
|
||||
|
||||
This addresses Sensitive Information Disclosure, Unsafe Consumption of APIs, and Insecure Output Handling risks.
|
||||
|
||||
## Provenance Rules
|
||||
|
||||
Every metadata-only reference must preserve enough provenance to explain where it came from without reading the underlying content:
|
||||
|
||||
- `companyId`
|
||||
- `issueId`
|
||||
- attachment/work-product id
|
||||
- producer identity when available
|
||||
- timestamps
|
||||
- an explicit `metadata_only` marker in any future reference/snapshot schema
|
||||
|
||||
## Review-Required Behavior
|
||||
|
||||
Human review is **not** required for plain metadata-only references that stay inside the allowlisted fields above.
|
||||
|
||||
Human review **is required**, with a separate security sign-off issue, before enabling any of the following:
|
||||
|
||||
- asset body extraction
|
||||
- work-product URL fetching
|
||||
- linked summaries generated from asset/work-product content
|
||||
- storing raw blob links or raw remote URLs in wiki source material
|
||||
- non-default-space routing for Paperclip-derived asset/work-product references
|
||||
|
||||
## Security Rationale
|
||||
|
||||
This gate exists because the current host surfaces have different trust properties:
|
||||
|
||||
- issue/comment/document text is first-party Paperclip content already exposed through company-scoped issue/document APIs
|
||||
- asset content is a blob download surface (`/api/assets/:id/content`) and can carry prompt-injection or parser-risk payloads
|
||||
- work products can point at arbitrary destinations through `url`, which reintroduces SSRF, token leakage, and prompt-injection risk if dereferenced automatically
|
||||
|
||||
Relevant threat classes:
|
||||
|
||||
- OWASP LLM Top 10: Prompt Injection, Sensitive Information Disclosure, Insecure Output Handling, Excessive Agency
|
||||
- OWASP API Top 10: SSRF, Unsafe Consumption of APIs, Broken Object Property Level Authorization
|
||||
- Saltzer & Schroeder: Least Privilege, Fail Securely, Complete Mediation, Secure Defaults
|
||||
|
||||
## Follow-Up Implementation Scope
|
||||
|
||||
A follow-up implementation issue is justified only for **metadata-only references**.
|
||||
|
||||
That implementation must:
|
||||
|
||||
- keep assets/work products out of source-bundle body text
|
||||
- never fetch blob bytes or remote URLs
|
||||
- redact capability-bearing link fields
|
||||
- mark references as `metadata_only`
|
||||
- ship tests proving source bundles/snapshots never contain `contentPath` or raw work-product `url` fields
|
||||
@@ -1,136 +0,0 @@
|
||||
# Local Plugin Development
|
||||
|
||||
This is the short happy-path guide for developing a Paperclip plugin from a folder on your machine. You will scaffold a plugin, run it in watch mode, install it into a running Paperclip instance from an absolute local path, and edit code with the plugin worker reloading after each rebuild.
|
||||
|
||||
For the full alpha surface — manifest fields, capabilities, managed agents/projects/routines, UI slots, scoped API routes — see [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22+ and `pnpm`.
|
||||
- A local Paperclip checkout you can run from source. Local plugin installs read source from disk, so the running server must be able to see the path you give it.
|
||||
|
||||
## The five steps
|
||||
|
||||
```bash
|
||||
# 1. Start Paperclip locally
|
||||
pnpm paperclipai run
|
||||
|
||||
# 2. Scaffold a plugin outside the Paperclip repo
|
||||
paperclipai plugin init @acme/hello-plugin --output ~/dev/paperclip-plugins
|
||||
|
||||
# 3. Install dependencies and start the watch build
|
||||
cd ~/dev/paperclip-plugins/hello-plugin
|
||||
pnpm install
|
||||
pnpm dev
|
||||
|
||||
# 4. In another terminal, install the plugin from its absolute path
|
||||
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
|
||||
|
||||
# 5. Confirm it loaded
|
||||
paperclipai plugin list
|
||||
paperclipai plugin inspect acme.hello-plugin
|
||||
```
|
||||
|
||||
That's the loop. The rest of this page explains what each step does and what to expect when you edit code.
|
||||
|
||||
### 1. Start Paperclip
|
||||
|
||||
```bash
|
||||
pnpm paperclipai run
|
||||
```
|
||||
|
||||
Paperclip listens on `http://127.0.0.1:3100` by default. The CLI talks to that server, so leave it running.
|
||||
|
||||
### 2. Scaffold the plugin
|
||||
|
||||
```bash
|
||||
paperclipai plugin init @acme/hello-plugin --output ~/dev/paperclip-plugins
|
||||
```
|
||||
|
||||
This creates `~/dev/paperclip-plugins/hello-plugin/` with `src/manifest.ts`, `src/worker.ts`, `src/ui/index.tsx`, an esbuild watch config, a Vitest config, and a snapshot of `@paperclipai/plugin-sdk` from your local Paperclip checkout. You can run the package and tests without publishing anything to npm.
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--template <default|connector|workspace|environment>` — starter shape.
|
||||
- `--category <connector|workspace|automation|ui|environment>` — manifest category.
|
||||
- `--display-name`, `--description`, `--author` — manifest metadata.
|
||||
- `--sdk-path <absolute-path>` — point at a specific `packages/plugins/sdk` checkout if you have more than one.
|
||||
|
||||
When `plugin init` finishes, it prints the next four commands literally. You can copy them.
|
||||
|
||||
### 3. Install dependencies and run the watch build
|
||||
|
||||
```bash
|
||||
cd ~/dev/paperclip-plugins/hello-plugin
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`pnpm dev` runs `esbuild --watch` against the plugin source and emits `dist/manifest.js`, `dist/worker.js`, and `dist/ui/`. Leave it running. Every time you save, esbuild rebuilds the affected output file.
|
||||
|
||||
If your plugin has UI and you want a browser-side dev server with hot module replacement during local UI iteration, run `pnpm dev:ui` in a second terminal. It serves `dist/ui/` on `http://127.0.0.1:4177`. This is optional; Paperclip can load the built UI directly from `dist/ui/` without it.
|
||||
|
||||
### 4. Install from the absolute path
|
||||
|
||||
```bash
|
||||
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
|
||||
```
|
||||
|
||||
The CLI auto-detects local paths (anything that looks absolute, starts with `./`, `../`, or `~`, or resolves to an existing folder relative to the current directory) and sends `{ isLocalPath: true }` to `POST /api/plugins/install` with the resolved absolute path. If you want to be explicit, pass `--local`.
|
||||
|
||||
You will see a confirmation like:
|
||||
|
||||
```
|
||||
Installing plugin from local path: /Users/you/dev/paperclip-plugins/hello-plugin
|
||||
✓ Installed acme.hello-plugin v0.1.0 (ready)
|
||||
Local plugin installs run trusted local code from your machine.
|
||||
Keep `pnpm dev` running in /Users/you/dev/paperclip-plugins/hello-plugin;
|
||||
Paperclip watches rebuilt dist output and reloads the plugin worker.
|
||||
```
|
||||
|
||||
Relative paths are resolved against the current working directory, so `paperclipai plugin install .` from inside the plugin folder works too.
|
||||
|
||||
### 5. Inspect
|
||||
|
||||
```bash
|
||||
paperclipai plugin list
|
||||
paperclipai plugin inspect acme.hello-plugin
|
||||
```
|
||||
|
||||
`list` shows plugin key, status, version, and short error. `inspect` prints the same record with the full last error if there is one. Both accept `--json` if you want to script against them.
|
||||
|
||||
## Reload semantics, honestly
|
||||
|
||||
Paperclip watches the on-disk plugin package after a local install. The watcher targets the runtime entrypoints declared in the package's `paperclipPlugin` field (`dist/manifest.js`, `dist/worker.js`, `dist/ui/`).
|
||||
|
||||
What that means in practice:
|
||||
|
||||
- **Worker code:** save a `.ts` file → esbuild rewrites `dist/worker.js` → Paperclip debounces ~500ms and restarts the plugin worker. The next worker call uses the new code. There is no in-process hot module replacement for worker code; it is a worker restart.
|
||||
- **Manifest:** save `src/manifest.ts` → `dist/manifest.js` rewrites → the worker restarts and the host re-reads the manifest.
|
||||
- **Plugin UI:** save a `.tsx` file → esbuild rewrites `dist/ui/` → Paperclip reloads the UI bundle on its next mount. To get HMR during UI iteration, run `pnpm dev:ui` and point at the dev server with `devUiUrl` in your manifest while developing.
|
||||
- **Without `pnpm dev`:** the watcher only fires on `dist/*` changes. If you stop the watch build, source edits do not reach Paperclip. Restart `pnpm dev` (or run `pnpm build` once) before expecting changes.
|
||||
- **`node_modules`, `.git`, `.paperclip-sdk`, and other dotfolders are ignored.** Adding a dependency requires the new code to actually be imported and rebuilt before the worker sees it.
|
||||
|
||||
The server never compiles plugin source for you. The package's own build scripts own that step.
|
||||
|
||||
## Local path plugins vs npm packages
|
||||
|
||||
Both go through the same install endpoint, but they mean different things:
|
||||
|
||||
- **Local path plugins are trusted local code.** Paperclip executes worker code from disk under the same trust boundary as the rest of the running instance. This is meant for developing or operating a plugin against a checkout you control. There is no signature check, no sandboxing of worker code, and no provenance metadata beyond the path. Do not install local-path plugins you did not write.
|
||||
- **npm packages are the deployable artifact.** `paperclipai plugin install @acme/plugin-foo` (optionally `--version 1.2.3`) installs from your configured npm registry, version-pins, and produces an install record that other operators can reproduce. Ship plugins this way.
|
||||
|
||||
When you are done iterating locally, publish the package and reinstall the npm-package form so the install reflects what you will ship.
|
||||
|
||||
## Common things to do next
|
||||
|
||||
- **Restart cleanly:** `paperclipai plugin disable <key>` pauses the plugin without removing it. `paperclipai plugin enable <key>` brings it back. `paperclipai plugin uninstall <key>` removes the install record; add `--force` to also purge plugin state and settings.
|
||||
- **Browse examples:** `paperclipai plugin examples` lists the bundled example plugins that ship with the repo, each with a ready-to-run `paperclipai plugin install <path>` line.
|
||||
- **Go deeper:** [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md) covers worker capabilities, managed agents/projects/routines, plugin database namespaces, scoped API routes, and the shared UI components in `@paperclipai/plugin-sdk/ui`. [`PLUGIN_SPEC.md`](./PLUGIN_SPEC.md) is the longer-form specification, including future ideas that are not yet implemented.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`Plugin install returned no plugin record` or `error` status.** Run `paperclipai plugin inspect <key>` for the last error. The most common causes are (1) the plugin has not built yet — run `pnpm dev` or `pnpm build` first, (2) the `paperclipPlugin` entries in `package.json` point at files that do not exist on disk, or (3) the manifest failed validation. The Paperclip server log has the full validation error.
|
||||
- **Edits do not seem to reload.** Confirm `pnpm dev` is still running and writing to `dist/`. If you renamed entry files, update the `paperclipPlugin.manifest` / `paperclipPlugin.worker` / `paperclipPlugin.ui` fields in `package.json` so the watcher targets them.
|
||||
- **Worker restarts but UI is stale.** Hard-reload the page. If you want HMR, run `pnpm dev:ui` and set `devUiUrl` in your manifest to `http://127.0.0.1:4177` during development.
|
||||
- **Path arguments fail on Windows.** Quote paths that contain spaces, and prefer absolute paths over `~`-prefixed paths in non-bash shells.
|
||||
@@ -4,8 +4,6 @@ This guide describes the current, implemented way to create a Paperclip plugin i
|
||||
|
||||
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
|
||||
|
||||
> **New to plugins?** Start with the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) — it walks the CLI happy path (`plugin init` → `pnpm dev` → `plugin install <path>`) end to end. Come back here for the full manifest surface, worker capabilities, and UI components.
|
||||
|
||||
## Current reality
|
||||
|
||||
- Treat plugin workers and plugin UI as trusted code.
|
||||
@@ -15,20 +13,28 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
|
||||
- Plugin database migrations are restricted to a host-derived plugin namespace.
|
||||
- Plugin-owned JSON API routes must be declared in the manifest and are mounted
|
||||
only under `/api/plugins/:pluginId/api/*`.
|
||||
- The host provides a small shared React component kit through
|
||||
`@paperclipai/plugin-sdk/ui`; use it for common Paperclip controls before
|
||||
building custom versions.
|
||||
- There is no host-provided shared React component kit for plugins yet.
|
||||
- `ctx.assets` is not supported in the current runtime.
|
||||
|
||||
## Scaffold a plugin
|
||||
|
||||
Use the CLI scaffold command:
|
||||
Use the scaffold package:
|
||||
|
||||
```bash
|
||||
paperclipai plugin init @yourscope/plugin-name --output /absolute/path/to/plugin-repos
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
|
||||
```
|
||||
|
||||
That creates `<output>/plugin-name/` with:
|
||||
For a plugin that lives outside the Paperclip repo:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
|
||||
--output /absolute/path/to/plugin-repos \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That creates a package with:
|
||||
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
@@ -39,13 +45,11 @@ That creates `<output>/plugin-name/` with:
|
||||
|
||||
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
|
||||
|
||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first. Pass `--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk` if you have more than one Paperclip checkout.
|
||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first.
|
||||
|
||||
## Local development workflow
|
||||
## Recommended local workflow
|
||||
|
||||
See the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) for the full happy path (`pnpm dev` → `paperclipai plugin install <absolute-path>` → `paperclipai plugin list`) and reload semantics.
|
||||
|
||||
Minimum verification from the generated plugin folder:
|
||||
From the generated plugin folder:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
@@ -54,6 +58,16 @@ pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
|
||||
```
|
||||
|
||||
## Supported alpha surface
|
||||
|
||||
Worker:
|
||||
@@ -69,11 +83,10 @@ Worker:
|
||||
- database namespace via `ctx.db`
|
||||
- scoped JSON API routes declared with `apiRoutes`
|
||||
- entities
|
||||
- projects, project workspaces, and plugin-managed projects
|
||||
- projects and project workspaces
|
||||
- companies
|
||||
- issues, comments, namespaced `plugin:<pluginKey>` origins, blocker relations, checkout assertions, assignment wakeups, and orchestration summaries
|
||||
- agents, plugin-managed agents, and agent sessions
|
||||
- plugin-managed routines
|
||||
- agents and agent sessions
|
||||
- goals
|
||||
- data/actions
|
||||
- streams
|
||||
@@ -130,161 +143,6 @@ handler. The worker receives sanitized headers, route params, query, parsed JSON
|
||||
body, actor context, and company id. Do not use plugin routes to claim core
|
||||
paths; they always remain under `/api/plugins/:pluginId/api/*`.
|
||||
|
||||
## Managed Paperclip resources
|
||||
|
||||
Plugins that provide durable Paperclip business objects should declare them in
|
||||
the manifest and let the host create or relink the actual records per company.
|
||||
Do this for plugin-owned agents, plugin-owned projects, and recurring automation.
|
||||
Do not hide long-lived work behind private plugin state when it should be visible
|
||||
to the board, scoped to a company, audited, budgeted, and assigned like normal
|
||||
Paperclip work.
|
||||
|
||||
Use these surfaces:
|
||||
|
||||
- Managed agents: declare top-level `agents[]` and require
|
||||
`agents.managed`. Use this when the plugin provides a named worker the board
|
||||
should see in the org, budget, pause, invoke, and inspect. Managed agents are
|
||||
normal Paperclip agents with plugin ownership metadata, not background plugin
|
||||
workers.
|
||||
- Managed projects: declare top-level `projects[]` and require
|
||||
`projects.managed`. Use this when the plugin needs a stable company-scoped
|
||||
project for its issues, routines, or workspace-oriented UI. Keep plugin work
|
||||
in a project instead of scattering generated issues across unrelated projects.
|
||||
- Managed routines: declare top-level `routines[]` and require
|
||||
`routines.managed`. Use this for scheduled, webhook, or manually triggered
|
||||
jobs that should create visible Paperclip issues. Prefer managed routines over
|
||||
plugin `jobs[]` for recurring business work; plugin jobs are for plugin
|
||||
runtime maintenance that does not need a board-visible task trail.
|
||||
|
||||
Managed resources are resolved by stable plugin keys, not hardcoded database
|
||||
ids. In a worker action or data handler, call `ctx.agents.managed.reconcile()`,
|
||||
`ctx.projects.managed.reconcile()`, and `ctx.routines.managed.reconcile()` for
|
||||
the current `companyId`. `reconcile()` creates the missing resource, relinks a
|
||||
recoverable binding, or returns the existing resource. `reset()` reapplies the
|
||||
manifest defaults when the operator wants to restore the plugin's suggested
|
||||
configuration.
|
||||
|
||||
Declare dependencies between managed resources with refs. A routine can point
|
||||
at a managed agent through `assigneeRef` and at a managed project through
|
||||
`projectRef`. Reconcile the referenced agent and project before reconciling the
|
||||
routine; if a ref is still missing, the routine resolution reports
|
||||
`missing_refs` instead of guessing.
|
||||
|
||||
```ts
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "example.research-plugin",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Research Plugin",
|
||||
description: "Creates a managed research agent and scheduled research routine.",
|
||||
author: "Example",
|
||||
categories: ["automation"],
|
||||
capabilities: [
|
||||
"agents.managed",
|
||||
"projects.managed",
|
||||
"routines.managed",
|
||||
"instance.settings.register",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
agentKey: "researcher",
|
||||
displayName: "Researcher",
|
||||
role: "research",
|
||||
title: "Research Agent",
|
||||
capabilities: "Runs recurring research briefs for this company.",
|
||||
adapterPreference: ["codex_local", "claude_local", "process"],
|
||||
instructions: {
|
||||
content: "Follow the Paperclip heartbeat and produce concise research briefs.",
|
||||
},
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
projectKey: "research",
|
||||
displayName: "Research",
|
||||
description: "Recurring research work created by the Research Plugin.",
|
||||
status: "in_progress",
|
||||
},
|
||||
],
|
||||
routines: [
|
||||
{
|
||||
routineKey: "weekly-brief",
|
||||
title: "Weekly research brief",
|
||||
description: "Create a short research brief for the board.",
|
||||
assigneeRef: { resourceKind: "agent", resourceKey: "researcher" },
|
||||
projectRef: { resourceKind: "project", resourceKey: "research" },
|
||||
priority: "medium",
|
||||
triggers: [
|
||||
{
|
||||
kind: "schedule",
|
||||
label: "Monday morning",
|
||||
cronExpression: "0 9 * * 1",
|
||||
timezone: "America/Chicago",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "settingsPage",
|
||||
id: "settings",
|
||||
displayName: "Research",
|
||||
exportName: "SettingsPage",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
```
|
||||
|
||||
In the worker, expose a small setup action or settings-page action that
|
||||
reconciles the resources for the selected company:
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export default definePlugin({
|
||||
setup(ctx) {
|
||||
ctx.actions.register("setup-company", async (params) => {
|
||||
const companyId = String(params.companyId ?? "");
|
||||
if (!companyId) throw new Error("companyId is required");
|
||||
|
||||
const project = await ctx.projects.managed.reconcile("research", companyId);
|
||||
const agent = await ctx.agents.managed.reconcile("researcher", companyId);
|
||||
const routine = await ctx.routines.managed.reconcile("weekly-brief", companyId);
|
||||
|
||||
return { project, agent, routine };
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Authoring rules:
|
||||
|
||||
- Keep keys stable once published. Renaming `agentKey`, `projectKey`, or
|
||||
`routineKey` creates a new managed resource from the host's point of view.
|
||||
- Use managed agents for plugin-provided labor. Use `ctx.agents.invoke()` or
|
||||
`ctx.agents.sessions` only after you have a real agent id, either selected by
|
||||
the operator or resolved from `ctx.agents.managed`.
|
||||
- Use managed routines for recurring or externally triggered work that should
|
||||
produce tasks. Schedule, webhook, and API triggers are visible routine
|
||||
triggers, and each run has the normal Paperclip issue/audit trail.
|
||||
- Use managed projects to keep plugin-generated work organized and to give
|
||||
project-scoped plugin UI a stable home. For filesystem access inside a
|
||||
project, still resolve project workspaces through `ctx.projects`.
|
||||
- Keep defaults conservative. Managed declarations are suggestions owned by the
|
||||
plugin, but the resulting resources are normal Paperclip records that the
|
||||
operator can inspect, pause, and adjust.
|
||||
|
||||
UI:
|
||||
|
||||
- `usePluginData`
|
||||
@@ -310,187 +168,6 @@ Mount surfaces currently wired in the host include:
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
## Shared host components
|
||||
|
||||
Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a
|
||||
Paperclip-native control. The host owns the implementation, so plugins inherit
|
||||
the board's current styling, ordering, recent selections, and dark-mode behavior
|
||||
without importing `ui/src` internals.
|
||||
|
||||
Currently exposed components include:
|
||||
|
||||
- `MarkdownBlock` and `MarkdownEditor` for rendered and editable markdown.
|
||||
- `FileTree` for serializable file and directory trees.
|
||||
- `IssuesList` for a native company-scoped issue table.
|
||||
- `AssigneePicker` for the same agent/user selector used in the new issue pane.
|
||||
Use the controlled `value` format `agent:<id>`, `user:<id>`, or `""`.
|
||||
- `ProjectPicker` for the same project selector used in the new issue pane.
|
||||
Use the controlled project id value, or `""` for no project.
|
||||
- `ManagedRoutinesList` for plugin-owned routine settings pages.
|
||||
|
||||
```tsx
|
||||
import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function PluginAssignmentControls({ companyId }: { companyId: string }) {
|
||||
const [assignee, setAssignee] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<AssigneePicker
|
||||
companyId={companyId}
|
||||
value={assignee}
|
||||
onChange={(value) => setAssignee(value)}
|
||||
/>
|
||||
<ProjectPicker
|
||||
companyId={companyId}
|
||||
value={projectId}
|
||||
onChange={setProjectId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## File and path UI
|
||||
|
||||
Plugin UI often needs to render a file tree, accept a folder path, or browse a
|
||||
project workspace. There are three different surfaces for that, and they map to
|
||||
different trust and data-flow boundaries. Pick the surface that matches the
|
||||
data the plugin actually has.
|
||||
|
||||
### When to use the shared `FileTree`
|
||||
|
||||
Use `FileTree` from `@paperclipai/plugin-sdk/ui` whenever the plugin only needs
|
||||
to render a serializable file/directory list and react to selection or
|
||||
expand/collapse. The host owns the implementation, so plugin UI inherits the
|
||||
board's icons, indent, focus ring, and dark-mode styling without importing host
|
||||
internals.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
FileTree,
|
||||
type FileTreeNode,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
const nodes: FileTreeNode[] = [
|
||||
{ name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] },
|
||||
{
|
||||
name: "wiki",
|
||||
path: "wiki",
|
||||
kind: "dir",
|
||||
children: [
|
||||
{ name: "index.md", path: "wiki/index.md", kind: "file", children: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function WikiTree() {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["wiki"]));
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<FileTree
|
||||
nodes={nodes}
|
||||
selectedFile={selected}
|
||||
expandedPaths={expanded}
|
||||
onSelectFile={(path) => setSelected(path)}
|
||||
onToggleDir={(path) =>
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
next.has(path) ? next.delete(path) : next.add(path);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Good fits:
|
||||
|
||||
- LLM Wiki page navigation in `packages/plugins/plugin-llm-wiki` builds a
|
||||
`FileTreeNode[]` from worker query results and renders it through `FileTree`.
|
||||
- The example `plugin-file-browser-example` lazily fetches a directory's
|
||||
children through a `loadFileList` action when `onToggleDir` fires, then
|
||||
merges the children into the local tree state — letting the shared component
|
||||
handle rendering and selection.
|
||||
|
||||
Boundary rules:
|
||||
|
||||
- Keep the prop surface serializable (`nodes`, `expandedPaths`, `checkedPaths`,
|
||||
`fileBadges`, `fileTones`). Do not pass arbitrary render functions across the
|
||||
plugin/host boundary in v1; the supported escape hatches are
|
||||
`fileBadges` (status pill keyed by path) and `fileTones` (row tone keyed by
|
||||
path).
|
||||
- Do not import the host's `FileTree.tsx` or any `ui/src/*` module. The SDK
|
||||
declaration is the only supported import path for plugin UI.
|
||||
- The shared `FileTree` is for rendering and selection. Plugin-specific editors,
|
||||
ingest flows, query forms, and lint runs stay inside the plugin and do not
|
||||
belong as `FileTree` props.
|
||||
|
||||
### When to declare `localFolders`
|
||||
|
||||
When the plugin needs operator-configured filesystem roots — typically for
|
||||
trusted local plugins like wiki tooling — declare `localFolders[]` on the
|
||||
manifest and add the `local.folders` capability. The host renders a settings
|
||||
surface for the operator to set the absolute path, validates the path
|
||||
server-side (containment, symlinks, required files/directories), and exposes
|
||||
`ctx.localFolders.readText()` and `ctx.localFolders.writeTextAtomic()` in the
|
||||
worker.
|
||||
|
||||
```ts
|
||||
export const manifest = {
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources", "pages"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- The data lives outside any project workspace.
|
||||
- Reads and writes need company-scoped configuration.
|
||||
- The operator picks the path once in plugin settings and the worker resolves
|
||||
files relative to that root.
|
||||
|
||||
Do not use `localFolders` to grant the UI direct browser-side access to the
|
||||
filesystem — there is no such capability. The browser still goes through the
|
||||
worker via `getData` / `performAction`, and the worker only exposes paths it
|
||||
chose to expose.
|
||||
|
||||
### When to keep worker-mediated project workspace browsing
|
||||
|
||||
When the data lives inside an existing project workspace, keep the browsing
|
||||
flow worker-mediated:
|
||||
|
||||
- The worker uses `ctx.projects.listWorkspaces()` to resolve the workspace
|
||||
path, then reads its filesystem with normal Node APIs.
|
||||
- The plugin UI calls a `getData` handler for the root listing and an action
|
||||
for lazy children, then renders them through `FileTree`.
|
||||
- The worker is the only side that touches the disk. The browser receives a
|
||||
serializable tree and never sees raw absolute paths it can replay.
|
||||
|
||||
The example `plugin-file-browser-example` is the reference for this pattern:
|
||||
the worker registers `fileList` (data) and `loadFileList` (action) over the
|
||||
same handler, and the UI uses the action for on-toggle directory loading so the
|
||||
shared `FileTree` stays the rendering surface.
|
||||
|
||||
### Mixing surfaces
|
||||
|
||||
A single plugin can use more than one of these. The LLM Wiki uses
|
||||
`localFolders` for its content root, then renders the resulting page list
|
||||
through `FileTree`. The file browser example uses `ctx.projects.listWorkspaces`
|
||||
to pick a workspace and renders its on-disk tree through `FileTree` with lazy
|
||||
loading. Pick the boundary per data source, not per plugin.
|
||||
|
||||
## Company routes
|
||||
|
||||
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
||||
|
||||
@@ -27,7 +27,7 @@ Current limitations to keep in mind:
|
||||
- Published npm packages are the intended install artifact for deployed plugins.
|
||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||
- The current runtime ships a small host-provided plugin UI component kit through `@paperclipai/plugin-sdk/ui`, but does not support plugin asset uploads/reads yet. Treat plugin asset APIs as future-scope ideas, not current implementation promises.
|
||||
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
||||
- Scoped plugin API routes are JSON-only and must be declared in `apiRoutes`.
|
||||
They mount under `/api/plugins/:pluginId/api/*`; plugins cannot shadow core
|
||||
API routes.
|
||||
@@ -976,23 +976,13 @@ export function DashboardWidget({ context }: PluginWidgetProps) {
|
||||
|
||||
The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides:
|
||||
|
||||
- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`, `useHostNavigation()`
|
||||
- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`
|
||||
- **Design tokens**: colors, spacing, typography, shadows matching the host theme
|
||||
- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc.
|
||||
- **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps`
|
||||
|
||||
Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge.
|
||||
|
||||
`useHostNavigation()` is the supported way for plugin UI to navigate to
|
||||
Paperclip-internal pages. It exposes `resolveHref(to)`, `navigate(to,
|
||||
options?)`, and `linkProps(to, options?)`. Plugin links should prefer
|
||||
`linkProps()` so anchors keep real `href` values for copy-link, modifier-click,
|
||||
middle-click, and open-in-new-tab behavior while plain left-clicks route through
|
||||
the host SPA router. The host resolves company-scoped paths against the active
|
||||
company prefix without double-prefixing already-prefixed paths. Plugin UI should
|
||||
not use raw same-origin `href`s or `window.location.assign()` for internal
|
||||
Paperclip navigation because those can force a full document reload.
|
||||
|
||||
### 19.0.2 Bundle Isolation
|
||||
|
||||
Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens.
|
||||
@@ -1072,11 +1062,6 @@ The host SDK ships shared components that plugins can import to quickly build UI
|
||||
| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs |
|
||||
| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection |
|
||||
| `Spinner` | Loading indicator | Data fetch states |
|
||||
| `FileTree` | Host-styled file/directory tree | Wiki pages, workspace files, import previews |
|
||||
| `IssuesList` | Host issue list | Plugin pages that need a native issue view |
|
||||
| `AssigneePicker` | Host assignee picker for agents and board users | Creating issues, assigning routines, filtering work |
|
||||
| `ProjectPicker` | Host project picker | Creating issues, scoping dashboards, filtering work |
|
||||
| `ManagedRoutinesList` | Host routine list | Plugin settings pages that manage routines |
|
||||
|
||||
Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 53 KiB |
@@ -75,28 +75,11 @@ Fields:
|
||||
```
|
||||
PATCH /api/routines/{routineId}
|
||||
{
|
||||
"status": "paused",
|
||||
"baseRevisionId": "{latestRevisionId}"
|
||||
"status": "paused"
|
||||
}
|
||||
```
|
||||
|
||||
All fields from create are updatable. `baseRevisionId` is optional for backward compatibility; when provided, stale values return `409 Conflict` with the current revision id. **Agents can only update routines assigned to themselves and cannot reassign a routine to another agent.**
|
||||
|
||||
## List Revisions
|
||||
|
||||
```
|
||||
GET /api/routines/{routineId}/revisions
|
||||
```
|
||||
|
||||
Returns append-only routine definition revisions newest first. Snapshots include routine fields and safe trigger metadata only; webhook secret values and `secretId` are never returned.
|
||||
|
||||
## Restore Revision
|
||||
|
||||
```
|
||||
POST /api/routines/{routineId}/revisions/{revisionId}/restore
|
||||
```
|
||||
|
||||
Restores a historical routine definition by creating a new latest revision copied from the selected revision. Historical revision rows, routine run history, and activity history are preserved. If restoring a deleted webhook trigger requires recreating it, the response can include one-time replacement secret material for that trigger.
|
||||
All fields from create are updatable. **Agents can only update routines assigned to themselves and cannot reassign a routine to another agent.**
|
||||
|
||||
## Add Trigger
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
title: Secrets Remote Import
|
||||
summary: AWS Secrets Manager metadata-only remote import API
|
||||
---
|
||||
|
||||
Remote import lets the board link existing AWS Secrets Manager entries as
|
||||
Paperclip `external_reference` secrets without copying plaintext into
|
||||
Paperclip.
|
||||
|
||||
Both routes are board-only and company-scoped. The selected provider vault must
|
||||
belong to the company, use `aws_secrets_manager`, and have a selectable status
|
||||
(`ready` or `warning`). Disabled, coming-soon, or cross-company vaults are
|
||||
rejected.
|
||||
|
||||
Remote import is an inventory and metadata workflow. Preview calls AWS
|
||||
`ListSecrets` only and import stores a Paperclip external reference plus
|
||||
fingerprint/version metadata. Neither route calls `GetSecretValue` or
|
||||
`BatchGetSecretValue`, requests `SecretString`, requires KMS decrypt, logs raw
|
||||
remote metadata, or copies secret plaintext into Paperclip.
|
||||
|
||||
## Preview Remote AWS Secrets
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/secrets/remote-import/preview
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"query": "stripe",
|
||||
"nextToken": "optional-provider-page-token",
|
||||
"pageSize": 50
|
||||
}
|
||||
```
|
||||
|
||||
`query` is optional and is sent to AWS as an inventory filter. Treat it as
|
||||
non-secret metadata because AWS may record list request parameters in
|
||||
CloudTrail. `nextToken` is an opaque AWS cursor; pass it back unchanged.
|
||||
`pageSize` is capped at 100.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"provider": "aws_secrets_manager",
|
||||
"nextToken": null,
|
||||
"candidates": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"remoteName": "prod/stripe",
|
||||
"name": "prod/stripe",
|
||||
"key": "prod-stripe",
|
||||
"providerVersionRef": null,
|
||||
"providerMetadata": {
|
||||
"lastChangedDate": "2026-05-06T00:00:00.000Z",
|
||||
"hasDescription": true
|
||||
},
|
||||
"status": "ready",
|
||||
"importable": true,
|
||||
"conflicts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Candidate `status` values:
|
||||
|
||||
- `ready`: no existing exact external reference and no name/key collision.
|
||||
- `duplicate`: an existing secret already has the exact provider `externalRef`.
|
||||
- `conflict`: the suggested Paperclip `name` or `key` is already in use.
|
||||
|
||||
Conflict `type` values are `exact_reference`, `name`, `key`, and
|
||||
`provider_guardrail`. AWS refs under Paperclip's own managed namespace are
|
||||
blocked as external references so one company cannot import another company's
|
||||
Paperclip-managed AWS secret through a broad runtime role.
|
||||
|
||||
## Import Remote AWS Secret References
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/secrets/remote-import
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"secrets": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"name": "Stripe production key",
|
||||
"key": "stripe-production-key",
|
||||
"description": "Stripe key used by production checkout",
|
||||
"providerVersionRef": null,
|
||||
"providerMetadata": {
|
||||
"lastChangedDate": "2026-05-06T00:00:00.000Z",
|
||||
"hasDescription": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The import response is row-level. Ready rows become active
|
||||
`external_reference` secrets with version metadata only. Exact-reference
|
||||
duplicates and name/key conflicts are skipped without failing the whole request.
|
||||
The `secrets` array accepts 1-100 rows, and the backend re-checks duplicates and
|
||||
conflicts at submit time.
|
||||
Each row may include an optional Paperclip `description` entered during review;
|
||||
blank descriptions are stored as `null`. AWS provider descriptions are not
|
||||
copied into this field.
|
||||
|
||||
```json
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"provider": "aws_secrets_manager",
|
||||
"importedCount": 1,
|
||||
"skippedCount": 1,
|
||||
"errorCount": 0,
|
||||
"results": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"name": "Stripe production key",
|
||||
"key": "stripe-production-key",
|
||||
"status": "imported",
|
||||
"reason": null,
|
||||
"secretId": "<paperclip-secret-id>",
|
||||
"conflicts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Activity logs record aggregate counts and provider/vault ids only, not remote
|
||||
secret names, ARNs, tags, or values.
|
||||
|
||||
Imported references may still fail during a future bound runtime resolution if
|
||||
the Paperclip runtime role can list the AWS secret but lacks
|
||||
`secretsmanager:GetSecretValue` or required KMS decrypt permission for that
|
||||
specific secret.
|
||||
@@ -25,357 +25,16 @@ POST /api/companies/{companyId}/secrets
|
||||
|
||||
The value is encrypted at rest. Only the secret ID and metadata are returned.
|
||||
|
||||
To link a provider-owned secret without copying the value into Paperclip, create
|
||||
an external-reference secret:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "prod-stripe-key",
|
||||
"provider": "aws_secrets_manager",
|
||||
"managedMode": "external_reference",
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/stripe",
|
||||
"providerVersionRef": "version-id-or-label"
|
||||
}
|
||||
```
|
||||
|
||||
Paperclip stores the provider reference and a non-sensitive fingerprint only.
|
||||
The value is resolved, when the provider is configured, through the server
|
||||
runtime path that enforces binding context and records access events.
|
||||
|
||||
## Provider Health
|
||||
## Update Secret
|
||||
|
||||
```
|
||||
GET /api/companies/{companyId}/secret-providers/health
|
||||
```
|
||||
|
||||
Returns provider setup diagnostics, warnings, and local backup guidance. Health
|
||||
responses must not include secret values or provider credentials.
|
||||
|
||||
For `aws_secrets_manager`, an unready health response names the missing
|
||||
non-secret provider environment variables, the AWS SDK default credential source
|
||||
expected by the server runtime, and the custody rule that AWS bootstrap
|
||||
credentials must not be stored in Paperclip `company_secrets`.
|
||||
|
||||
The equivalent CLI check is:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets doctor --company-id {companyId}
|
||||
```
|
||||
|
||||
## Provider Vaults
|
||||
|
||||
Provider vaults are named, company-scoped configurations that route secret
|
||||
material to one of the supported provider backends. See the
|
||||
[secrets deploy guide](/deploy/secrets#provider-vaults) for the operator model
|
||||
and custody rules.
|
||||
|
||||
All routes below require board auth and company access. Mutating routes emit
|
||||
`secret_provider_config.*` activity-log entries. No route in this surface
|
||||
returns provider credential values; submitting credential-shaped fields in
|
||||
`config` is rejected at validation time.
|
||||
|
||||
### List Vaults
|
||||
|
||||
```
|
||||
GET /api/companies/{companyId}/secret-provider-configs
|
||||
```
|
||||
|
||||
Returns every vault for the company (including disabled rows for audit), each
|
||||
with id, provider, displayName, status, isDefault, non-sensitive `config`,
|
||||
latest health snapshot (`healthStatus`, `healthCheckedAt`, `healthMessage`,
|
||||
`healthDetails`), `disabledAt`, and audit columns.
|
||||
|
||||
### Create Vault
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/secret-provider-configs
|
||||
{
|
||||
"provider": "aws_secrets_manager",
|
||||
"displayName": "Prod US-East",
|
||||
"isDefault": true,
|
||||
"config": {
|
||||
"region": "us-east-1",
|
||||
"namespace": "paperclip",
|
||||
"secretNamePrefix": "paperclip",
|
||||
"kmsKeyId": "arn:aws:kms:us-east-1:123456789012:key/abcd-...",
|
||||
"environmentTag": "production"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-provider `config` shapes:
|
||||
|
||||
- `local_encrypted`: optional `backupReminderAcknowledged: boolean`.
|
||||
- `aws_secrets_manager`: required `region`; optional `namespace`,
|
||||
`secretNamePrefix`, `kmsKeyId`, `ownerTag`, `environmentTag`.
|
||||
- `gcp_secret_manager` (coming soon): optional `projectId`, `location`,
|
||||
`namespace`, `secretNamePrefix`.
|
||||
- `vault` (coming soon): optional origin-only HTTPS `address`, `namespace`,
|
||||
`mountPath`, `secretPathPrefix`. `address` values with embedded credentials,
|
||||
paths, query strings, or fragments are rejected.
|
||||
|
||||
`status` defaults to `ready` for `local_encrypted` and `aws_secrets_manager`,
|
||||
and to `coming_soon` for `gcp_secret_manager` and `vault`. Coming-soon and
|
||||
disabled vaults cannot be marked `isDefault`. Setting `isDefault: true` clears
|
||||
the previous default for the same provider in the same transaction.
|
||||
|
||||
### Get Vault
|
||||
|
||||
```
|
||||
GET /api/secret-provider-configs/{id}
|
||||
```
|
||||
|
||||
### Update Vault
|
||||
|
||||
```
|
||||
PATCH /api/secret-provider-configs/{id}
|
||||
{
|
||||
"displayName": "Prod US-East-2",
|
||||
"config": {
|
||||
"region": "us-east-2",
|
||||
"kmsKeyId": "arn:aws:kms:us-east-2:123456789012:key/abcd-..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`config` is replaced wholesale on update — pass the full provider config
|
||||
payload, not a partial diff. Status transitions for `gcp_secret_manager` and
|
||||
`vault` are constrained to `coming_soon` and `disabled` until their runtime
|
||||
modules ship.
|
||||
|
||||
### Disable Vault
|
||||
|
||||
```
|
||||
DELETE /api/secret-provider-configs/{id}
|
||||
```
|
||||
|
||||
Soft-deletes the vault: status flips to `disabled`, `isDefault` clears, and
|
||||
`disabledAt` is stamped. Disabled vaults remain in `GET` results for audit
|
||||
purposes but are no longer offered in the secret create/rotate flow.
|
||||
|
||||
### Set Default
|
||||
|
||||
```
|
||||
POST /api/secret-provider-configs/{id}/default
|
||||
```
|
||||
|
||||
Marks the target vault as the default for its provider family and clears the
|
||||
previous default. Returns 422 when the target is `coming_soon` or `disabled`.
|
||||
|
||||
### Run Health Check
|
||||
|
||||
```
|
||||
POST /api/secret-provider-configs/{id}/health
|
||||
```
|
||||
|
||||
Runs a provider-specific health probe and persists the result on the vault.
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"configId": "<uuid>",
|
||||
"provider": "aws_secrets_manager",
|
||||
"status": "ready" | "warning" | "error" | "coming_soon" | "disabled",
|
||||
"message": "Provider vault is ready to handle managed writes",
|
||||
"details": {
|
||||
"code": "provider_ready",
|
||||
"message": "...",
|
||||
"guidance": ["..."]
|
||||
},
|
||||
"checkedAt": "2026-05-06T14:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Health responses never include provider credentials or secret values. For AWS
|
||||
vaults, `details.guidance` may include missing non-secret env names and the
|
||||
expected AWS SDK credential source; coming-soon vaults always return
|
||||
`status: "coming_soon"` with `code: "runtime_locked"` and never call into
|
||||
provider modules.
|
||||
|
||||
### Selecting A Vault When Creating Or Rotating Secrets
|
||||
|
||||
`POST /api/companies/{companyId}/secrets` and
|
||||
`POST /api/secrets/{secretId}/rotate` both accept an optional
|
||||
`providerConfigId` field that pins the secret to a specific vault. When
|
||||
omitted (or null), the operation runs through the deployment-level provider
|
||||
configuration — the same path existing installs already use. The board UI
|
||||
preselects the company's default vault for the chosen provider before
|
||||
submitting, so callers should usually send an explicit `providerConfigId`.
|
||||
Coming-soon and disabled vaults are rejected with a 422; a vault that does not
|
||||
match the secret's provider is rejected the same way.
|
||||
|
||||
```json
|
||||
POST /api/companies/{companyId}/secrets
|
||||
{
|
||||
"name": "prod-stripe-key",
|
||||
"provider": "aws_secrets_manager",
|
||||
"providerConfigId": "<vault-uuid>",
|
||||
"managedMode": "external_reference",
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/stripe"
|
||||
}
|
||||
```
|
||||
|
||||
### Response Redaction Rules
|
||||
|
||||
Every route in this surface enforces the same redaction contract:
|
||||
|
||||
- Secret values are never returned. The board UI never has a "reveal value"
|
||||
affordance; resolution happens server-side at runtime under a binding.
|
||||
- Provider credential values are never accepted, stored, returned, logged, or
|
||||
echoed in error messages. Submitting credential-shaped fields fails
|
||||
validation with a non-leaking error.
|
||||
- Activity log entries record vault id, provider, displayName, status, and
|
||||
isDefault transitions — never `config` payloads or health detail bodies.
|
||||
|
||||
## Remote Import From AWS Secrets Manager
|
||||
|
||||
Remote import links existing AWS Secrets Manager entries into Paperclip as
|
||||
`external_reference` secrets. Import stores provider reference metadata only; it
|
||||
does not copy the remote secret plaintext into Paperclip.
|
||||
|
||||
The routes are board-only and company-scoped. `providerConfigId` must point to
|
||||
a same-company AWS provider vault with status `ready` or `warning`. Disabled,
|
||||
coming-soon, non-AWS, and cross-company vaults are rejected. Imported secrets
|
||||
resolve later through the selected vault, so runtime reads still need
|
||||
`secretsmanager:GetSecretValue` and any required KMS decrypt permission on the
|
||||
selected external secret.
|
||||
|
||||
### Preview Remote Import Candidates
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/secrets/remote-import/preview
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"query": "stripe",
|
||||
"nextToken": "opaque-provider-token",
|
||||
"pageSize": 50
|
||||
}
|
||||
```
|
||||
|
||||
`query` is optional and is passed to AWS Secrets Manager inventory filtering.
|
||||
Treat it as non-secret metadata because AWS may record list request parameters
|
||||
in CloudTrail. `nextToken` is an opaque AWS cursor; callers must pass it back
|
||||
unchanged and must not synthesize offsets. `pageSize` is optional, defaults to
|
||||
50 in the UI, and is capped at 100.
|
||||
|
||||
Preview uses AWS `ListSecrets` only. It must not call `GetSecretValue` or
|
||||
`BatchGetSecretValue`, must not request `SecretString`, and must not require KMS
|
||||
decrypt. The response contains sanitized metadata for display and conflict
|
||||
decisions:
|
||||
|
||||
```json
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"provider": "aws_secrets_manager",
|
||||
"nextToken": null,
|
||||
"candidates": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"remoteName": "prod/stripe",
|
||||
"name": "prod/stripe",
|
||||
"key": "prod-stripe",
|
||||
"providerVersionRef": null,
|
||||
"providerMetadata": {
|
||||
"createdDate": "2026-05-06T00:00:00.000Z",
|
||||
"lastChangedDate": "2026-05-06T00:00:00.000Z",
|
||||
"hasDescription": true,
|
||||
"hasKmsKey": true,
|
||||
"tagCount": 3
|
||||
},
|
||||
"status": "ready",
|
||||
"importable": true,
|
||||
"conflicts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Candidate statuses:
|
||||
|
||||
- `ready`: the row can be selected for import.
|
||||
- `duplicate`: a Paperclip secret already links the same canonical provider
|
||||
reference for the same provider vault.
|
||||
- `conflict`: the row has a name/key collision or provider guardrail failure.
|
||||
|
||||
Conflict types are `exact_reference`, `name`, `key`, and
|
||||
`provider_guardrail`. AWS refs under Paperclip's own managed namespace are
|
||||
blocked as external references; use the Paperclip-managed secret flow for those
|
||||
resources instead.
|
||||
|
||||
### Import Selected Remote References
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/secrets/remote-import
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"secrets": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"name": "Stripe production key",
|
||||
"key": "stripe-production-key",
|
||||
"description": "Stripe key used by production checkout",
|
||||
"providerVersionRef": null,
|
||||
"providerMetadata": {
|
||||
"createdDate": "2026-05-06T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `secrets` array accepts 1-100 rows. Each row may override the suggested
|
||||
Paperclip `name`, `key`, optional Paperclip `description`,
|
||||
`providerVersionRef`, and sanitized `providerMetadata`. Blank descriptions are
|
||||
stored as `null`; AWS provider descriptions are not copied into Paperclip
|
||||
descriptions. The backend re-checks duplicate refs and name/key conflicts at
|
||||
submit time; a stale preview does not bypass those checks.
|
||||
|
||||
The import response is row-level:
|
||||
|
||||
```json
|
||||
{
|
||||
"providerConfigId": "<aws-vault-uuid>",
|
||||
"provider": "aws_secrets_manager",
|
||||
"importedCount": 1,
|
||||
"skippedCount": 1,
|
||||
"errorCount": 0,
|
||||
"results": [
|
||||
{
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/stripe",
|
||||
"name": "Stripe production key",
|
||||
"key": "stripe-production-key",
|
||||
"status": "imported",
|
||||
"reason": null,
|
||||
"secretId": "<paperclip-secret-id>",
|
||||
"conflicts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Row statuses:
|
||||
|
||||
- `imported`: Paperclip created an active `external_reference` secret and one
|
||||
metadata-only version row.
|
||||
- `skipped`: the row had an exact-reference duplicate or name/key conflict.
|
||||
- `error`: the provider rejected the reference or the row failed validation.
|
||||
|
||||
Activity logs for preview/import store aggregate counts, provider id, and vault
|
||||
id only. They must not store remote secret names, ARNs, descriptions, tags,
|
||||
plaintext values, provider credentials, or raw AWS error blobs.
|
||||
|
||||
## Rotate Secret
|
||||
|
||||
```
|
||||
POST /api/secrets/{secretId}/rotate
|
||||
PATCH /api/secrets/{secretId}
|
||||
{
|
||||
"value": "sk-ant-new-value..."
|
||||
}
|
||||
```
|
||||
|
||||
Creates a new version of the secret. Agents referencing `"version": "latest"`
|
||||
automatically get the new value on next heartbeat. Pin to a specific version
|
||||
when a bad `latest` rollout would affect many agents at once.
|
||||
Creates a new version of the secret. Agents referencing `"version": "latest"` automatically get the new value on next heartbeat.
|
||||
|
||||
## Using Secrets in Agent Config
|
||||
|
||||
@@ -393,20 +52,4 @@ Reference secrets in agent adapter config instead of inline values:
|
||||
}
|
||||
```
|
||||
|
||||
The server resolves and decrypts secret references at runtime, injecting the
|
||||
real value into the agent process environment. Paperclip's custody guarantees
|
||||
end at injection: the agent process can read, log, or forward the value, so
|
||||
treat any secret bound to an agent as exposed to that agent. See the custody
|
||||
boundaries note in the [secrets deploy guide](/deploy/secrets#custody-boundaries).
|
||||
|
||||
## Portability
|
||||
|
||||
Company export/import APIs represent agent and project environment requirements
|
||||
as declarations in the package manifest. Exports omit secret values, secret IDs,
|
||||
provider references, and encrypted provider material. Use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets declarations --company-id {companyId}
|
||||
```
|
||||
|
||||
to inspect the declarations that an export would emit before moving a package.
|
||||
The server resolves and decrypts secret references at runtime, injecting the real value into the agent process environment.
|
||||
|
||||
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 321 KiB |
@@ -57,16 +57,6 @@ pnpm paperclipai context set --api-key-env-var-name PAPERCLIP_API_KEY
|
||||
export PAPERCLIP_API_KEY=...
|
||||
```
|
||||
|
||||
Secret operations are available under `paperclipai secrets`:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
pnpm paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY
|
||||
pnpm paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref>
|
||||
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
```
|
||||
|
||||
Context is stored at `~/.paperclip/context.json`.
|
||||
|
||||
## Command Categories
|
||||
|
||||
@@ -67,8 +67,7 @@ Validates:
|
||||
|
||||
- Server configuration
|
||||
- Database connectivity
|
||||
- Secrets adapter configuration, including AWS Secrets Manager non-secret env
|
||||
config when selected
|
||||
- Secrets adapter configuration
|
||||
- Storage configuration
|
||||
- Missing key files
|
||||
|
||||
@@ -82,13 +81,6 @@ pnpm paperclipai configure --section secrets
|
||||
pnpm paperclipai configure --section storage
|
||||
```
|
||||
|
||||
`--section secrets` updates the deployment-level provider used as the fallback
|
||||
for secrets that do not target a specific company vault. Per-company provider
|
||||
vaults (named instances, default vault selection, multiple vaults per provider,
|
||||
coming-soon GCP/Vault) live in the board UI under
|
||||
`Company Settings → Secrets → Provider vaults` and the
|
||||
`/api/companies/{companyId}/secret-provider-configs` API.
|
||||
|
||||
## `paperclipai env`
|
||||
|
||||
Show resolved environment configuration:
|
||||
|
||||
@@ -5,52 +5,6 @@ summary: Master key, encryption, and strict mode
|
||||
|
||||
Paperclip encrypts secrets at rest using a local master key. Agent environment variables that contain sensitive values (API keys, tokens) are stored as encrypted secret references.
|
||||
|
||||
## Custody Boundaries
|
||||
|
||||
Paperclip protects secret values up to the moment they are handed to an agent
|
||||
or workload:
|
||||
|
||||
- Storage: values are encrypted at rest by the active provider. The local
|
||||
provider keeps them encrypted with a key that never leaves the host.
|
||||
- Transport: values are decrypted server-side and injected into the agent
|
||||
process environment, SSH command env, sandbox driver, or HTTP request
|
||||
immediately before the call. Paperclip does not return decrypted values to
|
||||
the board UI.
|
||||
- Audit: each resolution records a non-sensitive event (secret id, version,
|
||||
provider id, consumer, outcome) without the value or provider credentials.
|
||||
|
||||
Once a value reaches the consuming process, Paperclip can no longer guarantee
|
||||
secrecy. The agent (or sandbox, or remote host) can read the value, write it to
|
||||
its own logs or transcript, or pass it to downstream tools. Treat any secret
|
||||
you bind to an agent as exposed to that agent. Limit blast radius with bindings
|
||||
(only bind what each agent needs), short-lived provider credentials where the
|
||||
provider supports them, and rotation when an agent transcript or downstream
|
||||
system might have captured a value.
|
||||
|
||||
## Using Secrets In Runs
|
||||
|
||||
Creating a company secret does not automatically create an environment variable.
|
||||
You use a secret by binding it into an agent, project, environment, or plugin
|
||||
configuration field that supports secret references.
|
||||
|
||||
For agent and project environment variables:
|
||||
|
||||
1. Create or link the secret in `Company Settings > Secrets`.
|
||||
2. Open the agent's `Environment variables` field, or the project's `Env`
|
||||
field.
|
||||
3. Add the environment variable key the process expects, such as `GH_TOKEN` or
|
||||
`OPENAI_API_KEY`.
|
||||
4. Set the row source to `Secret`, select the stored secret, and choose either
|
||||
`latest` or a pinned version.
|
||||
|
||||
At runtime, Paperclip resolves the selected secret server-side and injects the
|
||||
resolved value under the env key from the binding row. The stored secret name
|
||||
can be human-readable; the binding key is what the agent process receives.
|
||||
|
||||
Project env applies to every issue run in that project. When a project env key
|
||||
matches an agent env key, the project value wins before Paperclip injects its
|
||||
own `PAPERCLIP_*` runtime variables.
|
||||
|
||||
## Default Provider: `local_encrypted`
|
||||
|
||||
Secrets are encrypted with a local master key stored at:
|
||||
@@ -60,13 +14,6 @@ Secrets are encrypted with a local master key stored at:
|
||||
```
|
||||
|
||||
This key is auto-created during onboarding. The key never leaves your machine.
|
||||
Paperclip best-effort enforces `0600` permissions when it creates or loads the
|
||||
key file. `paperclipai doctor` and the provider health API warn when the file is
|
||||
readable by group or other users.
|
||||
|
||||
Back up the key file together with database backups. A database backup without
|
||||
the key cannot decrypt local secrets, and a key backup without the database
|
||||
metadata is not enough to restore named secret versions.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -88,7 +35,6 @@ Validate secrets config:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai doctor
|
||||
pnpm paperclipai secrets doctor --company-id <company-id>
|
||||
```
|
||||
|
||||
### Environment Overrides
|
||||
@@ -109,279 +55,15 @@ PAPERCLIP_SECRETS_STRICT_MODE=true
|
||||
|
||||
Recommended for any deployment beyond local trusted.
|
||||
|
||||
Authenticated deployments default strict mode on unless explicitly overridden by
|
||||
configuration or `PAPERCLIP_SECRETS_STRICT_MODE=false`.
|
||||
|
||||
## External References
|
||||
|
||||
Provider-owned secrets can be linked without copying values into Paperclip by
|
||||
using `managedMode: "external_reference"` plus a provider `externalRef`.
|
||||
Paperclip stores metadata and a non-sensitive fingerprint, never the value.
|
||||
Runtime resolution remains server-side and binding-enforced.
|
||||
|
||||
The built-in AWS, GCP, and Vault provider IDs currently accept external
|
||||
reference metadata, but runtime resolution requires provider configuration in the
|
||||
deployment. Their provider health check reports this as a warning until
|
||||
configured.
|
||||
|
||||
For hosted Paperclip Cloud on AWS, see the AWS Secrets Manager operational
|
||||
contract — required env vars, IAM/KMS scoping, naming and tag conventions, and
|
||||
backup/rotation/incident runbooks — in `doc/SECRETS-AWS-PROVIDER.md`.
|
||||
|
||||
## Provider Vaults
|
||||
|
||||
A *provider vault* is a named, company-scoped configuration that points secret
|
||||
material at one of the supported provider backends. Each company can configure
|
||||
multiple vaults, including more than one vault per provider family, and pick a
|
||||
default vault per family for new secret operations. Existing secrets created
|
||||
before any vault was configured continue to resolve through the deployment-level
|
||||
default provider — no migration is required.
|
||||
|
||||
### Where to configure
|
||||
|
||||
Open `Company Settings → Secrets` in the board UI and switch to the
|
||||
`Provider vaults` tab. From there you can:
|
||||
|
||||
- Create a vault for any supported provider family.
|
||||
- Edit the non-secret config of an existing vault.
|
||||
- Set one ready vault per provider family as the company default.
|
||||
- Disable a vault (a soft delete that keeps audit history).
|
||||
- Run a health check against a vault and read the latest result inline.
|
||||
|
||||
The same operations are exposed under
|
||||
`/api/companies/{companyId}/secret-provider-configs` for automation. See the
|
||||
[secrets API reference](/api/secrets#provider-vaults) for the full route table.
|
||||
|
||||
### Custody Of Provider Credentials
|
||||
|
||||
Provider vaults intentionally store only **non-sensitive** configuration:
|
||||
region, project id, namespace, prefix, KMS key id, mount path, address, and
|
||||
similar routing metadata. The API, UI, and activity log never accept, return,
|
||||
or display provider credential values. Submitting fields with names like
|
||||
`accessKeyId`, `secretAccessKey`, `token`, `password`, `serviceAccountJson`,
|
||||
`privateKey`, `keyFile`, `unsealKey`, or any common credential alias is rejected
|
||||
at validation time.
|
||||
|
||||
That keeps the bootstrap rule from the AWS provider applicable to every
|
||||
provider family: **provider credentials live in deployment infrastructure
|
||||
identity, not in Paperclip company secrets**. Allowed credential sources are
|
||||
workload identity attached to the Paperclip server (instance profile, IRSA, ECS
|
||||
task role), `AWS_PROFILE` / SSO / shared config for local runs, an orchestrator
|
||||
secret store that boots the server, or short-lived shell credentials for local
|
||||
development. Do not paste long-lived API keys into the vault config.
|
||||
|
||||
### Vault Status
|
||||
|
||||
Each vault carries a status that drives what the runtime can do with it:
|
||||
|
||||
| Status | Meaning |
|
||||
|---------------|-----------------------------------------------------------------------------------------------|
|
||||
| `ready` | Selectable for create/rotate/resolve. Eligible to be the default. |
|
||||
| `warning` | Saved config exists but health needs attention (for example missing AWS env). Still selectable. |
|
||||
| `coming_soon` | Visible and editable as draft metadata, but locked out of all runtime operations. |
|
||||
| `disabled` | Soft-deleted. Hidden from the secret create/rotate flow. |
|
||||
|
||||
`gcp_secret_manager` and `vault` are pinned to `coming_soon` until their
|
||||
runtime modules ship. The settings UI lets you save draft configuration for
|
||||
those providers (and surfaces them on the vault list), but secret create,
|
||||
rotate, and resolve calls that target a coming-soon vault fail with a clear
|
||||
runtime-locked error.
|
||||
|
||||
### Default Vault Behavior
|
||||
|
||||
A company can mark **one** ready (or warning) vault per provider family as the
|
||||
default. The secret create and rotate dialogs preselect the default vault for
|
||||
the chosen provider so operators don't have to remember which vault to pick.
|
||||
Coming-soon and disabled vaults cannot be marked default; attempting to do so
|
||||
returns a validation error. Setting a new default automatically clears the
|
||||
previous default for that provider.
|
||||
|
||||
If a secret is created without any `providerConfigId` (no vaults exist yet, or
|
||||
the operator clears the selector), runtime resolution falls back to the
|
||||
deployment-level provider configuration — the same path existing installs use.
|
||||
This keeps secrets created before any provider vault was configured working
|
||||
without migration. Picking the default in the UI is an explicit selection, not
|
||||
a runtime fallback: the create call still sends an explicit `providerConfigId`.
|
||||
|
||||
### Multiple Vaults Per Provider
|
||||
|
||||
Multiple vaults from the same provider family are first-class. Common patterns:
|
||||
|
||||
- Two AWS vaults pointing at different regions or KMS keys for environment
|
||||
separation.
|
||||
- A staging Vault address alongside a production address.
|
||||
- A dedicated GCP project for a single product line while the rest of the
|
||||
company uses another.
|
||||
|
||||
Each vault has its own display name, status, default flag, and health record.
|
||||
Operators choose the vault explicitly when creating or rotating a secret; the
|
||||
default vault is preselected to avoid accidental routing to the wrong account.
|
||||
|
||||
### Per-Vault Health Checks
|
||||
|
||||
`POST /api/secret-provider-configs/{id}/health` runs a provider-specific health
|
||||
probe and stores the result on the vault row. The settings UI exposes the same
|
||||
action and renders the result inline. Health responses include a status,
|
||||
operator-facing message, and structured guidance (such as missing env var
|
||||
names, expected credential sources, and backup reminders). They never include
|
||||
provider credentials or secret values. Coming-soon vaults always return a
|
||||
`runtime_locked` health code and never call into provider modules.
|
||||
|
||||
### Provider-Specific Notes
|
||||
|
||||
**Local encrypted vaults** wrap the existing `local_encrypted` provider. The
|
||||
master key path and rotation guidance described above still applies. A local
|
||||
vault config is mostly bookkeeping plus an explicit acknowledgement that the
|
||||
key file is backed up alongside the database.
|
||||
|
||||
**AWS Secrets Manager vaults** read the per-vault `region`, `namespace`,
|
||||
`secretNamePrefix`, `kmsKeyId`, `ownerTag`, and `environmentTag` to route
|
||||
managed writes and external-reference reads. The vault config supplements (and
|
||||
can override) the deployment-level `PAPERCLIP_SECRETS_AWS_*` env. Bootstrap
|
||||
credentials still come from the AWS SDK default credential chain — see
|
||||
`doc/SECRETS-AWS-PROVIDER.md` for the full IAM and KMS contract.
|
||||
|
||||
**GCP Secret Manager** and **HashiCorp Vault** vaults are coming soon. You can
|
||||
save draft `projectId`, `location`, `namespace`, `address`, and `mountPath`
|
||||
metadata so the company is ready to flip them on when the provider modules
|
||||
ship. Vault `address` values must be origin-only `http(s)://host[:port]` URLs;
|
||||
addresses with embedded credentials, paths, query strings, or fragments are
|
||||
rejected.
|
||||
|
||||
### Remote Import From AWS Vaults
|
||||
|
||||
AWS provider vaults can import existing AWS Secrets Manager entries as
|
||||
Paperclip `external_reference` secrets. This is a metadata-only link: Paperclip
|
||||
stores the AWS ARN/path, a fingerprint/version reference, and binding metadata.
|
||||
It does not read, copy, store, log, or display the remote plaintext secret
|
||||
value during preview or import.
|
||||
|
||||
Operator flow in the board UI:
|
||||
|
||||
1. Open `Company Settings -> Secrets`.
|
||||
2. Confirm at least one AWS provider vault is `ready` or `warning`.
|
||||
3. In the `Secrets` tab, choose `Import from vault`.
|
||||
4. Select an AWS vault, search the remote inventory, and load more pages as
|
||||
needed.
|
||||
5. Check the rows to import, review/edit the Paperclip name and key, then
|
||||
submit.
|
||||
6. Review the result summary for created, skipped, and failed rows.
|
||||
|
||||
The preview list is intentionally paged and search-first. AWS accounts can have
|
||||
large per-Region inventories, and `ListSecrets` returns opaque `NextToken`
|
||||
cursors. Do not expect Paperclip to crawl a whole account in the background;
|
||||
load pages deliberately and retry throttled requests with backoff.
|
||||
|
||||
Remote import exposes AWS secret metadata visible to the Paperclip runtime
|
||||
role, including names/ARNs and safe derived fields such as dates, whether a
|
||||
description or KMS key exists, and tag count. Treat names, ARNs, tags, and
|
||||
search text as operational metadata that may be sensitive. The API and activity
|
||||
log must not store raw descriptions, tags, plaintext values, provider
|
||||
credentials, or raw AWS error blobs.
|
||||
|
||||
Required AWS posture:
|
||||
|
||||
- Preview needs optional `secretsmanager:ListSecrets` permission on
|
||||
`Resource: "*"`. AWS does not support constraining `ListSecrets` to
|
||||
individual secret ARNs or tags as an IAM boundary.
|
||||
- Preview/import must not call `secretsmanager:GetSecretValue`,
|
||||
`secretsmanager:BatchGetSecretValue`, or KMS decrypt.
|
||||
- Runtime resolution of an imported reference still needs
|
||||
`secretsmanager:GetSecretValue` on the selected external ARN/path and KMS
|
||||
decrypt when that secret uses a customer-managed key.
|
||||
- Keep managed create/rotate/delete permissions scoped to the Paperclip
|
||||
deployment prefix. Do not broaden managed write/delete permissions just
|
||||
because import inventory is enabled.
|
||||
|
||||
Safe scoping comes from deployment posture rather than AWS list filtering:
|
||||
dedicated Paperclip runtime roles per environment/account, AWS vaults pointed at
|
||||
the intended account and Region, import-enabled roles only where inventory
|
||||
exposure is acceptable, and board-only access to the import routes. Tags and
|
||||
name filters are search aids, not a permission model.
|
||||
|
||||
If import preview fails:
|
||||
|
||||
- `AccessDenied` or `not authorized`: the runtime role is missing
|
||||
`secretsmanager:ListSecrets`; add the optional inventory statement only if
|
||||
remote import should be enabled for that vault.
|
||||
- Throttling: retry after a short delay and narrow the search before loading
|
||||
more pages.
|
||||
- Invalid cursor: refresh the preview; AWS `NextToken` values are opaque and
|
||||
can expire or become stale.
|
||||
- Runtime resolution failure after import: verify `GetSecretValue` and KMS
|
||||
decrypt scope for the selected external secret. Being visible in inventory is
|
||||
not proof that the runtime role can read the value.
|
||||
|
||||
### Backup And Restore
|
||||
|
||||
Each provider family has a different backup story:
|
||||
|
||||
- `local_encrypted`: back up the local master key file and the Paperclip
|
||||
database together. Either alone is not enough to restore the encrypted
|
||||
values, and the vault row only records the path and acknowledgement, not the
|
||||
key bytes.
|
||||
- `aws_secrets_manager`: back up Paperclip's database for vault metadata
|
||||
(vault id, region, prefix, KMS key id, default flag, bindings, version
|
||||
pointers). The actual secret values live in AWS Secrets Manager under the
|
||||
configured prefix; restore by pointing the same Paperclip company at the
|
||||
same AWS namespace and confirming the runtime role still has
|
||||
`GetSecretValue` plus KMS decrypt. The full restore checklist lives in
|
||||
`doc/SECRETS-AWS-PROVIDER.md`.
|
||||
- `gcp_secret_manager` and `vault`: while these are coming soon, only the
|
||||
draft vault config exists in Paperclip. Database backups capture it. There
|
||||
is nothing to restore on the provider side until runtime support lands.
|
||||
|
||||
### AWS Provider Bootstrap Boundary
|
||||
|
||||
The AWS Secrets Manager provider cannot bootstrap itself from Paperclip
|
||||
`company_secrets`. Its initial AWS access must be present before the server can
|
||||
create or resolve AWS-backed company secrets, regardless of whether you use the
|
||||
deployment-level default or a per-company vault.
|
||||
|
||||
For Paperclip Cloud, provision the server runtime IAM role/workload identity,
|
||||
KMS key, deployment prefix, and non-secret `PAPERCLIP_SECRETS_AWS_*` environment
|
||||
configuration before enabling AWS-backed secrets in the board UI. For
|
||||
self-hosted and local runs, use the AWS SDK default credential chain: instance
|
||||
profile, ECS task role, EKS IRSA/OIDC web identity, AWS SSO/shared config via
|
||||
`AWS_PROFILE`, or short-lived shell credentials for local development.
|
||||
|
||||
Do not store AWS root credentials or long-lived IAM user access keys in
|
||||
Paperclip secrets. Bootstrap material belongs in infrastructure IAM/workload
|
||||
identity, the process environment, an AWS profile, or the orchestrator secret
|
||||
store.
|
||||
|
||||
## Migrating Inline Secrets
|
||||
|
||||
If you have existing agents with inline API keys in their config, migrate them to encrypted secret refs:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id>
|
||||
pnpm paperclipai secrets migrate-inline-env --company-id <company-id> --apply
|
||||
|
||||
# low-level script for direct database maintenance
|
||||
pnpm secrets:migrate-inline-env # dry run
|
||||
pnpm secrets:migrate-inline-env --apply # apply migration
|
||||
```
|
||||
|
||||
Use the CLI command for normal operations because it goes through the Paperclip
|
||||
API, creates or rotates secret records, and updates agent env bindings with
|
||||
audit logging.
|
||||
|
||||
## Portable Declarations
|
||||
|
||||
Company exports include only environment declarations. They do not include
|
||||
secret IDs, provider references, encrypted material, or plaintext values.
|
||||
|
||||
```sh
|
||||
pnpm paperclipai secrets declarations --company-id <company-id> --kind secret
|
||||
```
|
||||
|
||||
Before importing a package into another instance, use those declarations to
|
||||
create local values or link hosted provider references in the target deployment.
|
||||
For hosted providers such as AWS Secrets Manager, the hosted provider remains
|
||||
the value custodian; Paperclip stores metadata and provider version references,
|
||||
not provider credentials or plaintext secret values.
|
||||
|
||||
## Secret References in Agent Config
|
||||
|
||||
Agent environment variables use secret references:
|
||||
|
||||
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 701 KiB |
|
Before Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 546 KiB |
|
Before Width: | Height: | Size: 701 KiB |
|
Before Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 118 KiB |
@@ -15,12 +15,9 @@
|
||||
"build-storybook": "pnpm --filter @paperclipai/ui build-storybook",
|
||||
"build": "pnpm run preflight:workspace-links && pnpm -r build",
|
||||
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
|
||||
"typecheck:build-gaps": "pnpm run preflight:workspace-links && node scripts/run-typecheck-build-gaps.mjs",
|
||||
"test": "pnpm run test:run",
|
||||
"test:watch": "pnpm run preflight:workspace-links && vitest",
|
||||
"test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs",
|
||||
"test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode general",
|
||||
"test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode serialized",
|
||||
"db:generate": "pnpm --filter @paperclipai/db generate",
|
||||
"db:migrate": "pnpm --filter @paperclipai/db migrate",
|
||||
"issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts",
|
||||
@@ -33,22 +30,18 @@
|
||||
"release:stable": "./scripts/release.sh stable",
|
||||
"release:github": "./scripts/create-github-release.sh",
|
||||
"release:rollback": "./scripts/rollback-latest.sh",
|
||||
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
|
||||
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
||||
"docs:dev": "cd docs && npx mintlify dev",
|
||||
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
|
||||
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
|
||||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
|
||||
"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 scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.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",
|
||||
"evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval",
|
||||
"test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts",
|
||||
"test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed",
|
||||
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts",
|
||||
"perf:issue-chat-long-thread": "node scripts/measure-issue-chat-long-thread.mjs"
|
||||
"metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { prepareCommandManagedRuntime } from "./command-managed-runtime.js";
|
||||
import type { RunProcessResult } from "./server-utils.js";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
describe("command managed runtime", () => {
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanupDirs.length > 0) {
|
||||
const dir = cleanupDirs.pop();
|
||||
if (!dir) continue;
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the runtime overlay out of sandbox workspace sync by default", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
||||
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
||||
await mkdir(path.join(localWorkspaceDir, ".paperclip-runtime"), { recursive: true });
|
||||
await mkdir(remoteWorkspaceDir, { recursive: true });
|
||||
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
|
||||
await writeFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "{\"keep\":true}\n", "utf8");
|
||||
|
||||
const calls: Array<{
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
}> = [];
|
||||
const runner = {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<RunProcessResult> => {
|
||||
calls.push({ ...input });
|
||||
const startedAt = new Date().toISOString();
|
||||
const env = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
};
|
||||
const command =
|
||||
input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command;
|
||||
const args = [...(input.args ?? [])];
|
||||
if (
|
||||
input.stdin != null &&
|
||||
(input.command === "sh" || input.command === "bash") &&
|
||||
(args[0] === "-c" || args[0] === "-lc") &&
|
||||
typeof args[1] === "string"
|
||||
) {
|
||||
env.PAPERCLIP_TEST_STDIN = input.stdin;
|
||||
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
it("runs setup commands from a stable root cwd when staging into a nested remote workspace dir", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-nested-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
||||
const remoteBaseDir = path.join(rootDir, "remote-base");
|
||||
const remoteWorkspaceDir = path.join(remoteBaseDir, ".paperclip-runtime", "runs", "test", "workspace");
|
||||
await mkdir(localWorkspaceDir, { recursive: true });
|
||||
await mkdir(remoteBaseDir, { recursive: true });
|
||||
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
|
||||
|
||||
const calls: Array<{
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
}> = [];
|
||||
const runner = {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<RunProcessResult> => {
|
||||
calls.push({ ...input });
|
||||
const startedAt = new Date().toISOString();
|
||||
try {
|
||||
const result = await execFile(input.command === "sh" ? "/bin/sh" : input.command, input.args ?? [], {
|
||||
cwd: input.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...input.env,
|
||||
},
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
timeout: input.timeoutMs,
|
||||
});
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
pid: null,
|
||||
startedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: string | number | null;
|
||||
signal?: NodeJS.Signals | null;
|
||||
killed?: boolean;
|
||||
};
|
||||
return {
|
||||
exitCode: typeof err.code === "number" ? err.code : null,
|
||||
signal: err.signal ?? null,
|
||||
timedOut: Boolean(err.killed && input.timeoutMs),
|
||||
stdout: err.stdout ?? "",
|
||||
stderr: err.stderr ?? "",
|
||||
pid: null,
|
||||
startedAt,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
await prepareCommandManagedRuntime({
|
||||
runner,
|
||||
spec: {
|
||||
remoteCwd: remoteBaseDir,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
adapterKey: "codex",
|
||||
workspaceLocalDir: localWorkspaceDir,
|
||||
workspaceRemoteDir: remoteWorkspaceDir,
|
||||
});
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls.every((call) => call.cwd === "/")).toBe(true);
|
||||
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type SandboxManagedRuntimeClient,
|
||||
type SandboxRemoteExecutionSpec,
|
||||
} from "./sandbox-managed-runtime.js";
|
||||
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
|
||||
import type { RunProcessResult } from "./server-utils.js";
|
||||
|
||||
export interface CommandManagedRuntimeRunner {
|
||||
@@ -24,10 +23,10 @@ export interface CommandManagedRuntimeRunner {
|
||||
|
||||
export interface CommandManagedRuntimeSpec {
|
||||
providerKey?: string | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
leaseId?: string | null;
|
||||
remoteCwd: string;
|
||||
timeoutMs?: number | null;
|
||||
paperclipApiUrl?: string | null;
|
||||
}
|
||||
|
||||
export type CommandManagedRuntimeAsset = SandboxManagedRuntimeAsset;
|
||||
@@ -36,12 +35,6 @@ function shellQuote(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function mergeRuntimeExcludes(entries: string[] | undefined): string[] {
|
||||
return [...new Set([".paperclip-runtime", ...(entries ?? [])])];
|
||||
}
|
||||
|
||||
const REMOTE_WRITE_BASE64_CHUNK_SIZE = 32 * 1024;
|
||||
|
||||
function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
|
||||
if (Buffer.isBuffer(bytes)) return bytes;
|
||||
if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
|
||||
@@ -55,18 +48,16 @@ function requireSuccessfulResult(result: RunProcessResult, action: string): void
|
||||
throw new Error(`${action} failed with exit code ${result.exitCode ?? "null"}${detail}`);
|
||||
}
|
||||
|
||||
export function createCommandManagedRuntimeClient(input: {
|
||||
function createCommandManagedRuntimeClient(input: {
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
commandCwd: string;
|
||||
remoteCwd: string;
|
||||
timeoutMs: number;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
}): SandboxManagedRuntimeClient {
|
||||
const shellCommand = preferredShellForSandbox(input.shellCommand);
|
||||
const runShell = async (script: string, opts: { stdin?: string; timeoutMs?: number } = {}) => {
|
||||
const result = await input.runner.execute({
|
||||
command: shellCommand,
|
||||
args: shellCommandArgs(script),
|
||||
cwd: input.commandCwd,
|
||||
command: "sh",
|
||||
args: ["-lc", script],
|
||||
cwd: input.remoteCwd,
|
||||
stdin: opts.stdin,
|
||||
timeoutMs: opts.timeoutMs ?? input.timeoutMs,
|
||||
});
|
||||
@@ -80,53 +71,29 @@ export function createCommandManagedRuntimeClient(input: {
|
||||
},
|
||||
writeFile: async (remotePath, bytes) => {
|
||||
const body = toBuffer(bytes).toString("base64");
|
||||
const remoteDir = path.posix.dirname(remotePath);
|
||||
const remoteTempPath = `${remotePath}.paperclip-upload.b64`;
|
||||
|
||||
await runShell(
|
||||
`mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(remoteTempPath)} && : > ${shellQuote(remoteTempPath)}`,
|
||||
);
|
||||
for (let offset = 0; offset < body.length; offset += REMOTE_WRITE_BASE64_CHUNK_SIZE) {
|
||||
const chunk = body.slice(offset, offset + REMOTE_WRITE_BASE64_CHUNK_SIZE);
|
||||
await runShell(`printf '%s' ${shellQuote(chunk)} >> ${shellQuote(remoteTempPath)}`);
|
||||
}
|
||||
await runShell(
|
||||
`base64 -d < ${shellQuote(remoteTempPath)} > ${shellQuote(remotePath)} && rm -f ${shellQuote(remoteTempPath)}`,
|
||||
`mkdir -p ${shellQuote(path.posix.dirname(remotePath))} && base64 -d > ${shellQuote(remotePath)}`,
|
||||
{ stdin: body },
|
||||
);
|
||||
},
|
||||
readFile: async (remotePath) => {
|
||||
const result = await runShell(`base64 < ${shellQuote(remotePath)}`);
|
||||
return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64");
|
||||
},
|
||||
listFiles: async (remotePath) => {
|
||||
const result = await runShell(
|
||||
`if [ -d ${shellQuote(remotePath)} ]; then ` +
|
||||
`for entry in ${shellQuote(remotePath)}/*; do ` +
|
||||
`[ -f "$entry" ] || continue; ` +
|
||||
`basename "$entry"; ` +
|
||||
`done; ` +
|
||||
`fi`,
|
||||
);
|
||||
return result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
},
|
||||
remove: async (remotePath) => {
|
||||
const result = await input.runner.execute({
|
||||
command: shellCommand,
|
||||
args: shellCommandArgs(`rm -rf ${shellQuote(remotePath)}`),
|
||||
cwd: input.commandCwd,
|
||||
command: "sh",
|
||||
args: ["-lc", `rm -rf ${shellQuote(remotePath)}`],
|
||||
cwd: input.remoteCwd,
|
||||
timeoutMs: input.timeoutMs,
|
||||
});
|
||||
requireSuccessfulResult(result, `remove ${remotePath}`);
|
||||
},
|
||||
run: async (command, options) => {
|
||||
const result = await input.runner.execute({
|
||||
command: shellCommand,
|
||||
args: shellCommandArgs(command),
|
||||
cwd: input.commandCwd,
|
||||
command: "sh",
|
||||
args: ["-lc", command],
|
||||
cwd: input.remoteCwd,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
requireSuccessfulResult(result, command);
|
||||
@@ -144,15 +111,9 @@ export async function prepareCommandManagedRuntime(input: {
|
||||
preserveAbsentOnRestore?: string[];
|
||||
assets?: CommandManagedRuntimeAsset[];
|
||||
installCommand?: string | null;
|
||||
/** When provided alongside `installCommand`, skip the install if `command -v <detectCommand>` succeeds. */
|
||||
detectCommand?: string | null;
|
||||
}): Promise<PreparedSandboxManagedRuntime> {
|
||||
const timeoutMs = input.spec.timeoutMs && input.spec.timeoutMs > 0 ? input.spec.timeoutMs : 300_000;
|
||||
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
|
||||
// Managed-runtime sync/restore scripts use absolute paths throughout, so
|
||||
// run them from a stable cwd. The target workspace itself may be removed or
|
||||
// recreated during a run, which breaks shell startup if we chdir into it.
|
||||
const commandCwd = "/";
|
||||
const runtimeSpec: SandboxRemoteExecutionSpec = {
|
||||
transport: "sandbox",
|
||||
provider: input.spec.providerKey ?? "sandbox",
|
||||
@@ -160,62 +121,22 @@ export async function prepareCommandManagedRuntime(input: {
|
||||
remoteCwd: workspaceRemoteDir,
|
||||
timeoutMs,
|
||||
apiKey: null,
|
||||
paperclipApiUrl: input.spec.paperclipApiUrl ?? null,
|
||||
};
|
||||
const client = createCommandManagedRuntimeClient({
|
||||
runner: input.runner,
|
||||
commandCwd,
|
||||
remoteCwd: workspaceRemoteDir,
|
||||
timeoutMs,
|
||||
shellCommand: input.spec.shellCommand,
|
||||
});
|
||||
const shellCommand = preferredShellForSandbox(input.spec.shellCommand);
|
||||
|
||||
if (input.installCommand?.trim()) {
|
||||
const installCommand = input.installCommand.trim();
|
||||
const detectCommand = input.detectCommand?.trim();
|
||||
// Skip the install when the binary is already on PATH. Without this
|
||||
// probe the install runs unconditionally on every execute() call (and
|
||||
// also runs a second time after `ensureAdapterExecutionTargetCommandResolvable`
|
||||
// has already installed it during the resolvability gate).
|
||||
if (detectCommand) {
|
||||
const probe = await input.runner.execute({
|
||||
command: shellCommand,
|
||||
args: shellCommandArgs(`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`),
|
||||
cwd: commandCwd,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!probe.timedOut && (probe.exitCode ?? 1) === 0) {
|
||||
return await prepareSandboxManagedRuntime({
|
||||
spec: runtimeSpec,
|
||||
client,
|
||||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
workspaceRemoteDir,
|
||||
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
|
||||
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
||||
assets: input.assets,
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await input.runner.execute({
|
||||
command: shellCommand,
|
||||
args: shellCommandArgs(installCommand),
|
||||
cwd: commandCwd,
|
||||
command: "sh",
|
||||
args: ["-lc", input.installCommand.trim()],
|
||||
cwd: workspaceRemoteDir,
|
||||
timeoutMs,
|
||||
});
|
||||
// A failed install is not always fatal: the CLI may already be on PATH
|
||||
// from a previous lease, the template image, or another path entry. Log
|
||||
// and continue rather than aborting the agent run; downstream code that
|
||||
// exec's the CLI will surface a clear "command not found" if it is in
|
||||
// fact missing. The test path's `maybeRunSandboxInstallCommand` already
|
||||
// honors this contract — keep them consistent.
|
||||
if (result.timedOut || (result.exitCode ?? 0) !== 0) {
|
||||
const tail = (text: string) =>
|
||||
text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-3).join(" | ").slice(0, 480);
|
||||
const reason = result.timedOut ? "timed out" : `exited ${result.exitCode ?? "?"}`;
|
||||
console.warn(
|
||||
`[paperclip] managed-runtime install command ${reason}: ${installCommand} :: ${tail(result.stderr || result.stdout)}`,
|
||||
);
|
||||
}
|
||||
requireSuccessfulResult(result, input.installCommand.trim());
|
||||
}
|
||||
|
||||
return await prepareSandboxManagedRuntime({
|
||||
@@ -224,7 +145,7 @@ export async function prepareCommandManagedRuntime(input: {
|
||||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
workspaceRemoteDir,
|
||||
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
|
||||
workspaceExclude: input.workspaceExclude,
|
||||
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
||||
assets: input.assets,
|
||||
});
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***";
|
||||
|
||||
const COMMAND_CLI_SECRET_OPTION_RE =
|
||||
/(\B-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\s+|=)(["']?))[^\s"'`]+(\2)/gi;
|
||||
const COMMAND_ENV_SECRET_ASSIGNMENT_RE =
|
||||
/(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi;
|
||||
const COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi;
|
||||
const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
|
||||
const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g;
|
||||
const COMMAND_JWT_RE =
|
||||
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g;
|
||||
|
||||
export function redactCommandText(command: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string {
|
||||
return command
|
||||
.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`)
|
||||
.replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`)
|
||||
.replace(COMMAND_ENV_SECRET_ASSIGNMENT_RE, `$1${redactedValue}`)
|
||||
.replace(COMMAND_OPENAI_KEY_RE, redactedValue)
|
||||
.replace(COMMAND_GITHUB_TOKEN_RE, redactedValue)
|
||||
.replace(COMMAND_JWT_RE, redactedValue);
|
||||
}
|
||||
@@ -1,65 +1,14 @@
|
||||
import { createServer } from "node:http";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
||||
adapterExecutionTargetSessionIdentity,
|
||||
adapterExecutionTargetToRemoteSpec,
|
||||
adapterExecutionTargetUsesPaperclipBridge,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
resolveAdapterExecutionTargetTimeoutSec,
|
||||
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 () => {
|
||||
vi.unstubAllEnvs();
|
||||
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;
|
||||
const command = input.command === "bash" ? "/bin/bash" : input.command;
|
||||
return runChildProcess(`sandbox-run-${counter}`, command, input.args ?? [], {
|
||||
cwd: input.cwd ?? process.cwd(),
|
||||
env: input.env ?? {},
|
||||
stdin: input.stdin,
|
||||
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 () => ({
|
||||
@@ -112,89 +61,6 @@ describe("sandbox adapter execution targets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => {
|
||||
const sandboxTarget: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
remoteCwd: "/workspace",
|
||||
runner: createLocalSandboxRunner(),
|
||||
};
|
||||
|
||||
expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 0)).toBe(
|
||||
DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
||||
);
|
||||
expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 90)).toBe(90);
|
||||
expect(resolveAdapterExecutionTargetTimeoutSec({
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
remoteCwd: "/workspace",
|
||||
spec: {
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/workspace",
|
||||
remoteCwd: "/workspace",
|
||||
privateKey: "KEY",
|
||||
knownHosts: "host key",
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
}, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the caller timeout override when installing a missing sandbox command", async () => {
|
||||
const runner = {
|
||||
execute: vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "/usr/bin/opencode\n",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
remoteCwd: "/workspace",
|
||||
timeoutMs: 300_000,
|
||||
runner,
|
||||
};
|
||||
|
||||
await ensureAdapterExecutionTargetCommandResolvable(
|
||||
"opencode",
|
||||
target,
|
||||
"/local/workspace",
|
||||
{},
|
||||
{ installCommand: "npm install -g opencode", timeoutSec: 1800 },
|
||||
);
|
||||
|
||||
expect(runner.execute).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
command: "sh",
|
||||
args: ["-c", "npm install -g opencode"],
|
||||
timeoutMs: 1_800_000,
|
||||
}));
|
||||
});
|
||||
|
||||
it("runs shell commands through the same runner", async () => {
|
||||
const runner = {
|
||||
execute: vi.fn(async () => ({
|
||||
@@ -222,359 +88,9 @@ describe("sandbox adapter execution targets", () => {
|
||||
|
||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: "sh",
|
||||
args: ["-c", 'printf %s "$HOME"'],
|
||||
args: ["-lc", 'printf %s "$HOME"'],
|
||||
cwd: "/workspace",
|
||||
timeoutMs: 7000,
|
||||
}));
|
||||
});
|
||||
|
||||
it("strips inherited host identity env before sandbox execution", async () => {
|
||||
vi.stubEnv("PATH", "/host/bin:/usr/bin");
|
||||
vi.stubEnv("HOME", "/Users/local");
|
||||
vi.stubEnv("TMPDIR", "/var/folders/local/T");
|
||||
|
||||
const runner = {
|
||||
execute: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "ok\n",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
remoteCwd: "/workspace",
|
||||
runner,
|
||||
};
|
||||
|
||||
await runAdapterExecutionTargetProcess("run-1b", target, "agent-cli", ["--json"], {
|
||||
cwd: "/local/workspace",
|
||||
env: {
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/Users/local",
|
||||
TMPDIR: "/var/folders/local/T",
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
timeoutSec: 5,
|
||||
graceSec: 1,
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
env: {
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("preserves explicit remote identity env overrides for sandbox execution", async () => {
|
||||
vi.stubEnv("PATH", "/host/bin:/usr/bin");
|
||||
vi.stubEnv("HOME", "/Users/local");
|
||||
|
||||
const runner = {
|
||||
execute: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "ok\n",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
remoteCwd: "/workspace",
|
||||
runner,
|
||||
};
|
||||
|
||||
await runAdapterExecutionTargetProcess("run-1c", target, "agent-cli", ["--json"], {
|
||||
cwd: "/local/workspace",
|
||||
env: {
|
||||
PATH: "/custom/remote/bin:/usr/bin",
|
||||
HOME: "/home/sandbox",
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
timeoutSec: 5,
|
||||
graceSec: 1,
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
env: {
|
||||
PATH: "/custom/remote/bin:/usr/bin",
|
||||
HOME: "/home/sandbox",
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it("treats SSH targets as bridge-only", () => {
|
||||
const target = {
|
||||
kind: "remote" as const,
|
||||
transport: "ssh" as const,
|
||||
remoteCwd: "/workspace",
|
||||
spec: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "paperclip",
|
||||
remoteWorkspacePath: "/workspace",
|
||||
remoteCwd: "/workspace",
|
||||
privateKey: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(adapterExecutionTargetUsesPaperclipBridge(target)).toBe(true);
|
||||
expect(adapterExecutionTargetSessionIdentity(target)).toEqual({
|
||||
transport: "ssh",
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "paperclip",
|
||||
remoteCwd: "/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the provider-declared shell for sandbox helper commands", async () => {
|
||||
const runner = {
|
||||
execute: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "/home/sandbox",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "custom-provider",
|
||||
shellCommand: "bash",
|
||||
remoteCwd: "/workspace",
|
||||
runner,
|
||||
};
|
||||
|
||||
await runAdapterExecutionTargetShellCommand("run-2b", target, 'printf %s "$HOME"', {
|
||||
cwd: "/local/workspace",
|
||||
env: {},
|
||||
timeoutSec: 7,
|
||||
});
|
||||
|
||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: "bash",
|
||||
args: ["-c", 'printf %s "$HOME"'],
|
||||
cwd: "/workspace",
|
||||
timeoutMs: 7000,
|
||||
}));
|
||||
});
|
||||
|
||||
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,
|
||||
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("uses the effective adapter timeout when starting the sandbox callback bridge", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-timeout-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const remoteCwd = path.join(rootDir, "workspace");
|
||||
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
|
||||
await mkdir(runtimeRootDir, { recursive: true });
|
||||
|
||||
const delegateRunner = createLocalSandboxRunner();
|
||||
const runner = {
|
||||
execute: vi.fn(async (input: Parameters<typeof delegateRunner.execute>[0]) => delegateRunner.execute(input)),
|
||||
};
|
||||
const apiServer = createServer((req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
apiServer.once("error", reject);
|
||||
apiServer.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = apiServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected the bridge timeout test API server to listen on a TCP port.");
|
||||
}
|
||||
|
||||
const target: AdapterSandboxExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "cloudflare",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd,
|
||||
runner,
|
||||
timeoutMs: 30_000,
|
||||
};
|
||||
|
||||
const bridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||
runId: "run-bridge-timeout",
|
||||
target,
|
||||
runtimeRootDir,
|
||||
adapterKey: "codex",
|
||||
timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC,
|
||||
hostApiToken: "real-run-jwt",
|
||||
hostApiUrl: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
try {
|
||||
expect(bridge).not.toBeNull();
|
||||
expect(runner.execute).toHaveBeenCalled();
|
||||
expect(runner.execute.mock.calls.some(([input]) => input.timeoutMs === 1_800_000)).toBe(true);
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("fails oversized host responses with a 502 before returning them to the sandbox client", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-limit-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
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,
|
||||
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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as ssh from "./ssh.js";
|
||||
import * as serverUtils from "./server-utils.js";
|
||||
import {
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
runAdapterExecutionTargetProcess,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
} from "./execution-target.js";
|
||||
|
||||
describe("runAdapterExecutionTargetShellCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("quotes remote shell commands with the shared SSH quoting helper", async () => {
|
||||
@@ -45,68 +40,16 @@ describe("runAdapterExecutionTargetShellCommand", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// runSshCommand owns profile sourcing and the outer shell wrapper —
|
||||
// the caller passes the raw command string. Wrapping it here would
|
||||
// double-nest the login shell and re-source profiles after the explicit
|
||||
// env override, silently undoing identity-var preservation.
|
||||
expect(runSshCommandSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: "ssh.example.test",
|
||||
username: "ssh-user",
|
||||
}),
|
||||
`printf '%s\\n' "$HOME" && echo "it's ok"`,
|
||||
`sh -lc ${ssh.shellQuote(`printf '%s\\n' "$HOME" && echo "it's ok"`)}`,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes inherited host env before SSH shell execution", async () => {
|
||||
vi.stubEnv("PATH", "/host/bin:/usr/bin");
|
||||
vi.stubEnv("HOME", "/Users/local");
|
||||
|
||||
const runSshCommandSpy = vi.spyOn(ssh, "runSshCommand").mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
await runAdapterExecutionTargetShellCommand(
|
||||
"run-1b",
|
||||
{
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
remoteCwd: "/srv/paperclip/workspace",
|
||||
spec: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteCwd: "/srv/paperclip/workspace",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
"env",
|
||||
{
|
||||
cwd: "/tmp/local",
|
||||
env: {
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/Users/local",
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(runSshCommandSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a timedOut result when the SSH shell command times out", async () => {
|
||||
vi.spyOn(ssh, "runSshCommand").mockRejectedValue(Object.assign(new Error("timed out"), {
|
||||
code: "ETIMEDOUT",
|
||||
@@ -216,188 +159,3 @@ describe("runAdapterExecutionTargetShellCommand", () => {
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAdapterExecutionTargetProcess", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("sanitizes inherited host env before SSH process execution", async () => {
|
||||
vi.stubEnv("PATH", "/host/bin:/usr/bin");
|
||||
vi.stubEnv("HOME", "/Users/local");
|
||||
|
||||
const runChildProcessSpy = vi.spyOn(serverUtils, "runChildProcess").mockResolvedValue({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await runAdapterExecutionTargetProcess(
|
||||
"run-ssh-process",
|
||||
{
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
remoteCwd: "/srv/paperclip/workspace",
|
||||
spec: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteCwd: "/srv/paperclip/workspace",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
"agent-cli",
|
||||
["--json"],
|
||||
{
|
||||
cwd: "/tmp/local",
|
||||
env: {
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/Users/local",
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
timeoutSec: 5,
|
||||
graceSec: 1,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(runChildProcessSpy).toHaveBeenCalledWith(
|
||||
"run-ssh-process",
|
||||
"agent-cli",
|
||||
["--json"],
|
||||
expect.objectContaining({
|
||||
env: {
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureAdapterExecutionTargetRuntimeCommandInstalled", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("runs install commands for sandbox targets", async () => {
|
||||
const runner = {
|
||||
execute: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId: "run-install",
|
||||
target: {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "e2b",
|
||||
remoteCwd: "/remote/workspace",
|
||||
runner,
|
||||
},
|
||||
installCommand: "npm install -g @google/gemini-cli",
|
||||
cwd: "/local/workspace",
|
||||
env: { PATH: "/usr/bin" },
|
||||
timeoutSec: 30,
|
||||
});
|
||||
|
||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: "sh",
|
||||
args: ["-c", "npm install -g @google/gemini-cli"],
|
||||
cwd: "/remote/workspace",
|
||||
env: { PATH: "/usr/bin" },
|
||||
timeoutMs: 30_000,
|
||||
}));
|
||||
});
|
||||
|
||||
it("skips install commands for SSH targets", async () => {
|
||||
const runSshCommandSpy = vi.spyOn(ssh, "runSshCommand").mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
||||
runId: "run-skip",
|
||||
target: {
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
remoteCwd: "/srv/paperclip/workspace",
|
||||
spec: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteCwd: "/srv/paperclip/workspace",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
installCommand: "npm install -g @google/gemini-cli",
|
||||
cwd: "/tmp/local",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(runSshCommandSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAdapterExecutionTargetCwd", () => {
|
||||
const sshTarget = {
|
||||
kind: "remote" as const,
|
||||
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,15 +10,7 @@ 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 { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
|
||||
import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
|
||||
import {
|
||||
ensureCommandResolvable,
|
||||
resolveCommandForLogs,
|
||||
@@ -26,8 +18,6 @@ import {
|
||||
type RunProcessResult,
|
||||
type TerminalResultCleanupOptions,
|
||||
} from "./server-utils.js";
|
||||
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
|
||||
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
|
||||
|
||||
export interface AdapterLocalExecutionTarget {
|
||||
kind: "local";
|
||||
@@ -41,6 +31,7 @@ export interface AdapterSshExecutionTarget {
|
||||
environmentId?: string | null;
|
||||
leaseId?: string | null;
|
||||
remoteCwd: string;
|
||||
paperclipApiUrl?: string | null;
|
||||
spec: SshRemoteExecutionSpec;
|
||||
}
|
||||
|
||||
@@ -48,10 +39,10 @@ export interface AdapterSandboxExecutionTarget {
|
||||
kind: "remote";
|
||||
transport: "sandbox";
|
||||
providerKey?: string | null;
|
||||
shellCommand?: "bash" | "sh" | null;
|
||||
environmentId?: string | null;
|
||||
leaseId?: string | null;
|
||||
remoteCwd: string;
|
||||
paperclipApiUrl?: string | null;
|
||||
timeoutMs?: number | null;
|
||||
runner?: CommandManagedRuntimeRunner;
|
||||
}
|
||||
@@ -67,7 +58,6 @@ export type AdapterManagedRuntimeAsset = RemoteManagedRuntimeAsset;
|
||||
|
||||
export interface PreparedAdapterExecutionTargetRuntime {
|
||||
target: AdapterExecutionTarget;
|
||||
workspaceRemoteDir: string | null;
|
||||
runtimeRootDir: string | null;
|
||||
assetDirs: Record<string, string>;
|
||||
restoreWorkspace(): Promise<void>;
|
||||
@@ -92,15 +82,6 @@ export interface AdapterExecutionTargetShellOptions {
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface AdapterExecutionTargetPaperclipBridgeHandle {
|
||||
env: Record<string, string>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
|
||||
|
||||
export const DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC = 1_800;
|
||||
|
||||
function parseObject(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
@@ -115,27 +96,6 @@ function readStringMeta(parsed: Record<string, unknown>, key: string): string |
|
||||
return readString(parsed[key]);
|
||||
}
|
||||
|
||||
function resolveHostForUrl(rawHost: string): string {
|
||||
const host = rawHost.trim();
|
||||
if (!host || host === "0.0.0.0" || host === "::") return "localhost";
|
||||
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`;
|
||||
return host;
|
||||
}
|
||||
|
||||
function resolveDefaultPaperclipApiUrl(): string {
|
||||
const runtimeHost = resolveHostForUrl(
|
||||
process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost",
|
||||
);
|
||||
// 3100 matches the default Paperclip dev server port when the runtime does not provide one.
|
||||
const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100";
|
||||
return `http://${runtimeHost}:${runtimePort}`;
|
||||
}
|
||||
|
||||
function isBridgeDebugEnabled(env: NodeJS.ProcessEnv): boolean {
|
||||
const value = env.PAPERCLIP_BRIDGE_DEBUG?.trim().toLowerCase();
|
||||
return value === "1" || value === "true" || value === "yes";
|
||||
}
|
||||
|
||||
function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget {
|
||||
const parsed = parseObject(value);
|
||||
if (parsed.kind === "local") return true;
|
||||
@@ -170,48 +130,12 @@ export function adapterExecutionTargetRemoteCwd(
|
||||
return target?.kind === "remote" ? target.remoteCwd : localCwd;
|
||||
}
|
||||
|
||||
export function overrideAdapterExecutionTargetRemoteCwd(
|
||||
export function adapterExecutionTargetPaperclipApiUrl(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
remoteCwd: string | null | undefined,
|
||||
): AdapterExecutionTarget | null | undefined {
|
||||
const nextRemoteCwd = remoteCwd?.trim();
|
||||
if (!target || target.kind !== "remote" || !nextRemoteCwd) {
|
||||
return target;
|
||||
}
|
||||
if (target.remoteCwd === nextRemoteCwd) {
|
||||
return target;
|
||||
}
|
||||
if (target.transport === "ssh") {
|
||||
return {
|
||||
...target,
|
||||
remoteCwd: nextRemoteCwd,
|
||||
spec: {
|
||||
...target.spec,
|
||||
remoteCwd: nextRemoteCwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...target,
|
||||
remoteCwd: nextRemoteCwd,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAdapterExecutionTargetCwd(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
configuredCwd: string | null | undefined,
|
||||
localFallbackCwd: string,
|
||||
): string {
|
||||
if (typeof configuredCwd === "string" && configuredCwd.trim().length > 0) {
|
||||
return configuredCwd;
|
||||
}
|
||||
return adapterExecutionTargetRemoteCwd(target, localFallbackCwd);
|
||||
}
|
||||
|
||||
export function adapterExecutionTargetUsesPaperclipBridge(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
): boolean {
|
||||
return target?.kind === "remote";
|
||||
): string | null {
|
||||
if (target?.kind !== "remote") return null;
|
||||
if (target.transport === "ssh") return target.paperclipApiUrl ?? target.spec.paperclipApiUrl ?? null;
|
||||
return target.paperclipApiUrl ?? null;
|
||||
}
|
||||
|
||||
export function describeAdapterExecutionTarget(
|
||||
@@ -224,26 +148,6 @@ export function describeAdapterExecutionTarget(
|
||||
return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`;
|
||||
}
|
||||
|
||||
export function resolveAdapterExecutionTargetTimeoutSec(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
configuredTimeoutSec: number | null | undefined,
|
||||
): number {
|
||||
const normalizedConfiguredTimeoutSec =
|
||||
typeof configuredTimeoutSec === "number" && Number.isFinite(configuredTimeoutSec) && configuredTimeoutSec > 0
|
||||
? Math.floor(configuredTimeoutSec)
|
||||
: 0;
|
||||
if (normalizedConfiguredTimeoutSec > 0) return normalizedConfiguredTimeoutSec;
|
||||
// Local and SSH adapters preserve the historical "0 means no adapter
|
||||
// timeout" behavior. Sandbox-backed runs execute through provider RPCs
|
||||
// that usually apply their own shorter command defaults, so request an
|
||||
// explicit longer timeout for full adapter runs when the adapter leaves
|
||||
// timeoutSec unset.
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
return DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner {
|
||||
if (target.runner) return target.runner;
|
||||
throw new Error(
|
||||
@@ -251,47 +155,13 @@ function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandMan
|
||||
);
|
||||
}
|
||||
|
||||
function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | "sh" {
|
||||
return preferredShellForSandbox(target.shellCommand);
|
||||
}
|
||||
|
||||
type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget;
|
||||
|
||||
function adapterExecutionTargetCommandRunner(target: AdapterCommandCapableExecutionTarget): CommandManagedRuntimeRunner {
|
||||
if (target.transport === "ssh") {
|
||||
return createSshCommandManagedRuntimeRunner({
|
||||
spec: target.spec,
|
||||
defaultCwd: target.remoteCwd,
|
||||
maxBufferBytes: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4,
|
||||
});
|
||||
}
|
||||
return requireSandboxRunner(target);
|
||||
}
|
||||
|
||||
function adapterExecutionTargetShellCommand(target: AdapterCommandCapableExecutionTarget): "bash" | "sh" {
|
||||
return target.transport === "ssh" ? "sh" : preferredSandboxShell(target);
|
||||
}
|
||||
|
||||
function adapterExecutionTargetTimeoutMs(
|
||||
target: AdapterCommandCapableExecutionTarget,
|
||||
): number | null | undefined {
|
||||
return target.transport === "sandbox" ? target.timeoutMs : undefined;
|
||||
}
|
||||
|
||||
export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
command: string,
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: { installCommand?: string | null; timeoutSec?: number | null } = {},
|
||||
) {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
await ensureSandboxCommandResolvable(
|
||||
command,
|
||||
target,
|
||||
options.installCommand?.trim() || null,
|
||||
options.timeoutSec,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ensureCommandResolvable(command, cwd, env, {
|
||||
@@ -299,87 +169,6 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
});
|
||||
}
|
||||
|
||||
async function probeSandboxCommandResolvable(
|
||||
command: string,
|
||||
target: AdapterSandboxExecutionTarget,
|
||||
): Promise<{ resolved: boolean; timedOut: boolean; stderr: string }> {
|
||||
const runner = requireSandboxRunner(target);
|
||||
const probeScript = `command -v ${shellQuote(command)}`;
|
||||
const result = await runner.execute({
|
||||
command: "sh",
|
||||
args: ["-c", probeScript],
|
||||
cwd: target.remoteCwd,
|
||||
timeoutMs: target.timeoutMs ?? 15_000,
|
||||
});
|
||||
return {
|
||||
resolved: !result.timedOut && (result.exitCode ?? 1) === 0,
|
||||
timedOut: result.timedOut,
|
||||
stderr: result.stderr.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSandboxCommandResolvable(
|
||||
command: string,
|
||||
target: AdapterSandboxExecutionTarget,
|
||||
installCommand: string | null,
|
||||
timeoutSec?: number | null,
|
||||
): Promise<void> {
|
||||
// Probe whether the binary is resolvable inside the sandbox. We previously
|
||||
// short-circuited this for sandbox targets, which let the caller report a
|
||||
// success message even when the CLI was missing from the image. Now we run
|
||||
// a real `command -v` through the same runner the hello probe will use, so
|
||||
// the first step honestly reflects whether the binary is on PATH. The
|
||||
// sandbox provider is responsible for sourcing login profiles (e2b mirrors
|
||||
// SSH's buildSshSpawnTarget) so this and the hello probe agree on PATH.
|
||||
let probe = await probeSandboxCommandResolvable(command, target);
|
||||
if (probe.resolved) return;
|
||||
if (probe.timedOut) {
|
||||
throw new Error(`Timed out checking command "${command}" on sandbox target.`);
|
||||
}
|
||||
|
||||
// If the caller supplied an install command, attempt the install once via
|
||||
// the sandbox runner (which the sandbox provider wraps in a login shell)
|
||||
// and re-probe before reporting failure. This lets fresh sandbox leases
|
||||
// bring up the CLI before the resolvability gate, mirroring the test path.
|
||||
let installFailureDetail: string | null = null;
|
||||
if (installCommand) {
|
||||
const runner = requireSandboxRunner(target);
|
||||
const installTimeoutMs =
|
||||
typeof timeoutSec === "number" && Number.isFinite(timeoutSec) && timeoutSec > 0
|
||||
? Math.floor(timeoutSec * 1000)
|
||||
: target.timeoutMs ?? 300_000;
|
||||
try {
|
||||
const installResult = await runner.execute({
|
||||
command: "sh",
|
||||
args: shellCommandArgs(installCommand),
|
||||
cwd: target.remoteCwd,
|
||||
timeoutMs: installTimeoutMs,
|
||||
});
|
||||
if (installResult.timedOut) {
|
||||
installFailureDetail = `install command timed out: ${installCommand}`;
|
||||
} else if ((installResult.exitCode ?? 0) !== 0) {
|
||||
const tail = (text: string) =>
|
||||
text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-2).join(" | ").slice(0, 240);
|
||||
const reason = tail(installResult.stderr || installResult.stdout) || `exit ${installResult.exitCode ?? "?"}`;
|
||||
installFailureDetail = `install command exited ${installResult.exitCode ?? "?"}: ${reason}`;
|
||||
}
|
||||
} catch (err) {
|
||||
installFailureDetail = `install command threw: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
probe = await probeSandboxCommandResolvable(command, target);
|
||||
if (probe.resolved) return;
|
||||
if (probe.timedOut) {
|
||||
throw new Error(`Timed out checking command "${command}" on sandbox target.`);
|
||||
}
|
||||
}
|
||||
|
||||
const probeStderr = probe.stderr.length > 0 ? ` probe stderr: ${probe.stderr}` : "";
|
||||
const installDetail = installFailureDetail ? `; ${installFailureDetail}` : "";
|
||||
throw new Error(
|
||||
`Command "${command}" is not installed or not on PATH in the sandbox environment${installDetail}.${probeStderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveAdapterExecutionTargetCommandForLogs(
|
||||
command: string,
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
@@ -403,12 +192,11 @@ export async function runAdapterExecutionTargetProcess(
|
||||
): Promise<RunProcessResult> {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
const runner = requireSandboxRunner(target);
|
||||
const env = sanitizeRemoteExecutionEnv(options.env);
|
||||
return await runner.execute({
|
||||
command,
|
||||
args,
|
||||
cwd: target.remoteCwd,
|
||||
env,
|
||||
env: options.env,
|
||||
stdin: options.stdin,
|
||||
timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined,
|
||||
onLog: options.onLog,
|
||||
@@ -418,14 +206,9 @@ export async function runAdapterExecutionTargetProcess(
|
||||
});
|
||||
}
|
||||
|
||||
const env =
|
||||
target?.kind === "remote" && target.transport === "ssh"
|
||||
? sanitizeRemoteExecutionEnv(options.env)
|
||||
: options.env;
|
||||
|
||||
return await runChildProcess(runId, command, args, {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
env: options.env,
|
||||
stdin: options.stdin,
|
||||
timeoutSec: options.timeoutSec,
|
||||
graceSec: options.graceSec,
|
||||
@@ -445,16 +228,9 @@ export async function runAdapterExecutionTargetShellCommand(
|
||||
const onLog = options.onLog ?? (async () => {});
|
||||
if (target?.kind === "remote") {
|
||||
const startedAt = new Date().toISOString();
|
||||
const env = sanitizeRemoteExecutionEnv(options.env);
|
||||
if (target.transport === "ssh") {
|
||||
try {
|
||||
// Pass the raw command — `runSshCommand` owns profile sourcing and
|
||||
// the outer shell wrapper. Wrapping again here would nest a second
|
||||
// shell after the explicit `env KEY=VAL` overrides, re-sourcing
|
||||
// login profiles AFTER the override and silently undoing any
|
||||
// identity var (NVM_DIR / PATH / etc.) that a profile re-exports.
|
||||
const result = await runSshCommand(target.spec, command, {
|
||||
env,
|
||||
const result = await runSshCommand(target.spec, `sh -lc ${shellQuote(command)}`, {
|
||||
timeoutMs: (options.timeoutSec ?? 15) * 1000,
|
||||
});
|
||||
if (result.stdout) await onLog("stdout", result.stdout);
|
||||
@@ -506,12 +282,11 @@ export async function runAdapterExecutionTargetShellCommand(
|
||||
}
|
||||
}
|
||||
|
||||
const shellCommand = preferredSandboxShell(target);
|
||||
return await requireSandboxRunner(target).execute({
|
||||
command: shellCommand,
|
||||
args: shellCommandArgs(command),
|
||||
command: "sh",
|
||||
args: ["-lc", command],
|
||||
cwd: target.remoteCwd,
|
||||
env,
|
||||
env: options.env,
|
||||
timeoutMs: (options.timeoutSec ?? 15) * 1000,
|
||||
onLog,
|
||||
});
|
||||
@@ -532,111 +307,6 @@ export async function runAdapterExecutionTargetShellCommand(
|
||||
);
|
||||
}
|
||||
|
||||
export interface AdapterSandboxInstallCommandCheck {
|
||||
code: string;
|
||||
level: "info" | "warn" | "error";
|
||||
message: string;
|
||||
detail?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
// Best-effort run of an adapter-supplied install command on a sandbox target
|
||||
// before the resolvability + hello probe. Returns null for non-sandbox
|
||||
// targets so callers can no-op. Returns a structured check otherwise — never
|
||||
// throws — so the rest of the test still runs and reports the post-install
|
||||
// state honestly. Caller pushes the check into its result array; the test
|
||||
// report shows whether install was attempted and what came back.
|
||||
export async function maybeRunSandboxInstallCommand(input: {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
adapterKey: string;
|
||||
installCommand: string;
|
||||
/** When provided, skip the install if `command -v <detectCommand>` succeeds. */
|
||||
detectCommand?: string | null;
|
||||
env?: Record<string, string>;
|
||||
timeoutSec?: number;
|
||||
}): Promise<AdapterSandboxInstallCommandCheck | null> {
|
||||
const { target, adapterKey, installCommand } = input;
|
||||
if (!target || target.kind !== "remote" || target.transport !== "sandbox") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = installCommand.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
const code = `${adapterKey}_install_command_run`;
|
||||
|
||||
// Skip install when the binary is already on PATH. Avoids running
|
||||
// network-dependent installers (e.g. `curl ... | bash`) on every test
|
||||
// probe when the CLI is preinstalled on the lease/template.
|
||||
const detectCommand = input.detectCommand?.trim();
|
||||
if (detectCommand) {
|
||||
try {
|
||||
const probe = await runAdapterExecutionTargetShellCommand(
|
||||
input.runId,
|
||||
target,
|
||||
`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`,
|
||||
{
|
||||
cwd: target.remoteCwd,
|
||||
env: input.env ?? {},
|
||||
timeoutSec: 30,
|
||||
graceSec: 5,
|
||||
},
|
||||
);
|
||||
if (!probe.timedOut && probe.exitCode === 0) {
|
||||
return {
|
||||
code,
|
||||
level: "info",
|
||||
message: `${detectCommand} already on PATH; skipped install.`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to actually running the install — failure to probe
|
||||
// is not a reason to skip the install gate.
|
||||
}
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await runAdapterExecutionTargetShellCommand(input.runId, target, trimmed, {
|
||||
cwd: target.remoteCwd,
|
||||
env: input.env ?? {},
|
||||
timeoutSec: input.timeoutSec ?? 240,
|
||||
graceSec: 10,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
code,
|
||||
level: "warn",
|
||||
message: "Install command threw before completion.",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
const tail = (text: string) =>
|
||||
text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-3).join(" | ").slice(0, 480);
|
||||
if (result.timedOut) {
|
||||
return {
|
||||
code,
|
||||
level: "warn",
|
||||
message: `Install command timed out: ${trimmed}`,
|
||||
detail: tail(result.stderr || result.stdout),
|
||||
};
|
||||
}
|
||||
if ((result.exitCode ?? 1) === 0) {
|
||||
return {
|
||||
code,
|
||||
level: "info",
|
||||
message: `Install command ran: ${trimmed}`,
|
||||
...(tail(result.stdout) ? { detail: tail(result.stdout) } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
code,
|
||||
level: "warn",
|
||||
message: `Install command exited ${result.exitCode}: ${trimmed}`,
|
||||
detail: tail(result.stderr || result.stdout),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readAdapterExecutionTargetHomeDir(
|
||||
runId: string,
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
@@ -652,91 +322,6 @@ export async function readAdapterExecutionTargetHomeDir(
|
||||
return homeDir.length > 0 ? homeDir : null;
|
||||
}
|
||||
|
||||
export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input: {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
installCommand?: string | null;
|
||||
detectCommand?: string | null;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec?: number;
|
||||
graceSec?: number;
|
||||
onLog?: AdapterExecutionTargetShellOptions["onLog"];
|
||||
}): Promise<void> {
|
||||
const installCommand = input.installCommand?.trim();
|
||||
if (!installCommand || input.target?.kind !== "remote" || input.target.transport !== "sandbox") {
|
||||
return;
|
||||
}
|
||||
|
||||
const detectCommand = input.detectCommand?.trim();
|
||||
if (detectCommand) {
|
||||
const probe = await runAdapterExecutionTargetShellCommand(
|
||||
input.runId,
|
||||
input.target,
|
||||
`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`,
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: input.timeoutSec,
|
||||
graceSec: input.graceSec,
|
||||
},
|
||||
);
|
||||
if (!probe.timedOut && probe.exitCode === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runAdapterExecutionTargetShellCommand(
|
||||
input.runId,
|
||||
input.target,
|
||||
installCommand,
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: input.timeoutSec,
|
||||
graceSec: input.graceSec,
|
||||
onLog: input.onLog,
|
||||
},
|
||||
);
|
||||
|
||||
// A failed or timed-out install is not necessarily fatal: the CLI may already
|
||||
// be on PATH from a previous lease's install, the template image, or another
|
||||
// path entry. Re-run the detect probe (when one is configured) so a transient
|
||||
// install failure does not abort the agent run when the binary is reachable.
|
||||
const installFailed = result.timedOut || (result.exitCode ?? 0) !== 0;
|
||||
if (!installFailed) {
|
||||
return;
|
||||
}
|
||||
if (detectCommand) {
|
||||
const recheck = await runAdapterExecutionTargetShellCommand(
|
||||
input.runId,
|
||||
input.target,
|
||||
`command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`,
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: input.timeoutSec,
|
||||
graceSec: input.graceSec,
|
||||
},
|
||||
);
|
||||
if (!recheck.timedOut && recheck.exitCode === 0) {
|
||||
if (input.onLog) {
|
||||
const reason = result.timedOut ? "timed out" : `exited ${result.exitCode ?? "?"}`;
|
||||
await input.onLog(
|
||||
"stderr",
|
||||
`[paperclip] Install command ${reason} (${installCommand}) but ${detectCommand} is on PATH; continuing.\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.timedOut) {
|
||||
throw new Error(`Timed out while installing the adapter runtime command via: ${installCommand}`);
|
||||
}
|
||||
throw new Error(`Failed to install the adapter runtime command via: ${installCommand}`);
|
||||
}
|
||||
|
||||
export async function ensureAdapterExecutionTargetFile(
|
||||
runId: string,
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
@@ -751,64 +336,6 @@ 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 {
|
||||
@@ -820,6 +347,7 @@ export function adapterExecutionTargetSessionIdentity(
|
||||
environmentId: target.environmentId ?? null,
|
||||
leaseId: target.leaseId ?? null,
|
||||
remoteCwd: target.remoteCwd,
|
||||
...(target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -838,7 +366,8 @@ export function adapterExecutionTargetSessionMatches(
|
||||
readStringMeta(parsedSaved, "providerKey") === current?.providerKey &&
|
||||
readStringMeta(parsedSaved, "environmentId") === current?.environmentId &&
|
||||
readStringMeta(parsedSaved, "leaseId") === current?.leaseId &&
|
||||
readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd
|
||||
readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd &&
|
||||
readStringMeta(parsedSaved, "paperclipApiUrl") === (current?.paperclipApiUrl ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -863,6 +392,7 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
|
||||
environmentId: readStringMeta(parsed, "environmentId"),
|
||||
leaseId: readStringMeta(parsed, "leaseId"),
|
||||
remoteCwd: spec.remoteCwd,
|
||||
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl") ?? spec.paperclipApiUrl ?? null,
|
||||
spec,
|
||||
};
|
||||
}
|
||||
@@ -877,6 +407,7 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
|
||||
environmentId: readStringMeta(parsed, "environmentId"),
|
||||
leaseId: readStringMeta(parsed, "leaseId"),
|
||||
remoteCwd,
|
||||
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl"),
|
||||
timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null,
|
||||
};
|
||||
}
|
||||
@@ -897,6 +428,7 @@ export function adapterExecutionTargetFromRemoteExecution(
|
||||
environmentId: metadata.environmentId ?? null,
|
||||
leaseId: metadata.leaseId ?? null,
|
||||
remoteCwd: ssh.remoteCwd,
|
||||
paperclipApiUrl: ssh.paperclipApiUrl ?? null,
|
||||
spec: ssh,
|
||||
};
|
||||
}
|
||||
@@ -918,24 +450,18 @@ export function readAdapterExecutionTarget(input: {
|
||||
}
|
||||
|
||||
export async function prepareAdapterExecutionTargetRuntime(input: {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
adapterKey: string;
|
||||
workspaceLocalDir: string;
|
||||
timeoutSec?: number;
|
||||
workspaceRemoteDir?: string;
|
||||
workspaceExclude?: string[];
|
||||
preserveAbsentOnRestore?: string[];
|
||||
assets?: AdapterManagedRuntimeAsset[];
|
||||
installCommand?: string | null;
|
||||
/** When provided alongside `installCommand`, skip the install if the binary is already on PATH. */
|
||||
detectCommand?: string | null;
|
||||
}): Promise<PreparedAdapterExecutionTargetRuntime> {
|
||||
const target = input.target ?? { kind: "local" as const };
|
||||
if (target.kind === "local") {
|
||||
return {
|
||||
target,
|
||||
workspaceRemoteDir: null,
|
||||
runtimeRootDir: null,
|
||||
assetDirs: {},
|
||||
restoreWorkspace: async () => {},
|
||||
@@ -945,15 +471,12 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
|
||||
if (target.transport === "ssh") {
|
||||
const prepared = await prepareRemoteManagedRuntime({
|
||||
spec: target.spec,
|
||||
runId: input.runId,
|
||||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
workspaceRemoteDir: input.workspaceRemoteDir,
|
||||
assets: input.assets,
|
||||
});
|
||||
return {
|
||||
target,
|
||||
workspaceRemoteDir: prepared.workspaceRemoteDir,
|
||||
runtimeRootDir: prepared.runtimeRootDir,
|
||||
assetDirs: prepared.assetDirs,
|
||||
restoreWorkspace: prepared.restoreWorkspace,
|
||||
@@ -964,26 +487,20 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
|
||||
runner: requireSandboxRunner(target),
|
||||
spec: {
|
||||
providerKey: target.providerKey,
|
||||
shellCommand: target.shellCommand,
|
||||
leaseId: target.leaseId,
|
||||
remoteCwd: target.remoteCwd,
|
||||
timeoutMs:
|
||||
input.timeoutSec && input.timeoutSec > 0
|
||||
? input.timeoutSec * 1000
|
||||
: target.timeoutMs,
|
||||
timeoutMs: target.timeoutMs,
|
||||
paperclipApiUrl: target.paperclipApiUrl,
|
||||
},
|
||||
adapterKey: input.adapterKey,
|
||||
workspaceLocalDir: input.workspaceLocalDir,
|
||||
workspaceRemoteDir: input.workspaceRemoteDir,
|
||||
workspaceExclude: input.workspaceExclude,
|
||||
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
|
||||
assets: input.assets,
|
||||
installCommand: input.installCommand,
|
||||
detectCommand: input.detectCommand,
|
||||
});
|
||||
return {
|
||||
target,
|
||||
workspaceRemoteDir: prepared.workspaceRemoteDir,
|
||||
runtimeRootDir: prepared.runtimeRootDir,
|
||||
assetDirs: prepared.assetDirs,
|
||||
restoreWorkspace: prepared.restoreWorkspace,
|
||||
@@ -997,200 +514,3 @@ export function runtimeAssetDir(
|
||||
): string {
|
||||
return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key);
|
||||
}
|
||||
|
||||
function buildBridgeResponseHeaders(response: Response): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of ["content-type", "etag", "last-modified"]) {
|
||||
const value = response.headers.get(key);
|
||||
if (value && value.trim().length > 0) out[key] = value.trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildBridgeForwardUrl(baseUrl: string, request: { path: string; query: string }): URL {
|
||||
const url = new URL(request.path, baseUrl);
|
||||
const query = request.query.trim();
|
||||
url.search = query.startsWith("?") ? query.slice(1) : query;
|
||||
return url;
|
||||
}
|
||||
|
||||
function bridgeResponseBodyLimitError(maxBodyBytes: number): Error {
|
||||
return new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`);
|
||||
}
|
||||
|
||||
async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: number): Promise<string> {
|
||||
const rawContentLength = response.headers.get("content-length");
|
||||
if (rawContentLength) {
|
||||
const contentLength = Number.parseInt(rawContentLength, 10);
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
||||
throw bridgeResponseBodyLimitError(maxBodyBytes);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > maxBodyBytes) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw bridgeResponseBodyLimitError(maxBodyBytes);
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, totalBytes).toString("utf8");
|
||||
}
|
||||
|
||||
export async function startAdapterExecutionTargetPaperclipBridge(input: {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
runtimeRootDir: string | null | undefined;
|
||||
adapterKey: string;
|
||||
timeoutSec?: number | null;
|
||||
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") {
|
||||
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();
|
||||
const shellCommand = adapterExecutionTargetShellCommand(target);
|
||||
const runner = adapterExecutionTargetCommandRunner(target);
|
||||
const bridgeTimeoutMs =
|
||||
typeof input.timeoutSec === "number" && Number.isFinite(input.timeoutSec) && input.timeoutSec > 0
|
||||
? Math.trunc(input.timeoutSec * 1000)
|
||||
: adapterExecutionTargetTimeoutMs(target);
|
||||
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[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,
|
||||
remoteCwd: target.remoteCwd,
|
||||
timeoutMs: bridgeTimeoutMs,
|
||||
shellCommand,
|
||||
});
|
||||
// PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge
|
||||
// proxy request/response. The query string is logged verbatim, so callers
|
||||
// who pass auth tokens or other sensitive values as query parameters
|
||||
// should be aware those values appear in the host process's stdout when
|
||||
// this flag is enabled. Only intended for active debugging in trusted
|
||||
// environments.
|
||||
const bridgeDebugEnabled = isBridgeDebugEnabled(process.env);
|
||||
worker = await startSandboxCallbackBridgeWorker({
|
||||
client,
|
||||
queueDir,
|
||||
maxBodyBytes,
|
||||
handleRequest: async (request) => {
|
||||
const method = request.method.trim().toUpperCase() || "GET";
|
||||
if (bridgeDebugEnabled) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Bridge proxy ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
||||
);
|
||||
}
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(request.headers)) {
|
||||
if (value.trim().length === 0) continue;
|
||||
headers.set(key, value);
|
||||
}
|
||||
headers.set("authorization", `Bearer ${hostApiToken}`);
|
||||
headers.set("x-paperclip-run-id", input.runId);
|
||||
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), {
|
||||
method,
|
||||
headers,
|
||||
...(method === "GET" || method === "HEAD" ? {} : { body: request.body }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (bridgeDebugEnabled) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
headers: buildBridgeResponseHeaders(response),
|
||||
body: await readBridgeForwardResponseBody(response, maxBodyBytes),
|
||||
};
|
||||
},
|
||||
});
|
||||
server = await startSandboxCallbackBridgeServer({
|
||||
runner,
|
||||
remoteCwd: target.remoteCwd,
|
||||
assetRemoteDir,
|
||||
queueDir,
|
||||
bridgeToken,
|
||||
bridgeAsset,
|
||||
timeoutMs: bridgeTimeoutMs,
|
||||
maxBodyBytes,
|
||||
shellCommand,
|
||||
});
|
||||
} 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(),
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,14 +20,11 @@ export type {
|
||||
AdapterSkillContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
AdapterModelProfileKey,
|
||||
AdapterModelProfileDefinition,
|
||||
HireApprovedPayload,
|
||||
HireApprovedHookResult,
|
||||
ConfigFieldOption,
|
||||
ConfigFieldSchema,
|
||||
AdapterConfigSchema,
|
||||
AdapterRuntimeCommandSpec,
|
||||
ServerAdapterModule,
|
||||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
@@ -56,21 +53,4 @@ export {
|
||||
redactHomePathUserSegmentsInValue,
|
||||
redactTranscriptEntryPaths,
|
||||
} from "./log-redaction.js";
|
||||
export {
|
||||
REDACTED_COMMAND_TEXT_VALUE,
|
||||
redactCommandText,
|
||||
} from "./command-redaction.js";
|
||||
export { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js";
|
||||
export { inferOpenAiCompatibleBiller } from "./billing.js";
|
||||
// Keep the root adapter-utils entry browser-safe because the UI imports it.
|
||||
// The sandbox callback bridge stays available via its dedicated subpath export.
|
||||
export type {
|
||||
SandboxCallbackBridgeRequest,
|
||||
SandboxCallbackBridgeResponse,
|
||||
SandboxCallbackBridgeAsset,
|
||||
SandboxCallbackBridgeDirectories,
|
||||
SandboxCallbackBridgeRouteRule,
|
||||
SandboxCallbackBridgeQueueClient,
|
||||
SandboxCallbackBridgeWorkerHandle,
|
||||
StartedSandboxCallbackBridgeServer,
|
||||
} from "./sandbox-callback-bridge.js";
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
const REMOTE_EXECUTION_ENV_IDENTITY_KEYS = new Set([
|
||||
"PATH",
|
||||
"HOME",
|
||||
"PWD",
|
||||
"SHELL",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"NVM_DIR",
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
]);
|
||||
|
||||
function readEnvValueCaseInsensitive(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
||||
const direct = env[key];
|
||||
if (typeof direct === "string") return direct;
|
||||
const upper = key.toUpperCase();
|
||||
for (const [candidateKey, candidateValue] of Object.entries(env)) {
|
||||
if (candidateKey.toUpperCase() === upper && typeof candidateValue === "string") {
|
||||
return candidateValue;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function sanitizeRemoteExecutionEnv(
|
||||
env: Record<string, string>,
|
||||
inheritedEnv: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const normalizedKey = key.toUpperCase();
|
||||
if (!REMOTE_EXECUTION_ENV_IDENTITY_KEYS.has(normalizedKey)) {
|
||||
sanitized[key] = value;
|
||||
continue;
|
||||
}
|
||||
const inheritedValue = readEnvValueCaseInsensitive(inheritedEnv, key);
|
||||
if (typeof inheritedValue === "string" && inheritedValue === value) {
|
||||
continue;
|
||||
}
|
||||
sanitized[key] = value;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
restoreWorkspaceFromSshExecution,
|
||||
syncDirectoryToSsh,
|
||||
} from "./ssh.js";
|
||||
import { captureDirectorySnapshot } from "./workspace-restore-merge.js";
|
||||
|
||||
export interface RemoteManagedRuntimeAsset {
|
||||
key: string;
|
||||
@@ -45,6 +44,7 @@ export function buildRemoteExecutionSessionIdentity(spec: SshRemoteExecutionSpec
|
||||
port: spec.port,
|
||||
username: spec.username,
|
||||
remoteCwd: spec.remoteCwd,
|
||||
...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -58,37 +58,26 @@ export function remoteExecutionSessionMatches(saved: unknown, current: SshRemote
|
||||
asString(parsedSaved.host) === currentIdentity.host &&
|
||||
asNumber(parsedSaved.port) === currentIdentity.port &&
|
||||
asString(parsedSaved.username) === currentIdentity.username &&
|
||||
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd
|
||||
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd &&
|
||||
asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl)
|
||||
);
|
||||
}
|
||||
|
||||
export async function prepareRemoteManagedRuntime(input: {
|
||||
spec: SshRemoteExecutionSpec;
|
||||
runId: string;
|
||||
adapterKey: string;
|
||||
workspaceLocalDir: string;
|
||||
workspaceRemoteDir?: string;
|
||||
assets?: RemoteManagedRuntimeAsset[];
|
||||
}): Promise<PreparedRemoteManagedRuntime> {
|
||||
const baseWorkspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
|
||||
const workspaceRemoteDir = path.posix.join(
|
||||
baseWorkspaceRemoteDir,
|
||||
".paperclip-runtime",
|
||||
"runs",
|
||||
input.runId,
|
||||
"workspace",
|
||||
);
|
||||
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
|
||||
const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey);
|
||||
|
||||
const preparedWorkspace = await prepareWorkspaceForSshExecution({
|
||||
await prepareWorkspaceForSshExecution({
|
||||
spec: input.spec,
|
||||
localDir: input.workspaceLocalDir,
|
||||
remoteDir: workspaceRemoteDir,
|
||||
});
|
||||
const restoreExclude = preparedWorkspace.gitBacked ? [".git", ".paperclip-runtime"] : [".paperclip-runtime"];
|
||||
const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, {
|
||||
exclude: restoreExclude,
|
||||
});
|
||||
|
||||
const assetDirs: Record<string, string> = {};
|
||||
try {
|
||||
@@ -108,8 +97,6 @@ export async function prepareRemoteManagedRuntime(input: {
|
||||
spec: input.spec,
|
||||
localDir: input.workspaceLocalDir,
|
||||
remoteDir: workspaceRemoteDir,
|
||||
baselineSnapshot,
|
||||
restoreGitHistory: preparedWorkspace.gitBacked,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -125,8 +112,6 @@ export async function prepareRemoteManagedRuntime(input: {
|
||||
spec: input.spec,
|
||||
localDir: input.workspaceLocalDir,
|
||||
remoteDir: workspaceRemoteDir,
|
||||
baselineSnapshot,
|
||||
restoreGitHistory: preparedWorkspace.gitBacked,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,983 +0,0 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { prepareCommandManagedRuntime } from "./command-managed-runtime.js";
|
||||
import {
|
||||
authorizeSandboxCallbackBridgeRequestWithRoutes,
|
||||
createCommandManagedSandboxCallbackBridgeQueueClient,
|
||||
createFileSystemSandboxCallbackBridgeQueueClient,
|
||||
createSandboxCallbackBridgeAsset,
|
||||
createSandboxCallbackBridgeToken,
|
||||
sandboxCallbackBridgeDirectories,
|
||||
syncSandboxCallbackBridgeEntrypoint,
|
||||
startSandboxCallbackBridgeServer,
|
||||
startSandboxCallbackBridgeWorker,
|
||||
} from "./sandbox-callback-bridge.js";
|
||||
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 === "bash" ? "/bin/bash" : input.command;
|
||||
const args = [...(input.args ?? [])];
|
||||
if (
|
||||
input.stdin != null &&
|
||||
(input.command === "sh" || input.command === "bash") &&
|
||||
(args[0] === "-c" || args[0] === "-lc") &&
|
||||
typeof args[1] === "string"
|
||||
) {
|
||||
env.PAPERCLIP_TEST_STDIN = input.stdin;
|
||||
args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`;
|
||||
}
|
||||
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("handles SSH queue polling failures without emitting an unhandled rejection", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-ssh-failure-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
const queueDir = path.posix.join(rootDir, "queue");
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandledRejection = (reason: unknown) => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
process.on("unhandledRejection", onUnhandledRejection);
|
||||
|
||||
try {
|
||||
const worker = await startSandboxCallbackBridgeWorker({
|
||||
client: {
|
||||
makeDir: async () => {},
|
||||
listJsonFiles: async () => {
|
||||
throw new Error(
|
||||
"list /remote/.paperclip-runtime/gemini/paperclip-bridge/queue/requests failed with exit code 255: kex_exchange_identification: read: Connection reset by peer",
|
||||
);
|
||||
},
|
||||
readTextFile: async () => {
|
||||
throw new Error("unexpected readTextFile");
|
||||
},
|
||||
writeTextFile: async () => {
|
||||
throw new Error("unexpected writeTextFile");
|
||||
},
|
||||
rename: async () => {
|
||||
throw new Error("unexpected rename");
|
||||
},
|
||||
remove: async () => {},
|
||||
},
|
||||
queueDir,
|
||||
authorizeRequest: async () => null,
|
||||
handleRequest: async () => ({
|
||||
status: 200,
|
||||
body: "ok",
|
||||
}),
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await worker.stop();
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandledRejection);
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes remote response writes so stop does not recreate a late orphaned response", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-response-lock-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
const localWorkspaceDir = path.join(rootDir, "local-workspace");
|
||||
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
||||
await mkdir(localWorkspaceDir, { recursive: true });
|
||||
await mkdir(remoteWorkspaceDir, { recursive: true });
|
||||
await writeFile(path.join(localWorkspaceDir, "README.md"), "bridge response lock test\n", "utf8");
|
||||
|
||||
const runner = createExecRunner();
|
||||
const bridgeAsset = await createSandboxCallbackBridgeAsset();
|
||||
cleanupFns.push(bridgeAsset.cleanup);
|
||||
const prepared = await prepareCommandManagedRuntime({
|
||||
runner,
|
||||
spec: {
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
},
|
||||
adapterKey: "codex",
|
||||
workspaceLocalDir: localWorkspaceDir,
|
||||
assets: [{ key: "bridge", localDir: bridgeAsset.localDir }],
|
||||
});
|
||||
|
||||
const queueDir = path.posix.join(prepared.runtimeRootDir, "paperclip-bridge");
|
||||
const directories = sandboxCallbackBridgeDirectories(queueDir);
|
||||
const bridgeToken = createSandboxCallbackBridgeToken();
|
||||
const seenRequestIds: string[] = [];
|
||||
|
||||
const worker = await startSandboxCallbackBridgeWorker({
|
||||
client: createCommandManagedSandboxCallbackBridgeQueueClient({
|
||||
runner,
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
queueDir,
|
||||
authorizeRequest: async () => null,
|
||||
handleRequest: async (request) => {
|
||||
seenRequestIds.push(request.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ok: true, id: request.id }),
|
||||
};
|
||||
},
|
||||
});
|
||||
cleanupFns.push(async () => {
|
||||
await worker.stop();
|
||||
});
|
||||
|
||||
const bridge = await startSandboxCallbackBridgeServer({
|
||||
runner,
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
assetRemoteDir: prepared.assetDirs.bridge,
|
||||
queueDir,
|
||||
bridgeToken,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
cleanupFns.push(async () => {
|
||||
await bridge.stop();
|
||||
});
|
||||
|
||||
const responsePromise = fetch(`${bridge.baseUrl}/api/agents/me`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${bridgeToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 50 && seenRequestIds.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
expect(seenRequestIds).toHaveLength(1);
|
||||
await worker.stop({ drainTimeoutMs: 10 });
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.status).toBe(503);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "Bridge worker stopped before request could be handled.",
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
await expect(readdir(directories.responsesDir)).resolves.toEqual([]);
|
||||
await expect(
|
||||
readdir(directories.responsesDir).then((entries) =>
|
||||
entries.filter((entry) => entry.endsWith(".tmp") || entry.includes(".paperclip-write.lock")),
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects non-JSON request bodies and full queues at the bridge server", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-server-guards-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
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),
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses an already-uploaded bridge entrypoint when the remote file hash matches", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-sync-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
||||
const remoteAssetDir = path.posix.join(
|
||||
remoteWorkspaceDir,
|
||||
".paperclip-runtime",
|
||||
"codex",
|
||||
"paperclip-bridge",
|
||||
"server",
|
||||
);
|
||||
await mkdir(remoteWorkspaceDir, { recursive: true });
|
||||
|
||||
const bridgeAsset = await createSandboxCallbackBridgeAsset();
|
||||
cleanupFns.push(bridgeAsset.cleanup);
|
||||
const originalSource = await readFile(bridgeAsset.entrypoint, "utf8");
|
||||
const expandedSource = `${originalSource}\n// bridge payload padding\n`;
|
||||
await writeFile(bridgeAsset.entrypoint, expandedSource, "utf8");
|
||||
|
||||
const runner = createExecRunner();
|
||||
|
||||
const first = await syncSandboxCallbackBridgeEntrypoint({
|
||||
runner,
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
assetRemoteDir: remoteAssetDir,
|
||||
bridgeAsset,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const second = await syncSandboxCallbackBridgeEntrypoint({
|
||||
runner,
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
assetRemoteDir: remoteAssetDir,
|
||||
bridgeAsset,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(first.uploaded).toBe(true);
|
||||
expect(second.uploaded).toBe(false);
|
||||
await expect(readFile(path.posix.join(remoteAssetDir, "paperclip-bridge-server.mjs"), "utf8")).resolves.toBe(expandedSource);
|
||||
await expect(
|
||||
readdir(remoteAssetDir).then((entries) =>
|
||||
entries.filter(
|
||||
(entry) =>
|
||||
entry.endsWith(".paperclip-upload.b64") ||
|
||||
entry.endsWith(".partial") ||
|
||||
entry === ".paperclip-bridge-upload.lock",
|
||||
),
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a corrupted bridge entrypoint upload without committing a torn remote file", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-sync-corrupt-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
|
||||
const remoteAssetDir = path.posix.join(
|
||||
remoteWorkspaceDir,
|
||||
".paperclip-runtime",
|
||||
"codex",
|
||||
"paperclip-bridge",
|
||||
"server",
|
||||
);
|
||||
await mkdir(remoteWorkspaceDir, { recursive: true });
|
||||
|
||||
const bridgeAsset = await createSandboxCallbackBridgeAsset();
|
||||
cleanupFns.push(bridgeAsset.cleanup);
|
||||
const runner = {
|
||||
execute: async (input: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
stdin?: string;
|
||||
timeoutMs?: number;
|
||||
}) =>
|
||||
await createExecRunner().execute({
|
||||
...input,
|
||||
stdin: input.stdin != null ? "" : input.stdin,
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
syncSandboxCallbackBridgeEntrypoint({
|
||||
runner,
|
||||
remoteCwd: remoteWorkspaceDir,
|
||||
assetRemoteDir: remoteAssetDir,
|
||||
bridgeAsset,
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).rejects.toThrow(/sha mismatch/i);
|
||||
|
||||
await expect(readFile(path.posix.join(remoteAssetDir, "paperclip-bridge-server.mjs"), "utf8")).rejects.toThrow();
|
||||
await expect(
|
||||
readdir(remoteAssetDir).then((entries) =>
|
||||
entries.filter(
|
||||
(entry) =>
|
||||
entry.endsWith(".paperclip-upload.b64") ||
|
||||
entry.endsWith(".partial") ||
|
||||
entry === ".paperclip-bridge-upload.lock",
|
||||
),
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("permits the documented heartbeat surface and denies unrelated routes", () => {
|
||||
const allowed: Array<{ method: string; path: string }> = [
|
||||
{ method: "GET", path: "/api/agents/me" },
|
||||
{ method: "GET", path: "/api/agents/me/inbox-lite" },
|
||||
{ method: "GET", path: "/api/agents/me/inbox/mine" },
|
||||
{ method: "GET", path: "/api/agents/agent-1" },
|
||||
{ method: "GET", path: "/api/agents/agent-1/skills" },
|
||||
{ method: "POST", path: "/api/agents/agent-1/skills/sync" },
|
||||
{ method: "PATCH", path: "/api/agents/agent-1/instructions-path" },
|
||||
{ method: "GET", path: "/api/companies/co-1" },
|
||||
{ method: "GET", path: "/api/companies/co-1/dashboard" },
|
||||
{ method: "GET", path: "/api/companies/co-1/agents" },
|
||||
{ method: "GET", path: "/api/companies/co-1/issues" },
|
||||
{ method: "GET", path: "/api/companies/co-1/projects" },
|
||||
{ method: "GET", path: "/api/companies/co-1/goals" },
|
||||
{ method: "GET", path: "/api/companies/co-1/org" },
|
||||
{ method: "GET", path: "/api/companies/co-1/approvals" },
|
||||
{ method: "GET", path: "/api/companies/co-1/routines" },
|
||||
{ method: "GET", path: "/api/companies/co-1/skills" },
|
||||
{ method: "GET", path: "/api/projects/proj-1" },
|
||||
{ method: "GET", path: "/api/goals/goal-1" },
|
||||
{ method: "GET", path: "/api/issues/issue-1" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/heartbeat-context" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/comments" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/comments/c-1" },
|
||||
{ method: "POST", path: "/api/issues/issue-1/comments" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/documents" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/documents/plan" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/documents/plan/revisions" },
|
||||
{ method: "PUT", path: "/api/issues/issue-1/documents/plan" },
|
||||
{ method: "POST", path: "/api/issues/issue-1/checkout" },
|
||||
{ method: "POST", path: "/api/issues/issue-1/release" },
|
||||
{ method: "PATCH", path: "/api/issues/issue-1" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/approvals" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/interactions" },
|
||||
{ method: "GET", path: "/api/issues/issue-1/interactions/inter-1" },
|
||||
{ method: "POST", path: "/api/issues/issue-1/interactions" },
|
||||
{ method: "POST", path: "/api/issues/issue-1/interactions/inter-1/accept" },
|
||||
{ method: "POST", path: "/api/issues/issue-1/interactions/inter-1/reject" },
|
||||
{ method: "POST", path: "/api/issues/issue-1/interactions/inter-1/respond" },
|
||||
{ method: "POST", path: "/api/companies/co-1/issues" },
|
||||
{ method: "GET", path: "/api/approvals/ap-1" },
|
||||
{ method: "GET", path: "/api/approvals/ap-1/issues" },
|
||||
{ method: "GET", path: "/api/approvals/ap-1/comments" },
|
||||
{ method: "POST", path: "/api/approvals/ap-1/comments" },
|
||||
{ method: "POST", path: "/api/companies/co-1/approvals" },
|
||||
{ method: "GET", path: "/api/execution-workspaces/ws-1" },
|
||||
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/start" },
|
||||
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/stop" },
|
||||
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/restart" },
|
||||
{ method: "GET", path: "/api/routines/r-1" },
|
||||
{ method: "GET", path: "/api/routines/r-1/runs" },
|
||||
{ method: "POST", path: "/api/companies/co-1/routines" },
|
||||
{ method: "PATCH", path: "/api/routines/r-1" },
|
||||
{ method: "POST", path: "/api/routines/r-1/run" },
|
||||
{ method: "POST", path: "/api/routines/r-1/triggers" },
|
||||
{ method: "PATCH", path: "/api/routine-triggers/t-1" },
|
||||
{ method: "DELETE", path: "/api/routine-triggers/t-1" },
|
||||
];
|
||||
for (const request of allowed) {
|
||||
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBeNull();
|
||||
}
|
||||
|
||||
const denied: Array<{ method: string; path: string }> = [
|
||||
{ method: "DELETE", path: "/api/secrets" },
|
||||
// Pin the runtime-services regex to start/stop/restart only — anything
|
||||
// else (delete, reset, wipe, etc.) must stay denied even if the API
|
||||
// grows new actions later.
|
||||
{ method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/delete" },
|
||||
{ method: "POST", path: "/api/companies/co-1/agents" },
|
||||
{ method: "POST", path: "/api/agents/agent-1/pause" },
|
||||
{ method: "POST", path: "/api/agents/agent-1/terminate" },
|
||||
{ method: "POST", path: "/api/agents/agent-1/keys" },
|
||||
{ method: "POST", path: "/api/companies/co-1/exports" },
|
||||
{ method: "POST", path: "/api/companies/co-1/imports/apply" },
|
||||
{ method: "POST", path: "/api/companies/co-1/archive" },
|
||||
{ method: "DELETE", path: "/api/issues/issue-1/documents/plan" },
|
||||
{ method: "DELETE", path: "/api/issues/issue-1/approvals/ap-1" },
|
||||
{ method: "POST", path: "/api/approvals/ap-1/approve" },
|
||||
{ method: "POST", path: "/api/approvals/ap-1/reject" },
|
||||
{ method: "POST", path: "/api/companies/co-1/logo" },
|
||||
{ method: "GET", path: "/api/companies/co-1/secrets" },
|
||||
{ method: "PATCH", path: "/api/secrets/secret-1" },
|
||||
];
|
||||
for (const request of denied) {
|
||||
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBe(
|
||||
`Route not allowed: ${request.method} ${request.path}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("marks command-managed bridge operations with the bridge execution channel", async () => {
|
||||
const runner = {
|
||||
execute: vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
|
||||
runner,
|
||||
remoteCwd: "/workspace",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
await client.makeDir("/workspace/.paperclip-runtime/codex/paperclip-bridge/queue");
|
||||
|
||||
expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
env: {
|
||||
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js";
|
||||
|
||||
describe("buildSandboxNpmInstallCommand", () => {
|
||||
it("installs globally as root, via sudo when available, and under ~/.local otherwise", () => {
|
||||
const command = buildSandboxNpmInstallCommand("@google/gemini-cli");
|
||||
expect(command).toContain("if [ \"$(id -u)\" -eq 0 ]; then npm install -g '@google/gemini-cli';");
|
||||
expect(command).toContain("sudo -E npm install -g '@google/gemini-cli'");
|
||||
expect(command).toContain("npm install -g --prefix \"$HOME/.local\" '@google/gemini-cli'");
|
||||
});
|
||||
|
||||
it("bootstraps npm from a portable Node tarball when missing", () => {
|
||||
const command = buildSandboxNpmInstallCommand("@google/gemini-cli");
|
||||
expect(command).toContain("if ! command -v npm >/dev/null 2>&1; then");
|
||||
expect(command).toContain("https://nodejs.org/dist/");
|
||||
expect(command).toContain('export PATH="$HOME/.local/bin:$PATH"');
|
||||
});
|
||||
|
||||
it("shell-quotes package names", () => {
|
||||
expect(buildSandboxNpmInstallCommand("odd'pkg")).toContain("'odd'\"'\"'pkg'");
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
function shellSingleQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
// Bootstrap a usable npm when the sandbox image ships without one (e.g. the
|
||||
// default exe.dev VM image has sshd + a normal user homedir but no Node
|
||||
// toolchain). We install a portable Node tarball into $HOME/.local rather
|
||||
// than using apt-get because the distro-packaged Node is often old enough to
|
||||
// reject modern JS syntax (regex /v flag, etc.) used by adapter CLIs like
|
||||
// @google/gemini-cli. The bootstrap also sets PAPERCLIP_NPM_BOOTSTRAPPED=1
|
||||
// so the install step knows to skip sudo — sudo would reset PATH via
|
||||
// secure_path and lose visibility of the freshly-installed npm in
|
||||
// $HOME/.local/bin.
|
||||
const ENSURE_NPM_PREAMBLE =
|
||||
"PAPERCLIP_NPM_BOOTSTRAPPED=; " +
|
||||
'if ! command -v npm >/dev/null 2>&1; then ' +
|
||||
'NODE_ARCH="$(uname -m)"; ' +
|
||||
'case "$NODE_ARCH" in ' +
|
||||
"x86_64) NODE_ARCH=x64 ;; " +
|
||||
"aarch64|arm64) NODE_ARCH=arm64 ;; " +
|
||||
"esac; " +
|
||||
'NODE_VERSION="v22.11.0"; ' +
|
||||
'NODE_TARBALL="node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"; ' +
|
||||
'mkdir -p "$HOME/.local"; ' +
|
||||
'curl -fsSL "https://nodejs.org/dist/${NODE_VERSION}/${NODE_TARBALL}" -o "/tmp/${NODE_TARBALL}" && ' +
|
||||
'tar -xJf "/tmp/${NODE_TARBALL}" -C "$HOME/.local" --strip-components=1 && ' +
|
||||
'rm -f "/tmp/${NODE_TARBALL}" && ' +
|
||||
'export PATH="$HOME/.local/bin:$PATH" && ' +
|
||||
"PAPERCLIP_NPM_BOOTSTRAPPED=1; " +
|
||||
"fi;";
|
||||
|
||||
export function buildSandboxNpmInstallCommand(packageName: string): string {
|
||||
const quotedPackageName = shellSingleQuote(packageName);
|
||||
return [
|
||||
ENSURE_NPM_PREAMBLE,
|
||||
'if [ -n "$PAPERCLIP_NPM_BOOTSTRAPPED" ]; then',
|
||||
`npm install -g ${quotedPackageName};`,
|
||||
'elif [ "$(id -u)" -eq 0 ]; then',
|
||||
`npm install -g ${quotedPackageName};`,
|
||||
'elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then',
|
||||
`sudo -E npm install -g ${quotedPackageName};`,
|
||||
"else",
|
||||
`mkdir -p "$HOME/.local" && npm install -g --prefix "$HOME/.local" ${quotedPackageName};`,
|
||||
"fi",
|
||||
].join(" ");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
@@ -73,18 +73,11 @@ 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 });
|
||||
},
|
||||
run: async (command) => {
|
||||
await execFile("sh", ["-c", command], {
|
||||
await execFile("sh", ["-lc", command], {
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
},
|
||||
@@ -126,7 +119,7 @@ describe("sandbox managed runtime", () => {
|
||||
|
||||
await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe("remote workspace\n");
|
||||
await expect(readFile(path.join(localWorkspaceDir, "remote-only.txt"), "utf8")).resolves.toBe("sync back\n");
|
||||
await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).resolves.toBe("remove\n");
|
||||
await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"local\":true}\n");
|
||||
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n");
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { constants as fsConstants, promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
@@ -14,6 +13,7 @@ export interface SandboxRemoteExecutionSpec {
|
||||
remoteCwd: string;
|
||||
timeoutMs: number;
|
||||
apiKey: string | null;
|
||||
paperclipApiUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface SandboxManagedRuntimeAsset {
|
||||
@@ -27,7 +27,6 @@ export interface SandboxManagedRuntimeClient {
|
||||
makeDir(remotePath: string): Promise<void>;
|
||||
writeFile(remotePath: string, bytes: ArrayBuffer): Promise<void>;
|
||||
readFile(remotePath: string): Promise<Buffer | Uint8Array | ArrayBuffer>;
|
||||
listFiles(remotePath: string): Promise<string[]>;
|
||||
remove(remotePath: string): Promise<void>;
|
||||
run(command: string, options: { timeoutMs: number }): Promise<void>;
|
||||
}
|
||||
@@ -85,6 +84,7 @@ export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteEx
|
||||
remoteCwd,
|
||||
timeoutMs,
|
||||
apiKey: asString(parsed.apiKey).trim() || null,
|
||||
paperclipApiUrl: asString(parsed.paperclipApiUrl).trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export function buildSandboxExecutionSessionIdentity(spec: SandboxRemoteExecutio
|
||||
provider: spec.provider,
|
||||
sandboxId: spec.sandboxId,
|
||||
remoteCwd: spec.remoteCwd,
|
||||
...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}),
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -106,7 +107,8 @@ export function sandboxExecutionSessionMatches(saved: unknown, current: SandboxR
|
||||
asString(parsedSaved.transport) === currentIdentity.transport &&
|
||||
asString(parsedSaved.provider) === currentIdentity.provider &&
|
||||
asString(parsedSaved.sandboxId) === currentIdentity.sandboxId &&
|
||||
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd
|
||||
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd &&
|
||||
asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -249,9 +251,6 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
}): Promise<PreparedSandboxManagedRuntime> {
|
||||
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
|
||||
const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey);
|
||||
const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, {
|
||||
exclude: [...new Set([".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? []), ...(input.workspaceExclude ?? [])])],
|
||||
});
|
||||
|
||||
await withTempDir("paperclip-sandbox-sync-", async (tempDir) => {
|
||||
const workspaceTarPath = path.join(tempDir, "workspace.tar");
|
||||
@@ -267,7 +266,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
const preservedNames = new Set([".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? [])]);
|
||||
const findPreserveArgs = [...preservedNames].map((entry) => `! -name ${shellQuote(entry)}`).join(" ");
|
||||
await input.client.run(
|
||||
`sh -c ${shellQuote(
|
||||
`sh -lc ${shellQuote(
|
||||
`mkdir -p ${shellQuote(workspaceRemoteDir)} && ` +
|
||||
`find ${shellQuote(workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ${findPreserveArgs} -exec rm -rf -- {} + && ` +
|
||||
`tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` +
|
||||
@@ -289,7 +288,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
|
||||
await input.client.writeFile(remoteAssetTar, toArrayBuffer(assetTarBytes));
|
||||
await input.client.run(
|
||||
`sh -c ${shellQuote(
|
||||
`sh -lc ${shellQuote(
|
||||
`rm -rf ${shellQuote(remoteAssetDir)} && ` +
|
||||
`mkdir -p ${shellQuote(remoteAssetDir)} && ` +
|
||||
`tar -xf ${shellQuote(remoteAssetTar)} -C ${shellQuote(remoteAssetDir)} && ` +
|
||||
@@ -314,7 +313,7 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
|
||||
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
|
||||
await input.client.run(
|
||||
`sh -c ${shellQuote(
|
||||
`sh -lc ${shellQuote(
|
||||
`mkdir -p ${shellQuote(runtimeRootDir)} && ` +
|
||||
`tar -cf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} ` +
|
||||
`${tarExcludeFlags(input.workspaceExclude)} .`,
|
||||
@@ -330,10 +329,8 @@ export async function prepareSandboxManagedRuntime(input: {
|
||||
archivePath: localArchivePath,
|
||||
localDir: extractedDir,
|
||||
});
|
||||
await mergeDirectoryWithBaseline({
|
||||
baseline: baselineSnapshot,
|
||||
sourceDir: extractedDir,
|
||||
targetDir: input.workspaceLocalDir,
|
||||
await mirrorDirectory(extractedDir, input.workspaceLocalDir, {
|
||||
preserveAbsent: [".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? [])],
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function preferredShellForSandbox(shellCommand: string | null | undefined): "bash" | "sh" {
|
||||
return shellCommand === "bash" ? "bash" : "sh";
|
||||
}
|
||||
|
||||
export function shellCommandArgs(script: string): string[] {
|
||||
return ["-c", script];
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPaperclipWorkspaceEnv,
|
||||
appendWithByteCap,
|
||||
buildInvocationEnvForLogs,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
materializePaperclipSkillCopy,
|
||||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
renderPaperclipWakePrompt,
|
||||
runningProcesses,
|
||||
runChildProcess,
|
||||
sanitizeSshRemoteEnv,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
rewriteWorkspaceCwdEnvVarsForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
} from "./server-utils.js";
|
||||
|
||||
@@ -48,162 +39,6 @@ async function waitForTextMatch(read: () => string, pattern: RegExp, timeoutMs =
|
||||
return read().match(pattern);
|
||||
}
|
||||
|
||||
describe("buildInvocationEnvForLogs", () => {
|
||||
it("redacts inline secrets from resolved command metadata", () => {
|
||||
const loggedEnv = buildInvocationEnvForLogs(
|
||||
{ SAFE_VALUE: "visible" },
|
||||
{
|
||||
resolvedCommand: "env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret",
|
||||
},
|
||||
);
|
||||
|
||||
expect(loggedEnv.SAFE_VALUE).toBe("visible");
|
||||
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(
|
||||
"env OPENAI_API_KEY=***REDACTED*** custom-acp --token ***REDACTED***",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSshRemoteEnv", () => {
|
||||
it("drops inherited host shell identity variables for SSH remote execution", () => {
|
||||
expect(
|
||||
sanitizeSshRemoteEnv(
|
||||
{
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/Users/local",
|
||||
NVM_DIR: "/Users/local/.nvm",
|
||||
TMPDIR: "/var/folders/local/T",
|
||||
XDG_CONFIG_HOME: "/Users/local/.config",
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
{
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/Users/local",
|
||||
NVM_DIR: "/Users/local/.nvm",
|
||||
TMPDIR: "/var/folders/local/T",
|
||||
XDG_CONFIG_HOME: "/Users/local/.config",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
SAFE_VALUE: "visible",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit remote overrides even for filtered key names", () => {
|
||||
expect(
|
||||
sanitizeSshRemoteEnv(
|
||||
{
|
||||
PATH: "/custom/remote/bin:/usr/bin",
|
||||
HOME: "/home/agent",
|
||||
TMPDIR: "/tmp",
|
||||
SAFE_VALUE: "visible",
|
||||
},
|
||||
{
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/Users/local",
|
||||
TMPDIR: "/var/folders/local/T",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
PATH: "/custom/remote/bin:/usr/bin",
|
||||
HOME: "/home/agent",
|
||||
TMPDIR: "/tmp",
|
||||
SAFE_VALUE: "visible",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters identity keys via case-insensitive match against the inherited env", () => {
|
||||
expect(
|
||||
sanitizeSshRemoteEnv(
|
||||
{
|
||||
// Caller passed PATH in upper case while the inherited (Windows-style)
|
||||
// host env exposes it as Path. The lookup must still treat them as
|
||||
// equal so the leaked host PATH gets stripped.
|
||||
PATH: "/host/bin:/usr/bin",
|
||||
HOME: "/host/home",
|
||||
},
|
||||
{
|
||||
Path: "/host/bin:/usr/bin",
|
||||
home: "/host/home",
|
||||
},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves explicitly-set identity keys when the inherited env disagrees in case but not in value", () => {
|
||||
expect(
|
||||
sanitizeSshRemoteEnv(
|
||||
{
|
||||
PATH: "/explicit/remote/bin",
|
||||
},
|
||||
{
|
||||
Path: "/host/bin:/usr/bin",
|
||||
},
|
||||
),
|
||||
).toEqual({ PATH: "/explicit/remote/bin" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("materializePaperclipSkillCopy", () => {
|
||||
it("refuses to materialize into an ancestor of the source", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-"));
|
||||
try {
|
||||
const source = path.join(root, "parent", "skill");
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.writeFile(path.join(source, "SKILL.md"), "# skill\n", "utf8");
|
||||
|
||||
await expect(materializePaperclipSkillCopy(source, path.join(root, "parent"))).rejects.toThrow(
|
||||
/ancestor/,
|
||||
);
|
||||
await expect(fs.readFile(path.join(source, "SKILL.md"), "utf8")).resolves.toBe("# skill\n");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not delete and recopy an unchanged materialized skill target", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-"));
|
||||
try {
|
||||
const source = path.join(root, "source");
|
||||
const target = path.join(root, "target");
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.writeFile(path.join(source, "SKILL.md"), "# skill\n", "utf8");
|
||||
|
||||
const first = await materializePaperclipSkillCopy(source, target);
|
||||
expect(first.copiedFiles).toBe(1);
|
||||
await fs.writeFile(path.join(target, "local-marker.txt"), "keep\n", "utf8");
|
||||
|
||||
const second = await materializePaperclipSkillCopy(source, target);
|
||||
expect(second.copiedFiles).toBe(0);
|
||||
await expect(fs.readFile(path.join(target, "local-marker.txt"), "utf8")).resolves.toBe("keep\n");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("breaks stale materialization locks left by dead processes", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-"));
|
||||
try {
|
||||
const source = path.join(root, "source");
|
||||
const target = path.join(root, "target");
|
||||
const lock = `${target}.lock`;
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.writeFile(path.join(source, "SKILL.md"), "# skill\n", "utf8");
|
||||
await fs.mkdir(lock, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(lock, "owner.json"),
|
||||
JSON.stringify({ pid: 999_999_999, createdAt: "2000-01-01T00:00:00.000Z" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(materializePaperclipSkillCopy(source, target)).resolves.toMatchObject({ copiedFiles: 1 });
|
||||
await expect(fs.readFile(path.join(target, "SKILL.md"), "utf8")).resolves.toBe("# skill\n");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runChildProcess", () => {
|
||||
it("does not arm a timeout when timeoutSec is 0", async () => {
|
||||
const result = await runChildProcess(
|
||||
@@ -420,9 +255,6 @@ describe("renderPaperclipWakePrompt", () => {
|
||||
it("keeps the default local-agent prompt action-oriented", () => {
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Start actionable work in this heartbeat");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("do not stop at a plan");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("clear final disposition");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("evidence, not valid liveness paths by themselves");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("keep `in_progress` only when a live continuation path exists");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Prefer the smallest verification that proves the change");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Use child issues");
|
||||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("instead of polling agents, sessions, or processes");
|
||||
@@ -456,118 +288,8 @@ describe("renderPaperclipWakePrompt", () => {
|
||||
|
||||
expect(prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(prompt).toContain("Execution contract: take concrete action in this heartbeat");
|
||||
expect(prompt).toContain("clear final disposition");
|
||||
expect(prompt).toContain("evidence, not valid liveness paths by themselves");
|
||||
expect(prompt).toContain("Use child issues for long or parallel delegated work instead of polling");
|
||||
expect(prompt).toContain("named unblock owner/action");
|
||||
});
|
||||
|
||||
it("renders planning-mode directives for assignment and comment wakes", () => {
|
||||
const assignmentPrompt = renderPaperclipWakePrompt({
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
status: "in_progress",
|
||||
workMode: "planning",
|
||||
},
|
||||
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
|
||||
comments: [],
|
||||
fallbackFetchNeeded: false,
|
||||
});
|
||||
|
||||
expect(assignmentPrompt).toContain("- issue work mode: planning");
|
||||
expect(assignmentPrompt).toContain("Make the plan only. Do not write code or perform implementation work.");
|
||||
|
||||
const commentPrompt = renderPaperclipWakePrompt({
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
status: "in_progress",
|
||||
workMode: "planning",
|
||||
},
|
||||
commentIds: ["comment-1"],
|
||||
latestCommentId: "comment-1",
|
||||
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
|
||||
comments: [{ id: "comment-1", body: "Revise the plan" }],
|
||||
fallbackFetchNeeded: false,
|
||||
});
|
||||
|
||||
expect(commentPrompt).toContain("Update the plan only. Do not write code or perform implementation work.");
|
||||
});
|
||||
|
||||
it("does not render stale accepted-plan continuation guidance for later planning comment wakes", () => {
|
||||
const prompt = renderPaperclipWakePrompt({
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
status: "in_progress",
|
||||
workMode: "planning",
|
||||
},
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
commentIds: ["comment-1"],
|
||||
latestCommentId: "comment-1",
|
||||
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
|
||||
comments: [{ id: "comment-1", body: "Revise the plan" }],
|
||||
fallbackFetchNeeded: false,
|
||||
});
|
||||
|
||||
expect(prompt).toContain("Update the plan only. Do not write code or perform implementation work.");
|
||||
expect(prompt).not.toContain("accepted-plan continuation");
|
||||
expect(prompt).not.toContain("Create child issues from the approved plan only");
|
||||
});
|
||||
|
||||
it("renders accepted-plan continuation guidance for planning issues", () => {
|
||||
const prompt = renderPaperclipWakePrompt({
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
status: "in_progress",
|
||||
workMode: "planning",
|
||||
},
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
|
||||
comments: [],
|
||||
fallbackFetchNeeded: false,
|
||||
});
|
||||
|
||||
expect(prompt).toContain("accepted-plan continuation");
|
||||
expect(prompt).toContain("Create child issues from the approved plan only");
|
||||
expect(prompt).toContain("may create child implementation issues");
|
||||
expect(prompt).toContain("must not start implementation work on the planning issue itself");
|
||||
});
|
||||
|
||||
it("keeps accepted-plan guidance when stale comment ids have no loaded comments", () => {
|
||||
const prompt = renderPaperclipWakePrompt({
|
||||
reason: "issue_commented",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-3404",
|
||||
title: "Plan first",
|
||||
status: "in_progress",
|
||||
workMode: "planning",
|
||||
},
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
commentIds: ["stale-comment-1"],
|
||||
latestCommentId: "stale-comment-1",
|
||||
commentWindow: { requestedCount: 1, includedCount: 0, missingCount: 1 },
|
||||
comments: [],
|
||||
fallbackFetchNeeded: true,
|
||||
});
|
||||
|
||||
expect(prompt).toContain("accepted-plan continuation");
|
||||
expect(prompt).toContain("Create child issues from the approved plan only");
|
||||
expect(prompt).not.toContain("Update the plan only");
|
||||
expect(prompt).toContain("use child issues instead of polling");
|
||||
expect(prompt).toContain("mark blocked work with the unblock owner/action");
|
||||
});
|
||||
|
||||
it("renders dependency-blocked interaction guidance", () => {
|
||||
@@ -748,183 +470,6 @@ describe("applyPaperclipWorkspaceEnv", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shapePaperclipWorkspaceEnvForExecution", () => {
|
||||
it("rewrites workspace env paths for remote execution", () => {
|
||||
const shaped = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: "/tmp/workspace",
|
||||
workspaceWorktreePath: "/tmp/worktree",
|
||||
workspaceHints: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/tmp/workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
cwd: "/tmp/other-workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-3",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
],
|
||||
executionTargetIsRemote: true,
|
||||
executionCwd: "/remote/workspace",
|
||||
});
|
||||
|
||||
expect(shaped).toEqual({
|
||||
workspaceCwd: "/remote/workspace",
|
||||
workspaceWorktreePath: null,
|
||||
workspaceHints: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-3",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves local execution workspace paths unchanged", () => {
|
||||
const workspaceHints = [{ workspaceId: "workspace-1", cwd: "/tmp/workspace" }];
|
||||
const shaped = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: "/tmp/workspace",
|
||||
workspaceWorktreePath: "/tmp/worktree",
|
||||
workspaceHints,
|
||||
executionTargetIsRemote: false,
|
||||
executionCwd: "/remote/workspace",
|
||||
});
|
||||
|
||||
expect(shaped).toEqual({
|
||||
workspaceCwd: "/tmp/workspace",
|
||||
workspaceWorktreePath: "/tmp/worktree",
|
||||
workspaceHints,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteWorkspaceCwdEnvVarsForExecution", () => {
|
||||
it("rewrites custom *_WORKSPACE_CWD env vars for remote execution", () => {
|
||||
const env = rewriteWorkspaceCwdEnvVarsForExecution({
|
||||
workspaceCwd: "/host/workspace",
|
||||
executionCwd: "/remote/workspace",
|
||||
executionTargetIsRemote: true,
|
||||
env: {
|
||||
QA_PROJECT_WORKSPACE_CWD: "/host/workspace",
|
||||
RANDOM_WORKSPACE_CWD: "/host/workspace",
|
||||
OTHER_ENV: "/host/workspace",
|
||||
},
|
||||
});
|
||||
|
||||
expect(env).toEqual({
|
||||
QA_PROJECT_WORKSPACE_CWD: "/remote/workspace",
|
||||
RANDOM_WORKSPACE_CWD: "/remote/workspace",
|
||||
OTHER_ENV: "/host/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rewrite matching values for local execution", () => {
|
||||
const env = rewriteWorkspaceCwdEnvVarsForExecution({
|
||||
workspaceCwd: "/host/workspace",
|
||||
executionCwd: "/remote/workspace",
|
||||
executionTargetIsRemote: false,
|
||||
env: {
|
||||
QA_PROJECT_WORKSPACE_CWD: "/host/workspace",
|
||||
RANDOM_WORKSPACE_CWD_TOKEN: "/host/workspace",
|
||||
},
|
||||
});
|
||||
|
||||
expect(env).toEqual({
|
||||
QA_PROJECT_WORKSPACE_CWD: "/host/workspace",
|
||||
RANDOM_WORKSPACE_CWD_TOKEN: "/host/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("only rewrites matching *_WORKSPACE_CWD string values", () => {
|
||||
const env = rewriteWorkspaceCwdEnvVarsForExecution({
|
||||
workspaceCwd: "/host/workspace",
|
||||
executionCwd: "/remote/workspace",
|
||||
executionTargetIsRemote: true,
|
||||
env: {
|
||||
MATCHING_WORKSPACE_CWD: "/host/workspace/.",
|
||||
DIFFERENT_WORKSPACE_CWD: "/host/other-workspace",
|
||||
BLANK_WORKSPACE_CWD: " ",
|
||||
NON_STRING_WORKSPACE_CWD: 42,
|
||||
},
|
||||
});
|
||||
|
||||
expect(env).toEqual({
|
||||
MATCHING_WORKSPACE_CWD: "/remote/workspace",
|
||||
DIFFERENT_WORKSPACE_CWD: "/host/other-workspace",
|
||||
BLANK_WORKSPACE_CWD: " ",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshPaperclipWorkspaceEnvForExecution", () => {
|
||||
it("rewrites Paperclip workspace env to the prepared remote runtime cwd", () => {
|
||||
const env: Record<string, string> = {
|
||||
PAPERCLIP_WORKSPACE_CWD: "/remote/workspace",
|
||||
PAPERCLIP_WORKSPACE_WORKTREE_PATH: "/host/worktree",
|
||||
PAPERCLIP_WORKSPACES_JSON: JSON.stringify([
|
||||
{ workspaceId: "workspace-1", cwd: "/remote/workspace" },
|
||||
{ workspaceId: "workspace-2", cwd: "/tmp/other" },
|
||||
]),
|
||||
QA_PROJECT_WORKSPACE_CWD: "/remote/workspace",
|
||||
};
|
||||
|
||||
const shaped = refreshPaperclipWorkspaceEnvForExecution({
|
||||
env,
|
||||
envConfig: {
|
||||
QA_PROJECT_WORKSPACE_CWD: "/host/workspace",
|
||||
},
|
||||
workspaceCwd: "/host/workspace",
|
||||
workspaceWorktreePath: "/host/worktree",
|
||||
workspaceHints: [
|
||||
{ workspaceId: "workspace-1", cwd: "/host/workspace" },
|
||||
{ workspaceId: "workspace-2", cwd: "/tmp/other" },
|
||||
],
|
||||
executionTargetIsRemote: true,
|
||||
executionCwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace",
|
||||
});
|
||||
|
||||
expect(shaped).toEqual({
|
||||
workspaceCwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace",
|
||||
workspaceWorktreePath: null,
|
||||
workspaceHints: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace/.paperclip-runtime/runs/run-1/workspace");
|
||||
expect(env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined();
|
||||
expect(env.QA_PROJECT_WORKSPACE_CWD).toBe("/remote/workspace/.paperclip-runtime/runs/run-1/workspace");
|
||||
expect(JSON.parse(env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace",
|
||||
},
|
||||
{
|
||||
workspaceId: "workspace-2",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendWithByteCap", () => {
|
||||
it("keeps valid UTF-8 when trimming through multibyte text", () => {
|
||||
const output = appendWithByteCap("prefix ", "hello — world", 7);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
|
||||
import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js";
|
||||
import { redactCommandText } from "./command-redaction.js";
|
||||
import type {
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
@@ -79,45 +75,18 @@ export const runningProcesses = new Map<string, RunningProcess>();
|
||||
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
||||
const TERMINAL_RESULT_SCAN_OVERLAP_CHARS = 64 * 1024;
|
||||
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
|
||||
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
|
||||
const REDACTED_LOG_VALUE = "***REDACTED***";
|
||||
const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
|
||||
"../../skills",
|
||||
"../../../../../skills",
|
||||
];
|
||||
const MATERIALIZED_SKILL_SENTINEL = ".paperclip-materialized-skill.json";
|
||||
const MATERIALIZED_SKILL_LOCK_OWNER = "owner.json";
|
||||
const MATERIALIZED_SKILL_LOCK_STALE_MS = 30_000;
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolvePaperclipInstanceRootForAdapter(input: {
|
||||
homeDir?: string;
|
||||
instanceId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} = {}): string {
|
||||
const env = input.env ?? process.env;
|
||||
const homeRaw = input.homeDir?.trim() || env.PAPERCLIP_HOME?.trim();
|
||||
const homeDir = path.resolve(homeRaw ? expandHomePrefix(homeRaw) : path.resolve(os.homedir(), ".paperclip"));
|
||||
const instanceId = input.instanceId?.trim() || env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_PAPERCLIP_INSTANCE_ID;
|
||||
if (!PATH_SEGMENT_RE.test(instanceId)) throw new Error(`Invalid PAPERCLIP_INSTANCE_ID '${instanceId}'.`);
|
||||
return path.resolve(homeDir, "instances", instanceId);
|
||||
}
|
||||
|
||||
export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
"",
|
||||
"Execution contract:",
|
||||
"- Start actionable work in this heartbeat; do not stop at a plan unless the issue asks for planning.",
|
||||
"- Leave durable progress in comments, documents, or work products, then update the issue to a clear final disposition before ending the heartbeat.",
|
||||
"- Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
|
||||
"- Final disposition checklist: mark `done` when complete; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists.",
|
||||
"- Leave durable progress in comments, documents, or work products with a clear next action.",
|
||||
"- Prefer the smallest verification that proves the change; do not default to full workspace typecheck/build/test on every heartbeat unless the task scope warrants it.",
|
||||
"- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.",
|
||||
"- If woken by a human comment on a dependency-blocked issue, respond or triage the comment without treating the blocked deliverable work as unblocked.",
|
||||
@@ -142,11 +111,6 @@ export interface InstalledSkillTarget {
|
||||
kind: "symlink" | "directory" | "file";
|
||||
}
|
||||
|
||||
export interface MaterializedPaperclipSkillCopyResult {
|
||||
copiedFiles: number;
|
||||
skippedSymlinks: string[];
|
||||
}
|
||||
|
||||
interface PersistentSkillSnapshotOptions {
|
||||
adapterType: string;
|
||||
availableEntries: PaperclipSkillEntry[];
|
||||
@@ -305,7 +269,6 @@ type PaperclipWakeIssue = {
|
||||
identifier: string | null;
|
||||
title: string | null;
|
||||
status: string | null;
|
||||
workMode: string | null;
|
||||
priority: string | null;
|
||||
};
|
||||
|
||||
@@ -391,8 +354,6 @@ type PaperclipWakePayload = {
|
||||
executionStage: PaperclipWakeExecutionStage | null;
|
||||
continuationSummary: PaperclipWakeContinuationSummary | null;
|
||||
livenessContinuation: PaperclipWakeLivenessContinuation | null;
|
||||
interactionKind: string | null;
|
||||
interactionStatus: string | null;
|
||||
childIssueSummaries: PaperclipWakeChildIssueSummary[];
|
||||
childIssueSummaryTruncated: boolean;
|
||||
commentIds: string[];
|
||||
@@ -411,7 +372,6 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null
|
||||
const identifier = asString(issue.identifier, "").trim() || null;
|
||||
const title = asString(issue.title, "").trim() || null;
|
||||
const status = asString(issue.status, "").trim() || null;
|
||||
const workMode = asString(issue.workMode, "").trim() || null;
|
||||
const priority = asString(issue.priority, "").trim() || null;
|
||||
if (!id && !identifier && !title) return null;
|
||||
return {
|
||||
@@ -419,7 +379,6 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null
|
||||
identifier,
|
||||
title,
|
||||
status,
|
||||
workMode,
|
||||
priority,
|
||||
};
|
||||
}
|
||||
@@ -602,8 +561,6 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
||||
executionStage,
|
||||
continuationSummary,
|
||||
livenessContinuation,
|
||||
interactionKind: asString(payload.interactionKind, "").trim() || null,
|
||||
interactionStatus: asString(payload.interactionStatus, "").trim() || null,
|
||||
childIssueSummaries,
|
||||
childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false),
|
||||
commentIds,
|
||||
@@ -623,15 +580,6 @@ export function stringifyPaperclipWakePayload(value: unknown): string | null {
|
||||
return JSON.stringify(normalized);
|
||||
}
|
||||
|
||||
export function readPaperclipIssueWorkModeFromContext(value: unknown): string | null {
|
||||
const context = parseObject(value);
|
||||
const issue = parseObject(context.paperclipIssue);
|
||||
const direct = asString(issue.workMode, "").trim();
|
||||
if (direct) return direct;
|
||||
const wake = normalizePaperclipWakePayload(context.paperclipWake);
|
||||
return wake?.issue?.workMode ?? null;
|
||||
}
|
||||
|
||||
export function renderPaperclipWakePrompt(
|
||||
value: unknown,
|
||||
options: { resumedSession?: boolean } = {},
|
||||
@@ -655,7 +603,7 @@ export function renderPaperclipWakePrompt(
|
||||
"Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.",
|
||||
"Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.",
|
||||
"",
|
||||
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
|
||||
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.",
|
||||
"",
|
||||
`- reason: ${normalized.reason ?? "unknown"}`,
|
||||
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
|
||||
@@ -672,7 +620,7 @@ export function renderPaperclipWakePrompt(
|
||||
"Use this inline wake data first before refetching the issue thread.",
|
||||
"Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.",
|
||||
"",
|
||||
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress and then give the issue a clear final disposition before ending the heartbeat: `done`, `in_review` with a real reviewer/approval/interaction path, `blocked` with first-class blockers or a named unblock owner/action, delegated follow-up issues with blockers, or `in_progress` only when a live continuation path exists. Use child issues for long or parallel delegated work instead of polling. Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves.",
|
||||
"Execution contract: take concrete action in this heartbeat when the issue is actionable; do not stop at a plan unless planning was requested. Leave durable progress with a clear next action, use child issues instead of polling for long or parallel work, and mark blocked work with the unblock owner/action.",
|
||||
"",
|
||||
`- reason: ${normalized.reason ?? "unknown"}`,
|
||||
`- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`,
|
||||
@@ -684,31 +632,9 @@ export function renderPaperclipWakePrompt(
|
||||
if (normalized.issue?.status) {
|
||||
lines.push(`- issue status: ${normalized.issue.status}`);
|
||||
}
|
||||
if (normalized.issue?.workMode) {
|
||||
lines.push(`- issue work mode: ${normalized.issue.workMode}`);
|
||||
}
|
||||
if (normalized.issue?.priority) {
|
||||
lines.push(`- issue priority: ${normalized.issue.priority}`);
|
||||
}
|
||||
if (normalized.issue?.workMode === "planning") {
|
||||
const hasWakeComments = normalized.comments.length > 0;
|
||||
const acceptedPlanContinuation =
|
||||
!hasWakeComments &&
|
||||
normalized.interactionKind === "request_confirmation" && normalized.interactionStatus === "accepted";
|
||||
let directive = "Make the plan only. Do not write code or perform implementation work.";
|
||||
if (hasWakeComments) {
|
||||
directive = "Update the plan only. Do not write code or perform implementation work.";
|
||||
}
|
||||
if (acceptedPlanContinuation) {
|
||||
directive = "Create child issues from the approved plan only. Do not write code or perform implementation work on the planning issue.";
|
||||
}
|
||||
lines.push(`- planning directive: ${directive}`);
|
||||
if (acceptedPlanContinuation) {
|
||||
lines.push(
|
||||
"- accepted-plan continuation: you may create child implementation issues from the approved plan, but must not start implementation work on the planning issue itself",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (normalized.checkedOutByHarness) {
|
||||
lines.push("- checkout: already claimed by the harness for this run");
|
||||
}
|
||||
@@ -854,15 +780,11 @@ export function renderPaperclipWakePrompt(
|
||||
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? REDACTED_LOG_VALUE : value;
|
||||
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value;
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function redactCommandTextForLogs(command: string): string {
|
||||
return redactCommandText(command, REDACTED_LOG_VALUE);
|
||||
}
|
||||
|
||||
export function buildInvocationEnvForLogs(
|
||||
env: Record<string, string>,
|
||||
options: {
|
||||
@@ -884,7 +806,7 @@ export function buildInvocationEnvForLogs(
|
||||
|
||||
const resolvedCommand = options.resolvedCommand?.trim();
|
||||
if (resolvedCommand) {
|
||||
merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = redactCommandTextForLogs(resolvedCommand);
|
||||
merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand;
|
||||
}
|
||||
|
||||
return redactEnvForLogs(merged);
|
||||
@@ -948,177 +870,6 @@ export function applyPaperclipWorkspaceEnv(
|
||||
return env;
|
||||
}
|
||||
|
||||
export function shapePaperclipWorkspaceEnvForExecution(input: {
|
||||
workspaceCwd?: string | null;
|
||||
workspaceWorktreePath?: string | null;
|
||||
workspaceHints?: Array<Record<string, unknown>>;
|
||||
executionTargetIsRemote?: boolean;
|
||||
executionCwd?: string | null;
|
||||
}): {
|
||||
workspaceCwd: string | null;
|
||||
workspaceWorktreePath: string | null;
|
||||
workspaceHints: Array<Record<string, unknown>>;
|
||||
} {
|
||||
const workspaceCwd =
|
||||
typeof input.workspaceCwd === "string" && input.workspaceCwd.trim().length > 0
|
||||
? input.workspaceCwd.trim()
|
||||
: null;
|
||||
const workspaceWorktreePath =
|
||||
typeof input.workspaceWorktreePath === "string" && input.workspaceWorktreePath.trim().length > 0
|
||||
? input.workspaceWorktreePath.trim()
|
||||
: null;
|
||||
const workspaceHints = Array.isArray(input.workspaceHints) ? input.workspaceHints : [];
|
||||
|
||||
if (!input.executionTargetIsRemote) {
|
||||
return {
|
||||
workspaceCwd,
|
||||
workspaceWorktreePath,
|
||||
workspaceHints,
|
||||
};
|
||||
}
|
||||
|
||||
const executionCwd =
|
||||
typeof input.executionCwd === "string" && input.executionCwd.trim().length > 0
|
||||
? input.executionCwd.trim()
|
||||
: null;
|
||||
// On a remote target we must never fall back to the local workspaceCwd —
|
||||
// doing so leaks host paths into the remote env (the exact failure mode
|
||||
// this helper exists to prevent). Callers are expected to resolve
|
||||
// executionCwd via adapterExecutionTargetRemoteCwd before calling this
|
||||
// helper, which always returns a non-empty string. Surface a warning so
|
||||
// future callers don't silently regress to the leak.
|
||||
if (executionCwd === null) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[paperclip] shapePaperclipWorkspaceEnvForExecution called with executionCwd=null on a remote target; " +
|
||||
"stripping workspaceCwd to avoid leaking local paths into the remote environment.",
|
||||
);
|
||||
}
|
||||
const realizedWorkspaceCwd = executionCwd;
|
||||
const localWorkspaceCwd = workspaceCwd ? path.resolve(workspaceCwd) : null;
|
||||
const shapedWorkspaceHints = workspaceHints.map((hint) => {
|
||||
const nextHint = { ...hint };
|
||||
const hintCwd = typeof nextHint.cwd === "string" ? nextHint.cwd.trim() : "";
|
||||
if (!hintCwd) return nextHint;
|
||||
|
||||
if (localWorkspaceCwd && path.resolve(hintCwd) === localWorkspaceCwd) {
|
||||
if (realizedWorkspaceCwd) {
|
||||
nextHint.cwd = realizedWorkspaceCwd;
|
||||
} else {
|
||||
delete nextHint.cwd;
|
||||
}
|
||||
return nextHint;
|
||||
}
|
||||
|
||||
delete nextHint.cwd;
|
||||
return nextHint;
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceCwd: realizedWorkspaceCwd,
|
||||
workspaceWorktreePath: null,
|
||||
workspaceHints: shapedWorkspaceHints,
|
||||
};
|
||||
}
|
||||
|
||||
export function rewriteWorkspaceCwdEnvVarsForExecution(input: {
|
||||
env: Record<string, unknown>;
|
||||
workspaceCwd?: string | null;
|
||||
executionCwd?: string | null;
|
||||
executionTargetIsRemote?: boolean;
|
||||
}): Record<string, string> {
|
||||
const nextEnv = Object.fromEntries(
|
||||
Object.entries(input.env)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
) as Record<string, string>;
|
||||
const localWorkspaceCwd = typeof input.workspaceCwd === "string" && input.workspaceCwd.trim().length > 0
|
||||
? path.resolve(input.workspaceCwd)
|
||||
: null;
|
||||
// executionCwd is a remote path on the target host; we deliberately do not
|
||||
// run `path.resolve` against it because that applies host-Node semantics
|
||||
// (current working directory, host path separator) to a path that lives on
|
||||
// the remote shell. Callers always pass absolute remote paths, so we
|
||||
// forward the trimmed value verbatim.
|
||||
const remoteWorkspaceCwd = typeof input.executionCwd === "string" && input.executionCwd.trim().length > 0
|
||||
? input.executionCwd.trim()
|
||||
: null;
|
||||
|
||||
if (!input.executionTargetIsRemote || !localWorkspaceCwd || !remoteWorkspaceCwd) {
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(nextEnv)) {
|
||||
if (!key.endsWith("_WORKSPACE_CWD")) continue;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
if (path.resolve(trimmed) !== localWorkspaceCwd) continue;
|
||||
nextEnv[key] = remoteWorkspaceCwd;
|
||||
}
|
||||
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
export function refreshPaperclipWorkspaceEnvForExecution(input: {
|
||||
env: Record<string, string>;
|
||||
envConfig?: Record<string, unknown>;
|
||||
workspaceCwd?: string | null;
|
||||
workspaceSource?: string | null;
|
||||
workspaceStrategy?: string | null;
|
||||
workspaceId?: string | null;
|
||||
workspaceRepoUrl?: string | null;
|
||||
workspaceRepoRef?: string | null;
|
||||
workspaceBranch?: string | null;
|
||||
workspaceWorktreePath?: string | null;
|
||||
workspaceHints?: Array<Record<string, unknown>>;
|
||||
agentHome?: string | null;
|
||||
executionTargetIsRemote?: boolean;
|
||||
executionCwd?: string | null;
|
||||
}): {
|
||||
workspaceCwd: string | null;
|
||||
workspaceWorktreePath: string | null;
|
||||
workspaceHints: Array<Record<string, unknown>>;
|
||||
} {
|
||||
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
||||
workspaceCwd: input.workspaceCwd,
|
||||
workspaceWorktreePath: input.workspaceWorktreePath,
|
||||
workspaceHints: input.workspaceHints,
|
||||
executionTargetIsRemote: input.executionTargetIsRemote,
|
||||
executionCwd: input.executionCwd,
|
||||
});
|
||||
|
||||
delete input.env.PAPERCLIP_WORKSPACE_CWD;
|
||||
delete input.env.PAPERCLIP_WORKSPACE_WORKTREE_PATH;
|
||||
delete input.env.PAPERCLIP_WORKSPACES_JSON;
|
||||
|
||||
applyPaperclipWorkspaceEnv(input.env, {
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
workspaceSource: input.workspaceSource,
|
||||
workspaceStrategy: input.workspaceStrategy,
|
||||
workspaceId: input.workspaceId,
|
||||
workspaceRepoUrl: input.workspaceRepoUrl,
|
||||
workspaceRepoRef: input.workspaceRepoRef,
|
||||
workspaceBranch: input.workspaceBranch,
|
||||
workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath,
|
||||
agentHome: input.agentHome,
|
||||
});
|
||||
|
||||
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
|
||||
input.env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
|
||||
}
|
||||
|
||||
const shapedEnvConfig = rewriteWorkspaceCwdEnvVarsForExecution({
|
||||
env: input.envConfig ?? {},
|
||||
workspaceCwd: input.workspaceCwd,
|
||||
executionCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
executionTargetIsRemote: input.executionTargetIsRemote,
|
||||
});
|
||||
for (const [key, value] of Object.entries(shapedEnvConfig)) {
|
||||
input.env[key] = value;
|
||||
}
|
||||
|
||||
return shapedWorkspaceEnv;
|
||||
}
|
||||
|
||||
export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
||||
for (const key of Object.keys(env)) {
|
||||
@@ -1200,13 +951,6 @@ function quoteForCmd(arg: string) {
|
||||
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||
}
|
||||
|
||||
export function sanitizeSshRemoteEnv(
|
||||
env: Record<string, string>,
|
||||
inheritedEnv: NodeJS.ProcessEnv = process.env,
|
||||
): Record<string, string> {
|
||||
return sanitizeRemoteExecutionEnv(env, inheritedEnv);
|
||||
}
|
||||
|
||||
function resolveWindowsCmdShell(env: NodeJS.ProcessEnv): string {
|
||||
const fallbackRoot = env.SystemRoot || process.env.SystemRoot || "C:\\Windows";
|
||||
return path.join(fallbackRoot, "System32", "cmd.exe");
|
||||
@@ -1651,190 +1395,6 @@ export async function ensurePaperclipSkillSymlink(
|
||||
return "repaired";
|
||||
}
|
||||
|
||||
async function hashSkillDirectory(root: string): Promise<string> {
|
||||
const hash = createHash("sha256");
|
||||
|
||||
async function visit(candidate: string, relativePath: string): Promise<void> {
|
||||
const stat = await fs.lstat(candidate);
|
||||
if (stat.isSymbolicLink()) {
|
||||
hash.update(`symlink:${relativePath}\n`);
|
||||
return;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
hash.update(`dir:${relativePath}\n`);
|
||||
const entries = await fs.readdir(candidate, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const childRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
await visit(path.join(candidate, entry.name), childRelativePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stat.isFile()) {
|
||||
hash.update(`file:${relativePath}:${stat.mode}\n`);
|
||||
hash.update(await fs.readFile(candidate));
|
||||
hash.update("\n");
|
||||
return;
|
||||
}
|
||||
hash.update(`other:${relativePath}:${stat.mode}\n`);
|
||||
}
|
||||
|
||||
await visit(root, "");
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function materializedSkillFingerprintMatches(targetRoot: string, sourceFingerprint: string): Promise<boolean> {
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(path.join(targetRoot, MATERIALIZED_SKILL_SENTINEL), "utf8")) as unknown;
|
||||
const parsed = parseObject(raw);
|
||||
return parsed.version === 1 && parsed.sourceFingerprint === sourceFingerprint;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireMaterializeLock(lockDir: string): Promise<() => Promise<void>> {
|
||||
await fs.mkdir(path.dirname(lockDir), { recursive: true });
|
||||
const deadline = Date.now() + MATERIALIZED_SKILL_LOCK_STALE_MS;
|
||||
while (true) {
|
||||
try {
|
||||
await fs.mkdir(lockDir);
|
||||
await fs.writeFile(
|
||||
path.join(lockDir, MATERIALIZED_SKILL_LOCK_OWNER),
|
||||
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return async () => {
|
||||
await fs.rm(lockDir, { recursive: true, force: true });
|
||||
};
|
||||
} catch (err) {
|
||||
const code = err && typeof err === "object" ? (err as { code?: unknown }).code : null;
|
||||
if (code !== "EEXIST") throw err;
|
||||
if (await removeStaleMaterializeLock(lockDir, MATERIALIZED_SKILL_LOCK_STALE_MS)) continue;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out waiting for Paperclip skill materialization lock at ${lockDir}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = err && typeof err === "object" ? (err as { code?: unknown }).code : null;
|
||||
return code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStaleMaterializeLock(lockDir: string, staleMs: number): Promise<boolean> {
|
||||
const ownerPath = path.join(lockDir, MATERIALIZED_SKILL_LOCK_OWNER);
|
||||
let shouldRemove = false;
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(ownerPath, "utf8")) as unknown;
|
||||
const owner = parseObject(raw);
|
||||
const pid = typeof owner.pid === "number" ? owner.pid : 0;
|
||||
const createdAt = typeof owner.createdAt === "string" ? Date.parse(owner.createdAt) : Number.NaN;
|
||||
const ageMs = Number.isFinite(createdAt) ? Date.now() - createdAt : staleMs + 1;
|
||||
shouldRemove = !isPidAlive(pid) || ageMs > staleMs;
|
||||
} catch {
|
||||
const stat = await fs.stat(lockDir).catch(() => null);
|
||||
shouldRemove = !stat || Date.now() - stat.mtimeMs > staleMs;
|
||||
}
|
||||
if (!shouldRemove) return false;
|
||||
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function materializePaperclipSkillCopy(
|
||||
source: string,
|
||||
target: string,
|
||||
): Promise<MaterializedPaperclipSkillCopyResult> {
|
||||
const sourceRoot = path.resolve(source);
|
||||
const targetRoot = path.resolve(target);
|
||||
const relativeTarget = path.relative(sourceRoot, targetRoot);
|
||||
const relativeSource = path.relative(targetRoot, sourceRoot);
|
||||
if (
|
||||
!relativeTarget ||
|
||||
(!relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) ||
|
||||
!relativeSource ||
|
||||
(!relativeSource.startsWith("..") && !path.isAbsolute(relativeSource))
|
||||
) {
|
||||
throw new Error("Refusing to materialize a skill into itself, an ancestor, or one of its descendants.");
|
||||
}
|
||||
|
||||
const rootStat = await fs.lstat(sourceRoot);
|
||||
if (rootStat.isSymbolicLink()) {
|
||||
throw new Error("Refusing to materialize a skill root that is itself a symlink.");
|
||||
}
|
||||
if (!rootStat.isDirectory()) {
|
||||
throw new Error("Paperclip skills must be directories.");
|
||||
}
|
||||
|
||||
const result: MaterializedPaperclipSkillCopyResult = {
|
||||
copiedFiles: 0,
|
||||
skippedSymlinks: [],
|
||||
};
|
||||
|
||||
const lockDir = `${targetRoot}.lock`;
|
||||
const releaseLock = await acquireMaterializeLock(lockDir);
|
||||
const tempRoot = `${targetRoot}.tmp-${process.pid}-${randomUUID()}`;
|
||||
|
||||
async function copyEntry(sourcePath: string, targetPath: string, relativePath: string): Promise<void> {
|
||||
const stat = await fs.lstat(sourcePath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
result.skippedSymlinks.push(relativePath || ".");
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await fs.mkdir(targetPath, { recursive: true });
|
||||
const entries = await fs.readdir(sourcePath, { withFileTypes: true });
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
const childRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
await copyEntry(path.join(sourcePath, entry.name), path.join(targetPath, entry.name), childRelativePath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat.isFile()) {
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => {
|
||||
await fs.copyFile(sourcePath, targetPath);
|
||||
});
|
||||
await fs.chmod(targetPath, stat.mode).catch(() => {});
|
||||
result.copiedFiles += 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceFingerprint = await hashSkillDirectory(sourceRoot);
|
||||
if (await materializedSkillFingerprintMatches(targetRoot, sourceFingerprint)) return result;
|
||||
await copyEntry(sourceRoot, tempRoot, "");
|
||||
await fs.writeFile(
|
||||
path.join(tempRoot, MATERIALIZED_SKILL_SENTINEL),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint,
|
||||
copiedFiles: result.copiedFiles,
|
||||
skippedSymlinks: result.skippedSymlinks,
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
if (await materializedSkillFingerprintMatches(targetRoot, sourceFingerprint)) return result;
|
||||
await fs.rm(targetRoot, { recursive: true, force: true });
|
||||
await fs.rename(tempRoot, targetRoot);
|
||||
return result;
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {});
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeMaintainerOnlySkillSymlinks(
|
||||
skillsHome: string,
|
||||
allowedSkillNames: Iterable<string>,
|
||||
|
||||
@@ -37,10 +37,8 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = {
|
||||
};
|
||||
|
||||
export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
|
||||
"acpx_local",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"cursor_cloud",
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"hermes_local",
|
||||
@@ -49,11 +47,6 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
|
||||
]);
|
||||
|
||||
export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement> = {
|
||||
acpx_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "confirmed",
|
||||
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
|
||||
},
|
||||
claude_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "confirmed",
|
||||
@@ -64,11 +57,6 @@ export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement
|
||||
nativeContextManagement: "confirmed",
|
||||
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
|
||||
},
|
||||
cursor_cloud: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "unknown",
|
||||
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
},
|
||||
cursor: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "unknown",
|
||||
|
||||