mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +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>
126 lines
4.1 KiB
TypeScript
126 lines
4.1 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { randomBytes } from "node:crypto";
|
|
import { config as loadDotenv, parse as parseEnvFileContents } from "dotenv";
|
|
import { updateEnvFileContents, writeEnvFileAtomicallyIfChanged } from "@paperclipai/shared/env-file";
|
|
import { resolveConfigPath } from "./store.js";
|
|
|
|
const JWT_SECRET_ENV_KEY = "PAPERCLIP_AGENT_JWT_SECRET";
|
|
const PAPERCLIP_OWNED_ENV_KEY_PATTERN = /^PAPERCLIP_[A-Z0-9_]+$/;
|
|
function resolveEnvFilePath(configPath?: string) {
|
|
return path.resolve(path.dirname(resolveConfigPath(configPath)), ".env");
|
|
}
|
|
const loadedEnvFiles = new Set<string>();
|
|
|
|
function isNonEmpty(value: unknown): value is string {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
function parseEnvFile(contents: string) {
|
|
try {
|
|
return parseEnvFileContents(contents);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function emptyEnvFileContents() {
|
|
return [
|
|
"# Paperclip environment variables",
|
|
"# Generated by Paperclip CLI commands",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function paperclipOwnedEntries(entries: Record<string, string>): Record<string, string> {
|
|
return Object.fromEntries(
|
|
Object.entries(entries).filter(
|
|
([key, value]) => PAPERCLIP_OWNED_ENV_KEY_PATTERN.test(key) && value.trim().length > 0,
|
|
),
|
|
);
|
|
}
|
|
|
|
export function resolvePaperclipEnvFile(configPath?: string): string {
|
|
return resolveEnvFilePath(configPath);
|
|
}
|
|
|
|
export function resolveAgentJwtEnvFile(configPath?: string): string {
|
|
return resolveEnvFilePath(configPath);
|
|
}
|
|
|
|
export function loadPaperclipEnvFile(configPath?: string): void {
|
|
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
|
|
}
|
|
|
|
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
|
|
if (loadedEnvFiles.has(filePath)) return;
|
|
|
|
if (!fs.existsSync(filePath)) return;
|
|
loadedEnvFiles.add(filePath);
|
|
loadDotenv({ path: filePath, override: false, quiet: true });
|
|
}
|
|
|
|
export function readAgentJwtSecretFromEnv(configPath?: string): string | null {
|
|
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
|
|
const raw = process.env[JWT_SECRET_ENV_KEY];
|
|
return isNonEmpty(raw) ? raw!.trim() : null;
|
|
}
|
|
|
|
export function readAgentJwtSecretFromEnvFile(filePath = resolveEnvFilePath()): string | null {
|
|
if (!fs.existsSync(filePath)) return null;
|
|
|
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
const values = parseEnvFile(raw);
|
|
const value = values[JWT_SECRET_ENV_KEY];
|
|
return isNonEmpty(value) ? value!.trim() : null;
|
|
}
|
|
|
|
export function ensureAgentJwtSecret(configPath?: string): { secret: string; created: boolean } {
|
|
const existingEnv = readAgentJwtSecretFromEnv(configPath);
|
|
if (existingEnv) {
|
|
return { secret: existingEnv, created: false };
|
|
}
|
|
|
|
const envFilePath = resolveEnvFilePath(configPath);
|
|
const existingFile = readAgentJwtSecretFromEnvFile(envFilePath);
|
|
const secret = existingFile ?? randomBytes(32).toString("hex");
|
|
const created = !existingFile;
|
|
|
|
if (!existingFile) {
|
|
writeAgentJwtEnv(secret, envFilePath);
|
|
}
|
|
|
|
return { secret, created };
|
|
}
|
|
|
|
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
|
|
mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
|
|
}
|
|
|
|
export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record<string, string> {
|
|
if (!fs.existsSync(filePath)) return {};
|
|
return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
|
|
}
|
|
|
|
export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
|
|
const previousContents = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
|
|
const nextContents = updateEnvFileContents(previousContents ?? emptyEnvFileContents(), paperclipOwnedEntries(entries), {
|
|
valueEncoding: "minimal",
|
|
});
|
|
writeEnvFileAtomicallyIfChanged(filePath, previousContents, nextContents);
|
|
}
|
|
|
|
export function mergePaperclipEnvEntries(
|
|
entries: Record<string, string>,
|
|
filePath = resolveEnvFilePath(),
|
|
): Record<string, string> {
|
|
const current = readPaperclipEnvEntries(filePath);
|
|
const managedEntries = paperclipOwnedEntries(entries);
|
|
const next = {
|
|
...current,
|
|
...managedEntries,
|
|
};
|
|
writePaperclipEnvEntries(managedEntries, filePath);
|
|
return next;
|
|
}
|