diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index b50fc3e7c69..62f1d90fbb1 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -892,6 +892,9 @@ Seed modes: The CLI and managed runtime provisioner honor it even when a registered source instance is present. An explicit successful `worktree reseed`, or a replacement `worktree init --force` without `--empty`, clears the choice. +While that marker exists, startup uses the instance's saved JWT and tool-action +signing keys even if the shell inherited another instance's keys. Better Auth +uses its saved key or the fresh instance JWT key. Missing saved keys stop startup. Seeded worktree instances quarantine copied live execution by default for both `minimal` and `full` seeds. During restore, Paperclip disables copied agent timer heartbeats, resets copied `running` agents to `idle`, blocks and unassigns copied agent-owned `in_progress` issues, and unassigns copied agent-owned `todo`/`in_review` issues. This keeps a freshly booted worktree from starting agents for work already owned by the source instance. Pass `--preserve-live-work` only when you intentionally want the isolated worktree to resume copied assignments. diff --git a/server/src/__tests__/dev-runner-worktree.test.ts b/server/src/__tests__/dev-runner-worktree.test.ts index 5dd179edb58..786d8cd957f 100644 --- a/server/src/__tests__/dev-runner-worktree.test.ts +++ b/server/src/__tests__/dev-runner-worktree.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { bootstrapDevRunnerWorktreeEnv, + applyEmptyWorktreeSigningSecrets, isWorktreeSeedPending, isLinkedGitWorktreeCheckout, resolveWorktreeEnvFilePath, @@ -25,6 +26,18 @@ function createTempRoot(prefix: string): string { } describe("dev-runner worktree env bootstrap", () => { + it("uses saved empty-instance signing keys instead of inherited source keys", () => { + const root = createTempRoot("paperclip-empty-signing-keys-"); + fs.mkdirSync(path.join(root, ".paperclip")); + fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/empty\n"); + fs.writeFileSync(path.join(root, ".paperclip", "seed-empty"), "explicitly empty\n"); + fs.writeFileSync(resolveWorktreeEnvFilePath(root), 'PAPERCLIP_AGENT_JWT_SECRET="fresh-jwt"\nPAPERCLIP_TOOL_ACTION_SIGNING_SECRET="fresh-actions"\n'); + const env = { PAPERCLIP_AGENT_JWT_SECRET: "source-jwt", PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "source-actions", BETTER_AUTH_SECRET: "source-auth" }; + bootstrapDevRunnerWorktreeEnv(root, env); + expect(env).toMatchObject({ PAPERCLIP_AGENT_JWT_SECRET: "fresh-jwt", PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "fresh-actions", BETTER_AUTH_SECRET: "fresh-jwt" }); + fs.writeFileSync(resolveWorktreeEnvFilePath(root), 'PAPERCLIP_AGENT_JWT_SECRET="fresh-jwt"\n'); + expect(() => applyEmptyWorktreeSigningSecrets(root, env)).toThrow("missing its saved PAPERCLIP_TOOL_ACTION_SIGNING_SECRET"); + }); it("guards seed-pending worktrees until a seed-complete marker exists", () => { const root = createTempRoot("paperclip-dev-runner-seed-pending-"); fs.mkdirSync(path.join(root, ".paperclip"), { recursive: true }); diff --git a/server/src/__tests__/worktree-seed-server-spawn.test.ts b/server/src/__tests__/worktree-seed-server-spawn.test.ts index 47710c59fb1..860dfffcab3 100644 --- a/server/src/__tests__/worktree-seed-server-spawn.test.ts +++ b/server/src/__tests__/worktree-seed-server-spawn.test.ts @@ -2,7 +2,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -107,6 +107,24 @@ describe("managed worktree seed source through the server spawn path", () => { process.chdir(cwd); await worktreeInitCommand({ empty: true, fromConfig: sourceConfigPath, home: path.join(tempRoot, "instances") }); const configPath = path.join(cwd, ".paperclip", "config.json"); + const repoRoot = fileURLToPath(new URL("../../../", import.meta.url)); + const startup = await execFileAsync(process.execPath, [path.join(repoRoot, "cli/node_modules/tsx/dist/cli.mjs"), "--eval", ` + void (async () => { + const fs = await import('node:fs'); + const dotenv = (await import(${JSON.stringify(pathToFileURL(path.join(repoRoot, "server/node_modules/dotenv/lib/main.js")).href)})).default; + const envPath = ${JSON.stringify(path.join(cwd, ".paperclip", ".env"))}; + const saved = dotenv.parse(fs.readFileSync(envPath)); + await import(${JSON.stringify(pathToFileURL(path.join(repoRoot, "server/src/config.ts")).href)}); + const repaired = dotenv.parse(fs.readFileSync(envPath)); + console.log(JSON.stringify({ + jwt: process.env.PAPERCLIP_AGENT_JWT_SECRET === saved.PAPERCLIP_AGENT_JWT_SECRET, + actions: process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET === saved.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET, + auth: process.env.BETTER_AUTH_SECRET === saved.PAPERCLIP_AGENT_JWT_SECRET, + savedActions: repaired.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET === saved.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET + })); + })(); + `], { cwd, env: { ...process.env, PAPERCLIP_CONFIG: configPath, PAPERCLIP_AGENT_JWT_SECRET: "source-jwt", PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "source-actions", BETTER_AUTH_SECRET: "source-auth" } }); + expect(JSON.parse(startup.stdout.trim().split("\n").at(-1)!)).toEqual({ jwt: true, actions: true, auth: true, savedActions: true }); const workspace = { baseCwd, cwd, source: "project_primary" as const, projectId: "project-1", workspaceId: "project-workspace-1", repoUrl: null, repoRef: "HEAD", diff --git a/server/src/config.ts b/server/src/config.ts index 11de3894185..db5efb2cbda 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -2,11 +2,12 @@ import { readConfigFile, resolveDeploymentMode } from "./config-file.js"; import { parseChatWebhookPublicBaseUrl } from "./chat-webhook-public-url.js"; import { execFileSync } from "node:child_process"; import { existsSync, realpathSync } from "node:fs"; -import { resolve } from "node:path"; +import { basename, dirname, resolve } from "node:path"; import { config as loadDotenv } from "dotenv"; import { resolvePaperclipEnvPath } from "./paths.js"; import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js"; import { shouldLoadWorkingDirectoryEnv } from "./env-file-policy.js"; +import { applyEmptyWorktreeSigningSecrets } from "./dev-runner-worktree.js"; import { AUTH_BASE_URL_MODES, BIND_MODES, @@ -48,6 +49,9 @@ if (shouldLoadWorkingDirectoryEnv({ loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true }); } +if (basename(dirname(PAPERCLIP_ENV_FILE_PATH)) === ".paperclip") { + applyEmptyWorktreeSigningSecrets(dirname(dirname(PAPERCLIP_ENV_FILE_PATH))); +} maybeRepairLegacyWorktreeConfigAndEnvFiles(); const TAILSCALE_DETECT_TIMEOUT_MS = 3000; diff --git a/server/src/dev-runner-worktree.ts b/server/src/dev-runner-worktree.ts index b78e80fe70b..f28ddc06a88 100644 --- a/server/src/dev-runner-worktree.ts +++ b/server/src/dev-runner-worktree.ts @@ -57,6 +57,19 @@ export function resolveWorktreeEnvFilePath(rootDir: string): string { return path.resolve(rootDir, ".paperclip", ".env"); } +/** An explicitly empty instance owns its saved keys, even in an agent shell. */ +export function applyEmptyWorktreeSigningSecrets(rootDir: string, env: NodeJS.ProcessEnv = process.env): boolean { + if (!existsSync(path.resolve(rootDir, ".paperclip", "seed-empty"))) return false; + const entries = parseEnvFile(readFileSync(resolveWorktreeEnvFilePath(rootDir), "utf8")); + for (const key of ["PAPERCLIP_AGENT_JWT_SECRET", "PAPERCLIP_TOOL_ACTION_SIGNING_SECRET"] as const) { + if (!entries[key]?.trim()) throw new Error(`Empty worktree is missing its saved ${key}. Reinitialize its configuration before starting.`); + env[key] = entries[key]; + } + // Better Auth otherwise prefers an inherited key over this instance's JWT key. + env.BETTER_AUTH_SECRET = entries.BETTER_AUTH_SECRET?.trim() || entries.PAPERCLIP_AGENT_JWT_SECRET; + return true; +} + export function isWorktreeSeedPending(rootDir: string): boolean { const markerDir = path.resolve(rootDir, ".paperclip"); const manifestPath = path.resolve(markerDir, "seed-manifest.json"); @@ -134,6 +147,7 @@ export function bootstrapDevRunnerWorktreeEnv( if (typeof env[key] === "string" && env[key]!.trim().length > 0) continue; env[key] = value; } + applyEmptyWorktreeSigningSecrets(rootDir, env); return { envPath, diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index bd8d5d129d6..86ea6e1b258 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -16,6 +16,7 @@ import { } from "@paperclipai/shared/worktree-port-registry"; import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js"; import { rewriteUrlPort } from "./url-utils.js"; +import { applyEmptyWorktreeSigningSecrets } from "./dev-runner-worktree.js"; function nonEmpty(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -455,6 +456,8 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): { return { repairedConfig: false, repairedEnv: false }; } + applyEmptyWorktreeSigningSecrets(path.resolve(path.dirname(context.configPath), "..")); + process.env.PAPERCLIP_HOME = context.homeDir; process.env.PAPERCLIP_INSTANCE_ID = context.instanceId; process.env.PAPERCLIP_CONFIG = context.configPath;