mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The CLI and server both update Paperclip values in `.env` files > - The server preserved operator content, but the CLI rebuilt the complete file > - A CLI rerun could remove comments, custom values, ordering, and newline style > - Both paths need one editor with one value encoding and duplicate key policy > - The final integration also needs one regression test across the related setup and sync safety mechanisms > - This pull request moves the editor to the shared package and adds cross-cutting rerun-survival coverage > - The benefit is safe setup and worktree repair reruns that preserve operator edits ## Linked Issues or Issue Description **What happened?** The CLI rebuilt the complete `.env` file when it wrote a managed Paperclip value. This action removed comments, blank lines, custom keys, original quoting, and the original newline style. **Expected behavior** Paperclip must update only the managed assignments. It must preserve all unrelated bytes. It must skip the file replacement when all managed values are current. **Steps to reproduce** 1. Add comments, custom keys, quoted values, and CRLF newlines to the Paperclip `.env` file. 2. Run a CLI path that calls the agent JWT secret setup. 3. Observe that the old writer replaces the complete file. **Paperclip version or commit** The problem exists on `master` before this pull request. Related public context: Refs #437. ## What Changed - Add one shared line-preserving `.env` editor for the CLI and server. - Define minimal and JSON value encodings in the shared helper. - Update every stale duplicate of a managed key and preserve current duplicate encodings. - Preserve comments, ordering, blank lines, unknown keys, export prefixes, trailing comments, and newline style. - Write changed files through a same-directory temporary file and atomic rename. - Limit CLI updates to non-empty `PAPERCLIP_*` entries. - Skip the write when all managed values are current. - Add shared, CLI, and server regression coverage. - Refresh the branch after the related config, sandbox, and skill safety changes landed. - Add a cross-cutting integration test for config, env-file, managed-sandbox, and managed-instructions rerun survival. ## Verification - `pnpm exec vitest run packages/shared/src/env-file.test.ts packages/shared/src/config-schema.test.ts cli/src/__tests__/agent-jwt-env.test.ts cli/src/__tests__/config-store.test.ts server/src/__tests__/config-file.test.ts server/src/__tests__/worktree-config.test.ts` passes 39 tests. - `pnpm exec vitest run server/src/__tests__/rerun-survival.integration.test.ts` passes 4 tests. - `pnpm -r typecheck` passes on the previous head. GitHub CI reruns it on the refreshed head. - The previous head passed the complete general, serialized, workspace, and E2E matrix. GitHub CI reruns that matrix on the refreshed head. - `pnpm build` passes on the previous head. GitHub CI reruns it on the refreshed head. ## Risks - Low risk. The production change only affects managed `.env` assignments. - Existing managed assignments can keep their original quoting when their decoded values are current. - Changed CLI values keep the prior minimal encoding policy. Changed server values keep the prior JSON encoding policy. - Duplicate managed assignments now follow one explicit rule: Paperclip updates each stale occurrence. - The master refresh had one import-block conflict. The resolution keeps both the config merge imports and the env-file imports. - The added integration file is test-only. It has no database, API, or UI contract effect. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex from the GPT-5 family produced this change with reasoning, tool use, and code execution. The runtime did not expose the exact model ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
ensureAgentJwtSecret,
|
|
mergePaperclipEnvEntries,
|
|
readAgentJwtSecretFromEnv,
|
|
readPaperclipEnvEntries,
|
|
resolveAgentJwtEnvFile,
|
|
} from "../config/env.js";
|
|
import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js";
|
|
|
|
const ORIGINAL_ENV = { ...process.env };
|
|
|
|
function tempConfigPath(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-jwt-env-"));
|
|
const configDir = path.join(dir, "custom");
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
return path.join(configDir, "config.json");
|
|
}
|
|
|
|
describe("agent jwt env helpers", () => {
|
|
beforeEach(() => {
|
|
process.env = { ...ORIGINAL_ENV };
|
|
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = { ...ORIGINAL_ENV };
|
|
});
|
|
|
|
it("writes .env next to explicit config path", () => {
|
|
const configPath = tempConfigPath();
|
|
const result = ensureAgentJwtSecret(configPath);
|
|
|
|
expect(result.created).toBe(true);
|
|
|
|
const envPath = resolveAgentJwtEnvFile(configPath);
|
|
expect(fs.existsSync(envPath)).toBe(true);
|
|
const contents = fs.readFileSync(envPath, "utf-8");
|
|
expect(contents).toContain("PAPERCLIP_AGENT_JWT_SECRET=");
|
|
});
|
|
|
|
it("loads secret from .env next to explicit config path", () => {
|
|
const configPath = tempConfigPath();
|
|
const envPath = resolveAgentJwtEnvFile(configPath);
|
|
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=test-secret\n", { mode: 0o600 });
|
|
|
|
const loaded = readAgentJwtSecretFromEnv(configPath);
|
|
expect(loaded).toBe("test-secret");
|
|
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBe("test-secret");
|
|
});
|
|
|
|
it("doctor check passes when secret exists in adjacent .env", () => {
|
|
const configPath = tempConfigPath();
|
|
const envPath = resolveAgentJwtEnvFile(configPath);
|
|
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=check-secret\n", { mode: 0o600 });
|
|
|
|
const result = agentJwtSecretCheck(configPath);
|
|
expect(result.status).toBe("pass");
|
|
});
|
|
|
|
it("quotes hash-prefixed env values so dotenv round-trips them", () => {
|
|
const configPath = tempConfigPath();
|
|
const envPath = resolveAgentJwtEnvFile(configPath);
|
|
|
|
mergePaperclipEnvEntries(
|
|
{
|
|
PAPERCLIP_WORKTREE_COLOR: "#439edb",
|
|
},
|
|
envPath,
|
|
);
|
|
|
|
const contents = fs.readFileSync(envPath, "utf-8");
|
|
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
|
|
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
|
|
});
|
|
|
|
it("preserves operator content and CRLF while updating only managed entries", () => {
|
|
const configPath = tempConfigPath();
|
|
const envPath = resolveAgentJwtEnvFile(configPath);
|
|
const original = [
|
|
"# operator comment",
|
|
"DATABASE_URL='postgres://operator:encoded@localhost/paperclip'",
|
|
"",
|
|
"export PAPERCLIP_HOME = '/old path' # managed path",
|
|
"PAPERCLIP_DUPLICATE=stale",
|
|
'PAPERCLIP_DUPLICATE="current"',
|
|
"UNKNOWN_VALUE=operator-owned",
|
|
"",
|
|
].join("\r\n");
|
|
fs.writeFileSync(envPath, original, { mode: 0o600 });
|
|
|
|
mergePaperclipEnvEntries(
|
|
{
|
|
PAPERCLIP_HOME: "/new path",
|
|
PAPERCLIP_DUPLICATE: "current",
|
|
PAPERCLIP_WORKTREE_COLOR: "#439edb",
|
|
DATABASE_URL: "postgres://paperclip-must-not-overwrite",
|
|
},
|
|
envPath,
|
|
);
|
|
|
|
const updated = fs.readFileSync(envPath, "utf8");
|
|
expect(updated).toBe([
|
|
"# operator comment",
|
|
"DATABASE_URL='postgres://operator:encoded@localhost/paperclip'",
|
|
"",
|
|
'export PAPERCLIP_HOME = "/new path" # managed path',
|
|
"PAPERCLIP_DUPLICATE=current",
|
|
'PAPERCLIP_DUPLICATE="current"',
|
|
"UNKNOWN_VALUE=operator-owned",
|
|
'PAPERCLIP_WORKTREE_COLOR="#439edb"',
|
|
"",
|
|
].join("\r\n"));
|
|
expect(updated.replaceAll("\r\n", "")).not.toContain("\n");
|
|
});
|
|
|
|
it("does not replace the env file when managed values are already current", () => {
|
|
const configPath = tempConfigPath();
|
|
const envPath = resolveAgentJwtEnvFile(configPath);
|
|
const original = [
|
|
"# preserve this file byte-for-byte",
|
|
"export PAPERCLIP_HOME = '/same path'",
|
|
"UNKNOWN=\"operator encoding\"",
|
|
"",
|
|
].join("\n");
|
|
fs.writeFileSync(envPath, original, { mode: 0o600 });
|
|
const previousInode = fs.statSync(envPath).ino;
|
|
|
|
mergePaperclipEnvEntries({ PAPERCLIP_HOME: "/same path" }, envPath);
|
|
|
|
expect(fs.readFileSync(envPath, "utf8")).toBe(original);
|
|
expect(fs.statSync(envPath).ino).toBe(previousInode);
|
|
});
|
|
});
|