@kingsoftcloud/agentkit-harness-node 0.0.0-stage → 0.0.1

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 (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +1 -0
  3. package/README.md +124 -2
  4. package/THIRD_PARTY_NOTICES.md +66 -0
  5. package/ZCode-LICENSE +202 -0
  6. package/dist/index.mjs +4871 -0
  7. package/dist/native-command-plan.LICENSE.txt +221 -0
  8. package/dist/native-command-plan.json +16 -0
  9. package/dist/native-command-plan.mjs +8 -0
  10. package/dist/rpc-entry.mjs +5682 -0
  11. package/package.json +169 -4
  12. package/src/native/a2a-receipt-source.mjs +19 -0
  13. package/src/native/a2a-tools.mjs +517 -0
  14. package/src/native/activity-status.mjs +30 -0
  15. package/src/native/artifact-codec.mjs +481 -0
  16. package/src/native/artifact-store.mjs +1162 -0
  17. package/src/native/assistant-stream.mjs +76 -0
  18. package/src/native/background-command-port.mjs +95 -0
  19. package/src/native/browser-action-observation.mjs +43 -0
  20. package/src/native/browser-availability.mjs +22 -0
  21. package/src/native/browser-canvas-metrics.mjs +71 -0
  22. package/src/native/browser-dom.mjs +193 -0
  23. package/src/native/browser-playwright.mjs +1249 -0
  24. package/src/native/browser-vision.mjs +369 -0
  25. package/src/native/browser-workspace-policy.mjs +22 -0
  26. package/src/native/browser-workspace-protocol.mjs +95 -0
  27. package/src/native/browser-workspace-server.mjs +164 -0
  28. package/src/native/browser-workspace.mjs +355 -0
  29. package/src/native/captured-source.mjs +3 -0
  30. package/src/native/child-budget-allocation.mjs +87 -0
  31. package/src/native/child-capabilities.mjs +35 -0
  32. package/src/native/child-command-resource.mjs +20 -0
  33. package/src/native/child-history.mjs +188 -0
  34. package/src/native/child-merge.mjs +98 -0
  35. package/src/native/child-model-surface.mjs +19 -0
  36. package/src/native/child-observation.mjs +100 -0
  37. package/src/native/child-owner.mjs +620 -0
  38. package/src/native/child-publication.mjs +76 -0
  39. package/src/native/child-readers.mjs +366 -0
  40. package/src/native/child-tools.mjs +46 -0
  41. package/src/native/child-workspace-binding.mjs +29 -0
  42. package/src/native/child-workspace-tools.mjs +1 -0
  43. package/src/native/child-workspaces.mjs +655 -0
  44. package/src/native/children.mjs +365 -0
  45. package/src/native/command-plan-worker.mjs +14 -0
  46. package/src/native/command-plan.mjs +31 -0
  47. package/src/native/command-progress.mjs +64 -0
  48. package/src/native/command-shell.mjs +9 -0
  49. package/src/native/command-workspace-observation.mjs +36 -0
  50. package/src/native/component-tools.mjs +280 -0
  51. package/src/native/context-counter.mjs +133 -0
  52. package/src/native/context-markers.mjs +51 -0
  53. package/src/native/context-profile.mjs +72 -0
  54. package/src/native/context-status.mjs +27 -0
  55. package/src/native/conversation-command-control.mjs +133 -0
  56. package/src/native/delivery-timeline.mjs +39 -0
  57. package/src/native/dsh-background-ownership.mjs +102 -0
  58. package/src/native/dsh-managed-ownership.mjs +23 -0
  59. package/src/native/dsh-turn-contract.mjs +3 -0
  60. package/src/native/dsh-turn-ownership.mjs +54 -0
  61. package/src/native/dsh-turn-runtime.mjs +498 -0
  62. package/src/native/e2b-background-supervisor.mjs +347 -0
  63. package/src/native/e2b-background.mjs +368 -0
  64. package/src/native/e2b-command-state.mjs +40 -0
  65. package/src/native/e2b-command-tools.mjs +486 -0
  66. package/src/native/e2b-sandbox-owner.mjs +227 -0
  67. package/src/native/hook-executor.mjs +249 -0
  68. package/src/native/instruction-assembly.mjs +60 -0
  69. package/src/native/instruction-context.mjs +54 -0
  70. package/src/native/local-background-supervisor.mjs +108 -0
  71. package/src/native/local-background.mjs +207 -0
  72. package/src/native/local-command-evidence.mjs +57 -0
  73. package/src/native/local-command-policy-worker.mjs +197 -0
  74. package/src/native/local-command-policy.mjs +46 -0
  75. package/src/native/local-command-state.mjs +177 -0
  76. package/src/native/local-command-tools.mjs +302 -0
  77. package/src/native/local-execution-profile.mjs +36 -0
  78. package/src/native/local-output-policy.mjs +92 -0
  79. package/src/native/local-process-bin/darwin-arm64/launcher +0 -0
  80. package/src/native/local-process-bin/darwin-x64/launcher +0 -0
  81. package/src/native/local-process-bin/linux-arm64/launcher +0 -0
  82. package/src/native/local-process-bin/linux-x64/launcher +0 -0
  83. package/src/native/local-process-launcher.c +183 -0
  84. package/src/native/local-process-licenses/GCC-COPYING.RUNTIME +72 -0
  85. package/src/native/local-process-licenses/GCC-COPYING3 +674 -0
  86. package/src/native/local-process-licenses/NOTICE +21 -0
  87. package/src/native/local-process-licenses/musl-COPYRIGHT +193 -0
  88. package/src/native/local-process-manifest.json +32 -0
  89. package/src/native/local-process.mjs +246 -0
  90. package/src/native/local-resource-profile.mjs +32 -0
  91. package/src/native/memory-candidate.mjs +121 -0
  92. package/src/native/memory-extraction-run.mjs +60 -0
  93. package/src/native/memory-extraction-tools.mjs +116 -0
  94. package/src/native/memory-recall.mjs +65 -0
  95. package/src/native/memory-store.mjs +334 -0
  96. package/src/native/memory-tools.mjs +329 -0
  97. package/src/native/model-anthropic-cache.mjs +49 -0
  98. package/src/native/model-anthropic-tool-search.mjs +85 -0
  99. package/src/native/model-anthropic.mjs +753 -0
  100. package/src/native/model-chat-usage.mjs +24 -0
  101. package/src/native/model-inline-reasoning.mjs +111 -0
  102. package/src/native/model-media.mjs +174 -0
  103. package/src/native/model-normalize.mjs +228 -0
  104. package/src/native/model-openai-chat.mjs +561 -0
  105. package/src/native/model-port.mjs +361 -0
  106. package/src/native/model-provider-compat.mjs +129 -0
  107. package/src/native/model-replay.mjs +153 -0
  108. package/src/native/model-request.mjs +15 -0
  109. package/src/native/model-responses.mjs +1205 -0
  110. package/src/native/model-sse.mjs +171 -0
  111. package/src/native/model-surface-host.mjs +27 -0
  112. package/src/native/model-tool-search-policy.mjs +16 -0
  113. package/src/native/model-transport-observation.mjs +27 -0
  114. package/src/native/owned-child.mjs +114 -0
  115. package/src/native/owned-command.mjs +106 -0
  116. package/src/native/python-syntax-worker.mjs +60 -0
  117. package/src/native/python-syntax.mjs +50 -0
  118. package/src/native/read-interruption.mjs +16 -0
  119. package/src/native/result-stream.mjs +63 -0
  120. package/src/native/ruff-ast.mjs +95 -0
  121. package/src/native/run-observation.mjs +128 -0
  122. package/src/native/session-delivery-journal.mjs +96 -0
  123. package/src/native/session-delivery.mjs +434 -0
  124. package/src/native/shell-analysis.mjs +50 -0
  125. package/src/native/skill-disclosure.mjs +404 -0
  126. package/src/native/skill-events.mjs +164 -0
  127. package/src/native/skill-tools.mjs +813 -0
  128. package/src/native/source-page.mjs +73 -0
  129. package/src/native/sqlite-session-inbox.mjs +42 -0
  130. package/src/native/sqlite-store.mjs +617 -0
  131. package/src/native/tool-authority.mjs +99 -0
  132. package/src/native/tool-guidance.mjs +12 -0
  133. package/src/native/tool-observation.mjs +75 -0
  134. package/src/native/tool-states.mjs +14 -0
  135. package/src/native/verification-authority.mjs +37 -0
  136. package/src/native/web-tools.mjs +643 -0
  137. package/src/native/workspace-checkpoints.mjs +312 -0
  138. package/src/native/workspace-context.mjs +61 -0
  139. package/src/native/workspace-display.mjs +39 -0
  140. package/src/native/workspace-edit-match.mjs +19 -0
  141. package/src/native/workspace-edit-worker.mjs +15 -0
  142. package/src/native/workspace-editing.mjs +447 -0
  143. package/src/native/workspace-image.mjs +72 -0
  144. package/src/native/workspace-instructions.mjs +107 -0
  145. package/src/native/workspace-mutation-lock.mjs +74 -0
  146. package/src/native/workspace-patch-worker.mjs +17 -0
  147. package/src/native/workspace-patch.mjs +17 -0
  148. package/src/native/workspace-paths.mjs +225 -0
  149. package/src/native/workspace-state.mjs +272 -0
  150. package/src/native/workspace-subject-observer.mjs +84 -0
  151. package/src/native/workspace-tools.mjs +1740 -0
@@ -0,0 +1,1162 @@
1
+ /**
2
+ * Native Node artifact storage adapter.
3
+ *
4
+ * Exports {@link createNativeArtifactStore}, a factory that binds one
5
+ * trusted {@link RunScope} to a directory and returns an object that
6
+ * structurally satisfies the core ArtifactPort (write/read/search) plus a
7
+ * usageBytes helper for later persistent budget reconciliation.
8
+ *
9
+ * Design contract (see contracts.mts ArtifactPort / ArtifactRef /
10
+ * PortContext). This stage covers durable storage, content integrity,
11
+ * pagination and current-run search only. Core overflow scheduling, the
12
+ * ArtifactRead retention tool, available_artifacts permissions, cross-turn
13
+ * inheritance and the full context page-budget are wired by Codex later; do
14
+ * not assume they exist here.
15
+ *
16
+ * Cross-process publication discipline: every write to a run directory is
17
+ * serialized by a per-run SQLite database acquired with BEGIN IMMEDIATE (see
18
+ * the lock section below), so two writers across separate processes/instances
19
+ * never publish to the same run concurrently. Under the lock the writer (a)
20
+ * reconciles an existing sealed record for the identity (idempotent /
21
+ * conflict), else (b) writes a pending-intent sidecar pinning the *complete*
22
+ * write identity (content digest + size + mediaType + metadata) before
23
+ * publishing content, then (c) enforces the per-run byte quota atomically,
24
+ * (d) publishes content, (e) seals with the meta sidecar, (f) deletes the
25
+ * intent. A crash leaves an orphan content + intent pair; the next
26
+ * same-identity write reconciles it against the locked intent (rejecting
27
+ * changed type/metadata). A crash after sealing leaves a sealed artifact the
28
+ * caller may not have received, which usageBytes still counts. The lock is
29
+ * owned by the holder's LIVE database connection: when the owning process
30
+ * dies the OS reclaims its file descriptors and SQLite drops the write lock
31
+ * automatically, so there is NO stale-lock takeover and NO window in which
32
+ * two writers both believe they hold the lock — the next writer simply
33
+ * acquires fresh and finishes any interrupted publication under its own
34
+ * transaction.
35
+ */
36
+
37
+ import { promises as fs, constants as fsConstants, realpathSync } from 'node:fs';
38
+ const O_NOFOLLOW = fsConstants.O_NOFOLLOW;
39
+ const O_RDONLY = fsConstants.O_RDONLY;
40
+ import { dirname, join, resolve, relative, isAbsolute, sep } from 'node:path';
41
+ import { hostname } from 'node:os';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { DatabaseSync } from 'node:sqlite';
44
+ import { parseNativeJson } from '@kingsoftcloud/agentkit-harness-core/model-json';
45
+ import {
46
+ assertNonEmptyString, buildArtifactUri, canonicalStringify, decodeUtf8Fatal,
47
+ encodeUtf8, identityDigest, jsonEquals, parseArtifactUri, runDigest,
48
+ safeJsonParse, scopeDigest, scopeEquals, sha256Hex, sliceByCodePoints,
49
+ tempNonce, validateMediaType, validateMetadataObject, validateRef,
50
+ validateScope, validateBytesOption, validateLimitOption, validateDurableRecord,
51
+ } from './artifact-codec.mjs';
52
+
53
+ const DEFAULT_MAX_READ_CHARS = 32000;
54
+ const DEFAULT_MAX_SEARCH_MATCHES = 16;
55
+ const DEFAULT_MAX_QUERY_CHARS = 1024;
56
+ const DEFAULT_MAX_SCAN_BYTES = 1024 * 1024;
57
+ const DEFAULT_MAX_ITEM_BYTES = 64 * 1024 * 1024;
58
+ const DEFAULT_MAX_TOTAL_BYTES = 512 * 1024 * 1024;
59
+
60
+ const CONTENT_SUFFIX = '.content';
61
+ const META_SUFFIX = '.meta.json';
62
+ const INTENT_SUFFIX = '.intent.json';
63
+ const LOCK_NAME = '.lock';
64
+ const TMP_INFIX = '.tmp.';
65
+ const HEX64 = /^[0-9a-f]{64}$/;
66
+ const HOST = hostname();
67
+
68
+ // Async retry interval while waiting for a busy SQLite lock. This is never a
69
+ // synchronous wait (see acquireRunLock): busy_timeout is 0 so a contending
70
+ // taker throws immediately and retries via setTimeout, leaving the event loop
71
+ // free for a same-process owner's pending filesystem await to proceed.
72
+ const LOCK_POLL_MS = 50;
73
+ // Safety bound only. A crashed holder releases immediately — the OS reclaims the
74
+ // connection's file descriptors and SQLite drops the write lock — so this bounds
75
+ // only the wait for a live but slow holder. It is NOT a staleness threshold and
76
+ // there is no stale-lock takeover.
77
+ const LOCK_TIMEOUT_MS = 120000;
78
+ // Safety bound for the LIVE release commit only. COMMIT may briefly get
79
+ // SQLITE_BUSY when a concurrent taker is opening the lock database (a transient
80
+ // SHARED lock during its schema read); the transient reader releases in
81
+ // milliseconds, so this only bounds a pathologically stuck contender. Process
82
+ // death never reaches release() — the OS reclaims the fd and SQLite drops the
83
+ // lock — so this does not affect crash release.
84
+ const LOCK_RELEASE_TIMEOUT_MS = 10000;
85
+ const SQLITE_BUSY = 5;
86
+
87
+ // SQLite auxiliary files that may be created beside the lock database. The
88
+ // rollback journal (-journal) is created and removed per transaction in the
89
+ // default journal mode; -wal/-shm exist only under WAL (never enabled here).
90
+ // An attacker who can write the lock directory could pre-create any of them as
91
+ // a symlink/hardlink trap so SQLite writes journal contents outside the root,
92
+ // so every path SQLite may touch is validated before the database is opened.
93
+ const SQLITE_AUX_SUFFIXES = ['-journal', '-wal', '-shm'];
94
+
95
+ function err(code, message) {
96
+ return Object.assign(new Error(message || code), { code });
97
+ }
98
+
99
+ function checkSignal(signal) {
100
+ if (signal && signal.aborted) {
101
+ if (signal.reason instanceof Error) throw signal.reason;
102
+ throw err('artifact_aborted', 'artifact_aborted');
103
+ }
104
+ }
105
+
106
+ /** Test-only crash hook: exit at a controlled publish position. Inert when
107
+ * unset OR when no point is supplied (so the null-hook recovery path never
108
+ * trips the hook). */
109
+ function crashHook(point) {
110
+ if (point !== undefined && point !== null && process.env.NATIVE_ARTIFACT_CRASH_AFTER === point) {
111
+ process.exit(0);
112
+ }
113
+ }
114
+
115
+ /** Test-only hold hook: pause at a controlled publish position while still
116
+ * holding the run lock, so a deterministic boundary-scheduling test can prove a
117
+ * concurrent taker waits (SQLITE_BUSY retry) rather than split-braining. Inert
118
+ * unless NATIVE_ARTIFACT_HOLD_AT matches the point AND NATIVE_ARTIFACT_HOLD_MS
119
+ * is a positive number. Never affects production (env unset). */
120
+ async function holdHook(point) {
121
+ if (point !== undefined && point !== null && process.env.NATIVE_ARTIFACT_HOLD_AT === point) {
122
+ const ms = Number(process.env.NATIVE_ARTIFACT_HOLD_MS);
123
+ if (Number.isFinite(ms) && ms > 0) await new Promise((r) => setTimeout(r, ms));
124
+ }
125
+ }
126
+
127
+ /** Test-only signal: atomically drop a sentinel file once the run lock is held,
128
+ * so a boundary-scheduling test can deterministically start a competing taker
129
+ * AFTER the holder has acquired (no spawn-timing race). Inert unless
130
+ * NATIVE_ARTIFACT_SIGNAL_LOCK_FILE is set. Never affects production. */
131
+ async function signalLockAcquired() {
132
+ const f = process.env.NATIVE_ARTIFACT_SIGNAL_LOCK_FILE;
133
+ if (!f) return;
134
+ const tmp = f + '.tmp.' + tempNonce();
135
+ let fh;
136
+ try {
137
+ fh = await fs.open(tmp, 'wx', 0o600);
138
+ await fh.writeFile(String(process.pid));
139
+ await fh.sync();
140
+ } finally {
141
+ try { if (fh) await fh.close(); } catch { /* ignore */ }
142
+ }
143
+ try {
144
+ await fs.link(tmp, f);
145
+ } catch (error) {
146
+ if (error.code !== 'EEXIST') throw error;
147
+ } finally {
148
+ await safeUnlink(tmp);
149
+ }
150
+ }
151
+
152
+ /** Test-only TOCTOU hook: after the static pre-open validation passes and before
153
+ * the database is opened, replace the lock DB file at `path` with a symlink to
154
+ * NATIVE_ARTIFACT_SWAP_LOCK_DB_TO, deterministically simulating a concurrent swap
155
+ * of the path between the existence check and the open. This lets a regression
156
+ * prove the post-open realpath guard in openAndAcquireLock rejects the escape
157
+ * BEFORE the lock_owner schema is written outside the root. Inert unless the env
158
+ * var is set. Never affects production. */
159
+ async function swapHookForLockDb(path) {
160
+ const target = process.env.NATIVE_ARTIFACT_SWAP_LOCK_DB_TO;
161
+ if (!target) return;
162
+ try { await fs.unlink(path); } catch { /* may not exist yet */ }
163
+ await fs.symlink(target, path);
164
+ }
165
+
166
+ function nowIso() {
167
+ return new Date().toISOString();
168
+ }
169
+
170
+ /**
171
+ * Platform-independent containment: target is inside root when the relative
172
+ * path from root to target is empty, or a relative path that does not start
173
+ * with '..' and is not absolute. This correctly rejects cross-volume
174
+ * escapes (relative becomes '../..' or absolute on Windows) and does not
175
+ * depend on the path separator. Both inputs must be normalized (we pass
176
+ * realpath-resolved absolute paths). On case-insensitive filesystems the
177
+ * resolved paths reflect the filesystem's own case; on case-sensitive ones
178
+ * a different case resolves outside, as expected.
179
+ */
180
+ function isWithin(target, root) {
181
+ if (typeof target !== 'string' || typeof root !== 'string') return false;
182
+ const rel = relative(root, target);
183
+ if (rel === '') return true; // target === root
184
+ if (isAbsolute(rel)) return false; // cross-volume / different drive
185
+ if (rel === '..' || rel.startsWith('..' + sep) || rel.startsWith('..' + '/')) return false;
186
+ // A relative path with no '..' leading segment and not absolute is inside.
187
+ // Also reject any '..' segment anywhere (e.g. 'a/../../b').
188
+ const segs = rel.split(/[\\/]/);
189
+ if (segs.includes('..')) return false;
190
+ return true;
191
+ }
192
+
193
+ function assertWithin(target, root) {
194
+ if (!isWithin(target, root)) throw err('artifact_path_escape', 'artifact_path_escape');
195
+ }
196
+
197
+ /** Resolve the artifacts root, pinning it. Creates {dir}/artifacts and
198
+ * returns both the logical root ({dir}) and the realpath of the artifacts
199
+ * directory (used for containment checks). Run directories live under
200
+ * {root}/artifacts/{scopeDigest}/{runDigest} — a single artifacts layer. */
201
+ async function initRoot(directory) {
202
+ if (typeof directory !== 'string' || directory.length === 0) {
203
+ throw err('invalid_artifact_directory', 'invalid_artifact_directory');
204
+ }
205
+ const resolved = directory.startsWith('file:') ? fileURLToPath(new URL(directory)) : resolve(directory);
206
+ const artifactsDir = join(resolved, 'artifacts');
207
+ await fs.mkdir(artifactsDir, { recursive: true });
208
+ const rootReal = await fs.realpath(artifactsDir);
209
+ return { root: resolved, rootReal };
210
+ }
211
+
212
+ /** lstat; null on ENOENT. Throws on a symlink final component (no-follow). */
213
+ async function lstatNoFollow(absPath) {
214
+ let st;
215
+ try {
216
+ st = await fs.lstat(absPath);
217
+ } catch (error) {
218
+ if (error.code === 'ENOENT') return null;
219
+ throw error;
220
+ }
221
+ if (st.isSymbolicLink()) throw err('artifact_symlink_rejected', 'artifact_symlink_rejected');
222
+ return st;
223
+ }
224
+
225
+ /**
226
+ * Open an existing regular file with O_NOFOLLOW and read it, after a final
227
+ * no-follow stat that rejects symlinks and nonregular files. O_NOFOLLOW on
228
+ * the final component is enforced by the kernel where supported; we add a
229
+ * userspace lstat guard too. Returns a Buffer.
230
+ */
231
+ async function safeReadFile(absPath, root) {
232
+ const st = await lstatNoFollow(absPath);
233
+ if (!st) throw err('artifact_content_missing', 'artifact_content_missing');
234
+ if (!st.isFile()) throw err('artifact_record_corrupt', 'artifact_record_corrupt');
235
+ assertWithin(await fs.realpath(absPath), root);
236
+ let fh;
237
+ try {
238
+ // O_NOFOLLOW makes the kernel reject a symlink final component where
239
+ // supported (Linux). The userspace lstat guard above is the portable
240
+ // backstop; O_NOFOLLOW hardens the open against a TOCTOU swap.
241
+ fh = await fs.open(absPath, O_RDONLY | O_NOFOLLOW);
242
+ } catch (error) {
243
+ // EINVAL/ENOTSUP: the platform does not accept numeric flags or does
244
+ // not implement O_NOFOLLOW. Fall back to a plain read open; the lstat
245
+ // guard already excluded a symlink.
246
+ if (error.code === 'EINVAL' || error.code === 'ENOTSUP') {
247
+ fh = await fs.open(absPath, 'r');
248
+ } else {
249
+ throw error;
250
+ }
251
+ }
252
+ try {
253
+ return fh.readFile();
254
+ } finally {
255
+ await fh.close();
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Read + strictly validate a durable sidecar record. Structural / parse
261
+ * failures (duplicate keys, wrong types, invalid uri shape, bad metadata)
262
+ * throw invalid_artifact_record. Integrity / path-mismatch failures are
263
+ * asserted separately by the caller as artifact_record_corrupt.
264
+ */
265
+ async function safeReadMeta(absPath, root) {
266
+ const bytes = await safeReadFile(absPath, root);
267
+ return validateDurableRecord(bytes.toString('utf8'), 'invalid_artifact_record');
268
+ }
269
+ /** Boolean exists check (no-follow, contained). */
270
+ async function safeExists(absPath, root) {
271
+ const st = await lstatNoFollow(absPath);
272
+ if (!st) return false;
273
+ if (!st.isFile()) throw err('artifact_record_corrupt', 'artifact_record_corrupt');
274
+ assertWithin(await fs.realpath(absPath), root);
275
+ return true;
276
+ }
277
+
278
+ /**
279
+ * Create a directory inside the pinned root. We mkdir the deepest existing
280
+ * ancestor first, verify it is contained, then mkdir the rest; this avoids
281
+ * creating directories through an ancestor symlink that would escape the
282
+ * root. The final realpath is rechecked at the end.
283
+ */
284
+ async function ensureDirSafe(absPath, root) {
285
+ // realpath the longest existing prefix to detect an ancestor symlink that
286
+ // already escapes; then mkdir the remainder and recheck.
287
+ let probe = absPath;
288
+ const checked = [];
289
+ while (true) {
290
+ try {
291
+ const real = await fs.realpath(probe);
292
+ assertWithin(real, root);
293
+ break;
294
+ } catch (error) {
295
+ if (error.code !== 'ENOENT') throw error;
296
+ checked.unshift(probe);
297
+ const parent = dirname(probe);
298
+ if (parent === probe) {
299
+ throw err('artifact_path_escape', 'artifact_path_escape');
300
+ }
301
+ probe = parent;
302
+ }
303
+ }
304
+ for (const p of checked) {
305
+ try {
306
+ await fs.mkdir(p, { recursive: false });
307
+ } catch (error) {
308
+ // Another process created the directory between our realpath probe and
309
+ // this mkdir; tolerate it. Anything else (including a non-directory
310
+ // file at this path) must fail closed.
311
+ if (error.code !== 'EEXIST') throw error;
312
+ const st = await lstatNoFollow(p);
313
+ if (!st || !st.isDirectory()) throw err('artifact_path_escape', 'artifact_path_escape');
314
+ }
315
+ }
316
+ const real = await fs.realpath(absPath);
317
+ assertWithin(real, root);
318
+ }
319
+
320
+ async function fsyncDir(absPath) {
321
+ let fh;
322
+ try {
323
+ fh = await fs.open(absPath, 'r');
324
+ await fh.sync();
325
+ } catch (error) {
326
+ if (error.code !== 'EISDIR' && error.code !== 'EINVAL') {
327
+ try { if (fh) await fh.close(); } catch { /* ignore */ }
328
+ throw error;
329
+ }
330
+ } finally {
331
+ try { if (fh) await fh.close(); } catch { /* ignore */ }
332
+ }
333
+ }
334
+
335
+ async function safeUnlink(absPath) {
336
+ try { await fs.unlink(absPath); } catch (error) { if (error.code !== 'ENOENT') throw error; }
337
+ }
338
+
339
+ /**
340
+ * Atomically publish bytes to finalPath via a temp file + fsync + link.
341
+ * The temp is created with O_EXCL and 0o600 inside the same directory as the
342
+ * target. link() is non-overwriting (EEXIST = a concurrent racer already
343
+ * published), so concurrent same-identity writers cannot both win. The temp
344
+ * is always removed on the publishing path.
345
+ */
346
+ async function atomicPublish(finalPath, bytes, root, hook) {
347
+ const dir = dirname(finalPath);
348
+ const tmpPath = finalPath + TMP_INFIX + tempNonce();
349
+ let fh;
350
+ try {
351
+ fh = await fs.open(tmpPath, 'wx', 0o600);
352
+ } catch (error) {
353
+ if (error.code === 'EEXIST') throw err('artifact_temp_collision', 'artifact_temp_collision');
354
+ throw error;
355
+ }
356
+ try {
357
+ await fh.writeFile(bytes);
358
+ await fh.sync();
359
+ } finally {
360
+ await fh.close();
361
+ }
362
+ crashHook(hook?.afterTmp);
363
+ try {
364
+ await fs.link(tmpPath, finalPath);
365
+ } catch (error) {
366
+ await safeUnlink(tmpPath);
367
+ if (error.code === 'EEXIST') return 'exists';
368
+ throw error;
369
+ }
370
+ await fsyncDir(dir);
371
+ crashHook(hook?.afterLink);
372
+ await safeUnlink(tmpPath);
373
+ return 'published';
374
+ }
375
+
376
+ /** Parse run-directory entries into sealed / orphan / intent identities. */
377
+ async function scanRun(dir, root) {
378
+ let names;
379
+ try {
380
+ names = await fs.readdir(dir);
381
+ } catch (error) {
382
+ if (error.code === 'ENOENT') return { byId: new Map(), names: [] };
383
+ throw error;
384
+ }
385
+ assertWithin(await fs.realpath(dir), root);
386
+ const byId = new Map();
387
+ for (const name of names) {
388
+ if (name === LOCK_NAME) continue;
389
+ if (name.includes(TMP_INFIX)) continue; // never treat temps as published
390
+ let id = null;
391
+ let kind = null;
392
+ if (name.endsWith(CONTENT_SUFFIX)) {
393
+ id = name.slice(0, name.length - CONTENT_SUFFIX.length);
394
+ kind = 'content';
395
+ } else if (name.endsWith(META_SUFFIX)) {
396
+ id = name.slice(0, name.length - META_SUFFIX.length);
397
+ kind = 'meta';
398
+ } else if (name.endsWith(INTENT_SUFFIX)) {
399
+ id = name.slice(0, name.length - INTENT_SUFFIX.length);
400
+ kind = 'intent';
401
+ } else {
402
+ continue; // unknown file: ignore, never delete
403
+ }
404
+ if (!HEX64.test(id)) continue; // not one of ours
405
+ let entry = byId.get(id);
406
+ if (!entry) { entry = { id, content: null, meta: null, intent: null }; byId.set(id, entry); }
407
+ entry[kind] = join(dir, name);
408
+ }
409
+ return { byId, names };
410
+ }
411
+
412
+ /**
413
+ * Compute durable usage for a run. Sealed artifacts (content + meta that
414
+ * match) count meta.sizeBytes; orphan content with a matching intent also
415
+ * counts (published, result not returned); orphan content without intent
416
+ * counts the content file length. Each identity is counted exactly once.
417
+ * Corruption (meta without content, digest/size mismatch, unparseable or
418
+ * invalid meta/intent, a meta whose uri does not match its directory path)
419
+ * throws fail-closed so the budget never silently shrinks.
420
+ */
421
+ async function computeUsage(dir, root, expectedScopeDigest, expectedRunDigest) {
422
+ const { byId } = await scanRun(dir, root);
423
+ let total = 0;
424
+ for (const entry of byId.values()) {
425
+ if (entry.meta) {
426
+ const meta = await safeReadMeta(entry.meta, root);
427
+ assertRecordMatchesPath(meta, expectedScopeDigest, expectedRunDigest, entry.id, root);
428
+ if (!entry.content) throw err('artifact_record_corrupt', 'artifact_record_corrupt');
429
+ const content = await safeReadFile(entry.content, root);
430
+ if (sha256Hex(content) !== meta.digest.slice(7) || content.length !== meta.sizeBytes) {
431
+ throw err('artifact_record_corrupt', 'artifact_record_corrupt');
432
+ }
433
+ total += meta.sizeBytes;
434
+ } else if (entry.content) {
435
+ const st = await lstatNoFollow(entry.content);
436
+ if (!st) continue;
437
+ if (!st.isFile()) throw err('artifact_record_corrupt', 'artifact_record_corrupt');
438
+ assertWithin(await fs.realpath(entry.content), root);
439
+ // An orphan with an intent pins a size; trust the intent size if the
440
+ // digest matches the content, else charge the actual file length.
441
+ let charged = st.size;
442
+ if (entry.intent) {
443
+ const intent = await safeReadMeta(entry.intent, root);
444
+ assertRecordMatchesPath(intent, expectedScopeDigest, expectedRunDigest, entry.id, root);
445
+ if (sha256Hex(await safeReadFile(entry.content, root)) === intent.digest.slice(7)) {
446
+ charged = intent.sizeBytes;
447
+ }
448
+ }
449
+ total += charged;
450
+ }
451
+ }
452
+ return total;
453
+ }
454
+
455
+ /**
456
+ * Assert a stored record's uri scope/run/identity match the on-disk directory
457
+ * and the expected values derived from the request path. Prevents a tampered
458
+ * same-scope uri (pointing at another run or identity) from being admitted.
459
+ */
460
+ function assertRecordMatchesPath(record, expectedScopeDigest, expectedRunDigest, expectedIdentity, root) {
461
+ const parsed = parseArtifactUri(record.uri);
462
+ if (parsed.scopeDigest !== expectedScopeDigest) {
463
+ throw err('artifact_record_corrupt', 'artifact_record_corrupt');
464
+ }
465
+ if (parsed.runDigest !== expectedRunDigest) {
466
+ throw err('artifact_record_corrupt', 'artifact_record_corrupt');
467
+ }
468
+ if (parsed.identityDigest !== expectedIdentity) {
469
+ throw err('artifact_record_corrupt', 'artifact_record_corrupt');
470
+ }
471
+ void root;
472
+ }
473
+
474
+ /** Remove stale temp files (older than maxAgeMs) inside a run directory. */
475
+ async function cleanupStaleTempsIn(dir, root, maxAgeMs) {
476
+ let names;
477
+ try {
478
+ names = await fs.readdir(dir);
479
+ } catch (error) {
480
+ if (error.code === 'ENOENT') return 0;
481
+ throw error;
482
+ }
483
+ assertWithin(await fs.realpath(dir), root);
484
+ const now = Date.now();
485
+ let removed = 0;
486
+ for (const name of names) {
487
+ if (!name.includes(TMP_INFIX)) continue;
488
+ const abs = join(dir, name);
489
+ const st = await lstatNoFollow(abs);
490
+ if (!st) continue;
491
+ if (st.isSymbolicLink()) continue;
492
+ if (now - st.mtimeMs < maxAgeMs) continue;
493
+ await safeUnlink(abs);
494
+ removed += 1;
495
+ }
496
+ return removed;
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // Cross-process per-run lock (SQLite transaction)
501
+ //
502
+ // A per-run SQLite database at {root}/artifacts/.locks/{scopeDigest}/{runDigest}.sqlite
503
+ // is the real exclusion primitive. Acquisition is `BEGIN IMMEDIATE`, which the
504
+ // SQLite library serializes atomically across processes AND connections: the
505
+ // first taker gets a RESERVED write lock, every concurrent taker gets SQLITE_BUSY.
506
+ // The lock is owned by the LIVE database connection — it is held across the
507
+ // writer's filesystem operations and released only when that connection commits
508
+ // and closes. When the owning process dies (crash, kill, abort mid-write) the OS
509
+ // reclaims the file descriptors and SQLite releases the lock automatically, so
510
+ // there is no stale lock to "take over" and no split-brain window in which two
511
+ // writers both believe they hold the lock. A would-be taker that finds the lock
512
+ // busy retries asynchronously; with busy_timeout = 0 the retry never blocks the
513
+ // event loop synchronously, so a same-process owner with a pending filesystem
514
+ // await can still make progress (no deadlock).
515
+ //
516
+ // Owner identity (a per-acquire token, pid, host, bornAt) is recorded in a
517
+ // single-row table inside the same transaction, so release is owner-bound: only
518
+ // the holder's own connection can delete its row and commit — release is never
519
+ // an unconditional unlink of a shared path. An uncommitted owner row left by a
520
+ // crash is rolled back by SQLite journal recovery on the next open, so it never
521
+ // persists as a stale owner; a partial database left by a creator crash is
522
+ // likewise recovered by the rollback journal, so a missing/partial lock never
523
+ // permanently prevents recovery.
524
+ //
525
+ // PATH CONTAINMENT AND TRUSTED-DIRECTORY ASSUMPTION
526
+ // The lock DB and its SQLite auxiliary files (-journal/-wal/-shm) live under
527
+ // {root}/artifacts/.locks/{scopeDigest}/, whose path components are hex digests
528
+ // (no path separators, no '..'); the only writer is this store. SQLite's
529
+ // DatabaseSync opens by PATH and follows symlinks, and node:sqlite exposes no
530
+ // O_NOFOLLOW option, so a symlinked final component or a symlinked ancestor
531
+ // would otherwise redirect the lock_owner schema write (and the journal) to a
532
+ // file outside the pinned root — the demonstrated vulnerability. We defend in
533
+ // layers, and we do NOT claim a race-free guarantee the API cannot deliver:
534
+ // 1. ensureDirSafe: the lock directory is created contained, rejecting a
535
+ // statically-symlinked ancestor at creation time.
536
+ // 2. validateLockDbSurface (pre-open): lstat every path SQLite may touch (the
537
+ // DB plus -journal/-wal/-shm) — reject a symlink final component, a
538
+ // non-regular file, a hardlink (nlink > 1), or a realpath escaping the
539
+ // root; re-resolve the lock directory's realpath. This defeats a
540
+ // statically-placed trap. It is an existence check, NOT a race-free guard.
541
+ // 3. openAndAcquireLock post-open guard: AFTER `new DatabaseSync(path)`,
542
+ // re-resolve the CURRENT path string with realpathSync(path) and assert
543
+ // it is still within the root; if not, close BEFORE BEGIN IMMEDIATE so the
544
+ // lock_owner schema is never written outside the root. NOTE: this
545
+ // re-resolves the path STRING, not the file descriptor SQLite actually
546
+ // opened — node:sqlite exposes no way to read back that fd's identity, so
547
+ // this guard does NOT prove what DatabaseSync opened, and it does NOT
548
+ // close arbitrary concurrent swaps. It is a bounded single-swap check: it
549
+ // catches a swap whose effect is visible at the path string by the time
550
+ // realpathSync runs (a swap at or before that instant). A swap that
551
+ // happens AFTER realpathSync is not observed here, and SQLite continues
552
+ // holding whatever fd it opened at construction time.
553
+ // RESIDUAL (not promised closed): node:sqlite has no O_NOFOLLOW, so a swap to a
554
+ // symlink whose target does not yet exist can create an EMPTY file at that
555
+ // outside target during DatabaseSync construction; the post-open guard then
556
+ // re-resolves the path, detects the escape, and closes before any schema
557
+ // write, so no existing outside database is ever corrupted and the lock_owner
558
+ // schema never leaves the root. A swap that lands AFTER the post-open
559
+ // realpathSync is not caught here (SQLite already holds its in-root fd from
560
+ // construction, but the path string now points elsewhere; subsequent opens by
561
+ // other takers re-validate independently). This residual requires an attacker
562
+ // who can concurrently mutate artifacts/.locks between the checks and the
563
+ // open. Under the trusted-directory assumption — the artifacts root and its
564
+ // .locks subtree are Host-provided and not concurrently mutated by untrusted
565
+ // code — the static checks (ensureDirSafe + validateLockDbSurface) and the
566
+ // bounded post-open single-swap check are complete and the residual does not
567
+ // arise. We fail closed on every static signal we can detect; we do not
568
+ // promise a guarantee the node:sqlite API cannot provide.
569
+
570
+ function lockDirPath(root, scopeDigestValue) {
571
+ return join(root, 'artifacts', '.locks', scopeDigestValue);
572
+ }
573
+ function lockDbPath(root, scopeDigestValue, runDigestValue) {
574
+ return join(lockDirPath(root, scopeDigestValue), runDigestValue + '.sqlite');
575
+ }
576
+
577
+ function isSqliteBusy(error) {
578
+ return !!error && (
579
+ error.errcode === SQLITE_BUSY
580
+ || /locked|busy/i.test(String(error.errstr || error.message || ''))
581
+ );
582
+ }
583
+
584
+ /**
585
+ * Commit the holder's transaction, retrying asynchronously on SQLITE_BUSY.
586
+ * COMMIT upgrades the RESERVED write lock to EXCLUSIVE to flush the journal; a
587
+ * concurrent taker opening the database briefly holds a SHARED lock (schema
588
+ * read) and can make COMMIT get SQLITE_BUSY even though this connection is the
589
+ * sole writer. busy_timeout is 0 (so the write-ACQUIRE path never synchronously
590
+ * busy-waits and block a same-process owner's pending filesystem await), so we
591
+ * retry COMMIT the same way the acquire does — via setTimeout, keeping the
592
+ * event loop free. The transient reader releases in milliseconds. This is the
593
+ * LIVE release path only; a crashed holder never reaches here (the OS reclaims
594
+ * the connection's fd and SQLite drops the lock), so crash release is
595
+ * unaffected. A non-busy error, or busy past LOCK_RELEASE_TIMEOUT_MS, propagates
596
+ * (the caller rolls back and closes, releasing the lock).
597
+ */
598
+ async function commitWithBusyRetry(db) {
599
+ const start = Date.now();
600
+ while (true) {
601
+ try {
602
+ db.exec('COMMIT');
603
+ return;
604
+ } catch (error) {
605
+ if (!isSqliteBusy(error)) throw error;
606
+ if (Date.now() - start > LOCK_RELEASE_TIMEOUT_MS) throw error;
607
+ await new Promise((r) => setTimeout(r, LOCK_POLL_MS, undefined));
608
+ }
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Pre-open validation of the lock database path and every SQLite auxiliary file
614
+ * SQLite may touch beside it. Rejects a statically-placed symlink (final
615
+ * component), a non-regular file, a hardlink (nlink > 1), or any path whose
616
+ * realpath escapes the pinned root; re-resolves the lock directory's realpath.
617
+ * This defeats the demonstrated attack where the lock DB's final component is a
618
+ * symlink to an outside SQLite file.
619
+ *
620
+ * LIMIT: fs.lstat rejects only a symlink FINAL component; an intermediate
621
+ * ancestor symlink is detected via realpath of the existing file and of the
622
+ * parent directory. This is an existence check, not a race-free guarantee — a
623
+ * concurrent swap between this check and the open is caught by the post-open
624
+ * realpath guard in openAndAcquireLock, and the trusted-directory assumption
625
+ * (see above) bounds the residual.
626
+ */
627
+ async function validateLockDbSurface(dbPath, rootReal) {
628
+ const parent = dirname(dbPath);
629
+ // The lock directory must still be contained right before we touch the DB.
630
+ // ensureDirSafe created it contained, but an ancestor swap since then is
631
+ // caught here (realpath resolves ancestor symlinks) before any file is
632
+ // created. If the parent vanished (TOCTOU), fail closed.
633
+ let parentReal;
634
+ try {
635
+ parentReal = await fs.realpath(parent);
636
+ } catch (error) {
637
+ if (error.code === 'ENOENT') throw err('artifact_path_escape', 'artifact_path_escape');
638
+ throw error;
639
+ }
640
+ assertWithin(parentReal, rootReal);
641
+ // The lock DB is persistent (never deleted in normal operation), so a
642
+ // vanished-after-lstat DB is a real signal — validate it with realpath too.
643
+ await validateLockPathEntry(dbPath, rootReal, true);
644
+ // SQLite auxiliary files (-journal/-wal/-shm) are TRANSIENT: the rollback
645
+ // journal is created at BEGIN and deleted at COMMIT by a concurrent holder,
646
+ // so it may appear and disappear between our lstat and any realpath. We
647
+ // therefore validate aux files with lstat only (symlink / non-regular /
648
+ // hardlink) — the parent realpath already pinned the directory, and the DB
649
+ // post-open realpath guard catches an ancestor swap. realpath-ing a transient
650
+ // aux file would fire spuriously on a concurrent committer deleting its own
651
+ // journal.
652
+ for (const sfx of SQLITE_AUX_SUFFIXES) {
653
+ await validateLockPathEntry(dbPath + sfx, rootReal, false);
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Validate one lock-path entry (the DB or one aux file). lstat-based checks
659
+ * (symlink / non-regular / hardlink) defeat a statically-placed trap. When
660
+ * `doRealpath` is true (the persistent DB only), also resolve the realpath and
661
+ * assert containment; a vanished-after-lstat DB fails closed.
662
+ */
663
+ async function validateLockPathEntry(p, rootReal, doRealpath) {
664
+ let st;
665
+ try {
666
+ st = await fs.lstat(p);
667
+ } catch (error) {
668
+ if (error.code === 'ENOENT') return; // not present yet: nothing to validate
669
+ throw error;
670
+ }
671
+ if (st.isSymbolicLink()) throw err('artifact_symlink_rejected', 'artifact_symlink_rejected');
672
+ if (!st.isFile()) throw err('artifact_record_corrupt', 'artifact_record_corrupt');
673
+ if (st.nlink > 1) throw err('artifact_path_escape', 'artifact_path_escape'); // hardlink to outside
674
+ if (!doRealpath) return;
675
+ let real;
676
+ try {
677
+ real = await fs.realpath(p);
678
+ } catch (error) {
679
+ if (error.code === 'ENOENT') throw err('artifact_path_escape', 'artifact_path_escape');
680
+ throw error;
681
+ }
682
+ assertWithin(real, rootReal);
683
+ }
684
+
685
+ /**
686
+ * Open the lock database and acquire the write transaction atomically. The
687
+ * schema is created INSIDE the transaction (after BEGIN IMMEDIATE) so a second
688
+ * connection that finds the lock busy never contends on DDL. Owner identity is
689
+ * recorded in a single-row table. Returns the open connection (transaction
690
+ * held). Throws a busy error (errcode 5) if another connection holds the lock;
691
+ * the caller retries asynchronously. Any other SQLite error (corruption) is a
692
+ * hard failure — a partial database is recovered by the journal, not here.
693
+ *
694
+ * POST-OPEN GUARD: node:sqlite's DatabaseSync opens by path and follows
695
+ * symlinks (no O_NOFOLLOW option is exposed), so a concurrent swap of the
696
+ * path between validateLockDbSurface and this open cannot be fully closed in
697
+ * userspace. After `new DatabaseSync(path)` we call realpathSync(path) and
698
+ * assertWithin against the pinned root. This re-resolves the CURRENT path
699
+ * STRING — NOT the file descriptor SQLite actually opened (node:sqlite
700
+ * exposes no way to read that fd back), so the guard does NOT prove the
701
+ * identity of the fd DatabaseSync holds and does NOT close arbitrary
702
+ * concurrent swaps. It is a bounded single-swap check: it catches a swap
703
+ * whose effect is visible at the path string by the time realpathSync runs.
704
+ * If the path now resolves outside the root we close BEFORE BEGIN IMMEDIATE
705
+ * so the lock_owner schema is never written outside the root. A swap landing
706
+ * after this realpathSync is not caught here. Under the trusted-directory
707
+ * assumption above (the artifacts root and .locks subtree are Host-provided
708
+ * and not concurrently mutated by untrusted code) the static checks plus
709
+ * this bounded single-swap check are complete; the residual requires a
710
+ * concurrent attacker inside .locks.
711
+ */
712
+ function openAndAcquireLock(path, token, pid, rootReal) {
713
+ const db = new DatabaseSync(path, { timeout: 0 });
714
+ try {
715
+ assertWithin(realpathSync(path), rootReal);
716
+ } catch (error) {
717
+ try { db.close(); } catch { /* best effort before rethrowing */ }
718
+ throw err('artifact_path_escape', 'artifact_path_escape');
719
+ }
720
+ try {
721
+ db.exec('PRAGMA busy_timeout = 0');
722
+ db.exec('BEGIN IMMEDIATE');
723
+ db.exec(
724
+ 'CREATE TABLE IF NOT EXISTS lock_owner ('
725
+ + 'id INTEGER PRIMARY KEY CHECK (id = 1), '
726
+ + 'token TEXT NOT NULL, pid INTEGER NOT NULL, host TEXT NOT NULL, born_at TEXT NOT NULL)'
727
+ );
728
+ db.prepare(
729
+ 'INSERT INTO lock_owner (id, token, pid, host, born_at) VALUES (1, ?, ?, ?, ?) '
730
+ + 'ON CONFLICT(id) DO UPDATE SET token = excluded.token, pid = excluded.pid, '
731
+ + 'host = excluded.host, born_at = excluded.born_at'
732
+ ).run(token, pid, HOST, nowIso());
733
+ return db;
734
+ } catch (error) {
735
+ try { db.close(); } catch { /* best effort before rethrowing */ }
736
+ throw error;
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Acquire the per-run cross-process lock, retrying asynchronously on busy.
742
+ * Resolves to a handle whose release() commits and closes the owner's OWN
743
+ * connection (owner-bound). Throws artifact_lock_timeout after a safety bound,
744
+ * artifact_aborted on signal, or a SQLite error on unrecoverable corruption.
745
+ */
746
+ async function acquireRunLock(root, rootReal, scopeDigestValue, runDigestValue, signal) {
747
+ const pid = process.pid;
748
+ const path = lockDbPath(root, scopeDigestValue, runDigestValue);
749
+ const ldir = lockDirPath(root, scopeDigestValue);
750
+ // The lock directory is a sibling of the scope's run directories, inside the
751
+ // pinned artifacts root; create it contained so a symlinked ancestor that
752
+ // escapes is rejected before any database file is opened.
753
+ await ensureDirSafe(ldir, rootReal);
754
+ const token = tempNonce();
755
+ const start = Date.now();
756
+ let db;
757
+ while (true) {
758
+ checkSignal(signal);
759
+ try {
760
+ // Static pre-open validation of the lock DB path and every SQLite
761
+ // auxiliary file (-journal/-wal/-shm) SQLite may touch beside it: reject a
762
+ // symlink final component, a non-regular file, a hardlink (nlink > 1), or
763
+ // a realpath that escapes the pinned root. This defeats a statically-
764
+ // placed trap. It is NOT a race-free guarantee — see the post-open guard
765
+ // in openAndAcquireLock and the trusted-directory assumption above.
766
+ await validateLockDbSurface(path, rootReal);
767
+ await swapHookForLockDb(path); // test-only TOCTOU simulation; inert in production
768
+ db = openAndAcquireLock(path, token, pid, rootReal);
769
+ break;
770
+ } catch (error) {
771
+ if (!isSqliteBusy(error)) throw error;
772
+ if (Date.now() - start > LOCK_TIMEOUT_MS) {
773
+ throw err('artifact_lock_timeout', 'artifact_lock_timeout');
774
+ }
775
+ // setTimeout yields the event loop so a same-process owner's pending
776
+ // filesystem await can proceed; no synchronous busy-wait.
777
+ await new Promise((r) => setTimeout(r, LOCK_POLL_MS, undefined));
778
+ }
779
+ }
780
+ return {
781
+ path,
782
+ token,
783
+ pid,
784
+ async release() {
785
+ try {
786
+ // Owner-bound release: clear only our own row, then commit and close
787
+ // our own connection. Another process cannot reach this connection,
788
+ // so it cannot release a lock it does not own. COMMIT is retried on
789
+ // transient SQLITE_BUSY (a concurrent opener's brief SHARED lock); see
790
+ // commitWithBusyRetry. The finally still closes our connection on any
791
+ // failure, releasing the SQLite lock so the next writer can proceed.
792
+ db.prepare('DELETE FROM lock_owner WHERE id = 1 AND token = ?').run(token);
793
+ await commitWithBusyRetry(db);
794
+ } catch (error) {
795
+ try { db.exec('ROLLBACK'); } catch { /* best effort */ }
796
+ throw error;
797
+ } finally {
798
+ try { db.close(); } catch { /* ignore */ }
799
+ }
800
+ },
801
+ };
802
+ }
803
+
804
+ /**
805
+ * Before publishing a *new* artifact, a lock taker (including a stale-lock
806
+ * successor) must finish any interrupted publication left in the run: for
807
+ * each identity with content but no meta, if a matching intent exists, seal
808
+ * it; if a non-matching intent exists, this is an unresolved conflict the
809
+ * original writer would have rejected — leave the orphan and let the next
810
+ * same-identity write reconcile. We do not delete content we did not create.
811
+ */
812
+ async function finishInterruptedPublications(dir, root, expectedScopeDigest, expectedRunDigest) {
813
+ const { byId } = await scanRun(dir, root);
814
+ for (const entry of byId.values()) {
815
+ if (entry.meta) continue; // already sealed
816
+ if (!entry.content) continue;
817
+ if (!entry.intent) continue;
818
+ // There is an intent; try to seal the content with it.
819
+ try {
820
+ const intent = await safeReadMeta(entry.intent, root);
821
+ assertRecordMatchesPath(intent, expectedScopeDigest, expectedRunDigest, entry.id, root);
822
+ const content = await safeReadFile(entry.content, root);
823
+ if (sha256Hex(content) !== intent.digest.slice(7) || content.length !== intent.sizeBytes) {
824
+ continue; // content does not match the intent; leave for reconciliation
825
+ }
826
+ const metaPath = entry.content.slice(0, -CONTENT_SUFFIX.length) + META_SUFFIX;
827
+ const metaBytes = Buffer.from(canonicalStringify({
828
+ uri: intent.uri, digest: intent.digest, mediaType: intent.mediaType,
829
+ sizeBytes: intent.sizeBytes, metadata: intent.metadata, createdAt: nowIso(),
830
+ }), 'utf8');
831
+ await atomicPublish(metaPath, metaBytes, root, null);
832
+ await safeUnlink(entry.intent);
833
+ } catch {
834
+ // Any failure to finish leaves the orphan in place; a same-identity
835
+ // write will reconcile. We never silently drop content.
836
+ }
837
+ }
838
+ }
839
+
840
+ // ---------------------------------------------------------------------------
841
+ // Factory
842
+
843
+ /**
844
+ * Create a Native artifact store bound to one trusted scope and directory.
845
+ *
846
+ * @param {object} options
847
+ * @param {string} options.directory Root directory for artifact storage.
848
+ * @param {import('../../../agentkit-harness-core/src/contracts.mts').RunScope} options.scope
849
+ * Trusted fixed scope; every request must exactly match all four fields.
850
+ * @param {number} [options.maxReadChars] Max characters per read page (default 32000).
851
+ * @param {number} [options.maxSearchMatches] Max refs per search (default 16).
852
+ * @param {number} [options.maxQueryChars] Max query length (default 1024).
853
+ * @param {number} [options.maxScanBytes] Max content bytes scanned per search hit (default 1MiB).
854
+ * @param {number} [options.maxItemBytes] Max bytes per artifact (default 64MiB).
855
+ * @param {number} [options.maxTotalBytes] Max total bytes per run (default 512MiB).
856
+ * @returns {Promise<ArtifactStore>}
857
+ */
858
+ export async function createNativeArtifactStore(options) {
859
+ if (!options || typeof options !== 'object') throw err('invalid_artifact_options', 'invalid_artifact_options');
860
+ const { directory, scope } = options;
861
+ validateScope(scope);
862
+ const maxReadChars = validateLimitOption(options.maxReadChars, 'max_read_chars', DEFAULT_MAX_READ_CHARS, 1000000);
863
+ const maxSearchMatches = validateLimitOption(options.maxSearchMatches, 'max_search_matches', DEFAULT_MAX_SEARCH_MATCHES, 1000);
864
+ const maxQueryChars = validateLimitOption(options.maxQueryChars, 'max_query_chars', DEFAULT_MAX_QUERY_CHARS, 100000);
865
+ const maxScanBytes = validateBytesOption(options.maxScanBytes, 'max_scan_bytes', DEFAULT_MAX_SCAN_BYTES, Number.MAX_SAFE_INTEGER);
866
+ const maxItemBytes = validateBytesOption(options.maxItemBytes, 'max_item_bytes', DEFAULT_MAX_ITEM_BYTES, Number.MAX_SAFE_INTEGER);
867
+ const maxTotalBytes = validateBytesOption(options.maxTotalBytes, 'max_total_bytes', DEFAULT_MAX_TOTAL_BYTES, Number.MAX_SAFE_INTEGER);
868
+
869
+ const { root, rootReal } = await initRoot(directory);
870
+ const scopeDigestValue = scopeDigest(scope);
871
+ const boundScope = { tenantId: scope.tenantId, workspaceId: scope.workspaceId, userId: scope.userId, agentId: scope.agentId };
872
+
873
+ const runDir = (runDigestValue) => join(root, 'artifacts', scopeDigestValue, runDigestValue);
874
+
875
+ async function assertScope(request) {
876
+ if (!scopeEquals(request.scope, boundScope)) throw err('artifact_scope_mismatch', 'artifact_scope_mismatch');
877
+ }
878
+
879
+ /**
880
+ * Persist exact text idempotently for the supplied operation identity.
881
+ * Same identity + same content/type/metadata returns the same ref; same
882
+ * identity with different content/type/metadata is an explicit conflict.
883
+ * Publication is serialized per run across processes by the run lock.
884
+ */
885
+ async function write(request) {
886
+ await assertScope(request);
887
+ assertNonEmptyString(request.runId, 'invalid_run_id');
888
+ assertNonEmptyString(request.operationKey, 'invalid_operation_key');
889
+ validateMediaType(request.mediaType);
890
+ const metadata = validateMetadataObject(request.metadata);
891
+ checkSignal(request.signal);
892
+
893
+ const rd = runDigest(boundScope, request.runId);
894
+ const id = identityDigest(boundScope, request.runId, request.operationKey);
895
+ const dir = runDir(rd);
896
+ const contentPath = join(dir, id + CONTENT_SUFFIX);
897
+ const metaPath = join(dir, id + META_SUFFIX);
898
+ const intentPath = join(dir, id + INTENT_SUFFIX);
899
+ const uri = buildArtifactUri(scopeDigestValue, rd, id);
900
+
901
+ const contentBytes = encodeUtf8(request.content);
902
+ const digest = 'sha256:' + sha256Hex(contentBytes);
903
+ const sizeBytes = contentBytes.length;
904
+ const ref = { uri, digest, mediaType: request.mediaType, sizeBytes, metadata };
905
+ const intentRecord = { uri, digest, mediaType: request.mediaType, sizeBytes, metadata };
906
+ const metaRecord = { uri, digest, mediaType: request.mediaType, sizeBytes, metadata, createdAt: nowIso() };
907
+ const intentBytes = Buffer.from(canonicalStringify(intentRecord), 'utf8');
908
+ const metaBytes = Buffer.from(canonicalStringify(metaRecord), 'utf8');
909
+
910
+ if (sizeBytes > maxItemBytes) throw err('artifact_too_large', 'artifact_too_large');
911
+
912
+ const lock = await acquireRunLock(root, rootReal, scopeDigestValue, rd, request.signal);
913
+ try {
914
+ await signalLockAcquired();
915
+ // The run directory hosts the content/meta/intent files; create it
916
+ // contained under the lock so two writers never race on mkdir.
917
+ await ensureDirSafe(dir, rootReal);
918
+ // Finish any publication a dead predecessor left interrupted, so the
919
+ // successor never races a brand-new write for the same run.
920
+ await finishInterruptedPublications(dir, rootReal, scopeDigestValue, rd);
921
+ checkSignal(request.signal);
922
+
923
+ // Phase 1: reconcile an existing sealed record for this identity.
924
+ if (await safeExists(metaPath, rootReal)) {
925
+ const meta = await safeReadMeta(metaPath, rootReal);
926
+ assertRecordMatchesPath(meta, scopeDigestValue, rd, id, rootReal);
927
+ const content = await safeReadFile(contentPath, rootReal);
928
+ if (sha256Hex(content) !== meta.digest.slice(7) || content.length !== meta.sizeBytes) {
929
+ throw err('artifact_record_corrupt', 'artifact_record_corrupt');
930
+ }
931
+ if (
932
+ meta.uri !== ref.uri || meta.digest !== ref.digest || meta.mediaType !== ref.mediaType
933
+ || meta.sizeBytes !== ref.sizeBytes || !jsonEquals(meta.metadata, metadata)
934
+ ) {
935
+ throw err('artifact_identity_conflict', 'artifact_identity_conflict');
936
+ }
937
+ // Drop a stale intent from a prior interrupted attempt of this same identity.
938
+ await safeUnlink(intentPath);
939
+ return ref; // idempotent
940
+ }
941
+
942
+ // Phase 2: reconcile orphan content (content present, meta absent).
943
+ const contentOnDisk = await safeExists(contentPath, rootReal);
944
+ if (contentOnDisk) {
945
+ // The complete write identity is pinned by the intent sidecar, which is
946
+ // always published BEFORE content. So a legitimate orphan always has a
947
+ // matching intent. Without an intent, the orphan is ambiguous (tamper,
948
+ // or content from before intents existed): we must NOT seal it as the
949
+ // current request, because the bytes were never acknowledged and the
950
+ // original request's type/metadata are unknown. Fail as a conflict.
951
+ const priorIntentExists = await safeExists(intentPath, rootReal);
952
+ if (!priorIntentExists) {
953
+ throw err('artifact_identity_conflict', 'artifact_identity_conflict');
954
+ }
955
+ const priorIntent = await safeReadMeta(intentPath, rootReal);
956
+ assertRecordMatchesPath(priorIntent, scopeDigestValue, rd, id, rootReal);
957
+ const existing = await safeReadFile(contentPath, rootReal);
958
+ if (existing.length !== priorIntent.sizeBytes || ('sha256:' + sha256Hex(existing)) !== priorIntent.digest) {
959
+ // The orphan content does not match its own intent: corrupt.
960
+ throw err('artifact_record_corrupt', 'artifact_record_corrupt');
961
+ }
962
+ // The intent pins the original request; the current write must match it
963
+ // exactly (idempotent retry of the same interrupted write). A different
964
+ // type/metadata/digest/size is a conflict, never a re-seal as a new request.
965
+ if (
966
+ priorIntent.uri !== ref.uri || priorIntent.digest !== ref.digest
967
+ || priorIntent.mediaType !== ref.mediaType || priorIntent.sizeBytes !== ref.sizeBytes
968
+ || !jsonEquals(priorIntent.metadata, metadata)
969
+ ) {
970
+ throw err('artifact_identity_conflict', 'artifact_identity_conflict');
971
+ }
972
+ } else {
973
+ // Phase 3: publish the intent FIRST, pinning the complete identity,
974
+ // then enforce the quota, then publish the content. The quota check
975
+ // is atomic because we hold the run lock across all writers.
976
+ const intentResult = await atomicPublish(intentPath, intentBytes, rootReal, { afterTmp: 'intent-tmp', afterLink: 'intent' });
977
+ if (intentResult === 'exists') {
978
+ const priorIntent = await safeReadMeta(intentPath, rootReal);
979
+ assertRecordMatchesPath(priorIntent, scopeDigestValue, rd, id, rootReal);
980
+ if (
981
+ priorIntent.uri !== ref.uri || priorIntent.digest !== ref.digest
982
+ || priorIntent.mediaType !== ref.mediaType || priorIntent.sizeBytes !== ref.sizeBytes
983
+ || !jsonEquals(priorIntent.metadata, metadata)
984
+ ) {
985
+ throw err('artifact_identity_conflict', 'artifact_identity_conflict');
986
+ }
987
+ }
988
+ checkSignal(request.signal);
989
+ const usage = await computeUsage(dir, rootReal, scopeDigestValue, rd);
990
+ if (usage + sizeBytes > maxTotalBytes) {
991
+ // Roll back the intent so a retried write can re-evaluate budget.
992
+ await safeUnlink(intentPath);
993
+ throw err('artifact_budget_exceeded', 'artifact_budget_exceeded');
994
+ }
995
+ crashHook('before-content');
996
+ await holdHook('before-content');
997
+ const contentResult = await atomicPublish(contentPath, contentBytes, rootReal, { afterTmp: 'content-tmp', afterLink: 'content' });
998
+ if (contentResult === 'exists') {
999
+ const existing = await safeReadFile(contentPath, rootReal);
1000
+ if (sha256Hex(existing) !== digest.slice(7) || existing.length !== sizeBytes) {
1001
+ throw err('artifact_identity_conflict', 'artifact_identity_conflict');
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ // Phase 4: seal with the meta sidecar, then drop the intent.
1007
+ checkSignal(request.signal);
1008
+ const metaResult = await atomicPublish(metaPath, metaBytes, rootReal, { afterTmp: 'meta-tmp', afterLink: 'meta' });
1009
+ if (metaResult === 'exists') {
1010
+ const meta = await safeReadMeta(metaPath, rootReal);
1011
+ assertRecordMatchesPath(meta, scopeDigestValue, rd, id, rootReal);
1012
+ if (
1013
+ meta.uri !== ref.uri || meta.digest !== ref.digest || meta.mediaType !== ref.mediaType
1014
+ || meta.sizeBytes !== ref.sizeBytes || !jsonEquals(meta.metadata, metadata)
1015
+ ) {
1016
+ throw err('artifact_identity_conflict', 'artifact_identity_conflict');
1017
+ }
1018
+ const content = await safeReadFile(contentPath, rootReal);
1019
+ if (sha256Hex(content) !== meta.digest.slice(7) || content.length !== meta.sizeBytes) {
1020
+ throw err('artifact_record_corrupt', 'artifact_record_corrupt');
1021
+ }
1022
+ }
1023
+ await safeUnlink(intentPath);
1024
+ return ref;
1025
+ } finally {
1026
+ // Owner-bound release: commit + close this holder's own connection. If
1027
+ // the write threw, the finally still commits the (empty) lock-owner
1028
+ // transaction and closes the connection, releasing the SQLite lock so
1029
+ // the next writer can proceed; filesystem orphans are reconciled later.
1030
+ await lock.release();
1031
+ }
1032
+ }
1033
+
1034
+ /**
1035
+ * Read a bounded page after enforcing scope, exact typed ref match and
1036
+ * content integrity. Pagination uses Unicode code point indices.
1037
+ */
1038
+ async function read(request) {
1039
+ await assertScope(request);
1040
+ validateRef(request.ref);
1041
+ if (!Number.isInteger(request.offset) || request.offset < 0) throw err('invalid_artifact_offset', 'invalid_artifact_offset');
1042
+ if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > maxReadChars) throw err('invalid_artifact_limit', 'invalid_artifact_limit');
1043
+ checkSignal(request.signal);
1044
+
1045
+ const parsed = parseArtifactUri(request.ref.uri);
1046
+ if (parsed.scopeDigest !== scopeDigestValue) throw err('artifact_ref_outside_scope', 'artifact_ref_outside_scope');
1047
+ const dir = join(root, 'artifacts', parsed.scopeDigest, parsed.runDigest);
1048
+ const contentPath = join(dir, parsed.identityDigest + CONTENT_SUFFIX);
1049
+ const metaPath = join(dir, parsed.identityDigest + META_SUFFIX);
1050
+
1051
+ if (!(await safeExists(metaPath, rootReal))) throw err('artifact_ref_unknown', 'artifact_ref_unknown');
1052
+ const meta = await safeReadMeta(metaPath, rootReal);
1053
+ assertRecordMatchesPath(meta, parsed.scopeDigest, parsed.runDigest, parsed.identityDigest, rootReal);
1054
+ if (
1055
+ meta.uri !== request.ref.uri || meta.digest !== request.ref.digest || meta.mediaType !== request.ref.mediaType
1056
+ || meta.sizeBytes !== request.ref.sizeBytes || !jsonEquals(meta.metadata, request.ref.metadata)
1057
+ ) {
1058
+ throw err('artifact_ref_changed', 'artifact_ref_changed');
1059
+ }
1060
+ const content = await safeReadFile(contentPath, rootReal);
1061
+ if (sha256Hex(content) !== request.ref.digest.slice(7) || content.length !== request.ref.sizeBytes) {
1062
+ throw err('artifact_content_integrity_mismatch', 'artifact_content_integrity_mismatch');
1063
+ }
1064
+ if (sha256Hex(content) !== meta.digest.slice(7) || content.length !== meta.sizeBytes) {
1065
+ throw err('artifact_record_corrupt', 'artifact_record_corrupt');
1066
+ }
1067
+ checkSignal(request.signal);
1068
+ const text = decodeUtf8Fatal(content);
1069
+ return sliceByCodePoints(text, request.offset, request.limit, maxReadChars);
1070
+ }
1071
+
1072
+ /**
1073
+ * Literal, case-sensitive substring search over the current run's sealed
1074
+ * artifacts. Returns bounded refs in deterministic (URI) order. Only the
1075
+ * current run is scanned; other scopes or runs are never admitted.
1076
+ */
1077
+ async function search(request) {
1078
+ await assertScope(request);
1079
+ assertNonEmptyString(request.runId, 'invalid_run_id');
1080
+ if (typeof request.query !== 'string' || request.query.length === 0 || request.query.length > maxQueryChars) {
1081
+ throw err('invalid_artifact_search_query', 'invalid_artifact_search_query');
1082
+ }
1083
+ if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > maxSearchMatches) {
1084
+ throw err('invalid_artifact_search_limit', 'invalid_artifact_search_limit');
1085
+ }
1086
+ checkSignal(request.signal);
1087
+
1088
+ const rd = runDigest(boundScope, request.runId);
1089
+ const dir = runDir(rd);
1090
+ const { byId } = await scanRun(dir, rootReal);
1091
+
1092
+ // Deterministic order by URI (which embeds identity digest).
1093
+ const identities = [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1094
+ const matches = [];
1095
+ for (const entry of identities) {
1096
+ checkSignal(request.signal);
1097
+ if (!entry.meta) continue; // orphan content (not sealed) is not searchable
1098
+ if (!entry.content) {
1099
+ // meta present without content is corruption: fail closed, never skip
1100
+ throw err('artifact_record_corrupt', 'artifact_record_corrupt');
1101
+ }
1102
+ const meta = await safeReadMeta(entry.meta, rootReal);
1103
+ // Strictly validate the record belongs to THIS run and identity.
1104
+ assertRecordMatchesPath(meta, scopeDigestValue, rd, entry.id, rootReal);
1105
+ if (meta.sizeBytes > maxScanBytes) continue; // too large to scan this pass
1106
+ const content = await safeReadFile(entry.content, rootReal);
1107
+ if (sha256Hex(content) !== meta.digest.slice(7) || content.length !== meta.sizeBytes) {
1108
+ throw err('artifact_record_corrupt', 'artifact_record_corrupt');
1109
+ }
1110
+ const text = decodeUtf8Fatal(content); // fail closed on bad UTF-8
1111
+ if (text.includes(request.query)) {
1112
+ matches.push({
1113
+ uri: meta.uri, digest: meta.digest, mediaType: meta.mediaType,
1114
+ sizeBytes: meta.sizeBytes,
1115
+ // normalize the null-prototype parse result to a plain JSON object
1116
+ metadata: parseNativeJson(canonicalStringify(meta.metadata)),
1117
+ });
1118
+ if (matches.length >= request.limit) break;
1119
+ }
1120
+ }
1121
+ return matches;
1122
+ }
1123
+
1124
+ /** Durable bytes consumed by a run's published artifacts, for budget reconciliation. */
1125
+ async function usageBytes({ scope, runId } = {}) {
1126
+ if (!scopeEquals(scope, boundScope)) throw err('artifact_scope_mismatch', 'artifact_scope_mismatch');
1127
+ assertNonEmptyString(runId, 'invalid_run_id');
1128
+ const rd = runDigest(boundScope, runId);
1129
+ return computeUsage(runDir(rd), rootReal, scopeDigestValue, rd);
1130
+ }
1131
+
1132
+ /** Remove stale temp files across all run directories of this scope. */
1133
+ async function cleanupStaleTemps({ maxAgeMs = 60000 } = {}) {
1134
+ if (!Number.isInteger(maxAgeMs) || maxAgeMs < 0) throw err('invalid_temp_age', 'invalid_temp_age');
1135
+ const scopeDir = join(root, 'artifacts', scopeDigestValue);
1136
+ let runDirs;
1137
+ try {
1138
+ runDirs = await fs.readdir(scopeDir);
1139
+ } catch (error) {
1140
+ if (error.code === 'ENOENT') return 0;
1141
+ throw error;
1142
+ }
1143
+ let removed = 0;
1144
+ for (const rd of runDirs) {
1145
+ if (!HEX64.test(rd)) continue;
1146
+ removed += await cleanupStaleTempsIn(join(scopeDir, rd), rootReal, maxAgeMs);
1147
+ }
1148
+ return removed;
1149
+ }
1150
+
1151
+ return {
1152
+ write,
1153
+ read,
1154
+ search,
1155
+ usageBytes,
1156
+ cleanupStaleTemps,
1157
+ // Inspection helpers for tests / core reconciliation; not part of ArtifactPort.
1158
+ get scope() { return { ...boundScope }; },
1159
+ get directory() { return root; },
1160
+ get rootReal() { return rootReal; },
1161
+ };
1162
+ }