From deec68ab163a9726bc99307654d65fdcb17ad9c2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 10 Mar 2026 12:57:53 -0500 Subject: [PATCH] Copy seeded secrets key into worktree instances --- cli/src/__tests__/worktree.test.ts | 49 +++++++++++++++++++++++++ cli/src/commands/worktree.ts | 57 +++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 18bdabf1..9ce48cf7 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -1,5 +1,8 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { copySeededSecretsKey } from "../commands/worktree.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, @@ -122,4 +125,50 @@ describe("worktree helpers", () => { expect(full.excludedTables).toEqual([]); expect(full.nullifyColumns).toEqual({}); }); + + it("copies the source local_encrypted secrets key into the seeded worktree instance", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key"); + const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); + fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); + fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8"); + + const sourceConfig = buildSourceConfig(); + sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath; + + copySeededSecretsKey({ + sourceConfigPath, + sourceConfig, + sourceEnvEntries: {}, + targetKeyFilePath: targetKeyPath, + }); + + expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("writes the source inline secrets master key into the seeded worktree instance", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); + + copySeededSecretsKey({ + sourceConfigPath, + sourceConfig: buildSourceConfig(), + sourceEnvEntries: { + PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key", + }, + targetKeyFilePath: targetKeyPath, + }); + + expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index f9363ff8..8306c8ed 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, rmSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; @@ -18,6 +18,7 @@ import { expandHomePrefix } from "../config/home.js"; import type { PaperclipConfig } from "../config/schema.js"; import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; import { printPaperclipCliBanner } from "../utils/banner.js"; +import { resolveRuntimeLikePath } from "../utils/path-resolver.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, @@ -154,6 +155,54 @@ function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Reco return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; } +export function copySeededSecretsKey(input: { + sourceConfigPath: string; + sourceConfig: PaperclipConfig; + sourceEnvEntries: Record; + targetKeyFilePath: string; +}): void { + if (input.sourceConfig.secrets.provider !== "local_encrypted") { + return; + } + + mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true }); + + const sourceInlineMasterKey = + nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ?? + nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY); + if (sourceInlineMasterKey) { + writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, { + encoding: "utf8", + mode: 0o600, + }); + try { + chmodSync(input.targetKeyFilePath, 0o600); + } catch { + // best effort + } + return; + } + + const sourceKeyFileOverride = + nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ?? + nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE); + const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath; + const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath); + + if (!existsSync(sourceKeyFilePath)) { + throw new Error( + `Cannot seed worktree database because source local_encrypted secrets key was not found at ${sourceKeyFilePath}.`, + ); + } + + copyFileSync(sourceKeyFilePath, input.targetKeyFilePath); + try { + chmodSync(input.targetKeyFilePath, 0o600); + } catch { + // best effort + } +} + async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise { const moduleName = "embedded-postgres"; let EmbeddedPostgres: EmbeddedPostgresCtor; @@ -215,6 +264,12 @@ async function seedWorktreeDatabase(input: { const seedPlan = resolveWorktreeSeedPlan(input.seedMode); const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); + copySeededSecretsKey({ + sourceConfigPath: input.sourceConfigPath, + sourceConfig: input.sourceConfig, + sourceEnvEntries, + targetKeyFilePath: input.targetPaths.secretsKeyFilePath, + }); let sourceHandle: EmbeddedPostgresHandle | null = null; let targetHandle: EmbeddedPostgresHandle | null = null;