mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +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>
184 lines
8.5 KiB
TypeScript
184 lines
8.5 KiB
TypeScript
import { promises as fs } from "node:fs";
|
|
import fsSync from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { mergePaperclipConfig, paperclipConfigSchema } from "@paperclipai/shared";
|
|
import {
|
|
updateEnvFileContents,
|
|
writeEnvFileAtomicallyIfChanged,
|
|
} from "@paperclipai/shared/env-file";
|
|
import { resourceStatus, stockHash } from "../services/managed-resource-drift.js";
|
|
import { agentInstructionsService } from "../services/agent-instructions.js";
|
|
|
|
/**
|
|
* Cross-cutting setup/sync rerun survival test.
|
|
*
|
|
* Seeds hand edits across every formerly-destructive setup/sync surface, reruns the
|
|
* setup/sync mechanism, and asserts each edit survives. Each block drives the SAME
|
|
* production code the writers/reconcilers use:
|
|
* - config.json -> mergePaperclipConfig + paperclipConfigSchema (shared core
|
|
* used by BOTH writers: cli/src/config/store.ts:writeConfig
|
|
* and server/src/worktree-config.ts:writeConfigFile)
|
|
* - .env -> updateEnvFileContents + writeEnvFileAtomicallyIfChanged
|
|
* - managed sandbox env -> resourceStatus/stockHash drift gate (the `shouldWrite`
|
|
* classifier the boot reconciler and built-in sync share)
|
|
* - managed instructions -> agentInstructionsService materialize + the drift gate that
|
|
* makes built-in/plugin/portability rebuilds skip operator edits
|
|
*
|
|
* The DB-backed end-to-end variants of the sandbox-row, skills-assignment, and
|
|
* multi-caller instruction paths are additionally proven by the embedded-Postgres suites:
|
|
* server/src/__tests__/company-skills-service.test.ts (skills add/remove/replace)
|
|
* server/src/services/managed-environments.test.ts (sandbox row boot reconcile)
|
|
* server/src/__tests__/environment-service.test.ts (sandbox skip + stock update)
|
|
* server/src/__tests__/agent-instructions-service.test.ts (managed instruction drift)
|
|
*/
|
|
|
|
const cleanupDirs = new Set<string>();
|
|
|
|
async function tmp(prefix: string) {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
cleanupDirs.add(dir);
|
|
return dir;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
[...cleanupDirs].map(async (dir) => {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
cleanupDirs.delete(dir);
|
|
}),
|
|
);
|
|
delete process.env.PAPERCLIP_HOME;
|
|
delete process.env.PAPERCLIP_INSTANCE_ID;
|
|
});
|
|
|
|
const BASE_CONFIG = {
|
|
$meta: { version: 1, updatedAt: "2026-08-07T00:00:00.000Z", source: "configure" },
|
|
database: { mode: "embedded-postgres" },
|
|
logging: { mode: "file" },
|
|
server: {},
|
|
} as const;
|
|
|
|
/** Emulates a writer round-trip on disk: parse the update, merge over the parsed source. */
|
|
function writerRoundTrip(configPath: string, update: unknown): void {
|
|
const parsedUpdate = paperclipConfigSchema.parse(update);
|
|
const source = paperclipConfigSchema.parse(
|
|
JSON.parse(fsSync.readFileSync(configPath, "utf8")),
|
|
);
|
|
const next = paperclipConfigSchema.parse(mergePaperclipConfig(source, parsedUpdate));
|
|
fsSync.writeFileSync(configPath, JSON.stringify(next, null, 2) + "\n");
|
|
}
|
|
|
|
describe("setup/sync rerun survival — cross-cutting", () => {
|
|
it("preserves unknown top-level and nested config.json keys across a writer rerun", async () => {
|
|
const dir = await tmp("pap16587-config-");
|
|
const configPath = path.join(dir, "config.json");
|
|
|
|
// Seed a config that carries operator extension keys the schema does not know about.
|
|
const seeded = paperclipConfigSchema.parse({
|
|
...BASE_CONFIG,
|
|
customTopLevel: { keepMe: true, nested: [1, 2, 3] },
|
|
server: { ...BASE_CONFIG.server, customServerKey: "operator-owned" },
|
|
});
|
|
fsSync.writeFileSync(configPath, JSON.stringify(seeded, null, 2) + "\n");
|
|
|
|
// Rerun setup/sync with an unrelated known-field update (flip logging mode).
|
|
writerRoundTrip(configPath, { ...BASE_CONFIG, logging: { mode: "cloud" } });
|
|
|
|
const after = JSON.parse(fsSync.readFileSync(configPath, "utf8")) as Record<string, any>;
|
|
expect(after.customTopLevel).toEqual({ keepMe: true, nested: [1, 2, 3] });
|
|
expect(after.server.customServerKey).toBe("operator-owned");
|
|
expect(after.logging.mode).toBe("cloud"); // the intended update still applied
|
|
});
|
|
|
|
it("preserves comments, ordering, blank lines, and existing encodings in .env", async () => {
|
|
const dir = await tmp("pap16587-env-");
|
|
const envPath = path.join(dir, ".env");
|
|
const original = [
|
|
"# top comment",
|
|
"",
|
|
"FIRST=one",
|
|
'QUOTED="already json"',
|
|
"SECOND=two # trailing note",
|
|
"",
|
|
"# trailing comment",
|
|
"",
|
|
].join("\n");
|
|
fsSync.writeFileSync(envPath, original);
|
|
|
|
// Rerun that only changes SECOND; everything else must be byte-preserved.
|
|
const next = updateEnvFileContents(original, { SECOND: "changed" }, { valueEncoding: "minimal" });
|
|
const wrote = writeEnvFileAtomicallyIfChanged(envPath, original, next);
|
|
expect(wrote).toBe(true);
|
|
|
|
const after = fsSync.readFileSync(envPath, "utf8");
|
|
expect(after).toContain("# top comment");
|
|
expect(after).toContain("# trailing comment");
|
|
expect(after).toContain('QUOTED="already json"'); // encoding untouched
|
|
expect(after).toContain("SECOND=changed # trailing note"); // value changed, comment kept
|
|
expect(after.indexOf("FIRST=one")).toBeLessThan(after.indexOf("SECOND=")); // ordering kept
|
|
expect(after.split("\n").filter((l) => l === "").length).toBe(original.split("\n").filter((l) => l === "").length);
|
|
|
|
// A no-op rerun writes nothing.
|
|
expect(writeEnvFileAtomicallyIfChanged(envPath, after, updateEnvFileContents(after, { SECOND: "changed" }, { valueEncoding: "minimal" }))).toBe(false);
|
|
});
|
|
|
|
it("skips an operator-modified managed sandbox row but advances an untouched stale one", () => {
|
|
const stockV1 = { image: "node:20", packages: ["git"] };
|
|
const stockV2 = { image: "node:22", packages: ["git", "curl"] };
|
|
const v1Hash = stockHash(stockV1);
|
|
const v2Hash = stockHash(stockV2);
|
|
|
|
// Operator hand-edited the row away from stock -> rerun must NOT clobber it.
|
|
const operatorHash = stockHash({ image: "node:20", packages: ["git", "operator-tool"] });
|
|
expect(
|
|
resourceStatus({ resourceId: "env-1", currentHash: operatorHash, bindingStockHash: v1Hash, latestStockHash: v2Hash }),
|
|
).toBe("operator_modified");
|
|
|
|
// Untouched row still at last-synced stock, newer stock available -> update is exposed.
|
|
expect(
|
|
resourceStatus({ resourceId: "env-1", currentHash: v1Hash, bindingStockHash: v1Hash, latestStockHash: v2Hash }),
|
|
).toBe("stock_update_available");
|
|
|
|
// Already current -> no-op.
|
|
expect(
|
|
resourceStatus({ resourceId: "env-1", currentHash: v2Hash, bindingStockHash: v1Hash, latestStockHash: v2Hash }),
|
|
).toBe("stock_current");
|
|
});
|
|
|
|
it("keeps operator edits and additions in a managed instructions tree across a re-materialize", async () => {
|
|
const home = await tmp("pap16587-instr-home-");
|
|
process.env.PAPERCLIP_HOME = home;
|
|
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
|
|
|
|
const svc = agentInstructionsService();
|
|
const agent = { id: "agent-1", companyId: "company-1", name: "Agent 1", adapterConfig: {} };
|
|
const stockFiles = { "AGENTS.md": "# stock\n", "docs/TOOLS.md": "## stock tools\n" };
|
|
|
|
const first = await svc.materializeManagedBundle(agent, stockFiles, { entryFile: "AGENTS.md" });
|
|
const root = first.bundle.managedRootPath!;
|
|
|
|
// Operator hand-edits a managed file and adds a brand-new operator-only file.
|
|
await fs.writeFile(path.join(root, "AGENTS.md"), "# stock\n\noperator note\n", "utf8");
|
|
await fs.writeFile(path.join(root, "OPERATOR.md"), "operator-added\n", "utf8");
|
|
|
|
// The drift gate the built-in/plugin/portability rebuilds share: classify the tree.
|
|
const currentFiles = {
|
|
"AGENTS.md": await fs.readFile(path.join(root, "AGENTS.md"), "utf8"),
|
|
"docs/TOOLS.md": await fs.readFile(path.join(root, "docs", "TOOLS.md"), "utf8"),
|
|
};
|
|
const status = resourceStatus({
|
|
resourceId: agent.id,
|
|
currentHash: stockHash(currentFiles),
|
|
bindingStockHash: stockHash(stockFiles),
|
|
latestStockHash: stockHash(stockFiles),
|
|
});
|
|
expect(status).toBe("operator_modified"); // -> reconcile shouldWrite=false, tree left intact
|
|
|
|
// Prove the on-disk edits and additions still exist after the sync decision.
|
|
await expect(fs.readFile(path.join(root, "AGENTS.md"), "utf8")).resolves.toContain("operator note");
|
|
await expect(fs.readFile(path.join(root, "OPERATOR.md"), "utf8")).resolves.toBe("operator-added\n");
|
|
});
|
|
});
|