mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
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:
1 parent
f712015f7f
commit
769d1f7e53
6 files changed
+57
-2
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user