codex-chatgpt-control 0.5.1-alpha.2 → 0.5.1-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +27 -0
  3. package/contracts/v1/fixtures/backend-capabilities.json +40 -1
  4. package/contracts/v1/fixtures/backend-compatibility.json +21 -0
  5. package/contracts/v1/fixtures/backend-version.json +6 -1
  6. package/contracts/v1/fixtures/operation-action-prepared-event.json +42 -0
  7. package/contracts/v1/fixtures/operation-action.json +13 -0
  8. package/contracts/v1/fixtures/operation-artifact-receipt.json +14 -0
  9. package/contracts/v1/fixtures/operation-artifact-transfer-intent-event.json +24 -0
  10. package/contracts/v1/fixtures/operation-artifact-transfer-receipt-event.json +26 -0
  11. package/contracts/v1/fixtures/operation-artifact-transfer-state.json +178 -0
  12. package/contracts/v1/fixtures/operation-blocker.json +10 -0
  13. package/contracts/v1/fixtures/operation-collect-request.json +17 -0
  14. package/contracts/v1/fixtures/operation-collect-result.json +42 -0
  15. package/contracts/v1/fixtures/operation-control-receipt.json +13 -0
  16. package/contracts/v1/fixtures/operation-control-request.json +18 -0
  17. package/contracts/v1/fixtures/operation-control-result.json +31 -0
  18. package/contracts/v1/fixtures/operation-event.json +18 -0
  19. package/contracts/v1/fixtures/operation-handle.json +10 -0
  20. package/contracts/v1/fixtures/operation-inspect-request.json +13 -0
  21. package/contracts/v1/fixtures/operation-inspect-result.json +205 -0
  22. package/contracts/v1/fixtures/operation-ownership-baseline-event.json +34 -0
  23. package/contracts/v1/fixtures/operation-receipt.json +32 -0
  24. package/contracts/v1/fixtures/operation-recovery-decision.json +7 -0
  25. package/contracts/v1/fixtures/operation-recovery-observation.json +12 -0
  26. package/contracts/v1/fixtures/operation-request.json +33 -0
  27. package/contracts/v1/fixtures/operation-state.json +144 -0
  28. package/contracts/v1/fixtures/operation-submission-witness-event.json +20 -0
  29. package/contracts/v1/fixtures/operation-submission-witness.json +11 -0
  30. package/contracts/v1/fixtures/operation-submit-result.json +16 -0
  31. package/contracts/v1/fixtures/operation-target-established-event.json +21 -0
  32. package/contracts/v1/manifest.json +155 -1
  33. package/contracts/v1/parity-suite.json +96 -1
  34. package/contracts/v1/schemas/backend-compatibility.schema.json +65 -0
  35. package/contracts/v1/schemas/backend-request.schema.json +8 -2
  36. package/contracts/v1/schemas/capabilities.schema.json +81 -1
  37. package/contracts/v1/schemas/manifest.schema.json +57 -0
  38. package/contracts/v1/schemas/operation-action.schema.json +155 -0
  39. package/contracts/v1/schemas/operation-artifact-receipt.schema.json +89 -0
  40. package/contracts/v1/schemas/operation-blocker.schema.json +70 -0
  41. package/contracts/v1/schemas/operation-collect-request.schema.json +46 -0
  42. package/contracts/v1/schemas/operation-collect-result.schema.json +74 -0
  43. package/contracts/v1/schemas/operation-control-receipt.schema.json +108 -0
  44. package/contracts/v1/schemas/operation-control-request.schema.json +111 -0
  45. package/contracts/v1/schemas/operation-control-result.schema.json +112 -0
  46. package/contracts/v1/schemas/operation-event.schema.json +756 -0
  47. package/contracts/v1/schemas/operation-handle.schema.json +55 -0
  48. package/contracts/v1/schemas/operation-inspect-request.schema.json +42 -0
  49. package/contracts/v1/schemas/operation-inspect-result.schema.json +1662 -0
  50. package/contracts/v1/schemas/operation-receipt.schema.json +153 -0
  51. package/contracts/v1/schemas/operation-recovery.schema.json +314 -0
  52. package/contracts/v1/schemas/operation-request.schema.json +158 -0
  53. package/contracts/v1/schemas/operation-state.schema.json +691 -0
  54. package/contracts/v1/schemas/operation-submission-witness.schema.json +48 -0
  55. package/contracts/v1/schemas/operation-submit-result.schema.json +94 -0
  56. package/contracts/v1/surface-drift-policy.json +50 -0
  57. package/contracts/v1/vectors/operation-request-digest-v1.json +43 -0
  58. package/dist/codex-chatgpt-control-backend.mjs +43897 -9094
  59. package/dist/codex-chatgpt-control.bundle.mjs +46360 -10262
  60. package/dist/src/backend/client.d.ts +103 -2
  61. package/dist/src/backend/client.js +1436 -97
  62. package/dist/src/backend/compatibility.d.ts +12 -0
  63. package/dist/src/backend/compatibility.js +208 -0
  64. package/dist/src/backend/protocol.d.ts +84 -1
  65. package/dist/src/backend/protocol.js +31 -5
  66. package/dist/src/backend/runtime-identity.d.ts +10 -0
  67. package/dist/src/backend/runtime-identity.js +141 -0
  68. package/dist/src/backend/session.d.ts +10 -2
  69. package/dist/src/backend/session.js +478 -7
  70. package/dist/src/backend/stdio-server.d.ts +4 -1
  71. package/dist/src/backend/stdio-server.js +240 -28
  72. package/dist/src/browser/active-composer-file-input.d.ts +9 -0
  73. package/dist/src/browser/active-composer-file-input.js +33 -0
  74. package/dist/src/browser/attach.d.ts +4 -1
  75. package/dist/src/browser/attach.js +136 -28
  76. package/dist/src/browser/downloads.d.ts +2 -0
  77. package/dist/src/client.d.ts +38 -0
  78. package/dist/src/client.js +1222 -59
  79. package/dist/src/commands/configuration.js +13 -7
  80. package/dist/src/commands/doctor.d.ts +1 -1
  81. package/dist/src/commands/doctor.js +26 -3
  82. package/dist/src/commands/experience.js +35 -0
  83. package/dist/src/commands/files.js +4 -27
  84. package/dist/src/commands/modes.js +49 -17
  85. package/dist/src/commands/power-discovery.d.ts +130 -0
  86. package/dist/src/commands/power-discovery.js +627 -0
  87. package/dist/src/commands/project-sources.js +325 -31
  88. package/dist/src/commands/sequence.js +59 -32
  89. package/dist/src/commands/session.js +7 -2
  90. package/dist/src/commands/work.js +31 -0
  91. package/dist/src/errors.d.ts +9 -0
  92. package/dist/src/errors.js +22 -0
  93. package/dist/src/index.d.ts +6 -0
  94. package/dist/src/index.js +9 -0
  95. package/dist/src/operations/artifact-output.d.ts +69 -0
  96. package/dist/src/operations/artifact-output.js +1748 -0
  97. package/dist/src/operations/artifact-stream.d.ts +19 -0
  98. package/dist/src/operations/artifact-stream.js +35 -0
  99. package/dist/src/operations/artifact-transfer.d.ts +149 -0
  100. package/dist/src/operations/artifact-transfer.js +1312 -0
  101. package/dist/src/operations/browser-adapter.d.ts +161 -0
  102. package/dist/src/operations/browser-adapter.js +2023 -0
  103. package/dist/src/operations/browser-observation.d.ts +110 -0
  104. package/dist/src/operations/browser-observation.js +1208 -0
  105. package/dist/src/operations/browser-target.d.ts +103 -0
  106. package/dist/src/operations/browser-target.js +489 -0
  107. package/dist/src/operations/canonical.d.ts +4 -0
  108. package/dist/src/operations/canonical.js +412 -0
  109. package/dist/src/operations/chatgpt-runtime.d.ts +72 -0
  110. package/dist/src/operations/chatgpt-runtime.js +1150 -0
  111. package/dist/src/operations/client.d.ts +138 -0
  112. package/dist/src/operations/client.js +1143 -0
  113. package/dist/src/operations/collector.d.ts +195 -0
  114. package/dist/src/operations/collector.js +1039 -0
  115. package/dist/src/operations/control.d.ts +372 -0
  116. package/dist/src/operations/control.js +1498 -0
  117. package/dist/src/operations/file-identity.d.ts +33 -0
  118. package/dist/src/operations/file-identity.js +153 -0
  119. package/dist/src/operations/handle.d.ts +19 -0
  120. package/dist/src/operations/handle.js +727 -0
  121. package/dist/src/operations/index.d.ts +22 -0
  122. package/dist/src/operations/index.js +22 -0
  123. package/dist/src/operations/journal.d.ts +142 -0
  124. package/dist/src/operations/journal.js +1931 -0
  125. package/dist/src/operations/production-attachments.d.ts +116 -0
  126. package/dist/src/operations/production-attachments.js +1018 -0
  127. package/dist/src/operations/production-chatgpt-artifacts.d.ts +74 -0
  128. package/dist/src/operations/production-chatgpt-artifacts.js +1146 -0
  129. package/dist/src/operations/production-chatgpt-attachments.d.ts +71 -0
  130. package/dist/src/operations/production-chatgpt-attachments.js +1563 -0
  131. package/dist/src/operations/production-configuration.d.ts +42 -0
  132. package/dist/src/operations/production-configuration.js +1363 -0
  133. package/dist/src/operations/production-primitives.d.ts +53 -0
  134. package/dist/src/operations/production-primitives.js +1073 -0
  135. package/dist/src/operations/production-work-steer.d.ts +203 -0
  136. package/dist/src/operations/production-work-steer.js +1218 -0
  137. package/dist/src/operations/recovery.d.ts +80 -0
  138. package/dist/src/operations/recovery.js +174 -0
  139. package/dist/src/operations/runtime-adapter.d.ts +118 -0
  140. package/dist/src/operations/runtime-adapter.js +786 -0
  141. package/dist/src/operations/send-once.d.ts +224 -0
  142. package/dist/src/operations/send-once.js +1081 -0
  143. package/dist/src/operations/service.d.ts +281 -0
  144. package/dist/src/operations/service.js +2810 -0
  145. package/dist/src/operations/staging.d.ts +136 -0
  146. package/dist/src/operations/staging.js +630 -0
  147. package/dist/src/operations/state-machine.d.ts +29 -0
  148. package/dist/src/operations/state-machine.js +2047 -0
  149. package/dist/src/operations/submission.d.ts +423 -0
  150. package/dist/src/operations/submission.js +1676 -0
  151. package/dist/src/operations/turn-ownership.d.ts +202 -0
  152. package/dist/src/operations/turn-ownership.js +700 -0
  153. package/dist/src/operations/types.d.ts +454 -0
  154. package/dist/src/operations/types.js +20 -0
  155. package/dist/src/operations/wire-requests.d.ts +21 -0
  156. package/dist/src/operations/wire-requests.js +396 -0
  157. package/dist/src/operations/wire-results.d.ts +97 -0
  158. package/dist/src/operations/wire-results.js +818 -0
  159. package/dist/src/runner/responses.d.ts +3 -1
  160. package/dist/src/runner/responses.js +19 -1
  161. package/dist/src/runner/result.js +227 -55
  162. package/dist/src/runner/types.d.ts +12 -0
  163. package/dist/src/runtime/command-routing.d.ts +149 -0
  164. package/dist/src/runtime/command-routing.js +431 -0
  165. package/dist/src/runtime/coordinated-browser.d.ts +24 -0
  166. package/dist/src/runtime/coordinated-browser.js +315 -0
  167. package/dist/src/runtime/coordinated-page.d.ts +39 -0
  168. package/dist/src/runtime/coordinated-page.js +634 -0
  169. package/dist/src/runtime/operation-context.d.ts +142 -0
  170. package/dist/src/runtime/operation-context.js +410 -0
  171. package/dist/src/runtime/runtime-session.d.ts +95 -0
  172. package/dist/src/runtime/runtime-session.js +314 -0
  173. package/dist/src/runtime/tab-coordinator.d.ts +200 -0
  174. package/dist/src/runtime/tab-coordinator.js +1200 -0
  175. package/dist/src/runtime/value-boundaries.d.ts +18 -0
  176. package/dist/src/runtime/value-boundaries.js +49 -0
  177. package/dist/src/safety/untrusted-output.js +2 -1
  178. package/dist/src/scripts/backend-server.js +4 -1
  179. package/dist/src/scripts/live-smoke/harness.js +79 -7
  180. package/dist/src/scripts/live-smoke/scenarios.d.ts +28 -0
  181. package/dist/src/scripts/live-smoke/scenarios.js +110 -18
  182. package/dist/src/scripts/live-smoke/types.d.ts +3 -0
  183. package/dist/src/scripts/release-canary-module.js +17 -4
  184. package/dist/src/types.d.ts +38 -1
  185. package/package.json +1 -1
  186. package/references/2026-08-16-transactional-operations.md +459 -0
  187. package/references/agents-runner.md +34 -0
  188. package/references/backend-protocol.md +73 -0
  189. package/references/python-parity.md +72 -0
  190. package/references/responses-adapter.md +19 -0
  191. package/references/streaming.md +6 -0
