mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix: refresh preserved harness receipt facts without copying archives (#20322)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Volume-free deployment must preserve historical controller files before removing the old disk. > - A WEB preservation receipt binds an archive and source files to the selected session and database facts. > - Later metadata changes can make those facts stale while the preserved files and archive remain unchanged. > - The existing owner refuses every stale receipt, so preservation cannot converge. > - This pull request refreshes only facts freshness after it verifies the same source bytes, checkpoints and archive. > - Existing locks and an old-receipt digest comparison prevent a concurrent replacement from being overwritten. ## Linked Issues or Issue Description Refs #19814 for WEB evidence preservation without settling remote recovery. Refs #19832 for bounded larger inventories. Related harness receipt PRs were searched; no existing change provides this guarded refresh. **What happened?** A valid WEB preservation receipt becomes stale after later run metadata changes. Its original-volume owner refuses another preservation attempt because an invalid receipt already exists. This leaves an eligibility blocker even when the source and saved archive are unchanged. **Expected behavior** The original-volume owner can refresh stale facts only after it proves every other persisted binding unchanged. It retains the prior receipt and remote recovery state. **Steps to reproduce** 1. Preserve a settled session's WEB files and controller evidence. 2. Add later run metadata so its database facts digest changes. 3. Preserve the same scope again. The previous implementation refuses at the existing-receipt check. **Paperclip version or commit** The failure was reproduced on master `12523fb677585fcd6da929f868014514a4e96e87`. This branch includes master `1b7fe33432016b3b27ccdfbf7b8944da4b40726d`. **Deployment mode** Managed Linux server with PostgreSQL and durable object storage. ## What Changed - Reuse the exact prior archive for existing WEB receipts. Recheck current source bytes, controller history, checkpoint material and archive bindings. Read back ciphertext and verify its size and hash. - Permit only database/local-observation freshness changes. Selected session, source run, controller, archive, WEB entries, and assets must still match the prior proof. Local inode and timestamp observations may change across attempts; preserved bytes, paths and modes must match. - Recheck current facts and source files under the existing finalizer, task and run fences. Accept a matching concurrent winner or require the exact previous receipt digest before replacement. - Retain the prior database and local facts digests, witness fingerprint, observation time and receipt digest in the same company-scoped activity transaction. Require those four fields to reconstruct the exact prior proof from unchanged current bindings before replacement. Normal audit redaction stays enabled. - Preserve current-key checks and archive reads on ordinary idempotent calls. Keep active-source, changed-evidence and cancellation refusals. - Document the refresh contract. No schema, saved-run format or public API change. ## Verification - Genuine RED: the new real PostgreSQL/encrypted-archive case failed at the old previous-invalid gate. - Before the audit review follow-up, the full local owner suite passed **94/94 integration cases in 50.01 seconds**. It covers same-archive refresh with no PUT, audit history, current-key failures, unreadable archives, cancellation, changed files, replaced volume, active sources, matching concurrent winners and conflicting receipt replacement. - The cases also cover a source becoming active during an idempotent readback. Earlier 91- and 93-case runs preceded these final review guards and are not the final result. - Server typecheck, module boundaries and diff checks passed on the final production/test bytes before the clean master merge. Required CI supplies final-head breadth. Four narrow post-merge refresh/current-key/idempotent regressions passed in 5.70 seconds on `82e270f29fff98442c4184b96082f72f18a88721`. - The fixtures use a real disposable PostgreSQL database and actual local encrypted archives/storage. Controlled faults affect the stated storage or concurrency boundary. They do not prove a hosted refresh or archive plaintext cold restore. - The existing integration file now exceeds 1,000 lines. Keeping these cases with its private original-volume, controller, archive and PostgreSQL fixture avoids duplicating that setup. - Initial worktree preflight stalled on the global package-manager wrapper. Preflight completed with the existing pinned toolchain. No setup failure is counted as a product test pass. - Review found that activity redaction could change a full prior-receipt copy. A new regression enabled the real username censor and reproduced that mismatch. The fix stores only the four changed scalar fields and their original proof digest; a prepublication canonical reconstruction guard rejects dropped or unknown fields. - On the audit follow-up, 16 affected real PostgreSQL cases passed in 11.98 seconds; 80 unchanged cases were intentionally skipped. They include real redaction with a username-containing WEB path, reconstruction across two refreshes, unknown prior fields, CAS, active sources, current-key failures, unavailable archives and cancellation. Final server typecheck and boundaries passed. This was an intermediate revision; the final-head results appear below. - A second review found that comparing old inode/timestamp observations also broke ordinary valid retries. Three real cases reproduced valid-receipt touch/inode failures and stale-facts touch failure. The fix permits metadata freshness changes across attempts while keeping exact byte/path/mode/checkpoint/archive coverage and the existing locked within-attempt metadata fence. - Final local owner suite on the metadata follow-up: **102/102 integration cases passed in 44.04 seconds**, including valid touch/new-inode retries, stale refresh with the same archive and no PUT, four-scalar audit reconstruction, changed bytes/mode/entries, and a touch during archive readback that must refuse. Server typecheck, module boundaries and diff checks passed. The final-head CI and review results appear below. - Final head `c81475c927d0862dcb8d2c2356dc41ab4a27e63f`: [PR workflow attempt 5](https://github.com/paperclipai/paperclip/actions/runs/42667287803/attempts/5) completed successfully at 19:24:28 UTC on 2026-11-09. All 51 jobs are accounted for: 49 passed and 2 were skipped. The actual [final ci / verify](https://github.com/paperclipai/paperclip/actions/runs/42667287803/job/129382061558) and [E2E aggregate](https://github.com/paperclipai/paperclip/actions/runs/42667287803/job/129381486342) passed. Typecheck, build and the selected test lanes passed. The exact-head rollup is successful with no pending or failed checks. - Attempt 5 resolved both trusted workflows to `3a2f71db31aa99633610dc241f55f34ac65124de`, after #20339 merged. Its routing gate passed and real jobs started. This verifies successful startup and completion of this rerun; it does not exercise the empty-runner fallback because routing succeeded. - Earlier CI failures on `82e270f29fff98442c4184b96082f72f18a88721` and attempts 1–4 of this final-head workflow remain nonpasses. Attempt 1 had interrupted leaf jobs and failed aggregates. Attempts 2 and 3 ended in workflow failure without a final verify job. Attempt 4 failed startup with zero jobs and empty-runner errors. Those failures are not counted as passes or labeled unrelated. - [Greptile](https://github.com/paperclipai/paperclip/pull/20322#issuecomment-6746395090) and [Ponytail](https://github.com/paperclipai/paperclip/pull/20322#issuecomment-6746327827) reviewed the unchanged final head at **5/5**. Both historical review threads are resolved; no inline thread remains open. - Hosted application to an actual stale scope remains pending normal release delivery. ## Risks - Refresh must not bless different preservation evidence. The unchanged-binding checks, fresh file and checkpoint proof, exact archive reuse, source capability and locked receipt comparison remain mandatory. - A fresh capability has a new witness fingerprint and expiry. The audit retains the old fingerprint, database/local facts digests and observation time. All unchanged proof bindings remain on the current receipt. Their canonical reconstruction must match the old digest. The new capability is validated at entry and publication. - Readback verifies ciphertext size/hash and current-key checkpoint material. This change does not claim a new archive GCM/plaintext restore. - Archive identity and rows remain unchanged, so retention ownership does not change. Failed refreshes retain the previous receipt, original files and unresolved remote recovery obligations. - Older receipt shapes are not rewritten automatically. Normal validation and named preservation refusals remain in force. ## Model Used OpenAI Codex, GPT-6.1 Sol (`gpt-6.1-sol`), with reasoning, repository tools and code execution. Context-window size is not exposed by the runtime. ## 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
This commit is contained in:
1 parent
908d2aae7a
commit
0eacad0d0c
3 files changed
+262
-13
No files matched your search
@@ -122,6 +122,25 @@ original-volume anchor or filesystem read. These validations add bounded
|
||||
per-retained-scope database reads during startup. They do not add a worker or a
|
||||
poll. Missing or invalid byte evidence remains an enrollment blocker.
|
||||
|
||||
The original-volume owner can refresh a WEB receipt whose database facts digest
|
||||
has become stale only when fresh source inspection, current-key checkpoint
|
||||
validation and ciphertext hash/size readback prove the preserved bindings unchanged.
|
||||
It reuses the same archive and takes the existing finalizer, task and run fences before
|
||||
comparing the prior receipt digest and replacing it. The company activity log
|
||||
retains the prior database and local facts digests, source witness fingerprint,
|
||||
observation time and receipt digest. Those four changed fields must reconstruct
|
||||
the exact prior proof from the unchanged current bindings before publication. No source paths
|
||||
enter the audit delta; normal activity redaction remains enabled. Changed
|
||||
evidence, concurrent invalid receipts and active sources remain blocked; remote
|
||||
recovery obligations are unchanged.
|
||||
|
||||
Local inode and timestamp observations may change between preservation attempts
|
||||
without changing byte coverage. Valid receipts remain idempotent after those
|
||||
changes; stale database facts can refresh with the same archive and fresh local
|
||||
observations. During one attempt, the local metadata digest must remain stable
|
||||
from capture through the locked publication check. Changed bytes, paths or modes
|
||||
still invalidate preservation evidence.
|
||||
|
||||
Receipt validation adds bounded per-scope database reads to startup eligibility.
|
||||
It does not add a recurring scan or network work. Qualification must measure the
|
||||
selected cohort's startup latency on the serving revision before enrollment.
|
||||
|
||||
+206
-3
@@ -1,12 +1,12 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { chmod, cp, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, symlink, truncate, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { chmod, copyFile, cp, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, symlink, truncate, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir, userInfo } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||
import { afterAll, afterEach, beforeAll, expect, it, vi } from "vitest";
|
||||
import { activityLog, agentTaskSessions, agents, companies, companySkills, companySkillVersions, companySkillRepositorySnapshots, createDb, environmentLeases,
|
||||
heartbeatRuns, issues, nativeHarnessBackups, nativeRunnerCheckpoints, nativeRunFinalizations, nativeRunResults } from "@paperclipai/db";
|
||||
heartbeatRuns, instanceSettings, issues, nativeHarnessBackups, nativeRunnerCheckpoints, nativeRunFinalizations, nativeRunResults } from "@paperclipai/db";
|
||||
import { describeEmbeddedPostgres } from "../../__tests__/helpers/native-restart.js";
|
||||
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
|
||||
@@ -33,6 +33,7 @@ import { nativeToolContractFingerprintForTarget } from "./native-session-resume.
|
||||
import { nativeCheckpointRetirement } from "./native-runner-checkpoint-retirement.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
import { nativeControllerEvidenceDescriptor, NATIVE_CONTROLLER_WEB_LIMITS, type NativeControllerHistoryManifestEntry } from "./native-controller-archive.js";
|
||||
import { logActivity } from "../activity-log.js";
|
||||
|
||||
vi.mock("../../build-commit.js", () => ({ readBuildCommit: () => "a".repeat(40) }));
|
||||
|
||||
@@ -388,6 +389,208 @@ describeEmbeddedPostgres("owner-verified native harness filesystem independence"
|
||||
expect(await hasValidNativeHarnessFilesystemIndependence(db, f.input)).toBe(false);
|
||||
});
|
||||
|
||||
async function staleWebFixture() {
|
||||
const f = await webFixture(), first = await preserveNativeHarnessFilesystemIndependence(db, f.input);
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const previous = run!.runnerProfileJson!.nativeHarnessFilesystemIndependence as Record<string, unknown>;
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...run!.runnerProfileJson, retainedWarning: "later preservation metadata" } })
|
||||
.where(eq(heartbeatRuns.id, f.runId));
|
||||
expect(await hasValidNativeHarnessFilesystemIndependence(db, f.input)).toBe(false);
|
||||
return { ...f, first, previous };
|
||||
}
|
||||
|
||||
it.each([false, true])("refreshes stale WEB facts with the same verified source and archive while auditing the prior receipt (touched: %s)", async touched => {
|
||||
const f = await staleWebFixture();
|
||||
if (touched) { const stat = await lstat(f.supervisorPath); await utimes(f.supervisorPath, stat.atime, new Date(stat.mtimeMs + 5_000)); }
|
||||
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.input.companyId));
|
||||
const leases = await db.select().from(environmentLeases).where(eq(environmentLeases.companyId, f.input.companyId));
|
||||
const finalizers = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.companyId, f.input.companyId));
|
||||
const backups = await db.select().from(nativeHarnessBackups).where(eq(nativeHarnessBackups.companyId, f.input.companyId));
|
||||
const put = vi.spyOn(f.storage, "putFile");
|
||||
const get = vi.spyOn(f.storage, "getObject");
|
||||
const now = Date.now(), source = await verifyOriginalHarnessVolumeSource({ ...f.witness,
|
||||
observedAt: new Date(now).toISOString(), expiresAt: new Date(now + 120_000).toISOString() });
|
||||
expect(source.fingerprint).not.toBe(f.input.source.fingerprint);
|
||||
const result = await preserveNativeHarnessFilesystemIndependence(db, { ...f.input, source });
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const receipt = run!.runnerProfileJson!.nativeHarnessFilesystemIndependence as Record<string, unknown>;
|
||||
expect(receipt).toEqual({ ...f.previous, sourceCapabilityFingerprint: source.fingerprint,
|
||||
factsDigest: expect.any(String), localFactsDigest: expect.any(String), observedAt: expect.any(String) });
|
||||
if (touched) expect(receipt.localFactsDigest).not.toBe(f.previous.localFactsDigest);
|
||||
expect(receipt.factsDigest).not.toBe(f.previous.factsDigest);
|
||||
expect(result.receiptDigest).not.toBe(f.first.receiptDigest);
|
||||
expect(await hasValidNativeHarnessFilesystemIndependence(db, f.input)).toBe(true);
|
||||
expect(put).not.toHaveBeenCalled(); expect(get).toHaveBeenCalled();
|
||||
expect(await db.select().from(nativeHarnessBackups).where(eq(nativeHarnessBackups.companyId, f.input.companyId))).toEqual(backups);
|
||||
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.companyId, f.input.companyId))).toEqual(leases);
|
||||
expect(await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.companyId, f.input.companyId))).toEqual(finalizers);
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.input.companyId)))
|
||||
.toEqual(runs.map(row => ({ ...row, runnerProfileJson: { ...row.runnerProfileJson, nativeHarnessFilesystemIndependence: receipt } })));
|
||||
const audits = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.input.companyId),
|
||||
eq(activityLog.action, "native_harness_filesystem_independence_verified")));
|
||||
expect(audits).toHaveLength(2);
|
||||
expect(audits.find(row => row.details?.previousReceiptDigest === f.first.receiptDigest)?.details)
|
||||
.toMatchObject({ previousReceiptDigest: f.first.receiptDigest, previousReceiptState: { factsDigest: f.previous.factsDigest,
|
||||
localFactsDigest: f.previous.localFactsDigest, sourceCapabilityFingerprint: f.previous.sourceCapabilityFingerprint,
|
||||
observedAt: f.previous.observedAt }, factsDigest: receipt.factsDigest });
|
||||
const audit = audits.find(row => row.details?.previousReceiptDigest === f.first.receiptDigest)!.details!;
|
||||
expect(nativeSha256({ ...receipt, ...audit.previousReceiptState as Record<string, unknown> })).toBe(f.first.receiptDigest);
|
||||
expect((await preserveNativeHarnessFilesystemIndependence(db, f.input)).receiptDigest).toBe(result.receiptDigest);
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.input.companyId),
|
||||
eq(activityLog.action, "native_harness_filesystem_independence_verified")))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["touch", "new-inode"])("preserves a valid WEB receipt after metadata-only %s", async control => {
|
||||
const f = await webFixture(), first = await preserveNativeHarnessFilesystemIndependence(db, f.input);
|
||||
const before = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const backups = await db.select().from(nativeHarnessBackups).where(eq(nativeHarnessBackups.companyId, f.input.companyId));
|
||||
const stat = await lstat(f.supervisorPath);
|
||||
if (control === "touch") await utimes(f.supervisorPath, stat.atime, new Date(stat.mtimeMs + 5_000));
|
||||
else { await copyFile(f.supervisorPath, `${f.supervisorPath}.copy`); await rename(`${f.supervisorPath}.copy`, f.supervisorPath);
|
||||
expect((await lstat(f.supervisorPath)).ino).not.toBe(stat.ino); }
|
||||
expect(await hasValidNativeHarnessFilesystemIndependence(db, f.input)).toBe(true);
|
||||
const put = vi.spyOn(f.storage, "putFile"), get = vi.spyOn(f.storage, "getObject");
|
||||
expect((await preserveNativeHarnessFilesystemIndependence(db, f.input)).receiptDigest).toBe(first.receiptDigest);
|
||||
expect(get).toHaveBeenCalled(); expect(put).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId))).toEqual(before);
|
||||
expect(await db.select().from(nativeHarnessBackups).where(eq(nativeHarnessBackups.companyId, f.input.companyId))).toEqual(backups);
|
||||
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.input.companyId),
|
||||
eq(activityLog.action, "native_harness_filesystem_independence_verified")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps prior WEB proof reconstructable when activity redaction masks a source path", async () => {
|
||||
const f = await webFixture(), path = `codex-home/${userInfo().username}/notes.txt`;
|
||||
await mkdir(join(f.sourceRoot, "codex-home", userInfo().username));
|
||||
await writeFile(join(f.sourceRoot, path), "preserved local notes");
|
||||
const first = await preserveNativeHarnessFilesystemIndependence(db, f.input);
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const previous = run!.runnerProfileJson!.nativeHarnessFilesystemIndependence as Record<string, unknown>;
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...run!.runnerProfileJson, retainedWarning: "later metadata" } })
|
||||
.where(eq(heartbeatRuns.id, f.runId));
|
||||
const [settings] = await db.select().from(instanceSettings);
|
||||
await db.update(instanceSettings).set({ general: { ...settings!.general, censorUsernameInLogs: true } }).where(eq(instanceSettings.id, settings!.id));
|
||||
try {
|
||||
await logActivity(db, { companyId: f.input.companyId, actorType: "system", actorId: "redaction-fixture",
|
||||
action: "redaction_fixture", entityType: "heartbeat_run", entityId: f.runId, details: { path } });
|
||||
const [redacted] = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.input.companyId), eq(activityLog.action, "redaction_fixture")));
|
||||
expect(redacted!.details!.path).not.toBe(path);
|
||||
await preserveNativeHarnessFilesystemIndependence(db, f.input);
|
||||
const [current] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const receipt = current!.runnerProfileJson!.nativeHarnessFilesystemIndependence as Record<string, unknown>;
|
||||
const audits = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.input.companyId),
|
||||
eq(activityLog.action, "native_harness_filesystem_independence_verified")));
|
||||
const audit = audits.find(row => row.details?.previousReceiptDigest === first.receiptDigest)!.details!;
|
||||
const reconstructed = audit.previousReceipt ?? { ...receipt, ...audit.previousReceiptState as Record<string, unknown> };
|
||||
expect(nativeSha256(reconstructed)).toBe(first.receiptDigest);
|
||||
expect(reconstructed).toEqual(previous);
|
||||
expect(audit).not.toHaveProperty("previousReceipt");
|
||||
expect(audit.previousReceiptState).toEqual({ factsDigest: previous.factsDigest,
|
||||
localFactsDigest: previous.localFactsDigest, sourceCapabilityFingerprint: previous.sourceCapabilityFingerprint, observedAt: previous.observedAt });
|
||||
expect(await hasValidNativeHarnessFilesystemIndependence(db, f.input)).toBe(true);
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...current!.runnerProfileJson, retainedWarning: "another metadata update" } })
|
||||
.where(eq(heartbeatRuns.id, f.runId));
|
||||
await preserveNativeHarnessFilesystemIndependence(db, f.input);
|
||||
const [latest] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const history = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.input.companyId),
|
||||
eq(activityLog.action, "native_harness_filesystem_independence_verified")));
|
||||
expect(history).toHaveLength(3);
|
||||
for (const row of history.filter(row => row.details?.previousReceiptState)) {
|
||||
expect(nativeSha256({ ...latest!.runnerProfileJson!.nativeHarnessFilesystemIndependence as Record<string, unknown>,
|
||||
...row.details!.previousReceiptState as Record<string, unknown> })).toBe(row.details!.previousReceiptDigest);
|
||||
}
|
||||
} finally { await db.update(instanceSettings).set({ general: settings!.general }).where(eq(instanceSettings.id, settings!.id)); }
|
||||
});
|
||||
|
||||
it("refuses a WEB facts refresh that would drop unknown prior receipt fields", async () => {
|
||||
const f = await staleWebFixture();
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const profile = { ...run!.runnerProfileJson, nativeHarnessFilesystemIndependence: { ...f.previous, unknownEvidence: "retain" } };
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: profile }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(preserveNativeHarnessFilesystemIndependence(db, f.input)).rejects.toThrow("native_harness_filesystem_independence_unverified");
|
||||
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]!.runnerProfileJson).toEqual(profile);
|
||||
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.input.companyId),
|
||||
eq(activityLog.action, "native_harness_filesystem_independence_verified")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["wrong-key", "unreadable-archive", "active-source"])("reverifies valid WEB coverage before idempotent preservation: %s", async control => {
|
||||
const f = await webFixture(); await preserveNativeHarnessFilesystemIndependence(db, f.input);
|
||||
expect(await hasValidNativeHarnessFilesystemIndependence(db, f.input)).toBe(true);
|
||||
if (control === "wrong-key") vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "62".repeat(32));
|
||||
if (control === "unreadable-archive") vi.spyOn(f.storage, "getObject").mockRejectedValue(new Error("archive unavailable"));
|
||||
if (control === "active-source") {
|
||||
const get = f.storage.getObject.bind(f.storage); let changed = false;
|
||||
vi.spyOn(f.storage, "getObject").mockImplementation(async (...args) => {
|
||||
if (!changed) { changed = true; await db.update(heartbeatRuns).set({ controllerLeaseExpiresAt: new Date(Date.now() + 60_000) })
|
||||
.where(eq(heartbeatRuns.id, f.runId)); }
|
||||
return get(...args);
|
||||
});
|
||||
}
|
||||
const before = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
await expect(preserveNativeHarnessFilesystemIndependence(db, f.input)).rejects.toThrow();
|
||||
const after = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
expect(after[0]!.runnerProfileJson).toEqual(before[0]!.runnerProfileJson);
|
||||
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.input.companyId),
|
||||
eq(activityLog.action, "native_harness_filesystem_independence_verified")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["active-source", "changed-source", "changed-mode", "added-file", "wrong-key", "unreadable-archive", "aborted", "replaced-volume"])(
|
||||
"retains a stale WEB receipt when fresh proof fails: %s", async control => {
|
||||
const f = await staleWebFixture();
|
||||
if (control === "active-source") await db.update(heartbeatRuns).set({ controllerLeaseExpiresAt: new Date(Date.now() + 60_000) })
|
||||
.where(eq(heartbeatRuns.id, f.runId));
|
||||
if (control === "changed-source") await writeFile(f.supervisorPath, `${f.supervisor} `);
|
||||
if (control === "changed-mode") { const mode = (await lstat(f.supervisorPath)).mode & 0o777;
|
||||
await chmod(f.supervisorPath, mode === 0o400 ? 0o600 : 0o400); }
|
||||
if (control === "added-file") await writeFile(join(f.sourceRoot, "codex-home/uncovered.txt"), "new bytes");
|
||||
if (control === "wrong-key") vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "62".repeat(32));
|
||||
if (control === "unreadable-archive") vi.spyOn(f.storage, "getObject").mockRejectedValue(new Error("archive unavailable"));
|
||||
if (control === "replaced-volume") vi.stubEnv("RAILWAY_VOLUME_ID", randomUUID());
|
||||
const before = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
const backups = await db.select().from(nativeHarnessBackups).where(eq(nativeHarnessBackups.companyId, f.input.companyId));
|
||||
const put = vi.spyOn(f.storage, "putFile");
|
||||
await expect(preserveNativeHarnessFilesystemIndependence(db, { ...f.input,
|
||||
...(control === "aborted" ? { signal: AbortSignal.abort() } : {}) })).rejects.toThrow();
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId))).toEqual(before);
|
||||
expect(await db.select().from(nativeHarnessBackups).where(eq(nativeHarnessBackups.companyId, f.input.companyId))).toEqual(backups);
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.input.companyId),
|
||||
eq(activityLog.action, "native_harness_filesystem_independence_verified")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["matching-winner", "changed-receipt", "active-source", "changed-byte", "touched-source"])(
|
||||
"guards stale WEB refresh publication after concurrent %s", async control => {
|
||||
const f = await staleWebFixture();
|
||||
let release!: () => void, entered!: () => void, delay = true;
|
||||
const blocked = new Promise<void>(resolve => { release = resolve; }), ready = new Promise<void>(resolve => { entered = resolve; });
|
||||
const get = f.storage.getObject.bind(f.storage);
|
||||
vi.spyOn(f.storage, "getObject").mockImplementation(async (...args) => {
|
||||
if (delay) { delay = false; entered(); await blocked; } return get(...args);
|
||||
});
|
||||
const late = preserveNativeHarnessFilesystemIndependence(db, f.input);
|
||||
try {
|
||||
await ready;
|
||||
const winner = control === "matching-winner" ? await preserveNativeHarnessFilesystemIndependence(db, f.input) : null;
|
||||
if (control === "changed-receipt") {
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...run!.runnerProfileJson,
|
||||
nativeHarnessFilesystemIndependence: { ...f.previous, observedAt: new Date().toISOString() } } }).where(eq(heartbeatRuns.id, f.runId));
|
||||
}
|
||||
if (control === "active-source") await db.update(heartbeatRuns).set({ controllerLeaseExpiresAt: new Date(Date.now() + 60_000) })
|
||||
.where(eq(heartbeatRuns.id, f.runId));
|
||||
if (control === "changed-byte") await writeFile(f.supervisorPath, `${f.supervisor} `);
|
||||
if (control === "touched-source") { const stat = await lstat(f.supervisorPath);
|
||||
await utimes(f.supervisorPath, stat.atime, new Date(stat.mtimeMs + 5_000)); }
|
||||
const before = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
||||
release();
|
||||
if (winner) expect((await late).receiptDigest).toBe(winner.receiptDigest);
|
||||
else await expect(late).rejects.toThrow();
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId))).toEqual(before);
|
||||
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.input.companyId),
|
||||
eq(activityLog.action, "native_harness_filesystem_independence_verified")))).toHaveLength(winner ? 2 : 1);
|
||||
} finally { release(); await late.catch(() => undefined); }
|
||||
});
|
||||
|
||||
it.each(["active-web", "owned-finalizer", "physical-writer", "foreign-supervisor", "missing-manifest", "symlink", "foreign-controller"])(
|
||||
"refuses WEB evidence with %s and retains the original recovery state", async control => {
|
||||
const f = await webFixture(), where = eq(heartbeatRuns.id, f.runId);
|
||||
|
||||
@@ -189,10 +189,11 @@ async function assetBindings(db: Db, anchor: Run, sourceRunId: string) {
|
||||
}
|
||||
|
||||
function matchingReceipt(value: unknown, facts: Awaited<ReturnType<typeof databaseFacts>>, input: Scope,
|
||||
bindings: Awaited<ReturnType<typeof assetBindings>>["bindings"], controller: ControllerBinding, web?: WebBinding): Receipt | null {
|
||||
bindings: Awaited<ReturnType<typeof assetBindings>>["bindings"], controller: ControllerBinding, web?: WebBinding,
|
||||
factsDigest = facts.factsDigest): Receipt | null {
|
||||
const receipt = value as Receipt | undefined;
|
||||
if (receipt?.schema !== (web ? WEB_SCHEMA : SCHEMA) || receipt.selectedSessionId !== input.selectedSessionId || receipt.scopeDigest !== input.scopeDigest
|
||||
|| receipt.lastRunId !== facts.anchor.id || receipt.sourceRunId !== facts.source.id || receipt.factsDigest !== facts.factsDigest
|
||||
|| receipt.lastRunId !== facts.anchor.id || receipt.sourceRunId !== facts.source.id || receipt.factsDigest !== factsDigest
|
||||
|| !/^[a-f0-9]{64}$/.test(receipt.sourceCapabilityFingerprint) || !/^[a-f0-9]{64}$/.test(receipt.localFactsDigest)
|
||||
|| !Number.isFinite(Date.parse(receipt.observedAt)) || !Array.isArray(receipt.packages) || !Array.isArray(receipt.versions)) return null;
|
||||
return digest(bindings) === receipt.assetsDigest && digest(receipt.packages) === digest(bindings.packages)
|
||||
@@ -384,7 +385,7 @@ async function controllerBinding(db: Db, facts: Awaited<ReturnType<typeof databa
|
||||
|
||||
async function preserveControllerEvidence(db: Db, facts: Awaited<ReturnType<typeof databaseFacts>>, input: Scope & {
|
||||
source: OriginalHarnessVolumeCapability; storage: StorageService; signal: AbortSignal;
|
||||
}, history?: CapturedHistory, web?: CapturedWeb): Promise<ControllerBinding> {
|
||||
}, history?: CapturedHistory, web?: CapturedWeb, existing?: ControllerBinding): Promise<ControllerBinding> {
|
||||
const material = await readLegacyNativeControllerArchiveSource(facts.source);
|
||||
if (material.paths.root !== scopedRunnerdStateRoot(facts.execution)) fail();
|
||||
if (material.controller === undefined && !web) {
|
||||
@@ -411,6 +412,19 @@ async function preserveControllerEvidence(db: Db, facts: Awaited<ReturnType<type
|
||||
const durable = checkpoint.records.get(key); return durable === undefined || digest(JSON.parse(value)) !== digest(JSON.parse(durable));
|
||||
})) fail();
|
||||
}
|
||||
if (existing) {
|
||||
if (!existing.archive || existing.archive.controllerDigest !== (material.controller === undefined ? null : sha(material.controller))
|
||||
|| existing.archive.supervisorDigest !== (material.supervisor === undefined ? null : sha(material.supervisor))) fail();
|
||||
const current = await controllerBinding(db, facts, existing.archive, false, history?.binding, web?.binding);
|
||||
if (digest(current) !== digest(existing)) fail();
|
||||
const descriptor = nativeControllerEvidenceDescriptor({ companyId: input.companyId, issueId: facts.anchor.nativeIssueId!,
|
||||
runId: facts.source.id, controllerDigest: existing.archive.controllerDigest, supervisorDigest: existing.archive.supervisorDigest,
|
||||
purpose: "filesystem_independence", history: history?.binding.entries, historyBindingDigest: history ? digest(history.binding) : undefined,
|
||||
...(web ? { web: { entries: web.binding.entries, bindingDigest: digest(web.binding) } } : {}) });
|
||||
if (!await verifyDurableNativeHarnessBackup({ db, storage: input.storage, companyId: input.companyId,
|
||||
scopeDigest: descriptor.scopeDigest, manifestSha256: descriptor.manifestSha256, backupId: existing.archive.id, signal: input.signal })) fail();
|
||||
return current;
|
||||
}
|
||||
const [coordinator] = await db.select().from(nativeRunFinalizations).where(and(eq(nativeRunFinalizations.companyId, input.companyId),
|
||||
eq(nativeRunFinalizations.runId, facts.source.id)));
|
||||
if (!coordinator) fail();
|
||||
@@ -707,18 +721,29 @@ async function localFacts(facts: Awaited<ReturnType<typeof databaseFacts>>, asse
|
||||
async function preserveWebEvidence(db: Db, input: Scope & {
|
||||
source: OriginalHarnessVolumeCapability; storage: StorageService; signal: AbortSignal;
|
||||
}) {
|
||||
const facts = await databaseFacts(db, input, false, true), history = await captureAuthorityHistory(db, facts, input.signal);
|
||||
const facts = await databaseFacts(db, input, false, true);
|
||||
const previous = facts.anchor.runnerProfileJson?.[KEY] as Receipt | undefined;
|
||||
const previousDigest = previous == null ? null : digest(previous);
|
||||
if (previous != null && !previous.controller?.archive) fail();
|
||||
const history = await captureAuthorityHistory(db, facts, input.signal);
|
||||
const web = await captureWebEvidence(facts, input.signal);
|
||||
const controller = await preserveControllerEvidence(db, facts, input, history, web), bindings = { packages: [], versions: [] };
|
||||
const previous = facts.anchor.runnerProfileJson?.[KEY];
|
||||
const controller = await preserveControllerEvidence(db, facts, input, history, web, previous?.controller), bindings = { packages: [], versions: [] };
|
||||
if (previous != null) {
|
||||
if (!await hasValidNativeHarnessFilesystemIndependence(db, input)) fail();
|
||||
return { scopeDigest: input.scopeDigest, sourceRunId: facts.source.id, receiptDigest: digest(previous) };
|
||||
if (!/^[a-f0-9]{64}$/.test(previous.factsDigest)
|
||||
|| !matchingReceipt(previous, facts, input, bindings, controller, web.binding, previous.factsDigest)) fail();
|
||||
if (matchingReceipt(previous, facts, input, bindings, controller, web.binding)) {
|
||||
if (!await hasValidNativeHarnessFilesystemIndependence(db, input)) fail();
|
||||
await assertOriginalHarnessVolumeSource(input.source); input.signal.throwIfAborted();
|
||||
return { scopeDigest: input.scopeDigest, sourceRunId: facts.source.id, receiptDigest: previousDigest! };
|
||||
}
|
||||
}
|
||||
const receipt: Receipt = { schema: WEB_SCHEMA, selectedSessionId: input.selectedSessionId, scopeDigest: input.scopeDigest,
|
||||
lastRunId: facts.anchor.id, sourceRunId: facts.source.id, factsDigest: facts.factsDigest,
|
||||
sourceCapabilityFingerprint: input.source.fingerprint, localFactsDigest: web.localDigest,
|
||||
assetsDigest: digest(bindings), ...bindings, controller, web: web.binding, observedAt: new Date().toISOString() };
|
||||
const previousReceiptState = previous ? { factsDigest: previous.factsDigest, localFactsDigest: previous.localFactsDigest,
|
||||
sourceCapabilityFingerprint: previous.sourceCapabilityFingerprint, observedAt: previous.observedAt } : null;
|
||||
if (previousReceiptState && digest({ ...receipt, ...previousReceiptState }) !== previousDigest) fail();
|
||||
let published = receipt;
|
||||
const publications: ActivityPublication[] = [];
|
||||
await db.transaction(async transaction => {
|
||||
@@ -734,14 +759,16 @@ async function preserveWebEvidence(db: Db, input: Scope & {
|
||||
const existing = current.anchor.runnerProfileJson?.[KEY];
|
||||
if (existing != null) {
|
||||
const winner = matchingReceipt(existing, current, input, bindings, currentController, web.binding);
|
||||
if (!winner) fail(); published = winner; return;
|
||||
if (winner) { published = winner; return; }
|
||||
}
|
||||
if ((existing == null ? null : digest(existing)) !== previousDigest) fail();
|
||||
await tx.update(heartbeatRuns).set({ runnerProfileJson: { ...current.anchor.runnerProfileJson, [KEY]: receipt } })
|
||||
.where(and(eq(heartbeatRuns.id, current.anchor.id), eq(heartbeatRuns.companyId, input.companyId)));
|
||||
await logActivity(tx, { companyId: input.companyId, actorType: "system", actorId: "native-harness-preservation",
|
||||
action: "native_harness_filesystem_independence_verified", entityType: "heartbeat_run", entityId: current.anchor.id,
|
||||
agentId: current.anchor.agentId, runId: current.anchor.id, details: { scopeDigest: input.scopeDigest, sourceRunId: current.source.id,
|
||||
sourceCapabilityFingerprint: receipt.sourceCapabilityFingerprint, factsDigest: receipt.factsDigest, coverage: "web_evidence" } }, publications);
|
||||
sourceCapabilityFingerprint: receipt.sourceCapabilityFingerprint, factsDigest: receipt.factsDigest, coverage: "web_evidence",
|
||||
...(previousReceiptState ? { previousReceiptDigest: previousDigest, previousReceiptState } : {}) } }, publications);
|
||||
});
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
return { scopeDigest: input.scopeDigest, sourceRunId: facts.source.id, receiptDigest: digest(published) };
|
||||
|
||||
Reference in new issue
Block a user