From 0a11242c596e9e68a05845a4b990fdfec9e7f354 Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 1 Apr 2026 10:14:54 -0500 Subject: [PATCH] Fix worktree auth seeding and repair persistence --- cli/src/__tests__/worktree.test.ts | 86 +++++++++++++++ scripts/provision-worktree.sh | 28 ++++- server/src/__tests__/worktree-config.test.ts | 107 +++++++++++++++++++ server/src/worktree-config.ts | 14 ++- 4 files changed, 229 insertions(+), 6 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 3c2079d2b33..1f391e9a242 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; +import { authUsers, createDb, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "@paperclipai/db"; import { afterEach, describe, expect, it, vi } from "vitest"; import { copyGitHooksToWorktreeGitDir, @@ -28,6 +29,8 @@ import type { PaperclipConfig } from "../config/schema.js"; const ORIGINAL_CWD = process.cwd(); const ORIGINAL_ENV = { ...process.env }; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip; afterEach(() => { process.chdir(ORIGINAL_CWD); @@ -344,6 +347,89 @@ describe("worktree helpers", () => { } }); + itEmbeddedPostgres( + "seeds authenticated users into minimally cloned worktree instances", + async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-")); + const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed"); + const sourceHome = path.join(tempRoot, "source-home"); + const sourceConfigDir = path.join(sourceHome, "instances", "source"); + const sourceConfigPath = path.join(sourceConfigDir, "config.json"); + const sourceEnvPath = path.join(sourceConfigDir, ".env"); + const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key"); + const worktreeHome = path.join(tempRoot, ".paperclip-worktrees"); + const originalCwd = process.cwd(); + const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-"); + + try { + const sourceDbClient = createDb(sourceDb.connectionString); + await sourceDbClient.insert(authUsers).values({ + id: "user-existing", + email: "existing@paperclip.ing", + name: "Existing User", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); + fs.mkdirSync(worktreeRoot, { recursive: true }); + + const sourceConfig = buildSourceConfig(); + sourceConfig.database = { + mode: "postgres", + connectionString: sourceDb.connectionString, + }; + sourceConfig.logging.logDir = path.join(sourceConfigDir, "logs"); + sourceConfig.storage.localDisk.baseDir = path.join(sourceConfigDir, "storage"); + sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath; + + fs.writeFileSync(sourceConfigPath, JSON.stringify(sourceConfig, null, 2) + "\n", "utf8"); + fs.writeFileSync(sourceEnvPath, "", "utf8"); + fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8"); + + process.chdir(worktreeRoot); + await worktreeInitCommand({ + name: "PAP-999-auth-seed", + home: worktreeHome, + fromConfig: sourceConfigPath, + force: true, + }); + + const targetConfig = JSON.parse( + fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"), + ) as PaperclipConfig; + const { default: EmbeddedPostgres } = await import("embedded-postgres"); + const targetPg = new EmbeddedPostgres({ + databaseDir: targetConfig.database.embeddedPostgresDataDir, + user: "paperclip", + password: "paperclip", + port: targetConfig.database.embeddedPostgresPort, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + await targetPg.start(); + try { + const targetDb = createDb( + `postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`, + ); + const seededUsers = await targetDb.select().from(authUsers); + expect(seededUsers.some((row) => row.email === "existing@paperclip.ing")).toBe(true); + } finally { + await targetPg.stop(); + } + } finally { + process.chdir(originalCwd); + await sourceDb.cleanup(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, + 20000, + ); + it("avoids ports already claimed by sibling worktree instance configs", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-")); const repoRoot = path.join(tempRoot, "repo"); diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 19b0831ef5e..ba029b9d909 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -32,17 +32,34 @@ source_env_path="$(dirname "$source_config_path")/.env" mkdir -p "$paperclip_dir" run_isolated_worktree_init() { + local base_cli_runner="$base_cwd/cli/node_modules/tsx/dist/cli.mjs" + local base_cli_entry="$base_cwd/cli/src/index.ts" + + if [[ -f "$base_cli_runner" && -f "$base_cli_entry" ]]; then + ( + cd "$worktree_cwd" + node "$base_cli_runner" "$base_cli_entry" worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ) + return 0 + fi + if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then - pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ( + cd "$worktree_cwd" + pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ) return 0 fi if command -v paperclipai >/dev/null 2>&1; then - paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ( + cd "$worktree_cwd" + paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + ) return 0 fi - return 1 + return 127 } write_fallback_worktree_config() { @@ -296,6 +313,11 @@ EOF } if ! run_isolated_worktree_init; then + status=$? + if [[ "$status" -ne 127 ]]; then + exit "$status" + fi + echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2 write_fallback_worktree_config fi diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index 3317a254136..d3eb6a9f0ef 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -206,6 +206,113 @@ describe("worktree config repair", () => { expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); }); + it("does not persist transient runtime home overrides over repo-local worktree env", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-runtime-override-")); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const transientHome = path.join(tempRoot, "tests", "e2e", ".tmp", "multiuser-authenticated"); + const worktreeRoot = path.join(tempRoot, "PAP-989-multi-user-implementation-using-plan-from-pap-958"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const instanceId = "pap-989-multi-user-implementation-using-plan-from-pap-958"; + const stableInstanceRoot = path.join(isolatedHome, "instances", instanceId); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + ...buildLegacyConfig(transientHome), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(transientHome, "instances", instanceId, "db"), + embeddedPostgresPort: 54334, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(transientHome, "instances", instanceId, "data", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(transientHome, "instances", instanceId, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3104, + allowedHostnames: [], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(transientHome, "instances", instanceId, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(transientHome, "instances", instanceId, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + `PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`, + `PAPERCLIP_INSTANCE_ID=${JSON.stringify(instanceId)}`, + `PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`, + `PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`, + 'PAPERCLIP_IN_WORKTREE="true"', + 'PAPERCLIP_WORKTREE_NAME="PAP-989-multi-user-implementation-using-plan-from-pap-958"', + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-989-multi-user-implementation-using-plan-from-pap-958"; + process.env.PAPERCLIP_HOME = transientHome; + process.env.PAPERCLIP_INSTANCE_ID = instanceId; + process.env.PAPERCLIP_CONFIG = configPath; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + + expect(result).toEqual({ + repairedConfig: true, + repairedEnv: false, + }); + expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(stableInstanceRoot, "db")); + expect(repairedConfig.database.backup.dir).toBe(path.join(stableInstanceRoot, "data", "backups")); + expect(repairedConfig.logging.logDir).toBe(path.join(stableInstanceRoot, "logs")); + expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(stableInstanceRoot, "data", "storage")); + expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe( + path.join(stableInstanceRoot, "secrets", "master.key"), + ); + expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).not.toContain(`PAPERCLIP_HOME=${JSON.stringify(transientHome)}`); + expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome); + }); + it("rebalances duplicate ports for already isolated worktree configs", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-")); const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index 3656b7dc6a6..2c6170bca59 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -118,11 +118,19 @@ function resolveWorktreeRuntimeContext( const configPath = resolvePaperclipConfigPath(overrideConfigPath); const envPath = resolvePaperclipEnvPath(configPath); + const persistedEnv = readEnvEntries(envPath); const worktreeRoot = path.resolve(path.dirname(configPath), ".."); - const worktreeName = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot); - const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName); + const worktreeName = + nonEmpty(persistedEnv.PAPERCLIP_WORKTREE_NAME) ?? + nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? + path.basename(worktreeRoot); + const instanceId = + nonEmpty(persistedEnv.PAPERCLIP_INSTANCE_ID) ?? + nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? + sanitizeWorktreeInstanceId(worktreeName); const homeDir = resolveHomeAwarePath( - nonEmpty(env.PAPERCLIP_HOME) ?? + nonEmpty(persistedEnv.PAPERCLIP_HOME) ?? + nonEmpty(env.PAPERCLIP_HOME) ?? nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees", );