@@ -0,0 +1,1931 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { lstat, link, mkdir, open, opendir, realpath, rename, unlink } from "node:fs/promises";
3
+ import { homedir, hostname, platform } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
5
+ import { randomBytes, randomUUID } from "node:crypto";
6
+ import { nodeErrorCode } from "../errors.js";
7
+ import { isByteArrayView } from "../runtime/value-boundaries.js";
8
+ import { canonicalJson, hmacDigest } from "./canonical.js";
9
+ import { operationControlRequestDigest, operationHandleFromState, operationSubmitRequestDigest, validateOperationHandle } from "./handle.js";
10
+ import { assertOperationEventShape, assertOperationId, assertOperationStateShape, reduceOperationEvents } from "./state-machine.js";
11
+ import { OPERATION_EVENT_SCHEMA_VERSION, OPERATION_RECEIPT_SCHEMA_VERSION, OPERATION_SCHEMA_VERSION } from "./types.js";
12
+ const KEY_BYTES = 32;
13
+ const KEY_FILE = "journal.key";
14
+ const LOG_DIRECTORY = "logs";
15
+ const LOCK_DIRECTORY = "locks";
16
+ const TERMINAL_DIRECTORY = "terminals";
17
+ const SNAPSHOT_DIRECTORY = "snapshots";
18
+ const TOMBSTONE_DIRECTORY = "tombstones";
19
+ const TRACKED_STATE_DIRECTORIES = [
20
+ LOG_DIRECTORY,
21
+ TERMINAL_DIRECTORY,
22
+ SNAPSHOT_DIRECTORY,
23
+ TOMBSTONE_DIRECTORY
24
+ ];
25
+ const QUOTA_LOCK_FILE = "quota-admission.lock";
26
+ const QUOTA_COUNTER_FILE = "quota-state.json";
27
+ const LOCK_RECOVERY_SUFFIX = ".reclaim";
28
+ const TERMINAL_SCHEMA_VERSION = "chatgpt.browser_control.operation_terminal.v1";
29
+ const TOMBSTONE_SCHEMA_VERSION = "chatgpt.browser_control.operation_tombstone.v1";
30
+ const QUOTA_COUNTER_SCHEMA_VERSION = "chatgpt.browser_control.operation_quota_state.v1";
31
+ const GENESIS_EVENT_DIGEST = `hmac-sha256:${"0".repeat(64)}`;
32
+ const DEFAULT_MAX_STATE_BYTES = 64 * 1024 * 1024;
33
+ const MAX_SINGLE_RECORD_FILE_BYTES = 64 * 1024 * 1024;
34
+ const MAX_LOCK_RECORD_BYTES = 8 * 1024;
35
+ const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
36
+ /**
37
+ * Quota admission must remain bounded even when an attacker or a broken
38
+ * provider leaves a directory full of entries. This is deliberately an
39
+ * implementation ceiling, not a caller-controlled option: a scan that would
40
+ * exceed it fails closed before materializing the directory.
41
+ */
42
+ const MAX_QUOTA_SCAN_ENTRIES = 65_536;
43
+ /** Absolute byte ceiling for a quota scan, independent of a permissive option. */
44
+ const MAX_QUOTA_SCAN_BYTES = 256 * 1024 * 1024;
45
+ const LOCK_RETRY_MS = 10;
46
+ const POSIX_DIRECTORY_MODE = 0o700;
47
+ const POSIX_FILE_MODE = 0o600;
48
+ const EVIDENCE_DOMAIN_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
49
+ const MAX_EVIDENCE_MATERIAL_BYTES = 8 * 1024 * 1024;
50
+ const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
51
+ const MAX_TIMESTAMP_MS = 8_640_000_000_000_000;
52
+ const inProcessQueues = new Map();
53
+ export class OperationJournalError extends Error {
54
+ code;
55
+ constructor(code, message) {
56
+ super(message);
57
+ this.code = code;
58
+ this.name = "OperationJournalError";
59
+ }
60
+ }
61
+ export class OperationJournal {
62
+ key;
63
+ clock;
64
+ entropy;
65
+ stateRoot;
66
+ maxStateBytes;
67
+ lockTimeoutMs;
68
+ constructor(stateRoot, key, clock, entropy, options) {
69
+ this.key = key;
70
+ this.clock = clock;
71
+ this.entropy = entropy;
72
+ this.stateRoot = stateRoot;
73
+ this.maxStateBytes = options.maxStateBytes ?? DEFAULT_MAX_STATE_BYTES;
74
+ this.lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
75
+ this.faultInjector = options.faultInjector;
76
+ }
77
+ faultInjector;
78
+ /** Compute immutable request identity inside the journal key domain. */
79
+ submitRequestDigest(request, files) {
80
+ return operationSubmitRequestDigest(this.key, request, files);
81
+ }
82
+ /** Compute child control identity inside the same stable key domain. */
83
+ controlRequestDigest(request) {
84
+ return operationControlRequestDigest(this.key, request);
85
+ }
86
+ /**
87
+ * Derive privacy-preserving evidence in the stable journal key domain.
88
+ * Callers receive only the HMAC, never the state-root key. The bounded,
89
+ * explicit domain keeps unrelated evidence classes cryptographically
90
+ * separated while allowing browser adapters to avoid bare hashes of user
91
+ * content.
92
+ */
93
+ evidenceDigest(domain, material) {
94
+ if (!EVIDENCE_DOMAIN_PATTERN.test(domain)) {
95
+ throw new OperationJournalError("invalid_evidence_domain", "Evidence domain must be a bounded canonical label.");
96
+ }
97
+ let encoded;
98
+ try {
99
+ encoded = canonicalJson(material);
100
+ }
101
+ catch {
102
+ throw new OperationJournalError("invalid_evidence_material", "Evidence material must be finite canonical JSON.");
103
+ }
104
+ if (Buffer.byteLength(encoded, "utf8") > MAX_EVIDENCE_MATERIAL_BYTES) {
105
+ throw new OperationJournalError("evidence_material_too_large", "Evidence material exceeds the bounded digest input limit.");
106
+ }
107
+ return hmacDigest(this.key, `codex-chatgpt-control/operation-evidence/${domain}/v1`, material);
108
+ }
109
+ /** Issue a locator from durable state without exposing the journal key. */
110
+ handleFromState(state) {
111
+ return operationHandleFromState(this.key, state);
112
+ }
113
+ /** Reconcile a caller locator with freshly loaded durable state. */
114
+ validateHandle(handle, state) {
115
+ return validateOperationHandle(this.key, handle, state);
116
+ }
117
+ static async open(options = {}) {
118
+ validatePositiveInteger(options.maxStateBytes, "maxStateBytes");
119
+ validatePositiveInteger(options.lockTimeoutMs, "lockTimeoutMs");
120
+ const clock = resolveClock(options.clock);
121
+ const entropy = resolveEntropy(options.entropy);
122
+ const requestedRoot = options.stateRoot ?? defaultOperationStateRoot();
123
+ if (!isAbsolute(requestedRoot)) {
124
+ throw new OperationJournalError("state_root_not_absolute", "Operation stateRoot must be an absolute path.");
125
+ }
126
+ await ensureSecureDirectory(requestedRoot);
127
+ const canonicalRoot = await realpath(requestedRoot);
128
+ await ensureSecureDirectory(join(canonicalRoot, LOG_DIRECTORY));
129
+ await ensureSecureDirectory(join(canonicalRoot, LOCK_DIRECTORY));
130
+ await ensureSecureDirectory(join(canonicalRoot, TERMINAL_DIRECTORY));
131
+ await ensureSecureDirectory(join(canonicalRoot, SNAPSHOT_DIRECTORY));
132
+ await ensureSecureDirectory(join(canonicalRoot, TOMBSTONE_DIRECTORY));
133
+ const key = await loadOrCreateKey(canonicalRoot, entropy);
134
+ const journal = new OperationJournal(canonicalRoot, key, clock, entropy, options);
135
+ await journal.ensureQuotaCounter();
136
+ return journal;
137
+ }
138
+ async create(event) {
139
+ try {
140
+ return await this.append(event.operationId, 0, event);
141
+ }
142
+ catch (error) {
143
+ if (error instanceof OperationJournalError && error.code === "revision_conflict") {
144
+ return await this.load(event.operationId, event.requestDigest);
145
+ }
146
+ throw error;
147
+ }
148
+ }
149
+ async append(operationId, expectedRevision, event) {
150
+ assertOperationId(operationId);
151
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
152
+ throw new OperationJournalError("invalid_expected_revision", "expectedRevision must be a non-negative safe integer.");
153
+ }
154
+ const logPath = this.operationLogPath(operationId);
155
+ const lockPath = this.operationLockPath(operationId);
156
+ return serializeInProcess(lockPath, async () => {
157
+ const lock = await acquireLock(lockPath, this.lockTimeoutMs, this.clock, this.entropy);
158
+ try {
159
+ await this.inject("after_lock_acquired");
160
+ await this.assertNoTerminalOrTombstoneForAppend(operationId, expectedRevision);
161
+ const parsed = await readLog(logPath, this.key, true);
162
+ if (parsed.envelopes.length !== expectedRevision) {
163
+ throw new OperationJournalError("revision_conflict", `Expected operation revision ${expectedRevision}, found ${parsed.envelopes.length}.`);
164
+ }
165
+ if (expectedRevision === 0) {
166
+ if (event.type !== "operation_created" || event.operationId !== operationId) {
167
+ throw new OperationJournalError("creation_event_required", "Revision zero requires the matching operation_created event.");
168
+ }
169
+ }
170
+ else if (event.type === "operation_created") {
171
+ throw new OperationJournalError("duplicate_operation_created", "operation_created cannot be appended after revision one.");
172
+ }
173
+ const priorEvents = parsed.envelopes.map(envelope => envelope.event);
174
+ const storedEvent = jsonRoundTrip(event);
175
+ try {
176
+ assertOperationEventShape(storedEvent);
177
+ }
178
+ catch (error) {
179
+ const message = error instanceof Error ? error.message : String(error);
180
+ throw new OperationJournalError("invalid_operation_event", `Operation event is not safe to persist: ${message}`);
181
+ }
182
+ const nextState = reduceSafely([...priorEvents, storedEvent]);
183
+ if (nextState.operationId !== operationId) {
184
+ throw new OperationJournalError("operation_binding_mismatch", "Journal event does not match the requested operation ID.");
185
+ }
186
+ const revision = expectedRevision + 1;
187
+ const previousEventDigest = parsed.envelopes.at(-1)?.eventDigest ?? GENESIS_EVENT_DIGEST;
188
+ const eventDigest = journalEventDigest(this.key, revision, previousEventDigest, storedEvent);
189
+ const envelope = {
190
+ schemaVersion: OPERATION_EVENT_SCHEMA_VERSION,
191
+ revision,
192
+ previousEventDigest,
193
+ eventDigest,
194
+ event: storedEvent
195
+ };
196
+ const encoded = Buffer.from(`${canonicalJson(envelope)}\n`, "utf8");
197
+ const persist = () => appendRecord({
198
+ logPath,
199
+ encoded,
200
+ committedBytes: parsed.committedBytes,
201
+ partialTailBytes: parsed.partialTailBytes,
202
+ createExclusive: !parsed.exists,
203
+ syncParent: expectedRevision === 0,
204
+ inject: point => this.inject(point)
205
+ });
206
+ const byteDelta = encoded.byteLength - parsed.partialTailBytes;
207
+ await this.mutateQuotaTrackedState(async () => {
208
+ await persist();
209
+ return {
210
+ value: undefined,
211
+ byteDelta,
212
+ entryDelta: parsed.exists ? 0 : 1
213
+ };
214
+ }, expectedRevision === 0
215
+ ? counter => this.assertQuotaForNewOperation(counter, Math.max(0, byteDelta))
216
+ : undefined);
217
+ return {
218
+ state: nextState,
219
+ envelopes: [...parsed.envelopes, envelope],
220
+ committedBytes: parsed.committedBytes + encoded.byteLength,
221
+ partialTailBytes: 0,
222
+ lastEventDigest: eventDigest
223
+ };
224
+ }
225
+ finally {
226
+ await releaseLock(lock);
227
+ }
228
+ });
229
+ }
230
+ async load(operationId, expectedRequestDigest) {
231
+ assertOperationId(operationId);
232
+ const lockPath = this.operationLockPath(operationId);
233
+ const result = serializeInProcess(lockPath, async () => {
234
+ const lock = await acquireLock(lockPath, this.lockTimeoutMs, this.clock, this.entropy);
235
+ try {
236
+ return await this.loadLocked(operationId, expectedRequestDigest);
237
+ }
238
+ finally {
239
+ await releaseLock(lock);
240
+ }
241
+ });
242
+ result.catch(() => undefined);
243
+ return result;
244
+ }
245
+ /**
246
+ * Rebuilds the materialized snapshot cache from authoritative journal state.
247
+ * A snapshot is never used as a substitute for the authenticated log or
248
+ * terminal record, so a damaged cache can always be safely overwritten.
249
+ */
250
+ async refreshSnapshot(operationId) {
251
+ assertOperationId(operationId);
252
+ const lockPath = this.operationLockPath(operationId);
253
+ return serializeInProcess(lockPath, async () => {
254
+ const lock = await acquireLock(lockPath, this.lockTimeoutMs, this.clock, this.entropy);
255
+ try {
256
+ const loaded = await this.loadLocked(operationId);
257
+ const lastEventDigest = loaded.lastEventDigest ?? loaded.envelopes.at(-1)?.eventDigest;
258
+ if (lastEventDigest === undefined) {
259
+ throw corrupt("Authoritative operation state has no final event digest.");
260
+ }
261
+ const snapshot = {
262
+ schemaVersion: OPERATION_SCHEMA_VERSION,
263
+ lastEventDigest,
264
+ state: jsonRoundTrip(loaded.state)
265
+ };
266
+ const snapshotPath = this.snapshotPath(operationId);
267
+ await this.mutateQuotaTrackedState(async () => {
268
+ const beforeBytes = await optionalFileSize(snapshotPath);
269
+ await writeAuthenticatedAtomic(snapshotPath, snapshot, this.key, "codex-chatgpt-control/operation-snapshot/v1", "snapshotDigest", this.entropy, point => this.inject(point), true);
270
+ const afterBytes = await fileSize(snapshotPath);
271
+ return {
272
+ value: undefined,
273
+ byteDelta: afterBytes - (beforeBytes ?? 0),
274
+ entryDelta: beforeBytes === undefined ? 1 : 0
275
+ };
276
+ });
277
+ return snapshot;
278
+ }
279
+ finally {
280
+ await releaseLock(lock);
281
+ }
282
+ });
283
+ }
284
+ /** Reads and authenticates a snapshot cache without treating it as authority. */
285
+ async readSnapshot(operationId) {
286
+ assertOperationId(operationId);
287
+ const snapshotPath = this.snapshotPath(operationId);
288
+ const snapshot = await readAuthenticatedFile(snapshotPath, this.key, "codex-chatgpt-control/operation-snapshot/v1", "snapshotDigest", "journal_snapshot_corrupt");
289
+ if (!hasExactKeys(snapshot, ["schemaVersion", "lastEventDigest", "state", "snapshotDigest"])
290
+ ||
291
+ snapshot.schemaVersion !== OPERATION_SCHEMA_VERSION
292
+ || !isDigest(snapshot.lastEventDigest)
293
+ || !isRecord(snapshot.state)
294
+ || snapshot.state.operationId !== operationId
295
+ || !Number.isSafeInteger(snapshot.state.revision)) {
296
+ throw new OperationJournalError("journal_snapshot_corrupt", "Authenticated snapshot has an invalid shape.");
297
+ }
298
+ try {
299
+ assertOperationStateShape(snapshot.state);
300
+ }
301
+ catch (error) {
302
+ const message = error instanceof Error ? error.message : String(error);
303
+ throw new OperationJournalError("journal_snapshot_corrupt", `Authenticated snapshot state is invalid: ${message}`);
304
+ }
305
+ const { snapshotDigest: _snapshotDigest, ...publicSnapshot } = snapshot;
306
+ return publicSnapshot;
307
+ }
308
+ /**
309
+ * Converts a completed, receipt-bearing log into a durable terminal record.
310
+ * The terminal is fsynced before the historical log is removed. If a crash
311
+ * occurs between those steps, load() authenticates and reconciles both.
312
+ */
313
+ async compactCompleted(operationId) {
314
+ assertOperationId(operationId);
315
+ const lockPath = this.operationLockPath(operationId);
316
+ return serializeInProcess(lockPath, async () => {
317
+ const lock = await acquireLock(lockPath, this.lockTimeoutMs, this.clock, this.entropy);
318
+ try {
319
+ return await this.compactCompletedLocked(operationId);
320
+ }
321
+ finally {
322
+ await releaseLock(lock);
323
+ }
324
+ });
325
+ }
326
+ /**
327
+ * Explicitly prunes a completed terminal into a minimal authenticated
328
+ * tombstone. Pruning is never automatic and leaves an unmistakable expiry
329
+ * result instead of turning the operation into operation_not_found.
330
+ */
331
+ async pruneReceipt(operationId) {
332
+ assertOperationId(operationId);
333
+ // Ensure the terminal record is durable before replacing it with a tombstone.
334
+ try {
335
+ await this.compactCompleted(operationId);
336
+ }
337
+ catch (error) {
338
+ if (!(error instanceof OperationJournalError) || error.code !== "operation_receipt_expired")
339
+ throw error;
340
+ }
341
+ const lockPath = this.operationLockPath(operationId);
342
+ return serializeInProcess(lockPath, async () => {
343
+ const lock = await acquireLock(lockPath, this.lockTimeoutMs, this.clock, this.entropy);
344
+ try {
345
+ const tombstonePath = this.tombstonePath(operationId);
346
+ const terminalPath = this.terminalPath(operationId);
347
+ if (await pathExists(tombstonePath)) {
348
+ const tombstone = await readTombstone(tombstonePath, this.key);
349
+ let deletedTerminalBytes = 0;
350
+ if (await pathExists(terminalPath)) {
351
+ const terminal = await readTerminal(terminalPath, this.key);
352
+ assertSameIdentity(tombstone.operationId, tombstone.requestDigest, terminal.operationId, terminal.requestDigest);
353
+ deletedTerminalBytes = await this.removeQuotaTrackedFile(terminalPath);
354
+ }
355
+ const snapshotPath = this.snapshotPath(operationId);
356
+ const deletedSnapshotBytes = await this.removeQuotaTrackedFile(snapshotPath);
357
+ return {
358
+ status: "already_pruned",
359
+ operationId: tombstone.operationId,
360
+ requestDigest: tombstone.requestDigest,
361
+ deletedTerminalBytes,
362
+ deletedSnapshotBytes
363
+ };
364
+ }
365
+ const terminal = await readTerminal(terminalPath, this.key);
366
+ const tombstone = {
367
+ schemaVersion: TOMBSTONE_SCHEMA_VERSION,
368
+ operationId: terminal.operationId,
369
+ requestDigest: terminal.requestDigest,
370
+ tombstoneDigest: ""
371
+ };
372
+ tombstone.tombstoneDigest = hmacDigest(this.key, "codex-chatgpt-control/operation-tombstone/v1", withoutField(tombstone, "tombstoneDigest"));
373
+ await this.mutateQuotaTrackedState(async () => {
374
+ const beforeBytes = await optionalFileSize(tombstonePath);
375
+ await writeAtomicJson(tombstonePath, tombstone, this.entropy, point => this.inject(point), false);
376
+ const afterBytes = await fileSize(tombstonePath);
377
+ return {
378
+ value: undefined,
379
+ byteDelta: afterBytes - (beforeBytes ?? 0),
380
+ entryDelta: beforeBytes === undefined ? 1 : 0
381
+ };
382
+ });
383
+ const terminalBytes = await this.removeQuotaTrackedFile(terminalPath);
384
+ const snapshotPath = this.snapshotPath(operationId);
385
+ const snapshotBytes = await this.removeQuotaTrackedFile(snapshotPath);
386
+ return {
387
+ status: "pruned",
388
+ operationId: terminal.operationId,
389
+ requestDigest: terminal.requestDigest,
390
+ deletedTerminalBytes: terminalBytes,
391
+ deletedSnapshotBytes: snapshotBytes
392
+ };
393
+ }
394
+ finally {
395
+ await releaseLock(lock);
396
+ }
397
+ });
398
+ }
399
+ /**
400
+ * Permanently removes a pruned tombstone and any crash-residue state. This
401
+ * is intentionally a separately named destructive operation and requires an
402
+ * explicit acknowledgement at the call site.
403
+ */
404
+ async purgeTombstone(operationId, options) {
405
+ assertOperationId(operationId);
406
+ if (options?.acknowledge !== true) {
407
+ throw new OperationJournalError("journal_purge_ack_required", "Purging a receipt tombstone requires acknowledge: true.");
408
+ }
409
+ const lockPath = this.operationLockPath(operationId);
410
+ return serializeInProcess(lockPath, async () => {
411
+ const lock = await acquireLock(lockPath, this.lockTimeoutMs, this.clock, this.entropy);
412
+ try {
413
+ const tombstonePath = this.tombstonePath(operationId);
414
+ const tombstone = await readTombstone(tombstonePath, this.key);
415
+ const terminalPath = this.terminalPath(operationId);
416
+ if (await pathExists(terminalPath)) {
417
+ const terminal = await readTerminal(terminalPath, this.key);
418
+ if (terminal.operationId !== tombstone.operationId || terminal.requestDigest !== tombstone.requestDigest) {
419
+ throw corrupt("Tombstone and terminal identity do not match.");
420
+ }
421
+ }
422
+ const logPath = this.operationLogPath(operationId);
423
+ if (await pathExists(logPath)) {
424
+ const parsed = await readLog(logPath, this.key, false);
425
+ const state = reduceSafely(parsed.envelopes.map(envelope => envelope.event));
426
+ assertCompletedState(state, operationId);
427
+ if (state.requestDigest !== tombstone.requestDigest)
428
+ throw corrupt("Tombstone and log identity do not match.");
429
+ }
430
+ const paths = [
431
+ ["tombstone", tombstonePath],
432
+ ["terminal", terminalPath],
433
+ ["snapshot", this.snapshotPath(operationId)],
434
+ ["log", logPath]
435
+ ];
436
+ const deleted = [];
437
+ let deletedBytes = 0;
438
+ for (const [kind, path] of paths) {
439
+ const bytes = await this.removeQuotaTrackedFile(path);
440
+ if (bytes > 0) {
441
+ deleted.push(kind);
442
+ deletedBytes += bytes;
443
+ }
444
+ }
445
+ return { operationId: tombstone.operationId, requestDigest: tombstone.requestDigest, deleted, deletedBytes };
446
+ }
447
+ finally {
448
+ await releaseLock(lock);
449
+ }
450
+ });
451
+ }
452
+ operationLogPath(operationId) {
453
+ const stem = this.operationStem(operationId);
454
+ return childPath(this.stateRoot, LOG_DIRECTORY, `${stem}.jsonl`);
455
+ }
456
+ operationLockPath(operationId) {
457
+ const stem = this.operationStem(operationId);
458
+ return childPath(this.stateRoot, LOCK_DIRECTORY, `${stem}.lock`);
459
+ }
460
+ quotaLockPath() {
461
+ return childPath(this.stateRoot, LOCK_DIRECTORY, QUOTA_LOCK_FILE);
462
+ }
463
+ quotaCounterPath() {
464
+ return childPath(this.stateRoot, QUOTA_COUNTER_FILE);
465
+ }
466
+ terminalPath(operationId) {
467
+ const stem = this.operationStem(operationId);
468
+ return childPath(this.stateRoot, TERMINAL_DIRECTORY, `${stem}.terminal.json`);
469
+ }
470
+ snapshotPath(operationId) {
471
+ const stem = this.operationStem(operationId);
472
+ return childPath(this.stateRoot, SNAPSHOT_DIRECTORY, `${stem}.snapshot.json`);
473
+ }
474
+ tombstonePath(operationId) {
475
+ const stem = this.operationStem(operationId);
476
+ return childPath(this.stateRoot, TOMBSTONE_DIRECTORY, `${stem}.tombstone.json`);
477
+ }
478
+ operationStem(operationId) {
479
+ return hmacDigest(this.key, "codex-chatgpt-control/operation-path/v1", operationId).slice("hmac-sha256:".length);
480
+ }
481
+ async loadLocked(operationId, expectedRequestDigest) {
482
+ const tombstonePath = this.tombstonePath(operationId);
483
+ if (await pathExists(tombstonePath)) {
484
+ const tombstone = await readTombstone(tombstonePath, this.key);
485
+ if (expectedRequestDigest !== undefined && tombstone.requestDigest !== expectedRequestDigest) {
486
+ throw new OperationJournalError("operation_request_mismatch", "The operation ID already exists with a different immutable request digest.");
487
+ }
488
+ const terminalPath = this.terminalPath(operationId);
489
+ if (await pathExists(terminalPath)) {
490
+ const terminal = await readTerminal(terminalPath, this.key);
491
+ assertSameIdentity(tombstone.operationId, tombstone.requestDigest, terminal.operationId, terminal.requestDigest);
492
+ }
493
+ const logPath = this.operationLogPath(operationId);
494
+ if (await pathExists(logPath)) {
495
+ const parsed = await readLog(logPath, this.key, false);
496
+ const state = reduceSafely(parsed.envelopes.map(envelope => envelope.event));
497
+ assertCompletedState(state, operationId);
498
+ if (state.requestDigest !== tombstone.requestDigest)
499
+ throw corrupt("Tombstone and log identity do not match.");
500
+ }
501
+ throw new OperationJournalError("operation_receipt_expired", "The operation receipt was explicitly pruned; its durable tombstone remains.");
502
+ }
503
+ const terminalPath = this.terminalPath(operationId);
504
+ if (await pathExists(terminalPath)) {
505
+ const terminal = await readTerminal(terminalPath, this.key);
506
+ if (terminal.operationId !== operationId)
507
+ throw corrupt("Terminal record operation identity does not match its path.");
508
+ if (expectedRequestDigest !== undefined && terminal.requestDigest !== expectedRequestDigest) {
509
+ throw new OperationJournalError("operation_request_mismatch", "The operation ID already exists with a different immutable request digest.");
510
+ }
511
+ const logPath = this.operationLogPath(operationId);
512
+ if (await pathExists(logPath)) {
513
+ const parsed = await readLog(logPath, this.key, false);
514
+ reconcileTerminalWithLog(terminal, parsed, operationId);
515
+ return { ...parsed, state: terminal.state, lastEventDigest: terminal.lastEventDigest };
516
+ }
517
+ return terminalLoaded(terminal);
518
+ }
519
+ const parsed = await readLog(this.operationLogPath(operationId), this.key, false);
520
+ if (parsed.envelopes.length === 0) {
521
+ throw new OperationJournalError("operation_not_found", "No durable operation exists for this operation ID.");
522
+ }
523
+ const state = reduceSafely(parsed.envelopes.map(envelope => envelope.event));
524
+ if (state.operationId !== operationId) {
525
+ throw new OperationJournalError("operation_binding_mismatch", "The operation log is bound to a different operation ID.");
526
+ }
527
+ if (expectedRequestDigest !== undefined && state.requestDigest !== expectedRequestDigest) {
528
+ throw new OperationJournalError("operation_request_mismatch", "The operation ID already exists with a different immutable request digest.");
529
+ }
530
+ const lastEventDigest = parsed.envelopes.at(-1)?.eventDigest;
531
+ if (lastEventDigest === undefined)
532
+ throw corrupt("Operation log has no final event digest.");
533
+ return { ...parsed, state, lastEventDigest };
534
+ }
535
+ async assertNoTerminalOrTombstoneForAppend(operationId, expectedRevision) {
536
+ const tombstonePath = this.tombstonePath(operationId);
537
+ if (await pathExists(tombstonePath)) {
538
+ throw new OperationJournalError("operation_receipt_expired", "The operation receipt was explicitly pruned; its durable tombstone remains.");
539
+ }
540
+ const terminalPath = this.terminalPath(operationId);
541
+ if (!(await pathExists(terminalPath)))
542
+ return;
543
+ const terminal = await readTerminal(terminalPath, this.key);
544
+ if (expectedRevision === 0) {
545
+ throw new OperationJournalError("revision_conflict", "The operation already has a durable terminal record.");
546
+ }
547
+ throw new OperationJournalError("operation_compacted", `The completed operation is compacted at revision ${terminal.revision}; its terminal receipt is immutable.`);
548
+ }
549
+ async compactCompletedLocked(operationId) {
550
+ const tombstonePath = this.tombstonePath(operationId);
551
+ if (await pathExists(tombstonePath)) {
552
+ throw new OperationJournalError("operation_receipt_expired", "The operation receipt was explicitly pruned; its durable tombstone remains.");
553
+ }
554
+ const terminalPath = this.terminalPath(operationId);
555
+ const logPath = this.operationLogPath(operationId);
556
+ if (await pathExists(terminalPath)) {
557
+ const terminal = await readTerminal(terminalPath, this.key);
558
+ if (await pathExists(logPath)) {
559
+ const parsed = await readLog(logPath, this.key, false);
560
+ reconcileTerminalWithLog(terminal, parsed, operationId);
561
+ const deletedLogBytes = await this.removeQuotaTrackedFile(logPath, () => this.inject("after_log_deleted"));
562
+ return {
563
+ status: "already_compacted",
564
+ operationId: terminal.operationId,
565
+ requestDigest: terminal.requestDigest,
566
+ revision: terminal.revision,
567
+ lastEventDigest: terminal.lastEventDigest,
568
+ deletedLogBytes
569
+ };
570
+ }
571
+ assertCompletedState(terminal.state, operationId);
572
+ return {
573
+ status: "already_compacted",
574
+ operationId: terminal.operationId,
575
+ requestDigest: terminal.requestDigest,
576
+ revision: terminal.revision,
577
+ lastEventDigest: terminal.lastEventDigest,
578
+ deletedLogBytes: 0
579
+ };
580
+ }
581
+ const parsed = await readLog(logPath, this.key, false);
582
+ if (parsed.envelopes.length === 0)
583
+ throw new OperationJournalError("operation_not_found", "No durable operation exists for this operation ID.");
584
+ const state = reduceSafely(parsed.envelopes.map(envelope => envelope.event));
585
+ assertCompletedState(state, operationId);
586
+ const lastEventDigest = parsed.envelopes.at(-1)?.eventDigest;
587
+ if (lastEventDigest === undefined)
588
+ throw corrupt("Completed operation has no final event digest.");
589
+ const terminal = makeTerminalRecord(this.key, state, lastEventDigest);
590
+ await this.mutateQuotaTrackedState(async () => {
591
+ const beforeBytes = await optionalFileSize(terminalPath);
592
+ await writeAuthenticatedAtomic(terminalPath, terminal, this.key, "codex-chatgpt-control/operation-terminal/v1", "terminalDigest", this.entropy, point => this.inject(point), false);
593
+ const afterBytes = await fileSize(terminalPath);
594
+ return {
595
+ value: undefined,
596
+ byteDelta: afterBytes - (beforeBytes ?? 0),
597
+ entryDelta: beforeBytes === undefined ? 1 : 0
598
+ };
599
+ });
600
+ // Re-read the newly durable record before deleting the only historical log.
601
+ const durableTerminal = await readTerminal(terminalPath, this.key);
602
+ reconcileTerminalWithLog(durableTerminal, parsed, operationId);
603
+ const deletedLogBytes = await this.removeQuotaTrackedFile(logPath, () => this.inject("after_log_deleted"));
604
+ return {
605
+ status: "compacted",
606
+ operationId: durableTerminal.operationId,
607
+ requestDigest: durableTerminal.requestDigest,
608
+ revision: durableTerminal.revision,
609
+ lastEventDigest: durableTerminal.lastEventDigest,
610
+ deletedLogBytes
611
+ };
612
+ }
613
+ async ensureQuotaCounter() {
614
+ const quotaLockPath = this.quotaLockPath();
615
+ await serializeInProcess(quotaLockPath, async () => {
616
+ const quotaLock = await acquireLock(quotaLockPath, this.lockTimeoutMs, this.clock, this.entropy);
617
+ try {
618
+ await this.loadCurrentQuotaCounter();
619
+ }
620
+ finally {
621
+ await releaseLock(quotaLock);
622
+ }
623
+ });
624
+ }
625
+ async mutateQuotaTrackedState(mutation, preflight) {
626
+ const quotaLockPath = this.quotaLockPath();
627
+ return await serializeInProcess(quotaLockPath, async () => {
628
+ const quotaLock = await acquireLock(quotaLockPath, this.lockTimeoutMs, this.clock, this.entropy);
629
+ try {
630
+ const counter = await this.loadCurrentQuotaCounter();
631
+ preflight?.(counter);
632
+ const dirty = await writeQuotaCounter(this.quotaCounterPath(), {
633
+ ...counter,
634
+ revision: nextQuotaRevision(counter.revision),
635
+ dirty: true,
636
+ counterDigest: ""
637
+ }, this.key, this.entropy);
638
+ try {
639
+ const result = await mutation();
640
+ assertQuotaDelta(result.byteDelta, "byteDelta");
641
+ assertQuotaDelta(result.entryDelta, "entryDelta");
642
+ const totalBytes = dirty.totalBytes + result.byteDelta;
643
+ const entryCount = dirty.entryCount + result.entryDelta;
644
+ if (!Number.isSafeInteger(totalBytes) || totalBytes < 0
645
+ || !Number.isSafeInteger(entryCount) || entryCount < 0) {
646
+ throw new OperationJournalError("journal_quota_counter_corrupt", "Operation quota accounting produced an invalid total.");
647
+ }
648
+ await writeQuotaCounter(this.quotaCounterPath(), {
649
+ schemaVersion: QUOTA_COUNTER_SCHEMA_VERSION,
650
+ revision: nextQuotaRevision(dirty.revision),
651
+ totalBytes,
652
+ entryCount,
653
+ dirty: false,
654
+ directories: await readQuotaDirectoryFingerprints(this.stateRoot),
655
+ counterDigest: ""
656
+ }, this.key, this.entropy);
657
+ return result.value;
658
+ }
659
+ catch (error) {
660
+ // A thrown mutation may still have changed durable state. Rebuild
661
+ // while the global quota lock is held; if this process actually
662
+ // dies, the authenticated dirty bit forces the next opener to scan.
663
+ try {
664
+ await this.rebuildQuotaCounter(nextQuotaRevision(dirty.revision));
665
+ }
666
+ catch {
667
+ // Preserve the original mutation failure. The dirty counter is a
668
+ // fail-closed recovery marker for the next admission attempt.
669
+ }
670
+ throw error;
671
+ }
672
+ }
673
+ finally {
674
+ await releaseLock(quotaLock);
675
+ }
676
+ });
677
+ }
678
+ async removeQuotaTrackedFile(path, afterDelete) {
679
+ return await this.mutateQuotaTrackedState(async () => {
680
+ const beforeBytes = await optionalFileSize(path);
681
+ if (beforeBytes === undefined) {
682
+ return { value: 0, byteDelta: 0, entryDelta: 0 };
683
+ }
684
+ await unlink(path);
685
+ await syncDirectory(dirname(path));
686
+ await afterDelete?.();
687
+ return { value: beforeBytes, byteDelta: -beforeBytes, entryDelta: -1 };
688
+ });
689
+ }
690
+ async loadCurrentQuotaCounter() {
691
+ const path = this.quotaCounterPath();
692
+ if (!(await pathExists(path)))
693
+ return await this.rebuildQuotaCounter(1);
694
+ const counter = await readQuotaCounter(path, this.key);
695
+ if (counter.dirty)
696
+ return await this.rebuildQuotaCounter(nextQuotaRevision(counter.revision));
697
+ const currentDirectories = await readQuotaDirectoryFingerprints(this.stateRoot);
698
+ if (!sameQuotaDirectoryFingerprints(counter.directories, currentDirectories)) {
699
+ return await this.rebuildQuotaCounter(nextQuotaRevision(counter.revision));
700
+ }
701
+ return counter;
702
+ }
703
+ async rebuildQuotaCounter(revision) {
704
+ for (let attempt = 0; attempt < 2; attempt += 1) {
705
+ const before = await readQuotaDirectoryFingerprints(this.stateRoot);
706
+ const usage = await scanStateUsage(this.stateRoot);
707
+ const after = await readQuotaDirectoryFingerprints(this.stateRoot);
708
+ if (!sameQuotaDirectoryFingerprints(before, after))
709
+ continue;
710
+ return await writeQuotaCounter(this.quotaCounterPath(), {
711
+ schemaVersion: QUOTA_COUNTER_SCHEMA_VERSION,
712
+ revision,
713
+ totalBytes: usage.totalBytes,
714
+ entryCount: usage.entryCount,
715
+ dirty: false,
716
+ directories: after,
717
+ counterDigest: ""
718
+ }, this.key, this.entropy);
719
+ }
720
+ throw new OperationJournalError("journal_quota_state_changed", "Operation state changed while rebuilding its quota counter.");
721
+ }
722
+ assertQuotaForNewOperation(counter, additionalBytes) {
723
+ const total = counter.totalBytes;
724
+ if (counter.entryCount > MAX_QUOTA_SCAN_ENTRIES || total > MAX_QUOTA_SCAN_BYTES
725
+ || !Number.isSafeInteger(additionalBytes) || additionalBytes < 0 || additionalBytes > MAX_QUOTA_SCAN_BYTES - total) {
726
+ throw new OperationJournalError("journal_scan_limit", "Operation journal quota scan exceeded its hard byte limit.");
727
+ }
728
+ if (total + additionalBytes > this.maxStateBytes) {
729
+ throw new OperationJournalError("journal_quota_exceeded", "Operation journal quota is exhausted; existing safety evidence was preserved.");
730
+ }
731
+ }
732
+ async inject(point) {
733
+ await this.faultInjector?.(point);
734
+ }
735
+ }
736
+ export function defaultOperationStateRoot() {
737
+ if (platform() === "darwin") {
738
+ return join(homedir(), "Library", "Application Support", "codex-chatgpt-control", "operations-v1");
739
+ }
740
+ if (platform() === "win32") {
741
+ const localAppData = process.env.LOCALAPPDATA;
742
+ if (localAppData !== undefined && isAbsolute(localAppData)) {
743
+ return join(localAppData, "codex-chatgpt-control", "operations-v1");
744
+ }
745
+ return join(homedir(), "AppData", "Local", "codex-chatgpt-control", "operations-v1");
746
+ }
747
+ const xdgState = process.env.XDG_STATE_HOME;
748
+ if (xdgState !== undefined && isAbsolute(xdgState)) {
749
+ return join(xdgState, "codex-chatgpt-control", "operations-v1");
750
+ }
751
+ return join(homedir(), ".local", "state", "codex-chatgpt-control", "operations-v1");
752
+ }
753
+ function journalEventDigest(key, revision, previousEventDigest, event) {
754
+ return hmacDigest(key, "codex-chatgpt-control/operation-event/v1", {
755
+ schemaVersion: OPERATION_EVENT_SCHEMA_VERSION,
756
+ revision,
757
+ previousEventDigest,
758
+ event
759
+ });
760
+ }
761
+ function makeTerminalRecord(key, state, lastEventDigest) {
762
+ assertCompletedState(state, state.operationId);
763
+ const receipt = state.receipt;
764
+ if (receipt === undefined)
765
+ throw corrupt("Completed operation has no terminal receipt.");
766
+ const copiedReceipt = jsonRoundTrip(receipt);
767
+ const ownershipEvidenceDigests = terminalOwnershipEvidenceDigests(state);
768
+ const terminal = {
769
+ schemaVersion: TERMINAL_SCHEMA_VERSION,
770
+ operationId: state.operationId,
771
+ requestDigest: state.requestDigest,
772
+ revision: state.revision,
773
+ lastEventDigest,
774
+ state: jsonRoundTrip(state),
775
+ receipt: copiedReceipt,
776
+ artifactManifest: jsonRoundTrip(copiedReceipt.artifacts),
777
+ ownershipEvidenceDigests,
778
+ terminalDigest: ""
779
+ };
780
+ terminal.terminalDigest = hmacDigest(key, "codex-chatgpt-control/operation-terminal/v1", withoutField(terminal, "terminalDigest"));
781
+ return terminal;
782
+ }
783
+ function assertCompletedState(state, operationId) {
784
+ if (state.operationId !== operationId)
785
+ throw corrupt("Terminal state operation identity does not match its path.");
786
+ if (state.phase !== "completed" || state.receipt === undefined) {
787
+ throw new OperationJournalError("operation_not_compactable", "Only an operation with a durable completed receipt may be compacted.");
788
+ }
789
+ if (state.receipt.operationId !== operationId || state.receipt.requestDigest !== state.requestDigest) {
790
+ throw corrupt("Terminal receipt identity does not match operation state.");
791
+ }
792
+ if (state.receipt.schemaVersion !== OPERATION_RECEIPT_SCHEMA_VERSION) {
793
+ throw corrupt("Terminal receipt schema is unsupported.");
794
+ }
795
+ }
796
+ function assertSameIdentity(leftOperationId, leftRequestDigest, rightOperationId, rightRequestDigest) {
797
+ if (leftOperationId !== rightOperationId || leftRequestDigest !== rightRequestDigest) {
798
+ throw corrupt("Durable operation records disagree about immutable identity.");
799
+ }
800
+ }
801
+ function terminalLoaded(terminal) {
802
+ return {
803
+ state: jsonRoundTrip(terminal.state),
804
+ envelopes: [],
805
+ committedBytes: 0,
806
+ partialTailBytes: 0,
807
+ lastEventDigest: terminal.lastEventDigest
808
+ };
809
+ }
810
+ function reconcileTerminalWithLog(terminal, parsed, operationId) {
811
+ if (parsed.envelopes.length === 0)
812
+ throw corrupt("Terminal record has an empty authoritative log beside it.");
813
+ const state = reduceSafely(parsed.envelopes.map(envelope => envelope.event));
814
+ assertCompletedState(state, operationId);
815
+ const lastEventDigest = parsed.envelopes.at(-1)?.eventDigest;
816
+ if (terminal.operationId !== state.operationId
817
+ || terminal.requestDigest !== state.requestDigest
818
+ || terminal.revision !== state.revision
819
+ || terminal.lastEventDigest !== lastEventDigest
820
+ || canonicalJson(terminal.state) !== canonicalJson(state)) {
821
+ throw corrupt("Durable terminal record and operation log disagree.");
822
+ }
823
+ }
824
+ async function readTerminal(path, key) {
825
+ const value = await readAuthenticatedFile(path, key, "codex-chatgpt-control/operation-terminal/v1", "terminalDigest", "journal_terminal_corrupt");
826
+ if (!hasExactKeys(value, [
827
+ "schemaVersion",
828
+ "operationId",
829
+ "requestDigest",
830
+ "revision",
831
+ "lastEventDigest",
832
+ "state",
833
+ "receipt",
834
+ "artifactManifest",
835
+ "ownershipEvidenceDigests",
836
+ "terminalDigest"
837
+ ])
838
+ ||
839
+ value.schemaVersion !== TERMINAL_SCHEMA_VERSION
840
+ || typeof value.operationId !== "string"
841
+ || typeof value.requestDigest !== "string"
842
+ || !Number.isSafeInteger(value.revision)
843
+ || value.revision <= 0
844
+ || !isDigest(value.lastEventDigest)
845
+ || !isRecord(value.state)
846
+ || !isRecord(value.receipt)
847
+ || !Array.isArray(value.artifactManifest)
848
+ || !Array.isArray(value.ownershipEvidenceDigests)) {
849
+ throw corrupt("Authenticated terminal record has an invalid shape.");
850
+ }
851
+ assertOperationId(value.operationId);
852
+ if (!isDigest(value.requestDigest))
853
+ throw corrupt("Authenticated terminal request digest is invalid.");
854
+ const state = value.state;
855
+ try {
856
+ assertOperationStateShape(state);
857
+ }
858
+ catch (error) {
859
+ const message = error instanceof Error ? error.message : String(error);
860
+ throw corrupt(`Authenticated terminal state has an invalid shape: ${message}`);
861
+ }
862
+ if (state.schemaVersion !== OPERATION_SCHEMA_VERSION)
863
+ throw corrupt("Authenticated terminal state schema is unsupported.");
864
+ assertCompletedState(state, value.operationId);
865
+ if (state.requestDigest !== value.requestDigest || state.revision !== value.revision) {
866
+ throw corrupt("Authenticated terminal identity does not match its state.");
867
+ }
868
+ if (canonicalJson(state.receipt) !== canonicalJson(value.receipt)) {
869
+ throw corrupt("Authenticated terminal receipt does not match its state.");
870
+ }
871
+ if (state.receipt === undefined || canonicalJson(state.receipt.artifacts) !== canonicalJson(value.artifactManifest)) {
872
+ throw corrupt("Authenticated terminal artifact manifest does not match its receipt.");
873
+ }
874
+ if (!value.ownershipEvidenceDigests.every(isDigest)) {
875
+ throw corrupt("Authenticated terminal ownership evidence is invalid.");
876
+ }
877
+ const expectedOwnershipEvidence = terminalOwnershipEvidenceDigests(state);
878
+ if (canonicalJson(value.ownershipEvidenceDigests) !== canonicalJson(expectedOwnershipEvidence)) {
879
+ throw corrupt("Authenticated terminal ownership evidence does not match its state.");
880
+ }
881
+ return value;
882
+ }
883
+ /**
884
+ * Preserve every action-keyed ownership proof in a compact terminal record.
885
+ * The singular fields are compatibility projections of the original Send;
886
+ * include them as well so older authenticated state remains verifiable while
887
+ * Work-steer witnesses and baselines cannot disappear during compaction.
888
+ */
889
+ function terminalOwnershipEvidenceDigests(state) {
890
+ const receipt = state.receipt;
891
+ if (receipt === undefined)
892
+ throw corrupt("Completed operation has no terminal receipt.");
893
+ const witnesses = [
894
+ ...Object.values(state.submissionWitnesses ?? {}),
895
+ ...(state.submissionWitness === undefined ? [] : [state.submissionWitness])
896
+ ];
897
+ const baselines = [
898
+ ...Object.values(state.ownershipBaselines ?? {}),
899
+ ...(state.ownershipBaseline === undefined ? [] : [state.ownershipBaseline])
900
+ ];
901
+ return Array.from(new Set([
902
+ receipt.userTurnEvidenceDigest,
903
+ receipt.ownershipEvidenceDigest,
904
+ ...witnesses.flatMap(witness => [
905
+ witness.baselineSnapshotDigest,
906
+ witness.postSendDeltaDigest,
907
+ witness.operationUserEvidenceDigest
908
+ ]),
909
+ ...baselines.flatMap(baseline => [
910
+ baseline.targetBindingDigest,
911
+ baseline.baseline.snapshotDigest
912
+ ]),
913
+ ...Object.values(state.actions)
914
+ .map(action => action.evidenceDigest)
915
+ .filter((digest) => digest !== undefined)
916
+ ])).sort();
917
+ }
918
+ async function readTombstone(path, key) {
919
+ const value = await readAuthenticatedFile(path, key, "codex-chatgpt-control/operation-tombstone/v1", "tombstoneDigest", "journal_tombstone_corrupt");
920
+ if (!hasExactKeys(value, ["schemaVersion", "operationId", "requestDigest", "tombstoneDigest"])
921
+ ||
922
+ value.schemaVersion !== TOMBSTONE_SCHEMA_VERSION
923
+ || typeof value.operationId !== "string"
924
+ || !isDigest(value.requestDigest)) {
925
+ throw corrupt("Authenticated tombstone has an invalid shape.");
926
+ }
927
+ assertOperationId(value.operationId);
928
+ return value;
929
+ }
930
+ async function writeAuthenticatedAtomic(path, value, key, domain, digestField, entropy, inject, replaceExisting) {
931
+ const withDigest = {
932
+ ...value,
933
+ [digestField]: hmacDigest(key, domain, withoutField(value, digestField))
934
+ };
935
+ await writeAtomicJson(path, withDigest, entropy, inject, replaceExisting);
936
+ }
937
+ async function readAuthenticatedFile(path, key, domain, digestField, errorCode) {
938
+ let handle;
939
+ try {
940
+ handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
941
+ }
942
+ catch (error) {
943
+ if (isNodeError(error, "ENOENT")) {
944
+ throw new OperationJournalError("operation_not_found", "No durable operation record exists.");
945
+ }
946
+ if (isNodeError(error, "ELOOP"))
947
+ throw new OperationJournalError("unsafe_journal_entry", "Refusing to follow a symlinked operation record.");
948
+ throw error;
949
+ }
950
+ let raw;
951
+ try {
952
+ const metadata = await assertSecureFileHandle(handle, path);
953
+ if (!Number.isSafeInteger(metadata.size) || metadata.size > MAX_SINGLE_RECORD_FILE_BYTES) {
954
+ throw new OperationJournalError(errorCode, "Authenticated operation record exceeds its hard safety limit.");
955
+ }
956
+ raw = await handle.readFile({ encoding: "utf8" });
957
+ }
958
+ finally {
959
+ await handle.close();
960
+ }
961
+ let value;
962
+ try {
963
+ value = JSON.parse(raw);
964
+ }
965
+ catch {
966
+ throw new OperationJournalError(errorCode, "Authenticated operation record contains invalid JSON.");
967
+ }
968
+ if (!isRecord(value) || typeof value[digestField] !== "string") {
969
+ throw new OperationJournalError(errorCode, "Authenticated operation record has no valid checksum.");
970
+ }
971
+ const expected = hmacDigest(key, domain, withoutField(value, digestField));
972
+ if (value[digestField] !== expected) {
973
+ throw new OperationJournalError(errorCode, "Authenticated operation record checksum is invalid.");
974
+ }
975
+ return value;
976
+ }
977
+ async function readQuotaCounter(path, key) {
978
+ const value = await readAuthenticatedFile(path, key, "codex-chatgpt-control/operation-quota-state/v1", "counterDigest", "journal_quota_counter_corrupt");
979
+ if (!hasExactKeys(value, [
980
+ "schemaVersion",
981
+ "revision",
982
+ "totalBytes",
983
+ "entryCount",
984
+ "dirty",
985
+ "directories",
986
+ "counterDigest"
987
+ ])
988
+ || value.schemaVersion !== QUOTA_COUNTER_SCHEMA_VERSION
989
+ || !Number.isSafeInteger(value.revision) || value.revision < 1
990
+ || !Number.isSafeInteger(value.totalBytes) || value.totalBytes < 0
991
+ || !Number.isSafeInteger(value.entryCount) || value.entryCount < 0
992
+ || typeof value.dirty !== "boolean"
993
+ || !isDigest(value.counterDigest)
994
+ || !isQuotaDirectoryFingerprintRecord(value.directories)) {
995
+ throw new OperationJournalError("journal_quota_counter_corrupt", "Authenticated operation quota state has an invalid shape.");
996
+ }
997
+ return value;
998
+ }
999
+ async function writeQuotaCounter(path, material, key, entropy) {
1000
+ const withoutDigest = withoutField(material, "counterDigest");
1001
+ const value = {
1002
+ ...withoutDigest,
1003
+ counterDigest: hmacDigest(key, "codex-chatgpt-control/operation-quota-state/v1", withoutDigest)
1004
+ };
1005
+ await writeAtomicJson(path, value, entropy, async () => undefined, true);
1006
+ return value;
1007
+ }
1008
+ async function readQuotaDirectoryFingerprints(stateRoot) {
1009
+ const entries = await Promise.all(TRACKED_STATE_DIRECTORIES.map(async (directoryName) => {
1010
+ const path = childPath(stateRoot, directoryName);
1011
+ const metadata = await lstat(path, { bigint: true });
1012
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
1013
+ throw new OperationJournalError("unsafe_state_root", "Operation state path is not a secure directory.");
1014
+ }
1015
+ assertOwnerAndMode(metadata, path, POSIX_DIRECTORY_MODE);
1016
+ const fingerprint = {
1017
+ device: String(metadata.dev),
1018
+ inode: String(metadata.ino),
1019
+ modifiedNs: String(metadata.mtimeNs),
1020
+ changedNs: String(metadata.ctimeNs)
1021
+ };
1022
+ return [directoryName, fingerprint];
1023
+ }));
1024
+ return Object.fromEntries(entries);
1025
+ }
1026
+ function sameQuotaDirectoryFingerprints(left, right) {
1027
+ return TRACKED_STATE_DIRECTORIES.every(directoryName => {
1028
+ const a = left[directoryName];
1029
+ const b = right[directoryName];
1030
+ return a.device === b.device
1031
+ && a.inode === b.inode
1032
+ && a.modifiedNs === b.modifiedNs
1033
+ && a.changedNs === b.changedNs;
1034
+ });
1035
+ }
1036
+ function isQuotaDirectoryFingerprintRecord(value) {
1037
+ if (!isRecord(value) || !hasExactKeys(value, [...TRACKED_STATE_DIRECTORIES]))
1038
+ return false;
1039
+ return TRACKED_STATE_DIRECTORIES.every(directoryName => {
1040
+ const fingerprint = value[directoryName];
1041
+ return isRecord(fingerprint)
1042
+ && hasExactKeys(fingerprint, ["device", "inode", "modifiedNs", "changedNs"])
1043
+ && [fingerprint.device, fingerprint.inode, fingerprint.modifiedNs, fingerprint.changedNs]
1044
+ .every(item => typeof item === "string" && /^\d+$/u.test(item));
1045
+ });
1046
+ }
1047
+ function nextQuotaRevision(revision) {
1048
+ if (!Number.isSafeInteger(revision) || revision < 0 || revision >= Number.MAX_SAFE_INTEGER) {
1049
+ throw new OperationJournalError("journal_quota_counter_corrupt", "Operation quota revision is invalid.");
1050
+ }
1051
+ return revision + 1;
1052
+ }
1053
+ function assertQuotaDelta(value, label) {
1054
+ if (!Number.isSafeInteger(value)) {
1055
+ throw new OperationJournalError("journal_quota_counter_corrupt", `Operation quota ${label} is invalid.`);
1056
+ }
1057
+ }
1058
+ function withoutField(value, field) {
1059
+ const copy = { ...value };
1060
+ delete copy[field];
1061
+ return copy;
1062
+ }
1063
+ async function writeAtomicJson(path, value, entropy, inject, replaceExisting) {
1064
+ const directory = dirname(path);
1065
+ const temporaryPath = join(directory, `.${path.split(sep).at(-1) ?? "operation"}-${entropyUuid(entropy)}.tmp`);
1066
+ let handle;
1067
+ let createdTemporary = false;
1068
+ try {
1069
+ try {
1070
+ handle = await open(temporaryPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), POSIX_FILE_MODE);
1071
+ }
1072
+ catch (error) {
1073
+ if (isNodeError(error, "EEXIST")) {
1074
+ throw new OperationJournalError("journal_temp_conflict", "A temporary operation state file already exists.");
1075
+ }
1076
+ throw error;
1077
+ }
1078
+ createdTemporary = true;
1079
+ await handle.writeFile(`${canonicalJson(value)}\n`, "utf8");
1080
+ await handle.sync();
1081
+ await handle.close();
1082
+ handle = undefined;
1083
+ if (replaceExisting) {
1084
+ await assertReplaceableRegularFile(path);
1085
+ await rename(temporaryPath, path);
1086
+ }
1087
+ else {
1088
+ try {
1089
+ await link(temporaryPath, path);
1090
+ }
1091
+ catch (error) {
1092
+ if (isNodeError(error, "EEXIST")) {
1093
+ throw new OperationJournalError("durable_record_conflict", "Refusing to replace an existing durable operation record.");
1094
+ }
1095
+ throw error;
1096
+ }
1097
+ await unlink(temporaryPath);
1098
+ }
1099
+ await syncDirectory(directory);
1100
+ await inject(injectPointForPath(path));
1101
+ }
1102
+ finally {
1103
+ await handle?.close();
1104
+ if (createdTemporary) {
1105
+ await unlink(temporaryPath).catch(error => {
1106
+ if (!isNodeError(error, "ENOENT"))
1107
+ throw error;
1108
+ });
1109
+ }
1110
+ }
1111
+ }
1112
+ function injectPointForPath(path) {
1113
+ if (path.includes(`${SNAPSHOT_DIRECTORY}${sep}`))
1114
+ return "after_snapshot_synced";
1115
+ if (path.includes(`${TERMINAL_DIRECTORY}${sep}`))
1116
+ return "after_terminal_synced";
1117
+ return "after_tombstone_synced";
1118
+ }
1119
+ async function assertReplaceableRegularFile(path) {
1120
+ let metadata;
1121
+ try {
1122
+ metadata = await lstat(path);
1123
+ }
1124
+ catch (error) {
1125
+ if (isNodeError(error, "ENOENT"))
1126
+ return;
1127
+ throw error;
1128
+ }
1129
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
1130
+ throw new OperationJournalError("unsafe_journal_entry", "Refusing to replace a non-regular or symlinked operation cache.");
1131
+ }
1132
+ assertOwnerAndMode(metadata, path, POSIX_FILE_MODE);
1133
+ }
1134
+ async function pathExists(path) {
1135
+ try {
1136
+ await lstat(path);
1137
+ return true;
1138
+ }
1139
+ catch (error) {
1140
+ if (isNodeError(error, "ENOENT"))
1141
+ return false;
1142
+ throw error;
1143
+ }
1144
+ }
1145
+ async function fileSize(path) {
1146
+ const metadata = await lstat(path);
1147
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
1148
+ throw new OperationJournalError("unsafe_journal_entry", "Expected a regular operation state file.");
1149
+ }
1150
+ return metadata.size;
1151
+ }
1152
+ async function optionalFileSize(path) {
1153
+ try {
1154
+ return await fileSize(path);
1155
+ }
1156
+ catch (error) {
1157
+ if (isNodeError(error, "ENOENT"))
1158
+ return undefined;
1159
+ throw error;
1160
+ }
1161
+ }
1162
+ async function hasDurableState(stateRoot) {
1163
+ if (await pathExists(childPath(stateRoot, QUOTA_COUNTER_FILE)))
1164
+ return true;
1165
+ let scannedEntries = 0;
1166
+ for (const directoryName of TRACKED_STATE_DIRECTORIES) {
1167
+ const directory = childPath(stateRoot, directoryName);
1168
+ const handle = await opendir(directory);
1169
+ try {
1170
+ for await (const entry of handle) {
1171
+ scannedEntries += 1;
1172
+ assertQuotaScanEntryLimit(scannedEntries);
1173
+ if (entry.name !== ".DS_Store")
1174
+ return true;
1175
+ }
1176
+ }
1177
+ finally {
1178
+ await closeDirectory(handle);
1179
+ }
1180
+ }
1181
+ return false;
1182
+ }
1183
+ async function scanStateUsage(stateRoot) {
1184
+ let total = 0;
1185
+ let entryCount = 0;
1186
+ let scannedEntries = 0;
1187
+ const directories = [
1188
+ [LOG_DIRECTORY, /^[0-9a-f]{64}\.jsonl$/],
1189
+ [TERMINAL_DIRECTORY, /^[0-9a-f]{64}\.terminal\.json$/],
1190
+ [SNAPSHOT_DIRECTORY, /^[0-9a-f]{64}\.snapshot\.json$/],
1191
+ [TOMBSTONE_DIRECTORY, /^[0-9a-f]{64}\.tombstone\.json$/]
1192
+ ];
1193
+ const temporaryPattern = /^\.[a-z0-9][a-z0-9.-]*-[0-9a-f-]+\.tmp$/;
1194
+ for (const [directoryName, canonicalPattern] of directories) {
1195
+ const directory = childPath(stateRoot, directoryName);
1196
+ const handle = await opendir(directory);
1197
+ try {
1198
+ for await (const entry of handle) {
1199
+ scannedEntries += 1;
1200
+ assertQuotaScanEntryLimit(scannedEntries);
1201
+ if (entry.name === ".DS_Store")
1202
+ continue;
1203
+ entryCount += 1;
1204
+ if (!canonicalPattern.test(entry.name) && !temporaryPattern.test(entry.name)) {
1205
+ throw new OperationJournalError("unsafe_journal_entry", "Unexpected journal entry in operation state.");
1206
+ }
1207
+ const filePath = childPath(directory, entry.name);
1208
+ const metadata = await lstat(filePath);
1209
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
1210
+ throw new OperationJournalError("unsafe_journal_entry", "Unexpected operation state entry type.");
1211
+ }
1212
+ assertOwnerAndMode(metadata, filePath, POSIX_FILE_MODE);
1213
+ if (!Number.isSafeInteger(metadata.size) || metadata.size > MAX_QUOTA_SCAN_BYTES - total) {
1214
+ throw new OperationJournalError("journal_scan_limit", "Operation journal quota scan exceeded its hard byte limit.");
1215
+ }
1216
+ total += metadata.size;
1217
+ }
1218
+ }
1219
+ finally {
1220
+ await closeDirectory(handle);
1221
+ }
1222
+ }
1223
+ return { totalBytes: total, entryCount };
1224
+ }
1225
+ function assertQuotaScanEntryLimit(scannedEntries) {
1226
+ if (scannedEntries > MAX_QUOTA_SCAN_ENTRIES) {
1227
+ throw new OperationJournalError("journal_scan_limit", "Operation journal quota scan exceeded its hard entry limit.");
1228
+ }
1229
+ }
1230
+ async function closeDirectory(handle) {
1231
+ try {
1232
+ await handle.close();
1233
+ }
1234
+ catch (error) {
1235
+ if (isNodeError(error, "ERR_DIR_CLOSED"))
1236
+ return;
1237
+ throw error;
1238
+ }
1239
+ }
1240
+ async function appendRecord(args) {
1241
+ const noFollow = fsConstants.O_NOFOLLOW ?? 0;
1242
+ const createFlags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow;
1243
+ const appendFlags = fsConstants.O_WRONLY | fsConstants.O_APPEND | noFollow;
1244
+ const repairFlags = fsConstants.O_WRONLY | noFollow;
1245
+ let handle;
1246
+ try {
1247
+ handle = await open(args.logPath, args.createExclusive ? createFlags : args.partialTailBytes > 0 ? repairFlags : appendFlags, POSIX_FILE_MODE);
1248
+ }
1249
+ catch (error) {
1250
+ if (args.createExclusive && isNodeError(error, "EEXIST")) {
1251
+ throw new OperationJournalError("revision_conflict", "The operation log appeared during exclusive creation.");
1252
+ }
1253
+ throw error;
1254
+ }
1255
+ try {
1256
+ const metadata = await assertSecureFileHandle(handle, args.logPath);
1257
+ if (metadata.size !== args.committedBytes + args.partialTailBytes) {
1258
+ throw new OperationJournalError("revision_conflict", "The operation log changed before its next record could be appended.");
1259
+ }
1260
+ if (args.committedBytes + args.encoded.byteLength > MAX_SINGLE_RECORD_FILE_BYTES) {
1261
+ throw new OperationJournalError("journal_log_too_large", "The operation log reached its hard safety limit; existing evidence was preserved.");
1262
+ }
1263
+ if (args.partialTailBytes > 0) {
1264
+ await handle.truncate(args.committedBytes);
1265
+ await args.inject("after_partial_tail_truncated");
1266
+ await writeBufferAt(handle, args.encoded, args.committedBytes);
1267
+ }
1268
+ else {
1269
+ await handle.writeFile(args.encoded);
1270
+ }
1271
+ await args.inject("after_record_written");
1272
+ await handle.sync();
1273
+ await args.inject("after_record_synced");
1274
+ }
1275
+ finally {
1276
+ await handle.close();
1277
+ }
1278
+ if (args.syncParent)
1279
+ await syncDirectory(dirname(args.logPath));
1280
+ }
1281
+ async function writeBufferAt(handle, bytes, position) {
1282
+ let offset = 0;
1283
+ while (offset < bytes.byteLength) {
1284
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset, position + offset);
1285
+ if (bytesWritten <= 0) {
1286
+ throw new OperationJournalError("journal_write_failed", "Operation journal write made no progress.");
1287
+ }
1288
+ offset += bytesWritten;
1289
+ }
1290
+ }
1291
+ async function readLog(logPath, key, allowMissing) {
1292
+ let handle;
1293
+ try {
1294
+ handle = await open(logPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
1295
+ }
1296
+ catch (error) {
1297
+ if (isNodeError(error, "ENOENT") && allowMissing) {
1298
+ return { exists: false, envelopes: [], committedBytes: 0, partialTailBytes: 0 };
1299
+ }
1300
+ if (isNodeError(error, "ENOENT")) {
1301
+ throw new OperationJournalError("operation_not_found", "No durable operation exists for this operation ID.");
1302
+ }
1303
+ if (isNodeError(error, "ELOOP")) {
1304
+ throw new OperationJournalError("unsafe_journal_entry", "Refusing to follow a symlinked operation log.");
1305
+ }
1306
+ throw error;
1307
+ }
1308
+ let bytes;
1309
+ try {
1310
+ const metadata = await assertSecureFileHandle(handle, logPath);
1311
+ if (!Number.isSafeInteger(metadata.size) || metadata.size > MAX_SINGLE_RECORD_FILE_BYTES) {
1312
+ throw new OperationJournalError("journal_log_too_large", "The operation log exceeds its hard safety limit.");
1313
+ }
1314
+ bytes = await handle.readFile();
1315
+ }
1316
+ finally {
1317
+ await handle.close();
1318
+ }
1319
+ const lastNewline = bytes.lastIndexOf(0x0a);
1320
+ const committedBytes = lastNewline < 0 ? 0 : lastNewline + 1;
1321
+ const partialTailBytes = bytes.byteLength - committedBytes;
1322
+ const committedText = bytes.subarray(0, committedBytes).toString("utf8");
1323
+ const lines = committedText.length === 0 ? [] : committedText.slice(0, -1).split("\n");
1324
+ const envelopes = [];
1325
+ let expectedPrevious = GENESIS_EVENT_DIGEST;
1326
+ for (const [index, line] of lines.entries()) {
1327
+ if (line.length === 0)
1328
+ throw corrupt(`Empty committed record at revision ${index + 1}.`);
1329
+ let value;
1330
+ try {
1331
+ value = JSON.parse(line);
1332
+ }
1333
+ catch {
1334
+ throw corrupt(`Invalid committed JSON at revision ${index + 1}.`);
1335
+ }
1336
+ const envelope = assertEnvelope(value, index + 1);
1337
+ if (envelope.previousEventDigest !== expectedPrevious) {
1338
+ throw corrupt(`Broken previous-event hash at revision ${index + 1}.`);
1339
+ }
1340
+ const expectedDigest = journalEventDigest(key, envelope.revision, envelope.previousEventDigest, envelope.event);
1341
+ if (envelope.eventDigest !== expectedDigest) {
1342
+ throw corrupt(`Invalid event checksum at revision ${index + 1}.`);
1343
+ }
1344
+ envelopes.push(envelope);
1345
+ expectedPrevious = envelope.eventDigest;
1346
+ }
1347
+ if (envelopes.length > 0)
1348
+ reduceSafely(envelopes.map(envelope => envelope.event));
1349
+ // A prior process can stop after writing a complete checksummed line but
1350
+ // before confirming fsync. Re-loading that line must make it durable before
1351
+ // its intent can authorize any browser mutation.
1352
+ await ensureLogDurable(logPath);
1353
+ return { exists: true, envelopes, committedBytes, partialTailBytes };
1354
+ }
1355
+ async function ensureLogDurable(logPath) {
1356
+ let handle;
1357
+ try {
1358
+ handle = await open(logPath, fsConstants.O_RDWR | (fsConstants.O_NOFOLLOW ?? 0));
1359
+ }
1360
+ catch (error) {
1361
+ if (isNodeError(error, "ELOOP")) {
1362
+ throw new OperationJournalError("unsafe_journal_entry", "Refusing to follow a symlinked operation log.");
1363
+ }
1364
+ throw error;
1365
+ }
1366
+ try {
1367
+ await assertSecureFileHandle(handle, logPath);
1368
+ await handle.sync();
1369
+ }
1370
+ finally {
1371
+ await handle.close();
1372
+ }
1373
+ await syncDirectory(dirname(logPath));
1374
+ }
1375
+ function assertEnvelope(value, expectedRevision) {
1376
+ if (!isRecord(value))
1377
+ throw corrupt(`Committed revision ${expectedRevision} is not an object.`);
1378
+ if (!hasExactKeys(value, ["schemaVersion", "revision", "previousEventDigest", "eventDigest", "event"])) {
1379
+ throw corrupt(`Committed revision ${expectedRevision} has unexpected fields.`);
1380
+ }
1381
+ if (value.schemaVersion !== OPERATION_EVENT_SCHEMA_VERSION) {
1382
+ throw corrupt(`Unknown operation event schema at revision ${expectedRevision}.`);
1383
+ }
1384
+ if (value.revision !== expectedRevision)
1385
+ throw corrupt(`Non-sequential journal revision ${String(value.revision)}.`);
1386
+ if (!isDigest(value.previousEventDigest) || !isDigest(value.eventDigest)) {
1387
+ throw corrupt(`Invalid digest encoding at revision ${expectedRevision}.`);
1388
+ }
1389
+ if (!isRecord(value.event) || typeof value.event.type !== "string") {
1390
+ throw corrupt(`Invalid event payload at revision ${expectedRevision}.`);
1391
+ }
1392
+ return value;
1393
+ }
1394
+ async function loadOrCreateKey(stateRoot, entropy) {
1395
+ const keyPath = childPath(stateRoot, KEY_FILE);
1396
+ try {
1397
+ return await readKey(keyPath);
1398
+ }
1399
+ catch (error) {
1400
+ if (!(error instanceof OperationJournalError && error.code === "journal_key_missing"))
1401
+ throw error;
1402
+ }
1403
+ if (await hasDurableState(stateRoot)) {
1404
+ throw new OperationJournalError("journal_key_missing_with_state", "Durable operation state exists but its journal key is missing; refusing to create a new identity domain.");
1405
+ }
1406
+ const key = entropyBytes(entropy, KEY_BYTES);
1407
+ const temporaryKeyPath = childPath(stateRoot, `.journal-key-${entropyUuid(entropy)}.tmp`);
1408
+ let handle;
1409
+ try {
1410
+ handle = await open(temporaryKeyPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), POSIX_FILE_MODE);
1411
+ await handle.writeFile(key);
1412
+ await handle.sync();
1413
+ }
1414
+ finally {
1415
+ await handle?.close();
1416
+ }
1417
+ try {
1418
+ await link(temporaryKeyPath, keyPath);
1419
+ }
1420
+ catch (error) {
1421
+ if (!isNodeError(error, "EEXIST"))
1422
+ throw error;
1423
+ }
1424
+ finally {
1425
+ await unlink(temporaryKeyPath).catch(error => {
1426
+ if (!isNodeError(error, "ENOENT"))
1427
+ throw error;
1428
+ });
1429
+ }
1430
+ await syncDirectory(stateRoot);
1431
+ return readKey(keyPath);
1432
+ }
1433
+ async function readKey(keyPath) {
1434
+ let handle;
1435
+ try {
1436
+ handle = await open(keyPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
1437
+ }
1438
+ catch (error) {
1439
+ if (isNodeError(error, "ENOENT"))
1440
+ throw new OperationJournalError("journal_key_missing", "Operation journal key is missing.");
1441
+ if (isNodeError(error, "ELOOP"))
1442
+ throw new OperationJournalError("unsafe_journal_key", "Refusing to follow a symlinked journal key.");
1443
+ throw error;
1444
+ }
1445
+ try {
1446
+ await assertSecureFileHandle(handle, keyPath);
1447
+ const key = await handle.readFile();
1448
+ if (key.byteLength !== KEY_BYTES) {
1449
+ throw new OperationJournalError("invalid_journal_key", `Operation journal key must be ${KEY_BYTES} bytes.`);
1450
+ }
1451
+ if (key.every(byte => byte === 0)) {
1452
+ throw new OperationJournalError("invalid_journal_key", "Operation journal key is invalid.");
1453
+ }
1454
+ return key;
1455
+ }
1456
+ finally {
1457
+ await handle.close();
1458
+ }
1459
+ }
1460
+ async function ensureSecureDirectory(directory) {
1461
+ await mkdir(directory, { recursive: true, mode: POSIX_DIRECTORY_MODE });
1462
+ const metadata = await lstat(directory);
1463
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
1464
+ throw new OperationJournalError("unsafe_state_root", "Operation state path is not a secure directory.");
1465
+ }
1466
+ assertOwnerAndMode(metadata, directory, POSIX_DIRECTORY_MODE);
1467
+ }
1468
+ async function assertSecureFileHandle(handle, path) {
1469
+ const metadata = await handle.stat();
1470
+ if (!metadata.isFile())
1471
+ throw new OperationJournalError("unsafe_journal_entry", "Expected a regular operation state file.");
1472
+ assertOwnerAndMode(metadata, path, POSIX_FILE_MODE);
1473
+ return metadata;
1474
+ }
1475
+ function assertOwnerAndMode(metadata, path, expectedMode) {
1476
+ if (platform() === "win32")
1477
+ return;
1478
+ const getuid = process.getuid;
1479
+ if (typeof getuid === "function" && Number(metadata.uid) !== getuid()) {
1480
+ throw new OperationJournalError("unsafe_state_owner", "Operation state path is not owned by the current user.");
1481
+ }
1482
+ if ((Number(metadata.mode) & 0o077) !== 0) {
1483
+ throw new OperationJournalError("unsafe_state_permissions", `Operation state path permissions must be ${expectedMode.toString(8)} or stricter.`);
1484
+ }
1485
+ }
1486
+ async function acquireLock(lockPath, timeoutMs, clock, entropy) {
1487
+ const token = entropyUuid(entropy);
1488
+ const record = {
1489
+ schemaVersion: "chatgpt.browser_control.operation_lock.v1",
1490
+ token,
1491
+ pid: process.pid,
1492
+ hostname: hostname(),
1493
+ createdAt: clockTimestamp(clock)
1494
+ };
1495
+ const deadline = safeDeadline(clockNow(clock), timeoutMs);
1496
+ // Treat the configured timeout as a finite retry budget as well as a wall
1497
+ // deadline. An injected or adjusted wall clock may remain constant or move
1498
+ // backwards; neither condition may turn lock acquisition into an unbounded
1499
+ // loop. Forward wall-clock movement can still expire the wait earlier.
1500
+ let remainingWaitBudgetMs = timeoutMs;
1501
+ while (true) {
1502
+ let handle;
1503
+ let createdIdentity;
1504
+ try {
1505
+ handle = await open(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), POSIX_FILE_MODE);
1506
+ const metadata = await handle.stat();
1507
+ createdIdentity = { dev: metadata.dev, ino: metadata.ino };
1508
+ await handle.writeFile(`${canonicalJson(record)}\n`, "utf8");
1509
+ await handle.sync();
1510
+ await handle.close();
1511
+ return { path: lockPath, token };
1512
+ }
1513
+ catch (error) {
1514
+ await handle?.close();
1515
+ if (createdIdentity !== undefined) {
1516
+ await removeFailedExclusiveLock(lockPath, createdIdentity);
1517
+ }
1518
+ if (!isNodeError(error, "EEXIST"))
1519
+ throw error;
1520
+ let owner;
1521
+ try {
1522
+ owner = await readLock(lockPath);
1523
+ }
1524
+ catch (readError) {
1525
+ if (readError instanceof OperationJournalError && readError.code === "journal_lock_changed") {
1526
+ continue;
1527
+ }
1528
+ throw readError;
1529
+ }
1530
+ if (owner.hostname === hostname() && !processExists(owner.pid)) {
1531
+ const reclaimed = await quarantineAbandonedLock(lockPath, owner.token, clock, entropy);
1532
+ if (!reclaimed) {
1533
+ const remaining = Math.min(deadline - clockNow(clock), remainingWaitBudgetMs);
1534
+ if (remaining <= 0) {
1535
+ throw new OperationJournalError("journal_lock_timeout", "Timed out while another process was recovering an abandoned operation journal lock.");
1536
+ }
1537
+ const waitMs = Math.min(LOCK_RETRY_MS, remaining);
1538
+ remainingWaitBudgetMs -= waitMs;
1539
+ await clockSleep(clock, waitMs);
1540
+ }
1541
+ continue;
1542
+ }
1543
+ const remaining = Math.min(deadline - clockNow(clock), remainingWaitBudgetMs);
1544
+ if (remaining <= 0) {
1545
+ throw new OperationJournalError("journal_lock_timeout", "Timed out waiting for the operation journal lock.");
1546
+ }
1547
+ const waitMs = Math.min(LOCK_RETRY_MS, remaining);
1548
+ remainingWaitBudgetMs -= waitMs;
1549
+ await clockSleep(clock, waitMs);
1550
+ }
1551
+ }
1552
+ }
1553
+ async function readLock(lockPath) {
1554
+ let handle;
1555
+ try {
1556
+ handle = await open(lockPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
1557
+ }
1558
+ catch (error) {
1559
+ if (isNodeError(error, "ENOENT")) {
1560
+ throw new OperationJournalError("journal_lock_changed", "Operation journal lock changed while being inspected.");
1561
+ }
1562
+ if (isNodeError(error, "ELOOP")) {
1563
+ throw new OperationJournalError("journal_lock_corrupt", "Refusing to follow a symlinked operation lock.");
1564
+ }
1565
+ throw error;
1566
+ }
1567
+ let raw;
1568
+ try {
1569
+ const metadata = await assertSecureFileHandle(handle, lockPath);
1570
+ if (!Number.isSafeInteger(metadata.size) || metadata.size > MAX_LOCK_RECORD_BYTES) {
1571
+ throw new OperationJournalError("journal_lock_corrupt", "Operation journal lock record is too large.");
1572
+ }
1573
+ raw = await handle.readFile({ encoding: "utf8" });
1574
+ }
1575
+ finally {
1576
+ await handle.close();
1577
+ }
1578
+ let value;
1579
+ try {
1580
+ value = JSON.parse(raw);
1581
+ }
1582
+ catch {
1583
+ throw new OperationJournalError("journal_lock_corrupt", "Operation journal lock record is corrupt.");
1584
+ }
1585
+ if (!isRecord(value) ||
1586
+ !hasExactKeys(value, ["schemaVersion", "token", "pid", "hostname", "createdAt"]) ||
1587
+ value.schemaVersion !== "chatgpt.browser_control.operation_lock.v1" ||
1588
+ typeof value.token !== "string" ||
1589
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.token) ||
1590
+ typeof value.pid !== "number" ||
1591
+ !Number.isSafeInteger(value.pid) ||
1592
+ value.pid <= 0 ||
1593
+ typeof value.hostname !== "string" ||
1594
+ value.hostname.length === 0 ||
1595
+ value.hostname.length > 255 ||
1596
+ typeof value.createdAt !== "string" ||
1597
+ !isIsoTimestamp(value.createdAt)) {
1598
+ throw new OperationJournalError("journal_lock_corrupt", "Operation journal lock record has an invalid shape.");
1599
+ }
1600
+ return value;
1601
+ }
1602
+ /**
1603
+ * Reclaim one provably dead local owner without racing another reclaimer.
1604
+ *
1605
+ * A read-token-then-rename sequence is not a compare-and-swap: after the old
1606
+ * lock is renamed, another process can create a new canonical lock before a
1607
+ * second reclaimer performs its rename. The second rename would then steal
1608
+ * the live owner's lock. An exclusive recovery guard elects exactly one
1609
+ * cooperating reclaimer before it re-reads and moves the canonical path.
1610
+ *
1611
+ * Recovery guards are never reclaimed on age alone. If their owner dies,
1612
+ * recovery fails closed for manual diagnosis; this trades liveness for never
1613
+ * authorising overlapping journal writers.
1614
+ */
1615
+ async function quarantineAbandonedLock(lockPath, expectedToken, clock, entropy) {
1616
+ const guard = await tryAcquireRecoveryGuard(lockPath, clock, entropy);
1617
+ if (guard === undefined)
1618
+ return false;
1619
+ const quarantinePath = `${lockPath}.abandoned-${entropyUuid(entropy)}`;
1620
+ try {
1621
+ let current;
1622
+ try {
1623
+ current = await readLock(lockPath);
1624
+ }
1625
+ catch (error) {
1626
+ if (error instanceof OperationJournalError && error.code === "journal_lock_changed")
1627
+ return false;
1628
+ throw error;
1629
+ }
1630
+ if (current.token !== expectedToken ||
1631
+ current.hostname !== hostname() ||
1632
+ processExists(current.pid)) {
1633
+ return false;
1634
+ }
1635
+ try {
1636
+ await rename(lockPath, quarantinePath);
1637
+ }
1638
+ catch (error) {
1639
+ if (isNodeError(error, "ENOENT"))
1640
+ return false;
1641
+ throw error;
1642
+ }
1643
+ const moved = await readLock(quarantinePath);
1644
+ if (moved.token !== expectedToken) {
1645
+ // Never rename the unexpected record back: POSIX rename could atomically
1646
+ // overwrite a legitimate owner that acquired the canonical path meanwhile.
1647
+ // Preserve the quarantined claim for explicit diagnosis and fail closed.
1648
+ throw new OperationJournalError("journal_lock_changed", "Operation journal lock ownership changed during recovery.");
1649
+ }
1650
+ await unlink(quarantinePath);
1651
+ return true;
1652
+ }
1653
+ finally {
1654
+ await releaseLock(guard);
1655
+ }
1656
+ }
1657
+ async function tryAcquireRecoveryGuard(lockPath, clock, entropy) {
1658
+ const guardPath = `${lockPath}${LOCK_RECOVERY_SUFFIX}`;
1659
+ const token = entropyUuid(entropy);
1660
+ const record = {
1661
+ schemaVersion: "chatgpt.browser_control.operation_lock.v1",
1662
+ token,
1663
+ pid: process.pid,
1664
+ hostname: hostname(),
1665
+ createdAt: clockTimestamp(clock)
1666
+ };
1667
+ let handle;
1668
+ let createdIdentity;
1669
+ try {
1670
+ handle = await open(guardPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), POSIX_FILE_MODE);
1671
+ const metadata = await handle.stat();
1672
+ createdIdentity = { dev: metadata.dev, ino: metadata.ino };
1673
+ await handle.writeFile(`${canonicalJson(record)}\n`, "utf8");
1674
+ await handle.sync();
1675
+ return { path: guardPath, token };
1676
+ }
1677
+ catch (error) {
1678
+ if (createdIdentity !== undefined) {
1679
+ await handle?.close();
1680
+ handle = undefined;
1681
+ await removeFailedExclusiveLock(guardPath, createdIdentity);
1682
+ }
1683
+ if (!isNodeError(error, "EEXIST"))
1684
+ throw error;
1685
+ let owner;
1686
+ try {
1687
+ owner = await readLock(guardPath);
1688
+ }
1689
+ catch (readError) {
1690
+ if (readError instanceof OperationJournalError && readError.code === "journal_lock_changed")
1691
+ return undefined;
1692
+ throw readError;
1693
+ }
1694
+ if (owner.hostname === hostname() && !processExists(owner.pid)) {
1695
+ throw new OperationJournalError("journal_lock_recovery_abandoned", "An abandoned journal lock-recovery guard requires manual diagnosis; it will not be reclaimed automatically.");
1696
+ }
1697
+ return undefined;
1698
+ }
1699
+ finally {
1700
+ await handle?.close();
1701
+ }
1702
+ }
1703
+ async function removeFailedExclusiveLock(path, created) {
1704
+ let current;
1705
+ try {
1706
+ current = await lstat(path);
1707
+ }
1708
+ catch (error) {
1709
+ if (isNodeError(error, "ENOENT"))
1710
+ return;
1711
+ throw error;
1712
+ }
1713
+ if (current.isSymbolicLink()
1714
+ || !current.isFile()
1715
+ || current.dev !== created.dev
1716
+ || current.ino !== created.ino) {
1717
+ throw new OperationJournalError("journal_lock_cleanup_failed", "A failed exclusive lock creation no longer names the file that this process created.");
1718
+ }
1719
+ await unlink(path);
1720
+ }
1721
+ async function releaseLock(lock) {
1722
+ let owner;
1723
+ try {
1724
+ owner = await readLock(lock.path);
1725
+ }
1726
+ catch (error) {
1727
+ if (error instanceof OperationJournalError && error.code === "journal_lock_changed")
1728
+ return;
1729
+ throw error;
1730
+ }
1731
+ if (owner.token !== lock.token) {
1732
+ throw new OperationJournalError("journal_lock_lost", "Operation journal lock ownership changed before release.");
1733
+ }
1734
+ await unlink(lock.path);
1735
+ }
1736
+ function processExists(pid) {
1737
+ try {
1738
+ process.kill(pid, 0);
1739
+ return true;
1740
+ }
1741
+ catch (error) {
1742
+ return !isNodeError(error, "ESRCH");
1743
+ }
1744
+ }
1745
+ async function serializeInProcess(key, action) {
1746
+ const prior = inProcessQueues.get(key) ?? Promise.resolve();
1747
+ let release;
1748
+ const gate = new Promise(resolveGate => { release = resolveGate; });
1749
+ const tail = prior.catch(() => undefined).then(() => gate);
1750
+ inProcessQueues.set(key, tail);
1751
+ await prior.catch(() => undefined);
1752
+ try {
1753
+ // Mark the in-flight operation as observed immediately. Callers still
1754
+ // receive the original rejection, while Node/Vitest cannot misclassify a
1755
+ // deliberately handled error as an unhandled rejection during lock
1756
+ // cleanup.
1757
+ const result = action();
1758
+ result.catch(() => undefined);
1759
+ return await result;
1760
+ }
1761
+ finally {
1762
+ release();
1763
+ if (inProcessQueues.get(key) === tail)
1764
+ inProcessQueues.delete(key);
1765
+ }
1766
+ }
1767
+ async function syncDirectory(directory) {
1768
+ if (platform() === "win32")
1769
+ return;
1770
+ const handle = await open(directory, fsConstants.O_RDONLY);
1771
+ try {
1772
+ await handle.sync();
1773
+ }
1774
+ finally {
1775
+ await handle.close();
1776
+ }
1777
+ }
1778
+ function childPath(root, ...parts) {
1779
+ const candidate = resolve(root, ...parts);
1780
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
1781
+ if (candidate !== root && !candidate.startsWith(prefix)) {
1782
+ throw new OperationJournalError("state_path_escape", "Resolved operation state path escaped the configured root.");
1783
+ }
1784
+ return candidate;
1785
+ }
1786
+ function reduceSafely(events) {
1787
+ try {
1788
+ return reduceOperationEvents(events);
1789
+ }
1790
+ catch (error) {
1791
+ if (error instanceof OperationJournalError)
1792
+ throw error;
1793
+ const message = error instanceof Error ? error.message : String(error);
1794
+ throw corrupt(`Operation state reduction failed: ${message}`);
1795
+ }
1796
+ }
1797
+ function jsonRoundTrip(value) {
1798
+ const encoded = JSON.stringify(value);
1799
+ if (encoded === undefined)
1800
+ throw new OperationJournalError("event_not_serializable", "Operation value is not JSON serializable.");
1801
+ return JSON.parse(encoded);
1802
+ }
1803
+ function corrupt(message) {
1804
+ return new OperationJournalError("journal_corrupt", message);
1805
+ }
1806
+ function isDigest(value) {
1807
+ return typeof value === "string" && /^hmac-sha256:[0-9a-f]{64}$/.test(value);
1808
+ }
1809
+ function hasExactKeys(value, expected) {
1810
+ const keys = Object.keys(value);
1811
+ return keys.length === expected.length && expected.every(key => Object.hasOwn(value, key));
1812
+ }
1813
+ function isIsoTimestamp(value) {
1814
+ const parsed = new Date(value);
1815
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString() === value;
1816
+ }
1817
+ function isRecord(value) {
1818
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1819
+ }
1820
+ function isNodeError(error, code) {
1821
+ return nodeErrorCode(error) === code;
1822
+ }
1823
+ function validatePositiveInteger(value, label) {
1824
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {
1825
+ throw new OperationJournalError("invalid_journal_option", `${label} must be a positive safe integer.`);
1826
+ }
1827
+ }
1828
+ function resolveClock(value) {
1829
+ if (value === undefined) {
1830
+ return {
1831
+ now: () => Date.now(),
1832
+ sleep: milliseconds => delay(milliseconds)
1833
+ };
1834
+ }
1835
+ try {
1836
+ if (value === null
1837
+ || typeof value !== "object"
1838
+ || typeof value.now !== "function"
1839
+ || typeof value.sleep !== "function") {
1840
+ throw new Error("invalid clock");
1841
+ }
1842
+ }
1843
+ catch {
1844
+ throw new OperationJournalError("invalid_journal_clock", "Operation journal clock must provide callable now and sleep functions.");
1845
+ }
1846
+ return value;
1847
+ }
1848
+ function resolveEntropy(value) {
1849
+ if (value === undefined) {
1850
+ return {
1851
+ randomBytes: size => randomBytes(size),
1852
+ randomUUID: () => randomUUID()
1853
+ };
1854
+ }
1855
+ try {
1856
+ if (value === null
1857
+ || typeof value !== "object"
1858
+ || typeof value.randomBytes !== "function"
1859
+ || typeof value.randomUUID !== "function") {
1860
+ throw new Error("invalid entropy");
1861
+ }
1862
+ }
1863
+ catch {
1864
+ throw new OperationJournalError("invalid_journal_entropy", "Operation journal entropy must provide callable randomBytes and randomUUID functions.");
1865
+ }
1866
+ return value;
1867
+ }
1868
+ function entropyUuid(entropy) {
1869
+ let value;
1870
+ try {
1871
+ value = entropy.randomUUID();
1872
+ }
1873
+ catch {
1874
+ throw new OperationJournalError("invalid_journal_entropy", "Operation journal entropy failed to provide a UUID.");
1875
+ }
1876
+ if (typeof value !== "string" || !UUID_V4_PATTERN.test(value)) {
1877
+ throw new OperationJournalError("invalid_journal_entropy", "Operation journal entropy must provide canonical version-four UUIDs.");
1878
+ }
1879
+ return value;
1880
+ }
1881
+ function entropyBytes(entropy, size) {
1882
+ let value;
1883
+ try {
1884
+ value = entropy.randomBytes(size);
1885
+ }
1886
+ catch {
1887
+ throw new OperationJournalError("invalid_journal_entropy", "Operation journal entropy failed to provide key bytes.");
1888
+ }
1889
+ if (!isByteArrayView(value) || value.byteLength !== size) {
1890
+ throw new OperationJournalError("invalid_journal_entropy", "Operation journal entropy returned key bytes with an invalid length.");
1891
+ }
1892
+ const key = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
1893
+ if (key.every(byte => byte === 0)) {
1894
+ throw new OperationJournalError("invalid_journal_entropy", "Operation journal entropy returned an invalid key.");
1895
+ }
1896
+ return key;
1897
+ }
1898
+ function clockNow(clock) {
1899
+ let value;
1900
+ try {
1901
+ value = clock.now();
1902
+ }
1903
+ catch {
1904
+ throw new OperationJournalError("invalid_journal_clock", "Operation journal clock failed to provide time.");
1905
+ }
1906
+ if (typeof value !== "number"
1907
+ || !Number.isSafeInteger(value)
1908
+ || value < -MAX_TIMESTAMP_MS
1909
+ || value > MAX_TIMESTAMP_MS) {
1910
+ throw new OperationJournalError("invalid_journal_clock", "Operation journal clock returned an invalid timestamp.");
1911
+ }
1912
+ return value;
1913
+ }
1914
+ function clockTimestamp(clock) {
1915
+ return new Date(clockNow(clock)).toISOString();
1916
+ }
1917
+ async function clockSleep(clock, milliseconds) {
1918
+ try {
1919
+ await clock.sleep(milliseconds);
1920
+ }
1921
+ catch {
1922
+ throw new OperationJournalError("invalid_journal_clock", "Operation journal clock wait failed.");
1923
+ }
1924
+ }
1925
+ function safeDeadline(now, timeoutMs) {
1926
+ const deadline = now + timeoutMs;
1927
+ return Number.isSafeInteger(deadline) ? deadline : Number.MAX_SAFE_INTEGER;
1928
+ }
1929
+ function delay(milliseconds) {
1930
+ return new Promise(resolveDelay => setTimeout(resolveDelay, milliseconds));
1931
+ }