fix(dev): use saved signing keys for empty instances

Prefer the explicitly empty instance keys during dev bootstrap, server config loading, and legacy repair. Verify actual startup with inherited source credentials.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-06 23:55:42 -05:00
1 parent f712015f7f
commit 769d1f7e53
6 files changed
+57 -2

No files matched your search

+3
View File
@@ -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.
@@ -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 });
@@ -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",
+5 -1
View File
@@ -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;
+14
View File
@@ -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,
+3
View File
@@ -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;