@c4roa0/agentpay-user 0.0.0-stage → 0.3.0

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 (173) hide show
  1. package/AGENTS.md +137 -0
  2. package/CONTRIBUTING.md +16 -0
  3. package/LICENSE +373 -0
  4. package/NOTICE.md +20 -0
  5. package/README.md +84 -2
  6. package/SECURITY.md +16 -0
  7. package/doc/README.md +52 -0
  8. package/doc/TODO.md +97 -0
  9. package/doc/architecture.md +96 -0
  10. package/doc/design.md +60 -0
  11. package/doc/develope.md +68 -0
  12. package/doc/plans/user-wallet-distribution.md +91 -0
  13. package/doc/reference/harness-skill-runtime.md +120 -0
  14. package/doc/reference/local-cache-foundation.md +176 -0
  15. package/doc/reference/local-safe-owner-ui.md +25 -0
  16. package/doc/reference/local-update.md +18 -0
  17. package/doc/reference/multi-service-runtime.md +119 -0
  18. package/doc/reference/npm-integration-verification.md +67 -0
  19. package/doc/reference/onboarding-writing.md +48 -0
  20. package/doc/reference/original-order-recovery.md +97 -0
  21. package/doc/reference/private-anvil-onboarding-config.example.json +18 -0
  22. package/doc/reference/public-deployment-settings.md +56 -0
  23. package/doc/reference/random-skill-contract.md +18 -0
  24. package/doc/reference/result-file-cache.md +166 -0
  25. package/doc/reference/runtime-address-recovery.md +42 -0
  26. package/doc/reference/runtime-result-cache.md +157 -0
  27. package/doc/reference/runtime-startup-recovery.md +141 -0
  28. package/doc/reference/skill-input-output-cache.md +93 -0
  29. package/doc/reference/wallet-identity-policy-history.md +23 -0
  30. package/doc/runbooks/commands.md +89 -0
  31. package/doc/runbooks/deployment-authorization-update.md +42 -0
  32. package/doc/runbooks/emergency-payment-stop.md +80 -0
  33. package/doc/runbooks/local-autostart.md +212 -0
  34. package/doc/runbooks/local-cache.md +115 -0
  35. package/doc/runbooks/local-update.md +84 -0
  36. package/doc/runbooks/market-connection.md +73 -0
  37. package/doc/runbooks/npm-publication.md +56 -0
  38. package/doc/runbooks/official-test-package.md +62 -0
  39. package/doc/runbooks/order-cancellation.md +56 -0
  40. package/doc/runbooks/order-support.md +41 -0
  41. package/doc/runbooks/policy-renewal.md +80 -0
  42. package/doc/runbooks/private-anvil-terminal-setup.md +58 -0
  43. package/doc/runbooks/runtime-skill-client.md +155 -0
  44. package/doc/runbooks/safe-owner-web.md +104 -0
  45. package/doc/runbooks/safe-policy-migration.md +36 -0
  46. package/doc/runbooks/safe-policy-setup.md +52 -0
  47. package/doc/runbooks/terminal-authorization-preview.md +38 -0
  48. package/doc/runbooks/unlock-wallet.md +68 -0
  49. package/doc/runbooks/user-onboarding.md +87 -0
  50. package/doc/runbooks/wallet-identity.md +59 -0
  51. package/doc/security/public-source-review-2026-11-06.md +71 -0
  52. package/doc/usage.md +54 -0
  53. package/package.json +49 -4
  54. package/progress.md +178 -0
  55. package/run +29 -0
  56. package/scripts/agentpay-cli.mjs +11 -0
  57. package/scripts/authorize-existing-safe.mjs +211 -0
  58. package/scripts/autostart.mjs +186 -0
  59. package/scripts/cancel-order.mjs +76 -0
  60. package/scripts/check-installation.mjs +26 -0
  61. package/scripts/check-official-test-package.mjs +35 -0
  62. package/scripts/check-purchase-readiness.mjs +275 -0
  63. package/scripts/configure-runtime-client.mjs +16 -0
  64. package/scripts/guide-policy.mjs +64 -0
  65. package/scripts/initialize-caller-credential.mjs +11 -0
  66. package/scripts/install-skill-bundle.mjs +176 -0
  67. package/scripts/install-user.mjs +24 -0
  68. package/scripts/local-service.mjs +60 -0
  69. package/scripts/lock.mjs +11 -0
  70. package/scripts/login-notify.mjs +39 -0
  71. package/scripts/migrate-safe-policy.mjs +165 -0
  72. package/scripts/official-paid-test.mjs +9 -0
  73. package/scripts/poll-unconfirmed-notice.mjs +75 -0
  74. package/scripts/prepare-onboarding.mjs +33 -0
  75. package/scripts/preview-policy.mjs +65 -0
  76. package/scripts/random-model-ack-smoke.mjs +235 -0
  77. package/scripts/random-reject-smoke.mjs +147 -0
  78. package/scripts/renew.mjs +9 -0
  79. package/scripts/repair.mjs +10 -0
  80. package/scripts/revoke.mjs +9 -0
  81. package/scripts/select-skill-bundle.mjs +39 -0
  82. package/scripts/setup-local.mjs +199 -0
  83. package/scripts/status.mjs +13 -0
  84. package/scripts/test-package.mjs +35 -0
  85. package/scripts/unlock.mjs +13 -0
  86. package/scripts/update.mjs +7 -0
  87. package/scripts/user-setup.mjs +219 -0
  88. package/scripts/verify-npm-release.mjs +32 -0
  89. package/src/active-skill.mjs +160 -0
  90. package/src/anvil-onboarding-state.mjs +96 -0
  91. package/src/anvil-onboarding.mjs +1036 -0
  92. package/src/authorization-question-checkpoint.mjs +114 -0
  93. package/src/autostart-files.mjs +50 -0
  94. package/src/cache-management-cli.mjs +302 -0
  95. package/src/cache-management.mjs +47 -0
  96. package/src/caller-auth.mjs +58 -0
  97. package/src/client-request-lock.mjs +44 -0
  98. package/src/connect-market.mjs +408 -0
  99. package/src/demo-unlock-secret.mjs +64 -0
  100. package/src/deployment-status.mjs +198 -0
  101. package/src/file-cache-cli.mjs +83 -0
  102. package/src/file-cache-owner.mjs +74 -0
  103. package/src/file-cache-settings.mjs +50 -0
  104. package/src/file-cache.mjs +790 -0
  105. package/src/file-download.mjs +281 -0
  106. package/src/finalization-disclosure.mjs +16 -0
  107. package/src/guided-setup.mjs +194 -0
  108. package/src/index.mjs +1231 -0
  109. package/src/install-and-setup.mjs +38 -0
  110. package/src/install-preflight.mjs +201 -0
  111. package/src/install-user.mjs +279 -0
  112. package/src/installation-paths.mjs +31 -0
  113. package/src/invocation-result.mjs +148 -0
  114. package/src/invocation-store.mjs +385 -0
  115. package/src/local-data-directory.mjs +117 -0
  116. package/src/local-info.mjs +181 -0
  117. package/src/local-services.mjs +156 -0
  118. package/src/local-time.mjs +36 -0
  119. package/src/local-update.mjs +306 -0
  120. package/src/local-wallet-ipc.mjs +93 -0
  121. package/src/login-reminder.mjs +28 -0
  122. package/src/market-download-url.mjs +21 -0
  123. package/src/node-version.mjs +12 -0
  124. package/src/npm-update.mjs +126 -0
  125. package/src/official-paid-test.mjs +407 -0
  126. package/src/official-test-package.mjs +41 -0
  127. package/src/official-test-update.mjs +162 -0
  128. package/src/order-cancellation.mjs +241 -0
  129. package/src/order-closure.mjs +42 -0
  130. package/src/order-list-panel.mjs +110 -0
  131. package/src/order-review.mjs +424 -0
  132. package/src/order-status-panel.mjs +65 -0
  133. package/src/order-support.mjs +365 -0
  134. package/src/owner-approval-server.mjs +332 -0
  135. package/src/owner-signer.mjs +67 -0
  136. package/src/policy-info.mjs +38 -0
  137. package/src/policy-migration.mjs +84 -0
  138. package/src/policy-operations.mjs +67 -0
  139. package/src/policy-preview-server.mjs +236 -0
  140. package/src/policy-terminal-guide.mjs +377 -0
  141. package/src/prepare-local-services.mjs +143 -0
  142. package/src/prepare-onboarding.mjs +210 -0
  143. package/src/private-journal-files.mjs +29 -0
  144. package/src/readiness-profile.mjs +30 -0
  145. package/src/renew-policy.mjs +605 -0
  146. package/src/repair-market-wallet.mjs +137 -0
  147. package/src/restore-wallet.mjs +255 -0
  148. package/src/result-cache-view.mjs +276 -0
  149. package/src/runtime-client.mjs +1145 -0
  150. package/src/runtime-connection-settings.mjs +101 -0
  151. package/src/runtime-recovery.mjs +667 -0
  152. package/src/runtime-status.mjs +32 -0
  153. package/src/safe-owner-anvil.mjs +115 -0
  154. package/src/safe-policy-plan.mjs +195 -0
  155. package/src/service-reference.mjs +233 -0
  156. package/src/service-url.mjs +52 -0
  157. package/src/settlement-response.mjs +69 -0
  158. package/src/setup-autostart.mjs +197 -0
  159. package/src/setup-demo.mjs +152 -0
  160. package/src/skill-bundle.mjs +142 -0
  161. package/src/skill-client.mjs +198 -0
  162. package/src/skill-install-prompt.mjs +31 -0
  163. package/src/skill-package.mjs +50 -0
  164. package/src/skill-zip.mjs +220 -0
  165. package/src/status-panel.mjs +589 -0
  166. package/src/transport.mjs +48 -0
  167. package/src/uninstall-local.mjs +299 -0
  168. package/src/user-cli.mjs +354 -0
  169. package/src/user-setup-guide.mjs +193 -0
  170. package/src/wallet-control.mjs +182 -0
  171. package/src/wallet-ipc-error.mjs +16 -0
  172. package/src/wallet-package.mjs +22 -0
  173. package/update-pair.json +6 -0
