mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(worktree): never adopt non-worktree targets in worktree repair
Ambient PAPERCLIP_IN_WORKTREE leaking into a process (inherited shell env or the worktree-config test suite) made maybeRepairLegacyWorktreeConfigAndEnvFiles rewrite the main instance env at <home>/instances/default/.env, stamping it with PAPERCLIP_IN_WORKTREE=true and a stale worktree name. On the next service restart the main server then booted in worktree mode. Repair (and runtime port persistence) now only adopts a target when its config sits in a `<root>/.paperclip/` worktree layout AND the target's own persisted .env already declares PAPERCLIP_IN_WORKTREE=true. Instance-store configs like instances/default/config.json can never be adopted. Also make the test suite hermetic: scrub ambient PAPERCLIP_* env before each test. Previously the "rebalances duplicate ports" test inherited the real PAPERCLIP_CONFIG from agent shells and rewrote the machine's live default instance .env every time the suite ran (fixture name PAP-884-ai-commits-component is exactly what kept appearing in the contaminated file). PAP-13539 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e4e12bfb89
commit
2e2887b833
2 files changed
+130
-1
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyRuntimePortSelectionToConfig,
|
||||
maybePersistWorktreeRuntimePorts,
|
||||
@@ -11,6 +11,18 @@ import {
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
|
||||
// The ambient shell can carry real PAPERCLIP_* settings (agent shells export
|
||||
// PAPERCLIP_CONFIG pointing at the live default instance). Repair helpers
|
||||
// resolve paths from these, so a test that forgets to override one would
|
||||
// otherwise rewrite the machine's real config/env files.
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith("PAPERCLIP_")) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
|
||||
@@ -139,6 +151,101 @@ describe("worktree config repair", () => {
|
||||
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component");
|
||||
});
|
||||
|
||||
it("never rewrites a main-instance env when ambient worktree flags leak into the process", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-leak-"));
|
||||
const homeDir = path.join(tempRoot, ".paperclip");
|
||||
const instanceRoot = path.join(homeDir, "instances", "default");
|
||||
const configPath = path.join(instanceRoot, "config.json");
|
||||
const envPath = path.join(instanceRoot, ".env");
|
||||
|
||||
await fs.mkdir(instanceRoot, { recursive: true });
|
||||
const originalConfig = JSON.stringify(buildLegacyConfig(instanceRoot), null, 2) + "\n";
|
||||
await fs.writeFile(configPath, originalConfig, "utf8");
|
||||
const cleanEnv = [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by `paperclip onboard`",
|
||||
`PAPERCLIP_HOME=${JSON.stringify(homeDir)}`,
|
||||
'PAPERCLIP_INSTANCE_ID="default"',
|
||||
`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`,
|
||||
"",
|
||||
].join("\n");
|
||||
await fs.writeFile(envPath, cleanEnv, "utf8");
|
||||
|
||||
process.chdir(tempRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
||||
process.env.PAPERCLIP_HOME = homeDir;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
delete process.env.PAPERCLIP_CONTEXT;
|
||||
|
||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
|
||||
expect(result).toEqual({ repairedConfig: false, repairedEnv: false });
|
||||
expect(await fs.readFile(envPath, "utf8")).toBe(cleanEnv);
|
||||
expect(await fs.readFile(configPath, "utf8")).toBe(originalConfig);
|
||||
expect(process.env.PAPERCLIP_HOME).toBe(homeDir);
|
||||
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("default");
|
||||
});
|
||||
|
||||
it("does not persist runtime ports into a main-instance config when ambient worktree flags leak in", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-leak-ports-"));
|
||||
const homeDir = path.join(tempRoot, ".paperclip");
|
||||
const instanceRoot = path.join(homeDir, "instances", "default");
|
||||
const configPath = path.join(instanceRoot, "config.json");
|
||||
|
||||
await fs.mkdir(instanceRoot, { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(instanceRoot), null, 2) + "\n", "utf8");
|
||||
|
||||
process.chdir(tempRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
||||
process.env.PAPERCLIP_HOME = homeDir;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "default";
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
delete process.env.PORT;
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
maybePersistWorktreeRuntimePorts({ serverPort: 3999, databasePort: 54399 });
|
||||
|
||||
const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
expect(writtenConfig.server.port).toBe(3100);
|
||||
expect(writtenConfig.database.embeddedPostgresPort).toBe(54329);
|
||||
});
|
||||
|
||||
it("does not adopt a .paperclip config whose own env does not declare a worktree", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-unattested-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const paperclipDir = path.join(repoRoot, ".paperclip");
|
||||
const configPath = path.join(paperclipDir, "config.json");
|
||||
const envPath = path.join(paperclipDir, ".env");
|
||||
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
const originalConfig =
|
||||
JSON.stringify(buildLegacyConfig(path.join(tempRoot, "shared")), null, 2) + "\n";
|
||||
await fs.writeFile(configPath, originalConfig, "utf8");
|
||||
const nonWorktreeEnv = [
|
||||
"# Paperclip environment variables",
|
||||
`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`,
|
||||
"",
|
||||
].join("\n");
|
||||
await fs.writeFile(envPath, nonWorktreeEnv, "utf8");
|
||||
|
||||
process.chdir(repoRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component";
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = path.join(tempRoot, ".paperclip-worktrees");
|
||||
delete process.env.PAPERCLIP_HOME;
|
||||
delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
|
||||
const result = maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||
|
||||
expect(result).toEqual({ repairedConfig: false, repairedEnv: false });
|
||||
expect(await fs.readFile(envPath, "utf8")).toBe(nonWorktreeEnv);
|
||||
expect(await fs.readFile(configPath, "utf8")).toBe(originalConfig);
|
||||
});
|
||||
|
||||
it("avoids sibling worktree ports when repairing legacy configs", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-ports-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-880-thumbs-capture-for-evals-feature");
|
||||
@@ -552,6 +659,12 @@ describe("worktree config repair", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(paperclipDir, ".env"),
|
||||
["# Paperclip environment variables", "PAPERCLIP_IN_WORKTREE=true", ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-878-create-a-mine-tab-in-inbox";
|
||||
@@ -636,6 +749,12 @@ describe("worktree config repair", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(paperclipDir, ".env"),
|
||||
["# Paperclip environment variables", "PAPERCLIP_IN_WORKTREE=true", ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||
process.env.PAPERCLIP_WORKTREE_NAME = "PAP-125-public-base-url";
|
||||
|
||||
@@ -115,6 +115,16 @@ function resolveWorktreeRuntimeContext(
|
||||
const configPath = resolvePaperclipConfigPath(overrideConfigPath);
|
||||
const envPath = resolvePaperclipEnvPath(configPath);
|
||||
const persistedEnv = readEnvEntries(envPath);
|
||||
|
||||
// PAPERCLIP_IN_WORKTREE can leak in from a parent process or a sourced env
|
||||
// file while config resolution still points at a non-worktree target (for
|
||||
// example the default instance under <home>/instances/default). Only adopt
|
||||
// a target as a worktree when its config sits in a `<root>/.paperclip/`
|
||||
// layout and its own persisted env already declares it a worktree;
|
||||
// otherwise the repair would rewrite main-instance config and env files.
|
||||
if (path.basename(path.dirname(configPath)) !== ".paperclip") return null;
|
||||
if (persistedEnv.PAPERCLIP_IN_WORKTREE !== "true") return null;
|
||||
|
||||
const persistedConfigPath = nonEmpty(persistedEnv.PAPERCLIP_CONFIG);
|
||||
const persistedConfigLooksStale =
|
||||
persistedConfigPath !== null &&
|
||||
|
||||
Reference in new issue
Block a user