diff --git a/doc/DATABASE.md b/doc/DATABASE.md index fb0c57a6e78..223b0ff47bd 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -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. diff --git a/server/src/services/native-runtime/native-harness-filesystem-independence.integration.test.ts b/server/src/services/native-runtime/native-harness-filesystem-independence.integration.test.ts index 13c90368082..0c6a62c4d30 100644 --- a/server/src/services/native-runtime/native-harness-filesystem-independence.integration.test.ts +++ b/server/src/services/native-runtime/native-harness-filesystem-independence.integration.test.ts @@ -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; + 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; + 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 })).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; + 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; + 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 }; + 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, + ...row.details!.previousReceiptState as Record })).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(resolve => { release = resolve; }), ready = new Promise(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); diff --git a/server/src/services/native-runtime/native-harness-filesystem-independence.ts b/server/src/services/native-runtime/native-harness-filesystem-independence.ts index 452f5bce780..b818e32d760 100644 --- a/server/src/services/native-runtime/native-harness-filesystem-independence.ts +++ b/server/src/services/native-runtime/native-harness-filesystem-independence.ts @@ -189,10 +189,11 @@ async function assetBindings(db: Db, anchor: Run, sourceRunId: string) { } function matchingReceipt(value: unknown, facts: Awaited>, input: Scope, - bindings: Awaited>["bindings"], controller: ControllerBinding, web?: WebBinding): Receipt | null { + bindings: Awaited>["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>, input: Scope & { source: OriginalHarnessVolumeCapability; storage: StorageService; signal: AbortSignal; -}, history?: CapturedHistory, web?: CapturedWeb): Promise { +}, history?: CapturedHistory, web?: CapturedWeb, existing?: ControllerBinding): Promise { 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>, 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) };