Carry current master through Pi public installation

This commit is contained in:
Dotta committed 2026-10-08 14:22:49 -05:00
commit e00258afd9
2195 files changed
+255923 -79198

No files matched your search

+1 -1
View File
@@ -60,7 +60,7 @@
},
"devDependencies": {
"@types/node": "^24.0.0",
"tsx": "^4.23.12",
"tsx": "^4.23.15",
"typescript": "^7.0.2"
},
"engines": {
+113
View File
@@ -0,0 +1,113 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { credentialPath, mcpAccessToken, mcpResource, mcpRpc, replaceMcpCredential, withMcpCredentialLock } from "../client/mcp-auth.js";
const resource = "https://paperclip.example/mcp/paperclip";
let home: string;
beforeEach(async () => { home = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-credentials-")); vi.stubEnv("PAPERCLIP_HOME", home); });
afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); await fs.rm(home, { recursive: true, force: true }); });
async function seed(value: unknown, mode = 0o600) {
const file = credentialPath(resource);
await fs.mkdir(path.dirname(file), { mode: 0o700 });
await fs.writeFile(file, typeof value === "string" ? value : JSON.stringify(value), { mode });
return file;
}
const stored = () => ({ resource, issuer: "https://paperclip.example", clientId: "client", companyId: "company", accessToken: "private-access", refreshToken: "private-refresh", expiresAt: 0 });
describe("MCP protected credential transport", () => {
it("rejects noncanonical resources and isolates credential files by resource", () => {
for (const url of ["http://evil.example/mcp/paperclip", resource + "?token=secret", resource + "#x", "https://u:p@paperclip.example/mcp/paperclip", "https://paperclip.example/other"]) expect(() => mcpResource(url)).toThrow();
expect(mcpResource("http://127.0.0.1:3100/mcp/paperclip")).toContain("127.0.0.1");
expect(credentialPath(resource)).not.toBe(credentialPath("https://other.example/mcp/paperclip"));
});
it("serializes rotating refreshes and persists only the replacement token privately", async () => {
const file = await seed(stored()); let refreshes = 0;
vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => {
expect(init?.redirect).toBe("error");
if (url.includes("oauth-protected-resource")) return Response.json({ resource, authorization_servers: ["https://paperclip.example"] });
if (url.includes("oauth-authorization-server")) return Response.json({ issuer: "https://paperclip.example", token_endpoint: "https://paperclip.example/mcp/oauth/token", device_authorization_endpoint: "https://paperclip.example/mcp/oauth/device_authorization", registration_endpoint: "https://paperclip.example/mcp/oauth/register" });
expect(url).toBe("https://paperclip.example/mcp/oauth/token");
expect(JSON.parse(init!.body as string).refresh_token).toBe("private-refresh"); refreshes++;
return Response.json({ access_token: "replacement-access", refresh_token: "replacement-refresh", token_type: "Bearer", expires_in: 900 });
}));
expect(await Promise.all([mcpAccessToken(resource), mcpAccessToken(resource)])).toEqual(["replacement-access", "replacement-access"]);
expect(refreshes).toBe(1);
expect((await fs.stat(file)).mode & 0o077).toBe(0);
expect(await fs.readFile(file, "utf8")).not.toContain("private-refresh");
});
it("never sends a stored credential to another issuer", async () => {
await seed({ ...stored(), issuer: "https://evil.example" }); const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher);
await expect(mcpAccessToken(resource)).rejects.toThrow("Connect first"); expect(fetcher).not.toHaveBeenCalled();
});
it("fails closed when discovery changes without sending refresh credentials", async () => {
await seed(stored()); const fetcher = vi.fn(async () => Response.json({ resource, authorization_servers: ["https://evil.example"] })); vi.stubGlobal("fetch", fetcher);
await expect(mcpAccessToken(resource)).rejects.toThrow("does not match"); expect(fetcher).toHaveBeenCalledTimes(1);
});
it("rejects readable or malformed credential files without echoing secrets", async () => {
const file = await seed(stored(), 0o644);
await expect(mcpAccessToken(resource)).rejects.toThrow("private");
await fs.chmod(file, 0o600); await fs.writeFile(file, '{private-secret');
await expect(mcpAccessToken(resource)).rejects.toThrow("Invalid MCP credential file. Sign in again.");
});
it("does not expose upstream response text in protocol errors or retry writes", async () => {
const fetcher = vi.fn(async () => new Response("private-upstream-token", { status: 200 })); vi.stubGlobal("fetch", fetcher);
await expect(mcpRpc(resource, "private-access", "tools/call", { name: "paperclip_create_task" })).rejects.toThrow("invalid response");
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("releases the refresh mutex after failed work", async () => {
const file = credentialPath(resource);
await expect(withMcpCredentialLock(file, async () => { throw new Error("failed"); })).rejects.toThrow("failed");
await expect(withMcpCredentialLock(file, async () => "recovered")).resolves.toBe("recovered");
});
it("recovers an OS mutex after its owner is killed without a stale-file takeover", async () => {
const file = await seed(stored());
await fs.writeFile(file + ".mutex.sqlite", "", { mode: 0o600 });
const child = spawn(process.execPath, ["--input-type=module", "-e", 'import {DatabaseSync} from "node:sqlite"; const db=new DatabaseSync(process.argv[1]); db.exec("BEGIN IMMEDIATE"); console.log("locked"); setInterval(()=>{},1000);', file + ".mutex.sqlite"], { stdio: ["ignore", "pipe", "pipe"] });
try {
await once(child.stdout!, "data");
let entered = false;
const next = withMcpCredentialLock(file, async () => { entered = true; return "recovered"; });
await new Promise(resolve => setTimeout(resolve, 150));
expect(entered).toBe(false);
const exit = once(child, "exit"); child.kill("SIGKILL"); await exit;
await expect(next).resolves.toBe("recovered");
} finally { child.kill("SIGKILL"); }
});
it("preserves a working connection when replacement storage fails", async () => {
const file = await seed(stored());
const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher);
vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("disk failure"));
await expect(replaceMcpCredential(file, { ...stored(), accessToken: "new-access" }, vi.fn())).rejects.toThrow("disk failure");
expect(JSON.parse(await fs.readFile(file, "utf8")).accessToken).toBe("private-access");
expect(fetcher).not.toHaveBeenCalled();
});
it("keeps the OS lock held while a same-process caller waits", async () => {
const file = await seed(stored());
let release!: () => void;
let entered!: () => void;
const ready = new Promise<void>(resolve => { entered = resolve; });
const held = new Promise<void>(resolve => { release = resolve; });
const first = withMcpCredentialLock(file, async () => { entered(); await held; });
await ready;
let secondEntered = false;
const second = withMcpCredentialLock(file, async () => { secondEntered = true; });
try {
await new Promise(resolve => setTimeout(resolve, 100));
const probe = spawnSync(process.execPath, ["--input-type=module", "-e", 'import{DatabaseSync}from"node:sqlite";const db=new DatabaseSync(process.argv[1]);try{db.exec("BEGIN IMMEDIATE");process.exitCode=0}catch(e){process.exitCode=e.errcode===5?5:1}finally{db.close()}', file + ".mutex.sqlite"]);
expect(probe.status).toBe(5);
expect(secondEntered).toBe(false);
} finally { release(); await Promise.all([first, second]); }
});
it("retains the new saved credential and reports failed previous-grant cleanup", async () => {
const file = await seed(stored()); const notify = vi.fn();
vi.stubGlobal("fetch", vi.fn(async () => {
expect(JSON.parse(await fs.readFile(file, "utf8")).accessToken).toBe("new-access");
return new Response(null, { status: 503 });
}));
await replaceMcpCredential(file, { ...stored(), accessToken: "new-access" }, notify);
expect(notify).toHaveBeenCalledWith(expect.stringContaining("revoke it in Paperclip Connections"));
});
});
+38
View File
@@ -0,0 +1,38 @@
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, expect, it } from "vitest";
import { resolveCursorProvisioner } from "../commands/runtime.js";
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
async function fixture() {
const root = await realpath(await mkdtemp(join(tmpdir(), "cursor-public-setup-")));
roots.push(root);
await mkdir(join(root, "dist/vendor/paperclip-runner/cli"), { recursive: true });
await writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/server" }));
await writeFile(join(root, "dist/index.js"), "throw new Error('must not import server');");
const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-cursor.cjs");
await writeFile(provisioner, "fixture");
return { root, provisioner, url: pathToFileURL(join(root, "dist/index.js")).href };
}
it("resolves the public provisioner without starting the server", async () => {
const f = await fixture();
expect(await resolveCursorProvisioner(f.url)).toBe(f.provisioner);
});
it("rejects repository layouts and wrong package identities", async () => {
const f = await fixture();
await mkdir(join(f.root, "src")); await writeFile(join(f.root, "src/index.ts"), "fixture");
await expect(resolveCursorProvisioner(pathToFileURL(join(f.root, "src/index.ts")).href)).rejects.toThrow("published server layout");
await writeFile(join(f.root, "package.json"), JSON.stringify({ name: "untrusted" }));
await expect(resolveCursorProvisioner(f.url)).rejects.toThrow("identity is invalid");
});
it("rejects a provisioner linked outside the installed package", async () => {
const f = await fixture(); const other = await fixture();
await rm(f.provisioner); await symlink(other.provisioner, f.provisioner);
await expect(resolveCursorProvisioner(f.url)).rejects.toThrow("escapes");
});
+70 -14
View File
@@ -157,12 +157,12 @@ async function seedValidWorktreeSource(
userId,
role: "instance_admin",
});
await db.insert(companies).values({
id: companyId,
name: "Seed Source",
issuePrefix: "SEED",
requireBoardApprovalForNewAgents: false,
});
// Seed only columns present in the historical source schema. The current
// model includes accounting columns that the worktree migration adds later.
await db.$client`
insert into companies (id, name, issue_prefix, require_board_approval_for_new_agents)
values (${companyId}, 'Seed Source', 'SEED', false)
`;
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
@@ -511,7 +511,8 @@ describe("worktree helpers", () => {
expect(minimal.excludedTables).toContain("agent_task_sessions");
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
expect(full.excludedTables).toEqual([]);
expect(full.excludedTables).toEqual(["agent_identity_keys"]);
expect(minimal.excludedTables).toContain("agent_identity_keys");
expect(full.nullifyColumns).toEqual({});
});
@@ -1383,7 +1384,7 @@ describe("worktree helpers", () => {
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(executionWorkspace?.metadata).toEqual({
expect(executionWorkspace?.metadata).toMatchObject({
keep: "execution-metadata",
config: {
environmentId: "environment-1",
@@ -1471,6 +1472,36 @@ describe("worktree helpers", () => {
}
});
it("creates an explicitly empty worktree without inherited signing secrets or deferred copying", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-empty-"));
const originalCwd = process.cwd();
const originalJwt = process.env.PAPERCLIP_AGENT_JWT_SECRET;
const originalSigning = process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
try {
const repoRoot = path.join(tempRoot, "repo");
fs.mkdirSync(repoRoot, { recursive: true });
process.chdir(repoRoot);
process.env.PAPERCLIP_AGENT_JWT_SECRET = "source-jwt-secret";
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = "source-signing-secret";
await worktreeInitCommand({ empty: true, fromConfig: path.join(tempRoot, "missing.json"), home: path.join(tempRoot, "instances") });
const env = fs.readFileSync(path.join(repoRoot, ".paperclip/.env"), "utf8");
expect(env).not.toContain("source-jwt-secret");
expect(env).not.toContain("source-signing-secret");
expect(env).toContain("PAPERCLIP_AGENT_JWT_SECRET=");
expect(fs.existsSync(path.join(repoRoot, ".paperclip/seed-manifest.json"))).toBe(false);
expect(fs.existsSync(path.join(repoRoot, ".paperclip/seed-pending"))).toBe(false);
expect(fs.existsSync(path.join(repoRoot, ".paperclip/seed-empty"))).toBe(true);
await worktreeInitCommand({ seed: false, force: true, fromConfig: path.join(tempRoot, "missing.json"), home: path.join(tempRoot, "instances") });
expect(fs.existsSync(path.join(repoRoot, ".paperclip/seed-empty"))).toBe(false);
expect(readWorktreeSeedManifest(path.join(repoRoot, ".paperclip/config.json"))?.state).toBe("pending");
} finally {
process.chdir(originalCwd);
if (originalJwt === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwt;
if (originalSigning === undefined) delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET; else process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = originalSigning;
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("persists the current agent jwt secret into the worktree env file", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
const repoRoot = path.join(tempRoot, "repo");
@@ -1681,10 +1712,13 @@ describe("worktree helpers", () => {
// A lagging source must also have the prior schema. Deleting only the
// newest receipt from a fully migrated schema relied on that particular
// migration being idempotent and breaks when the new migration creates a
// table. Build the actual all-but-last schema before shuffling its history.
// table. Build the schema before the identity-repair migration, so this
// regression keeps testing that repair as later migrations are added.
const migrationsRoot = new URL("../../../packages/db/src/migrations/", import.meta.url);
const journal = JSON.parse(fs.readFileSync(new URL("meta/_journal.json", migrationsRoot), "utf8"));
const priorEntries = journal.entries.slice(0, -1);
const repairIndex = journal.entries.findIndex((entry: { tag: string }) => entry.tag === "0309_loving_the_hood");
expect(repairIndex).toBeGreaterThan(0);
const priorEntries = journal.entries.slice(0, repairIndex);
const priorMigrations = path.join(tempRoot, "prior-migrations");
fs.mkdirSync(path.join(priorMigrations, "meta"), { recursive: true });
fs.writeFileSync(path.join(priorMigrations, "meta", "_journal.json"), JSON.stringify({ ...journal, entries: priorEntries }));
@@ -1693,7 +1727,19 @@ describe("worktree helpers", () => {
}
const sourceDbClient = createDb(sourceDb.connectionString);
await migrate(drizzle(sourceDbClient.$client), { migrationsFolder: priorMigrations });
await seedValidWorktreeSource(sourceDb.connectionString);
const seed = await seedValidWorktreeSource(sourceDb.connectionString);
// An older filtered JavaScript backup retained event IDs but lost the
// identity generator. The pending migration must repair that schema.
const legacyAgentId = randomUUID();
await sourceDbClient.$client`
INSERT INTO agents (id, company_id, name, status)
VALUES (${legacyAgentId}, ${seed.companyId}, 'Legacy paused agent', 'paused')
`;
await sourceDbClient.$client`
INSERT INTO resource_lifecycle_events (company_id, resource_type, resource_id, action)
VALUES (${seed.companyId}, 'agent', ${legacyAgentId}, 'pause')
`;
await sourceDbClient.$client.unsafe('ALTER TABLE resource_lifecycle_events ALTER COLUMN id DROP IDENTITY');
await sourceDbClient.$client.unsafe(`
WITH pair AS (
SELECT
@@ -1724,7 +1770,7 @@ describe("worktree helpers", () => {
if (laggingMigrationState.status !== "needsMigrations") {
throw new Error("Expected the source migration journal to lag the code journal");
}
expect(laggingMigrationState.pendingMigrations).toHaveLength(1);
expect(laggingMigrationState.pendingMigrations).toHaveLength(journal.entries.length - repairIndex);
const expectedAppliedPrefix = laggingMigrationState.availableMigrations.slice(
0,
laggingMigrationState.appliedMigrations.length,
@@ -1806,6 +1852,16 @@ describe("worktree helpers", () => {
);
const seededUsers = await targetDb.select().from(authUsers);
expect(seededUsers.some((row) => row.email === "existing@paperclip.ing")).toBe(true);
const restoredEvents = await targetDb.$client`
SELECT id, action FROM resource_lifecycle_events WHERE resource_id = ${legacyAgentId} ORDER BY id
`;
expect(restoredEvents.map(row => row.action)).toEqual(["pause", "create"]);
expect(Number(restoredEvents[1].id)).toBeGreaterThan(Number(restoredEvents[0].id));
const [identity] = await targetDb.$client`
SELECT is_identity FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'resource_lifecycle_events' AND column_name = 'id'
`;
expect(identity.is_identity).toBe("YES");
},
);
@@ -2065,8 +2121,8 @@ describe("worktree helpers", () => {
}
});
it("uses streaming backup selection for full seeds and transformed backup selection for minimal seeds", () => {
expect(resolveWorktreeSeedBackupEngine(resolveWorktreeSeedPlan("full"))).toBe("auto");
it("uses transformed backups for both seed modes to omit agent identities", () => {
expect(resolveWorktreeSeedBackupEngine(resolveWorktreeSeedPlan("full"))).toBe("javascript");
expect(resolveWorktreeSeedBackupEngine(resolveWorktreeSeedPlan("minimal"))).toBe("javascript");
});
+210
View File
@@ -0,0 +1,210 @@
import fs from "node:fs/promises";
import { constants } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { createHash, randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import { PUBLIC_MCP_PATH, PUBLIC_MCP_SCOPES } from "@paperclipai/shared";
import { resolvePaperclipHomeDir } from "../config/home.js";
const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code";
type Json = Record<string, unknown>;
export interface McpCredential {
resource: string; issuer: string; clientId: string; companyId: string;
accessToken: string; refreshToken?: string; expiresAt: number;
}
export function mcpResource(raw: string) {
const url = new URL(raw);
if ((url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
|| url.username || url.password || url.search || url.hash || url.pathname !== PUBLIC_MCP_PATH) throw new Error("Use the exact Paperclip HTTPS MCP URL from Connections.");
return url.toString();
}
export function credentialPath(resource: string) {
return path.join(resolvePaperclipHomeDir(), "mcp", createHash("sha256").update(resource).digest("hex") + ".json");
}
async function readCredential(file: string): Promise<McpCredential | null> {
let handle;
try { handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
try {
const stat = await handle.stat();
if (!stat.isFile() || (stat.mode & 0o077) !== 0) throw new Error("MCP credential file must be private (mode 0600).");
let value: McpCredential;
try { value = JSON.parse(await handle.readFile("utf8")); }
catch { throw new Error("Invalid MCP credential file. Sign in again."); }
if (!value || typeof value !== "object" || ![value.resource, value.issuer, value.clientId, value.companyId, value.accessToken].every(v => typeof v === "string" && v.length > 0)
|| !Number.isFinite(value.expiresAt) || (value.refreshToken !== undefined && typeof value.refreshToken !== "string")) throw new Error("Invalid MCP credential file. Sign in again.");
return value;
} finally { await handle.close(); }
}
async function saveCredential(file: string, credential: McpCredential) {
const temporary = file + "." + randomUUID();
try {
await fs.writeFile(temporary, JSON.stringify(credential), { mode: 0o600, flag: "wx" });
await fs.rename(temporary, file);
} finally { await fs.unlink(temporary).catch(() => {}); }
}
/** One refresh owner per resource, including independent CLI/bridge processes. */
const processLocks = new Map<string, Promise<void>>();
export async function withMcpCredentialLock<T>(file: string, work: () => Promise<T>) {
const key = path.resolve(file);
const previous = processLocks.get(key) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>(resolve => { release = resolve; });
processLocks.set(key, current);
await previous;
try { return await withMcpOsLock(key, work); }
finally {
release();
if (processLocks.get(key) === current) processLocks.delete(key);
}
}
async function withMcpOsLock<T>(file: string, work: () => Promise<T>) {
const directory = path.dirname(file);
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) throw new Error("The MCP credential directory must be private (mode 0700).");
// SQLite's OS lock is released when a process exits, including during acquisition.
// No PID-file deletion or stale-lock takeover can race a new refresh owner.
const lockPath = file + ".mutex.sqlite";
// Never open/close an existing SQLite file outside SQLite: POSIX close can
// release another connection's process-owned lock. The in-process queue also
// serializes initial file creation before any SQLite descriptor is opened.
try {
await fs.writeFile(lockPath, "", { flag: "wx", mode: 0o600 });
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
const lockStat = await fs.lstat(lockPath);
if (!lockStat.isFile() || lockStat.isSymbolicLink() || (lockStat.mode & 0o077) !== 0) throw new Error("The MCP mutex file must be private (mode 0600).");
const lock = new DatabaseSync(lockPath);
const deadline = Date.now() + 30_000;
try {
lock.exec("PRAGMA busy_timeout = 0");
while (true) {
try { lock.exec("BEGIN IMMEDIATE"); break; }
catch (error) {
if ((error as { errcode?: number }).errcode !== 5) throw error;
if (Date.now() >= deadline) throw new Error("Another Paperclip MCP login or refresh is in progress. Try again.");
await sleep(100);
}
}
try { return await work(); }
finally { lock.exec("ROLLBACK"); }
} finally { lock.close(); }
}
async function jsonRequest(url: string, body?: Json) {
const response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(20_000),
...(body ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}) });
const data = await safeJson(response);
return { ok: response.ok, data };
}
async function safeJson(response: Response): Promise<Json> {
try {
const value = await response.json();
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
return value as Json;
} catch { throw new Error("Paperclip returned an invalid response. Check the connection before retrying."); }
}
async function revoke(credential: McpCredential) {
const response = await fetch(credential.issuer + "/mcp/oauth/revoke", { method: "POST", redirect: "error", signal: AbortSignal.timeout(20_000),
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: credential.clientId, token: credential.refreshToken ?? credential.accessToken }) });
if (!response.ok) throw new Error("Could not revoke the previous connection.");
}
async function discovery(resource: string) {
const origin = new URL(resource).origin;
const protectedResource = await jsonRequest(origin + "/.well-known/oauth-protected-resource" + PUBLIC_MCP_PATH);
if (!protectedResource.ok || protectedResource.data.resource !== resource || !Array.isArray(protectedResource.data.authorization_servers)
|| !protectedResource.data.authorization_servers.includes(origin)) throw new Error("Paperclip resource discovery does not match this instance.");
const metadata = await jsonRequest(origin + "/.well-known/oauth-authorization-server");
if (!metadata.ok || metadata.data.issuer !== origin || metadata.data.token_endpoint !== origin + "/mcp/oauth/token"
|| metadata.data.device_authorization_endpoint !== origin + "/mcp/oauth/device_authorization"
|| metadata.data.registration_endpoint !== origin + "/mcp/oauth/register") throw new Error("This Paperclip instance does not support device login.");
return origin;
}
function tokens(data: Json, credential: Omit<McpCredential, "accessToken" | "expiresAt">): McpCredential {
if (typeof data.access_token !== "string" || data.token_type !== "Bearer" || typeof data.expires_in !== "number" || data.expires_in <= 0) throw new Error("Invalid Paperclip token response. Reconnect.");
return { ...credential, accessToken: data.access_token, expiresAt: Date.now() + data.expires_in * 1000,
...(typeof data.refresh_token === "string" ? { refreshToken: data.refresh_token } : {}) };
}
export async function mcpRpc(resource: string, token: string, method: string, params: Json = {}) {
const version = "2026-07-28";
const response = await fetch(resource, { method: "POST", redirect: "error", signal: AbortSignal.timeout(60_000),
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "MCP-Protocol-Version": version, "Mcp-Method": method,
...(method === "tools/call" && typeof params.name === "string" ? { "Mcp-Name": params.name } : {}) },
body: JSON.stringify({ jsonrpc: "2.0", id: randomUUID(), method, params: { ...params, _meta: {
"io.modelcontextprotocol/protocolVersion": version, "io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": { name: "Paperclip CLI", version: "1" },
} } }),
});
if (!response.ok) throw new Error(`Paperclip MCP returned HTTP ${response.status}. Check the connection before retrying; a write may already have completed.`);
const data = await safeJson(response);
if (data.error || !data.result || typeof data.result !== "object") throw new Error("Paperclip could not complete this MCP request. Check the connection before retrying.");
return data.result as Json;
}
export async function loginMcpDevice(raw: string, companyId?: string, notify: (message: string) => void = console.error) {
const resource = mcpResource(raw);
const issuer = await discovery(resource);
const registration = await jsonRequest(issuer + "/mcp/oauth/register", { client_name: "Paperclip CLI (device)", redirect_uris: [], grant_types: [deviceGrant, "refresh_token"], response_types: [], token_endpoint_auth_method: "none" });
if (!registration.ok || typeof registration.data.client_id !== "string") throw new Error("Could not register the Paperclip device client.");
const clientId = registration.data.client_id;
const authorization = await jsonRequest(issuer + "/mcp/oauth/device_authorization", { client_id: clientId, resource, scope: PUBLIC_MCP_SCOPES.join(" "), ...(companyId ? { company_id: companyId } : {}) });
const device = authorization.data;
if (!authorization.ok || typeof device.device_code !== "string" || typeof device.user_code !== "string"
|| device.verification_uri !== issuer + "/mcp-device" || typeof device.expires_in !== "number") throw new Error("Could not start device authorization.");
const verification = new URL(device.verification_uri); verification.searchParams.set("user_code", device.user_code);
notify(`Approve Paperclip access in your browser: ${verification}\nConfirm code: ${device.user_code}\nWaiting for your approval…`);
let interval = typeof device.interval === "number" ? Math.max(5, device.interval) : 5;
const deadline = Date.now() + Math.min(600, device.expires_in) * 1000;
while (Date.now() < deadline) {
await sleep(interval * 1000);
const result = await jsonRequest(issuer + "/mcp/oauth/token", { grant_type: deviceGrant, client_id: clientId, resource, device_code: device.device_code });
if (!result.ok) {
if (result.data.error === "authorization_pending") continue;
if (result.data.error === "slow_down") { interval += 5; continue; }
throw new Error(result.data.error === "access_denied" ? "Connection declined. No access was granted." : "Device authorization expired or failed. Start login again.");
}
const credential = tokens(result.data, { resource, issuer, clientId, companyId: companyId ?? "" });
const identity = await mcpRpc(resource, credential.accessToken, "tools/call", { name: "paperclip_connection", arguments: {} });
const connected = identity.structuredContent as Json | undefined;
if (identity.isError || typeof connected?.companyId !== "string" || (companyId && connected.companyId !== companyId)) throw new Error("The approved organization did not match. Reconnect before doing work.");
credential.companyId = connected.companyId;
const file = credentialPath(resource);
await replaceMcpCredential(file, credential, notify);
return { resource, companyId: credential.companyId };
}
throw new Error("Device authorization expired. Start login again.");
}
export async function replaceMcpCredential(file: string, credential: McpCredential, notify: (message: string) => void) {
await withMcpCredentialLock(file, async () => {
const previous = await readCredential(file);
// Persist the working replacement before changing authority on the server.
await saveCredential(file, credential);
if (previous && previous.resource === credential.resource && previous.issuer === credential.issuer) {
try { await revoke(previous); }
catch { notify("Connected. The previous connection could not be revoked; revoke it in Paperclip Connections."); }
}
});
}
export async function mcpAccessToken(raw: string) {
const resource = mcpResource(raw);
const file = credentialPath(resource);
return withMcpCredentialLock(file, async () => {
const credential = await readCredential(file);
if (!credential || credential.resource !== resource || credential.issuer !== new URL(resource).origin) throw new Error("Connect first with paperclipai mcp login --device --url <MCP URL>.");
if (credential.expiresAt > Date.now() + 30_000) return credential.accessToken;
if (!credential.refreshToken) throw new Error("Paperclip access expired. Sign in again.");
// Validate issuer discovery again before sending a stored credential.
await discovery(resource);
const result = await jsonRequest(credential.issuer + "/mcp/oauth/token", { grant_type: "refresh_token", client_id: credential.clientId, resource, refresh_token: credential.refreshToken });
if (!result.ok) throw new Error("Paperclip access was revoked or expired. Sign in again.");
const updated = tokens(result.data, credential);
await saveCredential(file, updated);
return updated.accessToken;
});
}
+38
View File
@@ -0,0 +1,38 @@
import type { Command } from "commander";
import { createInterface } from "node:readline";
import { loginMcpDevice, mcpAccessToken, mcpResource, mcpRpc } from "../client/mcp-auth.js";
export function registerMcpCommands(program: Command) {
const mcp = program.command("mcp").description("Connect an assistant to Paperclip as a consenting human");
mcp.command("login").requiredOption("--device", "Authorize in a browser without a callback listener")
.requiredOption("--url <url>", "Canonical Paperclip MCP URL").option("--company <id>", "Restrict consent to this organization")
.action(async (options: { url: string; company?: string }) => {
const result = await loginMcpDevice(options.url, options.company);
console.log(`Connected to ${result.resource}\nOrganization: ${result.companyId}`);
});
mcp.command("proxy").description("Expose the authorized Paperclip tools over local MCP stdio")
.requiredOption("--url <url>", "The exact MCP URL used during login")
.action(async (options: { url: string }) => {
const resource = mcpResource(options.url);
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of lines) {
let message: { id?: string | number; method?: string; params?: Record<string, unknown> };
if (Buffer.byteLength(line) > 1024 * 1024) continue;
try { message = JSON.parse(line); } catch { continue; }
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
if (message.id === undefined) continue;
try {
let result: unknown;
if (message.method === "initialize") result = { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "paperclip", version: "1" } };
else if (message.method === "ping") result = {};
else if (message.method === "tools/list" || message.method === "tools/call") result = await mcpRpc(resource, await mcpAccessToken(resource), message.method, message.params ?? {});
else { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Method not supported." } }) + "\n"); continue; }
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }) + "\n");
} catch (error) {
// Never log request arguments, raw provider responses, or credentials.
const text = "Paperclip connection failed. Check access before retrying; a write may already have completed.";
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32603, message: text } }) + "\n");
}
}
});
}
+18 -52
View File
@@ -1,47 +1,28 @@
import { spawn } from "node:child_process";
import { lstat, readFile, realpath } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
import type { Command } from "commander";
/** Resolve the public server dependency, without importing/starting the server. */
export async function resolvePiProvisioner(serverUrl: string): Promise<string> {
/** Resolve the installed public dependency without importing or starting it. */
export async function resolveCursorProvisioner(serverUrl: string): Promise<string> {
const url = new URL(serverUrl);
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Pi setup requires an installed Paperclip server");
if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Cursor setup requires an installed Paperclip server");
const entry = await realpath(fileURLToPath(url));
if (!entry.endsWith("/dist/index.js")) throw new Error("Pi setup requires the published server layout");
if (!entry.endsWith("/dist/index.js")) throw new Error("Cursor setup requires the published server layout");
const root = resolve(dirname(entry), "..");
const manifest = join(root, "package.json");
const info = await lstat(manifest);
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536 || JSON.parse(await readFile(manifest, "utf8")).name !== "@paperclipai/server") throw new Error("Pi setup server package identity is invalid");
const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-pi.cjs");
if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error("Pi setup entrypoint escapes its server package");
if (!info.isFile() || info.isSymbolicLink() || info.size > 65536 || JSON.parse(await readFile(manifest, "utf8")).name !== "@paperclipai/server") throw new Error("Cursor setup server package identity is invalid");
const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-cursor.cjs");
if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error("Cursor setup entrypoint escapes its server package");
return provisioner;
}
export async function resolveRemoteCompanionImporter(serverUrl: string): Promise<string> {
const provisioner = await resolvePiProvisioner(serverUrl);
const serverRoot = resolve(dirname(provisioner), "../../../..");
const modulePath = join(serverRoot, "dist/services/native-runtime/remote-pi-companion.js");
if (await realpath(modulePath) !== modulePath || !(await lstat(modulePath)).isFile()) throw new Error("Remote companion importer escapes its installed server");
return modulePath;
}
/** Public downloads may use operator network settings, never application secrets. */
export function buildPiSetupEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const environment: NodeJS.ProcessEnv = { PATH: source.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" };
for (const key of ["LC_ALL", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "https_proxy", "http_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"]) {
if (typeof source[key] === "string") environment[key] = source[key];
}
return environment;
}
export async function setupPiRuntime(): Promise<void> {
const provisioner = await resolvePiProvisioner(import.meta.resolve("@paperclipai/server"));
// Only explicit setup downloads. Enable Node's proxy handling for the helper;
// provider keys, npm configuration, HOME and Node injection remain excluded.
const child = spawn(process.execPath, ["--use-env-proxy", provisioner], {
stdio: "inherit", env: buildPiSetupEnvironment(),
export async function setupCursorRuntime(): Promise<void> {
const provisioner = await resolveCursorProvisioner(import.meta.resolve("@paperclipai/server"));
const child = spawn(process.execPath, [provisioner], {
stdio: "inherit", env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "C.UTF-8" },
});
const cancel = () => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); };
process.on("SIGINT", cancel); process.on("SIGTERM", cancel);
@@ -51,7 +32,7 @@ export async function setupPiRuntime(): Promise<void> {
child.on("error", error => { spawnError = error; });
child.once("close", (code, signal) => {
if (spawnError) reject(spawnError);
else if (code !== 0 || signal) reject(new Error("Pi setup did not finish. Review its error; an existing invalid installation is never replaced automatically."));
else if (code !== 0 || signal) reject(new Error("Cursor setup did not finish. Review its error; an invalid installation is never replaced automatically."));
else accept();
});
});
@@ -59,26 +40,11 @@ export async function setupPiRuntime(): Promise<void> {
}
export function registerRuntimeCommands(program: Command): void {
const runtime = program.command("runtime").description("Manage explicitly installed agent runtimes");
runtime.command("import-remote <directory>")
.description("Import a verified Linux Pi companion into the installed server (no downloads)")
.requiredOption("--sha256 <digest>", "SHA256 of companion.json from the trusted release")
.action(async (directory: string, options: { sha256: string }) => {
const modulePath = await resolveRemoteCompanionImporter(import.meta.resolve("@paperclipai/server"));
const importer = await import(pathToFileURL(modulePath).href) as { importRemotePiCompanion(input: { directory: string; sha256: string; checkCancelled: () => void }): Promise<unknown> };
let cancelled = false;
const cancel = () => { cancelled = true; };
process.on("SIGINT", cancel); process.on("SIGTERM", cancel);
try {
const result = await importer.importRemotePiCompanion({ directory, sha256: options.sha256,
checkCancelled: () => { if (cancelled) throw new Error("Remote companion import cancelled"); } });
console.log(JSON.stringify(result));
} finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
});
runtime.command("setup <provider>")
.description("Install and verify the pinned Pi runtime for this host (public downloads; no model calls)")
program.command("runtime").description("Manage explicitly installed agent runtimes")
.command("setup <provider>")
.description("Install and verify the pinned Cursor runtime for this host (public downloads; no model calls)")
.action(async (provider: string) => {
if (provider !== "pi") throw new Error("Supported explicit runtime setup: paperclipai runtime setup pi");
await setupPiRuntime();
if (provider !== "cursor") throw new Error("Supported runtime setup: paperclipai runtime setup cursor");
await setupCursorRuntime();
});
}
+5 -1
View File
@@ -8,6 +8,7 @@ export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
export const WORKTREE_SEED_MANIFEST = "seed-manifest.json";
export const WORKTREE_SEED_PENDING_MARKER = "seed-pending";
export const WORKTREE_SEED_COMPLETE_MARKER = "seed-complete";
export const WORKTREE_SEED_EMPTY_MARKER = "seed-empty";
export const WORKTREE_SEED_LOCK_MARKER = "seed.lock";
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
@@ -58,6 +59,7 @@ export type WorktreeSeedPlan = {
};
const MINIMAL_WORKTREE_EXCLUDED_TABLES = [
"agent_identity_keys",
"activity_log",
"agent_runtime_state",
"agent_task_sessions",
@@ -97,6 +99,7 @@ export type WorktreeSeedMarkerPaths = {
manifest: string;
pending: string;
complete: string;
empty: string;
lock: string;
};
@@ -106,6 +109,7 @@ export function resolveWorktreeSeedMarkerPaths(configPath: string): WorktreeSeed
manifest: path.resolve(configDir, WORKTREE_SEED_MANIFEST),
pending: path.resolve(configDir, WORKTREE_SEED_PENDING_MARKER),
complete: path.resolve(configDir, WORKTREE_SEED_COMPLETE_MARKER),
empty: path.resolve(configDir, WORKTREE_SEED_EMPTY_MARKER),
lock: path.resolve(configDir, WORKTREE_SEED_LOCK_MARKER),
};
}
@@ -118,7 +122,7 @@ export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPla
if (mode === "full") {
return {
mode,
excludedTables: [],
excludedTables: ["agent_identity_keys"],
nullifyColumns: {},
};
}
+35 -11
View File
@@ -16,7 +16,7 @@ import {
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { createServer } from "node:net";
import { Readable } from "node:stream";
import * as p from "@clack/prompts";
@@ -118,6 +118,7 @@ type WorktreeInitOptions = {
serverPort?: number;
dbPort?: number;
seed?: boolean;
empty?: boolean;
seedMode?: string;
preserveLiveWork?: boolean;
force?: boolean;
@@ -245,6 +246,7 @@ export type EnsureWorktreeSeededResult = {
| "seeded"
| "verified_manifest"
| "complete_marker"
| "explicitly_empty"
| "legacy_unmarked"
| "legacy_database";
details?: SeedWorktreeDatabaseResult;
@@ -2221,6 +2223,9 @@ export async function ensureWorktreeSeeded(
): Promise<EnsureWorktreeSeededResult> {
const configPath = resolveConfigPath(opts.config);
const markers = resolveWorktreeSeedMarkerPaths(configPath);
if (existsSync(markers.empty)) {
return { seeded: false, reason: "explicitly_empty" };
}
const initialManifest = readWorktreeSeedManifest(configPath);
if (initialManifest?.state === "verified") {
return { seeded: false, reason: "verified_manifest" };
@@ -2282,6 +2287,9 @@ export async function ensureWorktreeSeeded(
mkdirSync(path.dirname(markers.lock), { recursive: true });
const releaseLock = await acquireWorktreeSeedLock(markers.lock);
try {
if (existsSync(markers.empty)) {
return { seeded: false, reason: "explicitly_empty" };
}
// These checks deliberately happen under the cross-process lock. A second
// service process waits for the first seed transaction, then observes the
// verified manifest instead of cloning the same database concurrently.
@@ -2434,8 +2442,10 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
rmSync(paths.configPath, { force: true });
rmSync(paths.envPath, { force: true });
const seedMarkers = resolveWorktreeSeedMarkerPaths(paths.configPath);
rmSync(seedMarkers.manifest, { force: true });
rmSync(seedMarkers.pending, { force: true });
rmSync(seedMarkers.complete, { force: true });
rmSync(seedMarkers.empty, { force: true });
rmSync(paths.instanceRoot, { recursive: true, force: true });
}
@@ -2464,6 +2474,11 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
});
try {
// Persist the no-copy choice before the config becomes visible to managed startup.
if (opts.empty) {
mkdirSync(paths.repoConfigDir, { recursive: true });
writeFileSync(resolveWorktreeSeedMarkerPaths(paths.configPath).empty, "Explicitly empty instance; automatic database copying is disabled.\n", { mode: 0o600 });
}
writeConfig(selectedConfig, paths.configPath);
writeWorktreePortRegistry(paths.homeDir, [
...registeredConfigPaths,
@@ -2471,6 +2486,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
]);
} catch (error) {
rmSync(paths.configPath, { force: true });
if (opts.empty) rmSync(resolveWorktreeSeedMarkerPaths(paths.configPath).empty, { force: true });
throw error;
}
@@ -2481,18 +2497,20 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
};
},
);
markWorktreeSeedPending({
configPath: paths.configPath,
sourceConfigPath,
targetInstanceId: instanceId,
seedMode,
});
if (!opts.empty) {
markWorktreeSeedPending({
configPath: paths.configPath,
sourceConfigPath,
targetInstanceId: instanceId,
seedMode,
});
}
const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath));
const existingAgentJwtSecret =
nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ??
opts.empty ? randomBytes(32).toString("base64url") : nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ??
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
const existingToolActionSigningSecret =
nonEmpty(sourceEnvEntries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ??
opts.empty ? randomBytes(32).toString("base64url") : nonEmpty(sourceEnvEntries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ??
nonEmpty(process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET);
mergePaperclipEnvEntries(
{
@@ -2511,7 +2529,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
let seedExecutionQuarantineSummary: SeededWorktreeExecutionQuarantineSummary | null = null;
let pausedScheduledRoutineCount: number | null = null;
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
if (opts.seed !== false) {
if (!opts.empty && opts.seed !== false) {
if (!sourceConfig) {
throw new Error(
`Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`,
@@ -2601,6 +2619,8 @@ export async function worktreeEnsureSeededCommand(opts: WorktreeEnsureSeededOpti
spinner.stop("Seeded isolated worktree database (minimal).");
} else if (result.reason === "legacy_database") {
spinner.stop("Validated and adopted an existing legacy worktree database.");
} else if (result.reason === "explicitly_empty") {
spinner.stop("Explicitly empty instance; automatic database copying is disabled.");
} else {
spinner.stop("Worktree database already has a verified seed manifest.");
}
@@ -2618,7 +2638,7 @@ export async function worktreeEnsureSeededCommand(opts: WorktreeEnsureSeededOpti
);
}
}
p.outro(pc.green("Worktree database seed complete."));
p.outro(pc.green(result.reason === "explicitly_empty" ? "Empty worktree ready." : "Worktree database seed complete."));
} catch (error) {
spinner.stop(pc.red("Failed to seed worktree database."));
throw error;
@@ -4320,6 +4340,8 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
expectedCompanyId: nonEmpty(process.env.PAPERCLIP_SEED_EXPECTED_COMPANY_ID) ?? undefined,
seedDatabase: seedWorktreeDatabase,
});
// An operator-confirmed successful reseed replaces the earlier empty-instance choice.
rmSync(markers.empty, { force: true });
spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`);
p.log.message(pc.dim(`Source: ${source.configPath}`));
p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`));
@@ -4456,6 +4478,7 @@ export function registerWorktreeCommands(program: Command): void {
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--empty", "Create an empty instance without immediate or deferred database copying", false)
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeMakeCommand);
@@ -4473,6 +4496,7 @@ export function registerWorktreeCommands(program: Command): void {
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--empty", "Create an empty instance without immediate or deferred database copying", false)
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeInitCommand);
+4
View File
@@ -1,5 +1,6 @@
import { registerRuntimeCommands } from "./commands/runtime.js";
import { registerEmailCommands } from "./commands/client/email.js";
import { registerMcpCommands } from "./commands/mcp.js";
import { Command } from "commander";
import { warnIfUnsupportedNodeVersion } from "@paperclipai/shared/node-version";
import { onboard } from "./commands/onboard.js";
@@ -32,6 +33,7 @@ import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.
import { loadPaperclipEnvFile } from "./config/env.js";
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
import { registerWorktreeCommands } from "./commands/worktree.js";
import { registerRuntimeCommands } from "./commands/runtime.js";
import { registerPluginCommands } from "./commands/client/plugin.js";
import { registerClientAuthCommands } from "./commands/client/auth.js";
import { registerConnectCommand } from "./commands/client/connect.js";
@@ -263,6 +265,7 @@ registerSecretCommands(program);
registerSkillsCommands(program);
registerTeamCommands(program);
registerWorktreeCommands(program);
registerRuntimeCommands(program);
registerEnvLabCommands(program);
registerPluginCommands(program);
@@ -279,6 +282,7 @@ auth
.action(bootstrapCeoInvite);
registerClientAuthCommands(auth);
registerMcpCommands(program);
async function main(): Promise<void> {
warnIfUnsupportedNodeVersion(process.versions.node, (message) => console.warn(message));