@@ -0,0 +1,790 @@
1
+ // Private content storage with explicit ownership, limits and integrity metadata.
2
+ // Cache cleanup must preserve protected request inputs and original-result recovery.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
5
+ // If a copy was not distributed with this file, see https://mozilla.org/MPL/2.0/.
6
+ import { cacheOwner } from "./file-cache-owner.mjs";
7
+ import { constants } from "node:fs";
8
+ import { mkdir, lstat, open, rename, unlink, readdir } from "node:fs/promises";
9
+ import { join, dirname, isAbsolute } from "node:path";
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import { readPrivateJson } from "./order-review.mjs";
12
+ import { downloadFile, fileError } from "./file-download.mjs";
13
+ import {
14
+ fileCacheLimits,
15
+ validateCacheLimits,
16
+ } from "./file-cache-settings.mjs";
17
+ export { fileCacheLimits } from "./file-cache-settings.mjs";
18
+ const digest = (v) => createHash("sha256").update(v).digest("hex");
19
+ const hex = /^[a-f0-9]{64}$/,
20
+ order = /^0x[a-f0-9]{64}$/,
21
+ request = /^[A-Za-z0-9._:-]{16,128}$/;
22
+ const states = ["downloading", "available", "failed", "deleting", "removed"];
23
+ const privateFile = (s) =>
24
+ s.isFile() &&
25
+ s.nlink === 1 &&
26
+ (typeof process.getuid !== "function" ||
27
+ (s.uid === process.getuid() && !(s.mode & 0o077)));
28
+ const scope = (r) => (r.purpose === "input" ? r.requestId : r.orderId);
29
+ // Preserve the original output IDs; inputs occupy an explicitly separate namespace.
30
+ const recordId = (r) =>
31
+ digest(
32
+ r.purpose === "input"
33
+ ? "input\n" + r.requestId + "\n" + r.fileKey
34
+ : r.orderId + "\n" + r.fileKey,
35
+ );
36
+ const generation = (r) => digest(JSON.stringify(r));
37
+ const fail = (code, message) => fileError(code, message);
38
+ export function createFileCache(
39
+ statePath,
40
+ {
41
+ download = downloadFile,
42
+ limits = fileCacheLimits,
43
+ privateOrigins = [],
44
+ } = {},
45
+ ) {
46
+ limits = validateCacheLimits(limits);
47
+ const directory = statePath + ".files",
48
+ index = join(directory, "index.json");
49
+ let owner;
50
+ async function exclusive(fn) {
51
+ await mkdir(directory, { mode: 0o700 }).catch((e) => {
52
+ if (e.code !== "EEXIST") throw e;
53
+ });
54
+ const s = await lstat(directory);
55
+ if (
56
+ !s.isDirectory() ||
57
+ s.isSymbolicLink() ||
58
+ (typeof process.getuid === "function" &&
59
+ (s.uid !== process.getuid() || s.mode & 0o077))
60
+ )
61
+ throw fail("FILE_CACHE_UNSAFE", "File cache permissions need review.");
62
+ owner = await cacheOwner(directory);
63
+ const next = owner.tail.then(fn);
64
+ owner.tail = next.catch(() => {});
65
+ return next;
66
+ }
67
+ async function sync(path = directory) {
68
+ const h = await open(path, "r");
69
+ try {
70
+ await h.sync();
71
+ } finally {
72
+ await h.close();
73
+ }
74
+ }
75
+ async function atomic(path, value) {
76
+ const temp = join(dirname(path), ".index-" + randomUUID());
77
+ let h;
78
+ try {
79
+ h = await open(temp, "wx", 0o600);
80
+ await h.writeFile(JSON.stringify(value));
81
+ await h.sync();
82
+ await h.close();
83
+ h = null;
84
+ await rename(temp, path);
85
+ await sync(dirname(path));
86
+ } finally {
87
+ await h?.close();
88
+ await unlink(temp).catch((e) => {
89
+ if (e.code !== "ENOENT") throw e;
90
+ });
91
+ }
92
+ }
93
+ async function persist(doc) {
94
+ if (Buffer.byteLength(JSON.stringify(doc)) > 8 * 1048576)
95
+ throw fail("FILE_COUNT_LIMIT", "File cache metadata limit reached.");
96
+ await atomic(index, doc);
97
+ }
98
+ async function discard(path) {
99
+ try {
100
+ const st = await lstat(path);
101
+ if (!privateFile(st) || st.isSymbolicLink())
102
+ throw fail(
103
+ "FILE_CACHE_UNSAFE",
104
+ "A cache file needs inspection before cleanup.",
105
+ );
106
+ await unlink(path);
107
+ } catch (e) {
108
+ if (e.code !== "ENOENT") throw e;
109
+ }
110
+ }
111
+ async function load() {
112
+ const doc = (await readPrivateJson(index, 8 * 1048576)) || {
113
+ version: 2,
114
+ files: {},
115
+ };
116
+ if (
117
+ ![1, 2].includes(doc.version) ||
118
+ !doc.files ||
119
+ Array.isArray(doc.files) ||
120
+ typeof doc.files !== "object" ||
121
+ Object.keys(doc.files).length > 100000
122
+ )
123
+ throw fail("FILE_CACHE_INVALID", "File cache metadata needs review.");
124
+ const upgrading = doc.version === 1;
125
+ for (const [id, r] of Object.entries(doc.files)) {
126
+ if (upgrading && r) {
127
+ r.purpose = "output";
128
+ r.sourceKind = "url";
129
+ }
130
+ if (
131
+ !hex.test(id) ||
132
+ !r ||
133
+ !["input", "output"].includes(r.purpose) ||
134
+ !["url", "local"].includes(r.sourceKind) ||
135
+ (r.purpose === "input"
136
+ ? !request.test(r.requestId || "")
137
+ : !order.test(r.orderId || "") || r.sourceKind !== "url") ||
138
+ (r.revision !== undefined &&
139
+ (typeof r.revision !== "string" ||
140
+ !/^[a-f0-9-]{36}$/.test(r.revision))) ||
141
+ !states.includes(r.state) ||
142
+ !Number.isSafeInteger(r.bytes) ||
143
+ r.bytes < 0 ||
144
+ typeof r.fileKey !== "string" ||
145
+ !/^[A-Za-z0-9._-]{1,80}$/.test(r.fileKey) ||
146
+ id !== recordId(r) ||
147
+ (r.sourceKind === "url"
148
+ ? typeof r.url !== "string" || r.url.length > 8192
149
+ : typeof r.sourcePath !== "string" ||
150
+ !isAbsolute(r.sourcePath) ||
151
+ r.sourcePath.length > 8192) ||
152
+ typeof r.filename !== "string" ||
153
+ r.filename.length > 200 ||
154
+ /[\x00-\x1f\x7f/\\]/.test(r.filename) ||
155
+ !Number.isSafeInteger(r.updatedAtMs) ||
156
+ r.updatedAtMs < 0 ||
157
+ (r.sha256 != null && !hex.test(r.sha256)) ||
158
+ (r.expectedSha256 != null && !hex.test(r.expectedSha256)) ||
159
+ (r.state === "available" && !r.sha256) ||
160
+ (r.purpose === "input" && typeof r.protected !== "boolean") ||
161
+ (r.reservation !== undefined &&
162
+ (!Number.isSafeInteger(r.reservation) || r.reservation < 0))
163
+ )
164
+ throw fail("FILE_CACHE_INVALID", "File cache metadata needs review.");
165
+ }
166
+ doc.version = 2;
167
+ if (!owner.initialized) {
168
+ for (const name of await readdir(directory))
169
+ if (/^\.index-[a-f0-9-]{36}$/.test(name))
170
+ await discard(join(directory, name));
171
+ let changed = upgrading;
172
+ for (const [id, r] of Object.entries(doc.files)) {
173
+ if (["deleting", "removed"].includes(r.state)) {
174
+ await discard(join(directory, id + ".bin"));
175
+ await discard(join(directory, id + ".part"));
176
+ if (r.state === "deleting") {
177
+ r.state = "removed";
178
+ r.updatedAtMs = Date.now();
179
+ changed = true;
180
+ }
181
+ } else if (r.state === "downloading") {
182
+ // A durable final file may precede the index commit. Preserve it only when a
183
+ // saved digest proves this is the completed transfer, otherwise fail closed.
184
+ let recovered = false;
185
+ if (r.sha256) {
186
+ try {
187
+ await verify(id, r);
188
+ recovered = true;
189
+ } catch {
190
+ try {
191
+ await verify(id, r, ".part");
192
+ await rename(
193
+ join(directory, id + ".part"),
194
+ join(directory, id + ".bin"),
195
+ );
196
+ await sync();
197
+ recovered = true;
198
+ } catch {}
199
+ }
200
+ if (recovered) r.state = "available";
201
+ }
202
+ if (!recovered) {
203
+ await discard(join(directory, id + ".bin"));
204
+ r.state = "failed";
205
+ r.error = "FILE_TRANSFER_INTERRUPTED";
206
+ r.bytes = 0;
207
+ }
208
+ await discard(join(directory, id + ".part"));
209
+ delete r.reservation;
210
+ r.updatedAtMs = Date.now();
211
+ changed = true;
212
+ }
213
+ }
214
+ await sync();
215
+ if (changed) await persist(doc);
216
+ owner.initialized = true;
217
+ } else if (upgrading)
218
+ throw fail(
219
+ "FILE_CACHE_INVALID",
220
+ "Cache metadata changed while this Runtime was running.",
221
+ );
222
+ return doc;
223
+ }
224
+ const publicRow = (id, r) => ({
225
+ id,
226
+ purpose: r.purpose,
227
+ ...(r.purpose === "input"
228
+ ? { requestId: r.requestId, protected: r.protected }
229
+ : { orderId: r.orderId }),
230
+ fileKey: r.fileKey,
231
+ filename: r.filename,
232
+ transport:
233
+ r.sourceKind === "local"
234
+ ? "local"
235
+ : r.url.startsWith("https:")
236
+ ? "https"
237
+ : "http",
238
+ state: r.state,
239
+ bytes: r.bytes,
240
+ sha256: r.sha256 || null,
241
+ integrity:
242
+ r.state !== "available"
243
+ ? "not_verified"
244
+ : r.expectedSha256
245
+ ? "expected_digest_matched"
246
+ : "local_digest_only",
247
+ updatedAtMs: r.updatedAtMs,
248
+ error: r.error || null,
249
+ ...(r.state === "available"
250
+ ? { localPath: join(directory, id + ".bin") }
251
+ : {}),
252
+ });
253
+ async function usage(doc) {
254
+ let bytes = 0,
255
+ inputBytes = 0,
256
+ outputBytes = 0,
257
+ otherBytes = 0,
258
+ reservedBytes = 0;
259
+ const sizes = new Map();
260
+ for (const name of await readdir(directory)) {
261
+ const st = await lstat(join(directory, name));
262
+ if (!privateFile(st) || st.isSymbolicLink())
263
+ throw fail(
264
+ "FILE_CACHE_UNSAFE",
265
+ "File cache contains an unsafe entry. Inspect it before continuing.",
266
+ );
267
+ if (
268
+ name === "index.json" ||
269
+ name === "owner.lock" ||
270
+ name.startsWith(".index-")
271
+ ) {
272
+ otherBytes += st.size;
273
+ continue;
274
+ }
275
+ bytes += st.size;
276
+ sizes.set(name, st.size);
277
+ const r = doc.files[name.slice(0, 64)];
278
+ if (/^[a-f0-9]{64}\.(bin|part)$/.test(name) && r) {
279
+ if (r.purpose === "input") inputBytes += st.size;
280
+ else outputBytes += st.size;
281
+ }
282
+ }
283
+ for (const [id, r] of Object.entries(doc.files))
284
+ if (r.state === "downloading")
285
+ reservedBytes += Math.max(
286
+ 0,
287
+ (r.reservation ?? limits.singleBytes) -
288
+ (sizes.get(id + ".part") || sizes.get(id + ".bin") || 0),
289
+ );
290
+ return {
291
+ bytes,
292
+ inputBytes,
293
+ outputBytes,
294
+ unknownBytes: bytes - inputBytes - outputBytes,
295
+ metadataBytes: otherBytes,
296
+ reservedBytes,
297
+ };
298
+ }
299
+ async function verify(id, row, extension = ".bin") {
300
+ const h = await open(
301
+ join(directory, id + extension),
302
+ constants.O_RDONLY | (constants.O_NOFOLLOW || 0),
303
+ );
304
+ try {
305
+ const s = await h.stat();
306
+ if (!privateFile(s) || s.size !== row.bytes)
307
+ throw Error("Invalid cache file");
308
+ const hash = createHash("sha256");
309
+ for await (const part of h.createReadStream({ autoClose: false }))
310
+ hash.update(part);
311
+ if (hash.digest("hex") !== row.sha256)
312
+ throw Error("Invalid cache digest");
313
+ } finally {
314
+ await h.close();
315
+ }
316
+ }
317
+ function rowFor(doc, id) {
318
+ if (!hex.test(id || "") || !doc.files[id])
319
+ throw fail("FILE_NOT_FOUND", "Use a full file ID from agentpay cache list.");
320
+ return doc.files[id];
321
+ }
322
+ async function deleteRow(doc, id) {
323
+ const r = rowFor(doc, id);
324
+ if (r.purpose === "input" && r.protected)
325
+ throw fail(
326
+ "FILE_PROTECTED",
327
+ "This input is kept for request recovery and cannot be cleaned yet.",
328
+ );
329
+ if (r.state === "downloading")
330
+ throw fail("FILE_BUSY", "Wait for this transfer before removing it.");
331
+ r.state = "deleting";
332
+ r.updatedAtMs = Date.now();
333
+ await persist(doc);
334
+ await discard(join(directory, id + ".bin"));
335
+ await discard(join(directory, id + ".part"));
336
+ await sync();
337
+ r.state = "removed";
338
+ r.updatedAtMs = Date.now();
339
+ await persist(doc);
340
+ return publicRow(id, r);
341
+ }
342
+ async function transfer(input, local = false) {
343
+ const {
344
+ orderId,
345
+ requestId,
346
+ fileKey,
347
+ url,
348
+ sourcePath,
349
+ filename = "download",
350
+ sha256 = null,
351
+ } = input;
352
+ const purpose = requestId !== undefined ? "input" : "output";
353
+ if (
354
+ (purpose === "input"
355
+ ? !request.test(requestId || "")
356
+ : !order.test(orderId || "")) ||
357
+ (purpose === "input" && orderId !== undefined) ||
358
+ (local && purpose !== "input") ||
359
+ typeof fileKey !== "string" ||
360
+ !/^[A-Za-z0-9._-]{1,80}$/.test(fileKey) ||
361
+ (local
362
+ ? typeof sourcePath !== "string" ||
363
+ !isAbsolute(sourcePath) ||
364
+ sourcePath.length > 8192
365
+ : typeof url !== "string" || url.length > 8192) ||
366
+ typeof filename !== "string" ||
367
+ filename.length > 200 ||
368
+ /[\x00-\x1f\x7f/\\]/.test(filename) ||
369
+ (sha256 !== null && !hex.test(sha256))
370
+ )
371
+ throw fail(
372
+ "FILE_INPUT_INVALID",
373
+ "Supply the original scope, file key and explicit source.",
374
+ );
375
+ const identity =
376
+ purpose === "input"
377
+ ? { purpose, requestId, protected: true }
378
+ : { purpose, orderId };
379
+ const id = recordId({ ...identity, fileKey });
380
+ let reused, previousDigest, source, sourceStat;
381
+ const part = join(directory, id + ".part");
382
+ let h,
383
+ started = false;
384
+ try {
385
+ await exclusive(async () => {
386
+ const doc = await load(),
387
+ old = doc.files[id];
388
+ previousDigest = old?.sha256;
389
+ if (
390
+ old &&
391
+ (old.expectedSha256 !== sha256 ||
392
+ old.sourceKind !== (local ? "local" : "url") ||
393
+ (!local &&
394
+ old.url !== url &&
395
+ !(old.sha256 || old.expectedSha256)) ||
396
+ (local && old.sourcePath !== sourcePath))
397
+ )
398
+ throw fail(
399
+ "FILE_REFERENCE_CONFLICT",
400
+ "Keep the original file identity and digest when retrying.",
401
+ );
402
+ if (old?.state === "downloading" || old?.state === "deleting")
403
+ throw fail("FILE_BUSY", "This file is being changed. Retry shortly.");
404
+ if (old?.state === "available") {
405
+ try {
406
+ await verify(id, old);
407
+ reused = publicRow(id, old);
408
+ return;
409
+ } catch {
410
+ old.state = "failed";
411
+ old.error = "FILE_CACHE_CORRUPT";
412
+ await persist(doc);
413
+ throw fail(
414
+ "FILE_CACHE_CORRUPT",
415
+ "The saved copy changed or disappeared. Preserve the original request and inspect this file.",
416
+ );
417
+ }
418
+ }
419
+ if (old?.purpose === "input" && old.sha256)
420
+ throw fail(
421
+ "FILE_CACHE_CORRUPT",
422
+ "Original input bytes are unavailable. Keep this request for recovery; do not substitute new input.",
423
+ );
424
+ const rows = Object.values(doc.files);
425
+ if (
426
+ rows.filter((r) => r.state === "downloading").length >=
427
+ limits.concurrent
428
+ )
429
+ throw fail(
430
+ "FILE_BUSY",
431
+ "Other files are being saved. Retry shortly.",
432
+ );
433
+ if (
434
+ (!old || old.state === "removed") &&
435
+ rows.filter(
436
+ (r) =>
437
+ r.state !== "removed" &&
438
+ r.purpose === purpose &&
439
+ scope(r) === scope(identity),
440
+ ).length >= limits.filesPerResult
441
+ )
442
+ throw fail(
443
+ "FILE_COUNT_LIMIT",
444
+ "The file limit for this request or Order was reached.",
445
+ );
446
+ if (!old && rows.length >= limits.records)
447
+ throw fail(
448
+ "FILE_HISTORY_LIMIT",
449
+ "File history is full. Increase the record limit with agentpay cache settings.",
450
+ );
451
+ let reservation = limits.singleBytes;
452
+ if (local) {
453
+ source = await open(
454
+ sourcePath,
455
+ constants.O_RDONLY |
456
+ (constants.O_NOFOLLOW || 0) |
457
+ (constants.O_NONBLOCK || 0),
458
+ );
459
+ sourceStat = await source.stat();
460
+ if (!sourceStat.isFile())
461
+ throw fail(
462
+ "FILE_INPUT_INVALID",
463
+ "Select a regular file, not a directory or device.",
464
+ );
465
+ if (sourceStat.size > limits.singleBytes)
466
+ throw fail(
467
+ "FILE_TOO_LARGE",
468
+ "Input file exceeds the local size limit.",
469
+ );
470
+ reservation = sourceStat.size;
471
+ }
472
+ const used = await usage(doc);
473
+ if (used.bytes + used.reservedBytes + reservation > limits.totalBytes)
474
+ throw fail(
475
+ "FILE_CACHE_FULL",
476
+ "Cache is full. Run agentpay cache to review space and limits.",
477
+ );
478
+ await discard(part);
479
+ await discard(join(directory, id + ".bin"));
480
+ doc.files[id] = {
481
+ ...identity,
482
+ fileKey,
483
+ revision: randomUUID(),
484
+ ...(local
485
+ ? { sourceKind: "local", sourcePath }
486
+ : { sourceKind: "url", url }),
487
+ filename,
488
+ expectedSha256: sha256,
489
+ ...(previousDigest ? { sha256: previousDigest } : {}),
490
+ state: "downloading",
491
+ bytes: 0,
492
+ reservation,
493
+ updatedAtMs: Date.now(),
494
+ };
495
+ await persist(doc);
496
+ started = true;
497
+ });
498
+ if (reused) return reused;
499
+ h = await open(part, "wx", 0o600);
500
+ const hash = createHash("sha256");
501
+ let bytes = 0;
502
+ const write = async (chunk) => {
503
+ bytes += chunk.length;
504
+ if (bytes > limits.singleBytes || (local && bytes > sourceStat.size))
505
+ throw fail("FILE_TOO_LARGE", "File exceeds its reserved size.");
506
+ hash.update(chunk);
507
+ await h.writeFile(chunk);
508
+ };
509
+ if (local) {
510
+ for await (const chunk of source.createReadStream({ autoClose: false }))
511
+ await write(chunk);
512
+ const after = await source.stat();
513
+ if (
514
+ after.size !== sourceStat.size ||
515
+ after.mtimeMs !== sourceStat.mtimeMs ||
516
+ after.ctimeMs !== sourceStat.ctimeMs ||
517
+ bytes !== sourceStat.size
518
+ )
519
+ throw fail(
520
+ "FILE_SOURCE_CHANGED",
521
+ "The source changed while saving. Wait for it to finish before retrying.",
522
+ );
523
+ } else
524
+ await download(url, {
525
+ maxBytes: limits.singleBytes,
526
+ timeoutMs: limits.timeoutMs,
527
+ privateOrigins,
528
+ write,
529
+ });
530
+ const actual = hash.digest("hex");
531
+ if ((sha256 || previousDigest) && actual !== (sha256 || previousDigest))
532
+ throw fail(
533
+ "FILE_DIGEST_MISMATCH",
534
+ "File contents do not match the original digest.",
535
+ );
536
+ await h.sync();
537
+ await h.close();
538
+ h = null;
539
+ return await exclusive(async () => {
540
+ const doc = await load(),
541
+ r = doc.files[id];
542
+ Object.assign(r, { bytes, sha256: actual, updatedAtMs: Date.now() });
543
+ await persist(doc); // durable digest before publishing bytes: restart can reconcile.
544
+ await rename(part, join(directory, id + ".bin"));
545
+ await sync();
546
+ r.state = "available";
547
+ delete r.reservation;
548
+ r.error = null;
549
+ await persist(doc);
550
+ return publicRow(id, r);
551
+ });
552
+ } catch (error) {
553
+ await h?.close();
554
+ h = null;
555
+ if (started)
556
+ await exclusive(async () => {
557
+ const doc = await load(),
558
+ r = doc.files[id];
559
+ // Never discard a completed final file after an index write failure.
560
+ try {
561
+ if (r.sha256) {
562
+ await verify(id, r);
563
+ r.state = "available";
564
+ r.error = null;
565
+ delete r.reservation;
566
+ await persist(doc);
567
+ return;
568
+ }
569
+ } catch {}
570
+ await discard(part);
571
+ r.state = "failed";
572
+ r.bytes = 0;
573
+ delete r.reservation;
574
+ r.error = error.code?.startsWith("FILE_")
575
+ ? error.code
576
+ : "FILE_STORAGE_FAILED";
577
+ await persist(doc);
578
+ }).catch(() => {});
579
+ if (error.code?.startsWith("FILE_")) throw error;
580
+ throw fail(
581
+ "FILE_STORAGE_FAILED",
582
+ "Could not save this file. Check disk space and permissions; no purchase was made.",
583
+ );
584
+ } finally {
585
+ await source?.close();
586
+ }
587
+ }
588
+ const api = {
589
+ async list(orderId, { offset = 0, limit = 100 } = {}) {
590
+ return (await api.listAll({ orderId, offset, limit })).items;
591
+ },
592
+ async listAll({
593
+ purpose,
594
+ state,
595
+ orderId,
596
+ requestId,
597
+ offset = 0,
598
+ limit = 100,
599
+ } = {}) {
600
+ if (
601
+ (purpose !== undefined && !["input", "output"].includes(purpose)) ||
602
+ (state !== undefined && !states.includes(state)) ||
603
+ (orderId !== undefined && !order.test(orderId)) ||
604
+ (requestId !== undefined && !request.test(requestId)) ||
605
+ !Number.isSafeInteger(offset) ||
606
+ offset < 0 ||
607
+ !Number.isSafeInteger(limit) ||
608
+ limit < 1 ||
609
+ limit > 100
610
+ )
611
+ throw fail(
612
+ "FILE_INPUT_INVALID",
613
+ "Use valid filters and a page size from 1 to 100.",
614
+ );
615
+ return exclusive(async () => {
616
+ const doc = await load(),
617
+ rows = Object.entries(doc.files)
618
+ .filter(
619
+ ([, r]) =>
620
+ (!purpose || r.purpose === purpose) &&
621
+ (!state || r.state === state) &&
622
+ (!orderId || r.orderId === orderId) &&
623
+ (!requestId || r.requestId === requestId),
624
+ )
625
+ .sort(
626
+ (a, b) =>
627
+ b[1].updatedAtMs - a[1].updatedAtMs || a[0].localeCompare(b[0]),
628
+ );
629
+ return {
630
+ total: rows.length,
631
+ items: rows
632
+ .slice(offset, offset + limit)
633
+ .map(([id, r]) => publicRow(id, r)),
634
+ };
635
+ });
636
+ },
637
+ async summary() {
638
+ return exclusive(async () => {
639
+ const doc = await load(),
640
+ used = await usage(doc);
641
+ return {
642
+ ...used,
643
+ limits,
644
+ records: Object.keys(doc.files).length,
645
+ overLimit: used.bytes + used.reservedBytes > limits.totalBytes,
646
+ directory,
647
+ };
648
+ });
649
+ },
650
+ async details(id) {
651
+ return exclusive(async () => {
652
+ const doc = await load();
653
+ return publicRow(id, rowFor(doc, id));
654
+ });
655
+ },
656
+ async verified(id) {
657
+ return exclusive(async () => {
658
+ const doc = await load(),
659
+ r = rowFor(doc, id);
660
+ if (r.state !== "available")
661
+ throw fail(
662
+ "FILE_NOT_AVAILABLE",
663
+ "This file has no complete local copy.",
664
+ );
665
+ try {
666
+ await verify(id, r);
667
+ } catch {
668
+ throw fail(
669
+ "FILE_CACHE_CORRUPT",
670
+ "The saved bytes could not be verified.",
671
+ );
672
+ }
673
+ return publicRow(id, r);
674
+ });
675
+ },
676
+ async remove({ orderId, fileKey, confirmed }) {
677
+ if (
678
+ !order.test(orderId || "") ||
679
+ typeof fileKey !== "string" ||
680
+ !/^[A-Za-z0-9._-]{1,80}$/.test(fileKey)
681
+ )
682
+ throw fail(
683
+ "FILE_INPUT_INVALID",
684
+ "Supply the original Order ID and file key.",
685
+ );
686
+ if (confirmed !== true)
687
+ throw fail(
688
+ "FILE_CONFIRM_REQUIRED",
689
+ "Confirm removal of this local copy.",
690
+ );
691
+ return exclusive(async () =>
692
+ deleteRow(await load(), recordId({ orderId, fileKey })),
693
+ );
694
+ },
695
+ async cleanPreview() {
696
+ return exclusive(async () => {
697
+ const doc = await load();
698
+ return Object.entries(doc.files)
699
+ .filter(
700
+ ([, r]) =>
701
+ !r.protected && ["available", "failed"].includes(r.state),
702
+ )
703
+ .slice(0, 100)
704
+ .map(([id, r]) => ({
705
+ id,
706
+ generation: generation(r),
707
+ filename: r.filename,
708
+ bytes: r.bytes,
709
+ }));
710
+ });
711
+ },
712
+ async clean({ items, confirmed }) {
713
+ if (
714
+ confirmed !== true ||
715
+ !Array.isArray(items) ||
716
+ items.length > 100 ||
717
+ items.some(
718
+ (i) => !i || !hex.test(i.id || "") || !hex.test(i.generation || ""),
719
+ ) ||
720
+ new Set(items.map((i) => i.id)).size !== items.length
721
+ )
722
+ throw fail(
723
+ "FILE_CONFIRM_REQUIRED",
724
+ "Confirm the exact preview before cleanup.",
725
+ );
726
+ return exclusive(async () => {
727
+ const doc = await load();
728
+ for (const i of items) {
729
+ const r = rowFor(doc, i.id);
730
+ if (
731
+ r.protected ||
732
+ !["available", "failed"].includes(r.state) ||
733
+ generation(r) !== i.generation
734
+ )
735
+ throw fail(
736
+ "FILE_PREVIEW_CHANGED",
737
+ "Files changed since preview. Review cleanup again.",
738
+ );
739
+ }
740
+ for (const i of items) await deleteRow(doc, i.id);
741
+ return { removed: items.length };
742
+ });
743
+ },
744
+ // Internal only: caller must first verify this request's durable delivery.
745
+ async releaseInputs(requestId) {
746
+ if (!request.test(requestId || ""))
747
+ throw fail("FILE_INPUT_INVALID", "Invalid original request ID.");
748
+ return exclusive(async () => {
749
+ const doc = await load();
750
+ let changed = 0;
751
+ for (const r of Object.values(doc.files))
752
+ if (
753
+ r.purpose === "input" &&
754
+ r.requestId === requestId &&
755
+ r.protected
756
+ ) {
757
+ if (r.state === "downloading")
758
+ throw fail(
759
+ "FILE_BUSY",
760
+ "Input saving is still running. Retry release later.",
761
+ );
762
+ r.protected = false;
763
+ r.updatedAtMs = Date.now();
764
+ changed++;
765
+ }
766
+ if (changed) await persist(doc);
767
+ return { released: changed };
768
+ });
769
+ },
770
+ async settings(overrides) {
771
+ return exclusive(async () => {
772
+ await load();
773
+ if (overrides === undefined) return limits;
774
+ if (
775
+ !overrides ||
776
+ typeof overrides !== "object" ||
777
+ Array.isArray(overrides)
778
+ )
779
+ throw fail("FILE_INPUT_INVALID", "Supply a settings object.");
780
+ const next = validateCacheLimits({ ...limits, ...overrides });
781
+ await atomic(statePath + ".file-cache-settings.json", next);
782
+ limits = next;
783
+ return limits;
784
+ });
785
+ },
786
+ cache: (input) => transfer(input),
787
+ importInput: (input) => transfer(input, true),
788
+ };
789
+ return api;
790
+ }