Files
PaperClipAI/server/src/__tests__/rerun-survival.integration.test.ts
DottaandPaperclip 9485ffea70 fix(config): preserve env files during managed updates (#10980)
## 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>
2026-08-07 00:54:43 -05:00

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");
});
});