mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Carry current master through Pi public installation
This commit is contained in:
commit
e00258afd9
2195 files changed
+255923
-79198
No files matched your search
+1
-1
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"tsx": "^4.23.12",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user