@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,655 @@
1
+ /**
2
+ * Scoped Node child file workspaces and version-checked publication.
3
+ *
4
+ * Ports ksadk.harness.workspace_adapters.native_isolation @355059d to a pure
5
+ * Node/ESM module. These are immutable input assets, not a second Run ledger:
6
+ * file tools are confined to the child directory; arbitrary host-user processes
7
+ * are not a sandbox. The host keeps workspaces for follow-up and recovery.
8
+ *
9
+ * Faithful to the reference semantics that matter:
10
+ * - ``prepare`` freezes the actual input files ONCE, preserving dirty and
11
+ * untracked content; it is idempotent and never resets a child's changes.
12
+ * - ``merge`` prechecks ALL conflicts before writing; an unrelated parent
13
+ * change is preserved; an OS failure reports the real applied prefix
14
+ * (multi-file publication is NOT an atomic transaction).
15
+ * - a previous publication is granted ONLY by a caller-supplied receipt
16
+ * (``previous``) whose immutable inventory is verified against disk; a
17
+ * saved inventory existing on disk never authorizes another merge.
18
+ * - identity is a Host-supplied 64-hex string; model paths are never accepted.
19
+ * - .git/.agentkit/.harness-tools/.artifacts are ignored; symlinks and special
20
+ * files are rejected; the 64 MiB payload limit is enforced.
21
+ *
22
+ * Cross-process coordination uses a per-path SQLite BEGIN IMMEDIATE transaction
23
+ * (the same primitive the Node workspace state store uses). SQLite's busy_timeout
24
+ * retries a contended BEGIN, but that retry is a SYNCHRONOUS busy-wait that
25
+ * blocks the Node event loop — callers must not hold the lock across an await.
26
+ * The OS releases the lock when the process exits (no stale lockfile recovery).
27
+ * Production workspace-tools.mjs acquires the same source-mutation lock for
28
+ * builtin writes. Arbitrary commands and external editors do not participate.
29
+ * The Host owns Run/fence/approval and
30
+ * child-completion checks; this module provides the synchronous final guard
31
+ * checkpoints and never loosens a boundary after an await.
32
+ */
33
+ import { createHash } from 'node:crypto';
34
+ import {
35
+ chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync,
36
+ openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync,
37
+ renameSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync, writeSync,
38
+ } from 'node:fs';
39
+ import { dirname, join, relative, resolve, sep } from 'node:path';
40
+
41
+ import { publishWorkspaceBytes, sha256, isInside, realpathExisting } from './workspace-paths.mjs';
42
+ import { acquireWorkspaceMutationLock } from './workspace-mutation-lock.mjs';
43
+
44
+ export const CHILD_FILE_WORKSPACE_VERSION = 'native-child-files/v1';
45
+ export const CHILD_FILE_PUBLICATION_VERSION = 'native-child-publication/v1';
46
+ export const IGNORED_PATH_PARTS = new Set(['.git', '.agentkit', '.harness-tools', '.artifacts']);
47
+ export const MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
48
+ const HEX64 = /^[0-9a-f]{64}$/u;
49
+ const REVISION_RE = /^sha256:[0-9a-f]{64}$/u;
50
+
51
+ /** A safe host diagnostic that contains no source text or credentials. */
52
+ export class ChildWorkspaceError extends Error {
53
+ constructor(reason) { super(reason); this.name = 'ChildWorkspaceError'; this.code = reason; }
54
+ }
55
+
56
+ /** A frozen child workspace input snapshot. */
57
+ export class ChildFileWorkspace {
58
+ constructor(isolationId, root, baseline, baseRevision) {
59
+ this.isolationId = isolationId;
60
+ this.root = root;
61
+ this.baseline = baseline;
62
+ this.baseRevision = baseRevision;
63
+ }
64
+ }
65
+
66
+ /** A host grant from an acknowledged merge, never inferred from disk. */
67
+ export class ChildFilePublication {
68
+ constructor(revision, inventoryDigest) {
69
+ this.revision = revision;
70
+ this.inventoryDigest = inventoryDigest;
71
+ }
72
+ }
73
+
74
+ /** A directory entry in the immutable inventory. */
75
+ export class DirectoryEntry {
76
+ constructor(path, kind, mode, contentDigest) {
77
+ this.path = path;
78
+ this.kind = kind;
79
+ this.mode = mode;
80
+ this.contentDigest = contentDigest ?? null;
81
+ }
82
+ equals(other) {
83
+ return other && this.path === other.path && this.kind === other.kind
84
+ && this.mode === other.mode && (this.contentDigest ?? null) === (other.contentDigest ?? null);
85
+ }
86
+ }
87
+
88
+ // ---- cross-process coordination ----------------------------------------------
89
+
90
+ /** Acquire shared workspace-mutation locks for one or more paths, all-or-nothing
91
+ * by sorted canonical order. Uses the shared workspace-mutation-lock.mjs so
92
+ * builtin workspace-tools workers (which the Host wires to the same lock)
93
+ * coordinate with this module. SQLite BEGIN IMMEDIATE; synchronous busy-wait
94
+ * BLOCKS the event loop — never hold across an await. The OS releases on
95
+ * process exit. */
96
+ function lockPaths(paths, { timeoutMs = 10000 } = {}) {
97
+ const sorted = [...new Set(paths.filter(Boolean).map(p => resolve(p)))].sort();
98
+ const releases = [];
99
+ try {
100
+ for (const p of sorted) releases.push(acquireWorkspaceMutationLock(p, { timeoutMs }));
101
+ } catch (error) {
102
+ for (const r of releases) { try { r(); } catch { /* best-effort */ } }
103
+ if (error.code === 'workspace_mutation_busy') throw new ChildWorkspaceError('child_workspace_lock_timeout');
104
+ throw error;
105
+ }
106
+ return () => { for (const r of releases) { try { r(); } catch { /* best-effort */ } } };
107
+ }
108
+
109
+ // ---- inventory capture -------------------------------------------------------
110
+
111
+ const MAX_INVENTORY_ENTRIES = 2048;
112
+ const MAX_INVENTORY_BYTES = 64 * 1024 * 1024;
113
+
114
+ /** Capture a stable inventory: two passes must agree. Mirrors the reference
115
+ * double-capture (native_directory) and the Node workspace-tools observe.
116
+ * Exported for testing and for the Host's final guard checkpoint. */
117
+ export function captureInventory(root) {
118
+ const first = scanInventory(root);
119
+ const second = scanInventory(root);
120
+ if (first.digest !== second.digest || first.entryCount !== second.entryCount
121
+ || !entriesIdentical(first.list, second.list)) {
122
+ throw new ChildWorkspaceError('child_workspace_changed_during_capture');
123
+ }
124
+ return { revision: 'sha256:' + second.digest, entries: second.byPath };
125
+ }
126
+
127
+ function entriesIdentical(a, b) {
128
+ if (a.length !== b.length) return false;
129
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
130
+ return true;
131
+ }
132
+
133
+ function scanInventory(root) {
134
+ const hash = createHash('sha256');
135
+ hash.update('ksadk.directory-workspace/v1\0');
136
+ const state = { entries: 0, hashedBytes: 0, list: [], byPath: Object.create(null) };
137
+ scanDir(root, '', hash, state, root);
138
+ state.list.sort();
139
+ return { digest: hash.digest('hex'), entryCount: state.entries, list: state.list, byPath: state.byPath };
140
+ }
141
+
142
+ function isIgnored(name) { return IGNORED_PATH_PARTS.has(name); }
143
+
144
+ function scanDir(root, rel, hash, state, rootReal) {
145
+ const dir = rel ? join(root, rel) : root;
146
+ let dirents;
147
+ try { dirents = readdirSync(dir, { withFileTypes: true }); } catch { throw new ChildWorkspaceError('child_workspace_capture_failed'); }
148
+ dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
149
+ for (const d of dirents) {
150
+ if (isIgnored(d.name)) continue;
151
+ const entryRel = rel ? `${rel}/${d.name}` : d.name;
152
+ if (entryRel.includes('\\') || entryRel.includes('\0')) throw new ChildWorkspaceError('child_workspace_unsupported_path');
153
+ state.entries += 1;
154
+ if (state.entries > MAX_INVENTORY_ENTRIES) throw new ChildWorkspaceError('child_workspace_capture_limit');
155
+ const abs = join(root, entryRel);
156
+ let info;
157
+ try { info = lstatSync(abs, { bigint: true }); } catch { throw new ChildWorkspaceError('child_workspace_capture_failed'); }
158
+ if (info.isSymbolicLink()) throw new ChildWorkspaceError('child_workspace_symlink_entry');
159
+ // The digest hashes ONLY path, mode, kind and content digest — NOT mtimeNs
160
+ // or ino (a copy of the same content has a different mtime/ino but is the
161
+ // same workspace revision). The before/after identity (including mtime/ino)
162
+ // is a per-entry change-detection check within ONE scan, not part of the
163
+ // revision or the cross-scan comparison.
164
+ const before = entryIdentity(info);
165
+ hash.update(`\0path\0${entryRel}`);
166
+ hash.update(`\0mode:${Number(info.mode & 0o777n).toString(8)}`);
167
+ if (info.isDirectory()) {
168
+ hash.update('\0directory');
169
+ scanDir(root, entryRel, hash, state, rootReal);
170
+ state.list.push(entryRel);
171
+ state.byPath[entryRel] = new DirectoryEntry(entryRel, 'directory', Number(info.mode & 0o777n), null);
172
+ } else if (info.isFile()) {
173
+ if (state.hashedBytes + Number(info.size) > MAX_INVENTORY_BYTES) throw new ChildWorkspaceError('child_workspace_capture_limit');
174
+ const { raw, hex, readBytes } = hashFile(abs, Number(info.size));
175
+ if (readBytes !== Number(info.size)) throw new ChildWorkspaceError('child_workspace_changed_during_capture');
176
+ // Feed the RAW 32-byte file digest into the directory hash (matching
177
+ // workspace-tools.mjs), not the 64-char hex string.
178
+ hash.update('\0file\0');
179
+ hash.update(raw);
180
+ state.hashedBytes += readBytes;
181
+ state.list.push(entryRel);
182
+ state.byPath[entryRel] = new DirectoryEntry(entryRel, 'file', Number(info.mode & 0o777n), 'sha256:' + hex);
183
+ } else {
184
+ throw new ChildWorkspaceError('child_workspace_special_entry');
185
+ }
186
+ let after;
187
+ try { after = entryIdentity(lstatSync(abs, { bigint: true })); } catch { throw new ChildWorkspaceError('child_workspace_capture_failed'); }
188
+ if (after !== before) throw new ChildWorkspaceError('child_workspace_changed_during_capture');
189
+ }
190
+ }
191
+
192
+ function entryIdentity(info) {
193
+ return `${info.mode}|${info.size}|${info.mtimeNs}|${info.ctimeNs}|${info.ino}`;
194
+ }
195
+
196
+ function hashFile(abs, size) {
197
+ const fileHash = createHash('sha256');
198
+ const fd = openSync(abs, 'r');
199
+ let readBytes = 0;
200
+ try {
201
+ let remaining = size;
202
+ const buf = Buffer.alloc(1024 * 1024);
203
+ while (remaining > 0) {
204
+ const n = readSync(fd, buf, 0, Math.min(buf.length, remaining), null);
205
+ if (n === 0) break;
206
+ readBytes += n;
207
+ fileHash.update(buf.subarray(0, n));
208
+ remaining -= n;
209
+ }
210
+ } finally { closeSync(fd); }
211
+ // Return the RAW 32-byte digest for the directory hash (matching
212
+ // workspace-tools.mjs which feeds fileHash.digest() directly into the
213
+ // directory hash, NOT the 64-char hex string). Also return the hex form
214
+ // for the DirectoryEntry content_digest ('sha256:' + hex).
215
+ const raw = fileHash.digest();
216
+ return { raw, hex: raw.toString('hex'), readBytes };
217
+ }
218
+
219
+ /** Read a file payload bounded by MAX_PAYLOAD_BYTES, verifying its content
220
+ * digest matches the expected entry. Rejects symlinks, specials, and a changed
221
+ * digest (the file was modified after the inventory capture). */
222
+ function readPayload(root, rel, expected) {
223
+ const target = join(root, rel);
224
+ let info;
225
+ try { info = lstatSync(target, { bigint: true }); } catch { throw new ChildWorkspaceError('child_workspace_file_changed'); }
226
+ if (info.isSymbolicLink() || !info.isFile()) throw new ChildWorkspaceError('child_workspace_file_changed');
227
+ const fd = openSync(target, 'r');
228
+ let value;
229
+ try {
230
+ value = Buffer.alloc(0);
231
+ const chunks = [];
232
+ let total = 0;
233
+ const buf = Buffer.alloc(1024 * 1024);
234
+ for (;;) {
235
+ const n = readSync(fd, buf, 0, buf.length, null);
236
+ if (n === 0) break;
237
+ total += n;
238
+ if (total > MAX_PAYLOAD_BYTES) throw new ChildWorkspaceError('child_workspace_file_changed');
239
+ chunks.push(Buffer.from(buf.subarray(0, n)));
240
+ }
241
+ value = Buffer.concat(chunks);
242
+ } finally { closeSync(fd); }
243
+ if ('sha256:' + sha256(value) !== expected.contentDigest) throw new ChildWorkspaceError('child_workspace_file_changed');
244
+ return value;
245
+ }
246
+
247
+ // ---- DirectoryChildWorkspaces ------------------------------------------------
248
+
249
+ export class DirectoryChildWorkspaces {
250
+ /** @param {{source:string, storage:string, sourceDatabase?:string}} opts */
251
+ constructor({ source, storage, sourceDatabase } = {}) {
252
+ if (!source) throw new TypeError('DirectoryChildWorkspaces requires source');
253
+ if (!storage) throw new TypeError('DirectoryChildWorkspaces requires storage');
254
+ // Canonicalize roots: resolve symlinks for existing paths (macOS /var is a
255
+ // symlink to /private/var — this normalizes it). For a not-yet-created
256
+ // storage, resolve the closest existing ancestor and append the tail
257
+ // lexically. Pin the canonical forms; every prepare/load/merge re-checks
258
+ // the live realpath against these to detect a root or ancestor swap.
259
+ this.source = canonicalRoot(source);
260
+ this.storage = canonicalRoot(storage);
261
+ this.sourceDatabase = sourceDatabase ? resolve(sourceDatabase) : null;
262
+ // The storage must NOT be inside the source: a capture of the source would
263
+ // then recurse into the storage tree. Reject symlinked source/storage roots
264
+ // and a storage that is the source or beneath it. Use canonical paths so a
265
+ // symlinked alias cannot bypass this.
266
+ if (isSymlink(resolve(source)) || isSymlink(resolve(storage)))
267
+ throw new ChildWorkspaceError('child_workspace_root_symlink');
268
+ if (this.storage === this.source || isInside(this.storage, this.source))
269
+ throw new ChildWorkspaceError('child_workspace_storage_inside_source');
270
+ }
271
+
272
+ /** Re-verify the live canonical roots match the pinned roots. A symlink or
273
+ * ancestor swap after construction changes the realpath; that is a new root,
274
+ * not the one this module was bound to. */
275
+ _checkRoots() {
276
+ if (canonicalRoot(this.source) !== this.source) throw new ChildWorkspaceError('child_workspace_root_changed');
277
+ if (canonicalRoot(this.storage) !== this.storage) throw new ChildWorkspaceError('child_workspace_root_changed');
278
+ }
279
+
280
+ /** Verify a workspace object's root/baseline actually belong to THIS storage
281
+ * under the original identity and were not swapped to an external directory.
282
+ * Also verify the workspace's own manifest fields (source, base_revision,
283
+ * isolation_id) match this module's binding. A forged workspace.root must
284
+ * never publish outside the bound source. */
285
+ _validateWorkspace(workspace) {
286
+ if (!workspace || typeof workspace.isolationId !== 'string' || !HEX64.test(workspace.isolationId))
287
+ throw new ChildWorkspaceError('child_workspace_identity_invalid');
288
+ this._checkRoots();
289
+ const expectedTarget = canonicalRoot(join(this.storage, workspace.isolationId));
290
+ const expectedRoot = join(expectedTarget, 'work');
291
+ const expectedBaseline = join(expectedTarget, 'base');
292
+ // Use realpath (via canonicalRoot) so a symlink swap on the workspace root
293
+ // or an ancestor is detected — resolve() alone only lexical-normalizes and
294
+ // does NOT follow symlinks for the non-existent tail.
295
+ if (canonicalRoot(workspace.root) !== expectedRoot || canonicalRoot(workspace.baseline) !== expectedBaseline)
296
+ throw new ChildWorkspaceError('child_workspace_path_escape');
297
+ if (isSymlink(workspace.root) || isSymlink(workspace.baseline)
298
+ || isSymlink(expectedTarget) || isSymlink(join(this.storage, workspace.isolationId)))
299
+ throw new ChildWorkspaceError('child_workspace_path_escape');
300
+ // The root and baseline must still be directories (not replaced with a file).
301
+ if (!isDir(workspace.root) || !isDir(workspace.baseline))
302
+ throw new ChildWorkspaceError('child_workspace_path_escape');
303
+ const original = this.load(workspace.isolationId);
304
+ if (workspace.baseRevision !== original.baseRevision)
305
+ throw new ChildWorkspaceError('child_workspace_identity_changed');
306
+ }
307
+
308
+ /** Freeze actual input files once, preserving dirty and untracked content.
309
+ * Idempotent: a re-prepare for the same identity reuses the existing child
310
+ * and never resets its changes. */
311
+ prepare(identity) {
312
+ if (typeof identity !== 'string' || !HEX64.test(identity)) throw new ChildWorkspaceError('child_workspace_identity_invalid');
313
+ this._checkRoots();
314
+ mkdirSync(this.storage, { recursive: true });
315
+ const target = join(this.storage, identity);
316
+ // Lock BOTH source and target (sorted canonical order) so the parent source
317
+ // cannot be written while we capture and copy its files. The lock is
318
+ // synchronous and released in a finally — never held across an await.
319
+ const release = lockPaths([this.source, target]);
320
+ try {
321
+ if (existsSync(target)) return this.load(identity);
322
+ const temporary = join(this.storage, `.child-input-${identity.slice(0, 12)}-${process.pid}`);
323
+ mkdirSync(temporary, { recursive: true });
324
+ try {
325
+ if (existsSync(target)) return this.load(identity);
326
+ const { revision, entries } = captureInventory(this.source);
327
+ mkdirSync(join(temporary, 'base'), { recursive: true });
328
+ mkdirSync(join(temporary, 'work'), { recursive: true });
329
+ const sortedEntries = Object.entries(entries).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
330
+ // Copy files first, then set directory modes in reverse so a parent
331
+ // mode is not overridden by a later mkdir.
332
+ for (const [, entry] of sortedEntries) {
333
+ if (entry.kind === 'directory') {
334
+ mkdirSync(join(temporary, 'base', entry.path), { recursive: true });
335
+ mkdirSync(join(temporary, 'work', entry.path), { recursive: true });
336
+ }
337
+ }
338
+ for (const [, entry] of sortedEntries) {
339
+ if (entry.kind === 'file') {
340
+ const payload = readPayload(this.source, entry.path, entry);
341
+ for (const tree of ['base', 'work']) {
342
+ const dest = join(temporary, tree, entry.path);
343
+ mkdirSync(dirname(dest), { recursive: true });
344
+ writeFileSync(dest, payload);
345
+ chmodSync(dest, entry.mode);
346
+ }
347
+ }
348
+ }
349
+ for (let i = sortedEntries.length - 1; i >= 0; i--) {
350
+ const [, entry] = sortedEntries[i];
351
+ if (entry.kind === 'directory') {
352
+ chmodSync(join(temporary, 'base', entry.path), entry.mode);
353
+ chmodSync(join(temporary, 'work', entry.path), entry.mode);
354
+ }
355
+ }
356
+ // Re-verify the source did not change during the copy (held under the
357
+ // source lock, but a concurrent non-coordinated writer could still change
358
+ // it; the double-capture catches that).
359
+ const current = captureInventory(this.source);
360
+ if (current.revision !== revision) throw new ChildWorkspaceError('child_workspace_source_changed');
361
+ writeFileSync(join(temporary, 'inputs.json'), JSON.stringify({
362
+ version: CHILD_FILE_WORKSPACE_VERSION, identity, source: this.source, base_revision: revision,
363
+ }));
364
+ try { renameSync(temporary, target); }
365
+ catch (error) {
366
+ if (error.code === 'ENOTEMPTY' || error.code === 'EEXIST') {
367
+ // Another process published the same identity; reuse it.
368
+ return this.load(identity);
369
+ }
370
+ throw error;
371
+ }
372
+ return this.load(identity);
373
+ } finally {
374
+ if (existsSync(temporary)) { try { rmdirRecursive(temporary); } catch { /* best-effort */ } }
375
+ }
376
+ } finally { release(); }
377
+ }
378
+
379
+ /** Load an existing frozen workspace, verifying its manifest identity. */
380
+ load(identity) {
381
+ if (typeof identity !== 'string' || !HEX64.test(identity)) throw new ChildWorkspaceError('child_workspace_identity_invalid');
382
+ this._checkRoots();
383
+ const target = join(this.storage, identity);
384
+ if (!existsSync(target)) throw new ChildWorkspaceError('child_workspace_not_prepared');
385
+ const manifestPath = join(target, 'inputs.json');
386
+ if (isSymlink(manifestPath) || isSymlink(target)) throw new ChildWorkspaceError('child_workspace_identity_changed');
387
+ let data;
388
+ try { data = JSON.parse(readFileSync(manifestPath, 'utf-8')); } catch { throw new ChildWorkspaceError('child_workspace_identity_changed'); }
389
+ if (data.version !== CHILD_FILE_WORKSPACE_VERSION || data.identity !== identity
390
+ || data.source !== this.source || isSymlink(join(target, 'work')) || !isDir(join(target, 'work'))
391
+ || !REVISION_RE.test(data.base_revision) || !isDir(join(target, 'base'))
392
+ || isSymlink(join(target, 'base'))) throw new ChildWorkspaceError('child_workspace_identity_changed');
393
+ return new ChildFileWorkspace(identity, join(target, 'work'), join(target, 'base'), data.base_revision);
394
+ }
395
+
396
+ /** Return an observer bound to the workspace root, sharing the double-capture
397
+ * revision algorithm with createNativeWorkspaceTools().observe. */
398
+ observer(workspace) {
399
+ return {
400
+ observe: () => {
401
+ // Reject a forged workspace.root — the observer must not vouch for an
402
+ // arbitrary external path. This validates ownership before capturing.
403
+ try { this._validateWorkspace(workspace); } catch (error) {
404
+ if (error instanceof ChildWorkspaceError)
405
+ return { revision: null, changedPaths: [], status: 'unavailable', reason: error.code };
406
+ throw error;
407
+ }
408
+ try {
409
+ const { revision } = captureInventory(workspace.root);
410
+ return { revision, changedPaths: [], status: 'ok', reason: null };
411
+ } catch (error) {
412
+ if (error instanceof ChildWorkspaceError) return { revision: null, changedPaths: [], status: 'unavailable', reason: error.code };
413
+ return { revision: null, changedPaths: [], status: 'unavailable', reason: 'capture_failed' };
414
+ }
415
+ },
416
+ };
417
+ }
418
+
419
+ // ---- publication inventory -------------------------------------------------
420
+
421
+ _publicationPath(workspace, revision) {
422
+ if (typeof revision !== 'string' || !REVISION_RE.test(revision)) throw new ChildWorkspaceError('child_publication_identity_invalid');
423
+ return join(dirname(workspace.root), 'publications', `${revision.slice(7)}.json`);
424
+ }
425
+
426
+ /** Verify a caller-supplied previous publication against its immutable disk
427
+ * inventory. A missing/tampered/foreign inventory returns null (no grant). */
428
+ _verifyPreviousPublication(workspace, previous) {
429
+ if (!previous || typeof previous.revision !== 'string' || typeof previous.inventoryDigest !== 'string') return null;
430
+ const target = this._publicationPath(workspace, previous.revision);
431
+ if (!existsSync(target) || isSymlink(target) || isSymlink(dirname(target))) return null;
432
+ let value;
433
+ try { value = readFileSync(target); } catch { return null; }
434
+ if ('sha256:' + sha256(value) !== previous.inventoryDigest) return null;
435
+ let data;
436
+ try { data = JSON.parse(value); } catch { return null; }
437
+ if (data.version !== CHILD_FILE_PUBLICATION_VERSION || data.isolation_id !== workspace.isolationId
438
+ || data.source !== this.source || data.base_revision !== workspace.baseRevision
439
+ || data.child_revision !== previous.revision) return null;
440
+ const entries = Object.create(null);
441
+ for (const e of data.entries) entries[e.path] = new DirectoryEntry(e.path, e.kind, e.mode, e.content_digest);
442
+ return entries;
443
+ }
444
+
445
+ _savePublication(workspace, revision, entries) {
446
+ const target = this._publicationPath(workspace, revision);
447
+ const value = Buffer.from(JSON.stringify({
448
+ version: CHILD_FILE_PUBLICATION_VERSION, isolation_id: workspace.isolationId,
449
+ source: this.source, base_revision: workspace.baseRevision,
450
+ child_revision: revision, entries: Object.values(entries).map(e => ({
451
+ path: e.path, kind: e.kind, mode: e.mode, content_digest: e.contentDigest,
452
+ })),
453
+ }), 'utf-8');
454
+ mkdirSync(dirname(target), { recursive: true });
455
+ if (isSymlink(target) || isSymlink(dirname(target))) throw new ChildWorkspaceError('child_publication_inventory_changed');
456
+ if (existsSync(target)) {
457
+ if (readFileSync(target).toString('utf-8') !== value.toString('utf-8')) throw new ChildWorkspaceError('child_publication_inventory_changed');
458
+ } else {
459
+ publishWorkspaceBytes(target, value, { createOnly: true });
460
+ }
461
+ return { revision, inventory_digest: 'sha256:' + sha256(value) };
462
+ }
463
+
464
+ /** Publish eligible files only when the parent still matches the base.
465
+ * A conflict publishes nothing. An OS failure reports the real applied
466
+ * prefix — this is NOT an atomic multi-file transaction.
467
+ * @param {object} [opts] merge options
468
+ * @param {string} opts.expectedRevision the current child workspace revision
469
+ * @param {ChildFilePublication} [opts.previous] a caller-supplied acknowledged publication
470
+ * @param {function():true} [opts.assertCurrent] a synchronous Host guard the
471
+ * module calls after acquiring the lock, after the precheck, and before
472
+ * each real publish/delete. It must return strictly === true; any other
473
+ * value (or throw) aborts the merge with no further writes. The Host owns
474
+ * the real ledger/fence/receipt; this module never assumes true by default. */
475
+ merge(workspace, { expectedRevision, previous, assertCurrent } = {}) {
476
+ // Reject a forged workspace whose root/baseline do not belong to this
477
+ // storage under the original identity — before any lock or write.
478
+ this._validateWorkspace(workspace);
479
+ const applied = [];
480
+ const guard = () => {
481
+ if (assertCurrent === undefined) return;
482
+ try {
483
+ if (typeof assertCurrent !== 'function') throw new ChildWorkspaceError('child_workspace_guard_failed');
484
+ const result = assertCurrent();
485
+ if (result !== true) {
486
+ if (result && typeof result.then === 'function') Promise.resolve(result).catch(() => {});
487
+ throw new ChildWorkspaceError('child_workspace_guard_failed');
488
+ }
489
+ } catch (error) {
490
+ // A revoked lease after a published prefix cannot become a clean
491
+ // rejection inviting a blind retry. Preserve the actual prefix for
492
+ // the owning Native ToolPort's uncertain receipt.
493
+ const failure = new ChildWorkspaceError('child_workspace_guard_failed');
494
+ failure.applied = [...applied];
495
+ failure.effectStarted = applied.length > 0;
496
+ throw failure;
497
+ }
498
+ };
499
+ guard();
500
+ // All conflict checks happen BEFORE any write. The critical section is
501
+ // synchronous; no boundary is loosened after an await (there is no await).
502
+ const release = lockPaths([this.source, workspace.root, workspace.baseline]);
503
+ try {
504
+ // The post-lock guard is INSIDE the try so a guard failure still releases
505
+ // all locks in the finally below. A guard failure must never leak a lock.
506
+ guard();
507
+ const baseCapture = captureInventory(workspace.baseline);
508
+ if (baseCapture.revision !== workspace.baseRevision) throw new ChildWorkspaceError('child_workspace_baseline_changed');
509
+ const childCapture = captureInventory(workspace.root);
510
+ if (childCapture.revision !== expectedRevision) throw new ChildWorkspaceError('child_workspace_after_delivery_changed');
511
+ const verifiedPrevious = previous ? this._verifyPreviousPublication(workspace, previous) : null;
512
+ const usedPrevious = !!verifiedPrevious;
513
+ const baseline = verifiedPrevious ?? baseCapture.entries;
514
+ const proposal = childCapture.entries;
515
+ // Files that are files in EITHER the baseline or the proposal.
516
+ const files = new Set();
517
+ for (const [path, entry] of Object.entries(baseline)) if (entry.kind === 'file') files.add(path);
518
+ for (const [path, entry] of Object.entries(proposal)) if (entry.kind === 'file') files.add(path);
519
+ const changed = [...files].filter(path => {
520
+ const b = baseline[path], p = proposal[path];
521
+ return !b || !p || !b.equals(p);
522
+ }).sort();
523
+ // A file becoming a directory or vice versa is a hard error.
524
+ for (const path of changed) {
525
+ const b = baseline[path], p = proposal[path];
526
+ if (b && p && b.kind !== p.kind) throw new ChildWorkspaceError('child_workspace_file_kind_changed');
527
+ }
528
+ const directories = new Set();
529
+ for (const [path, entry] of Object.entries(baseline)) if (entry.kind === 'directory') directories.add(path);
530
+ for (const [path, entry] of Object.entries(proposal)) if (entry.kind === 'directory') directories.add(path);
531
+ const changedDirectories = [...directories].filter(path => {
532
+ const b = baseline[path], p = proposal[path];
533
+ return !b || !p || !b.equals(p);
534
+ });
535
+ const removedDirectories = new Set(changedDirectories.filter(path => !proposal[path]));
536
+ // Re-capture the parent to detect conflicts with concurrent changes.
537
+ // The comparison is a strict three-way check: parent.get(path) must be in
538
+ // (baseline.get(path), proposal.get(path)), where null (absent) is a real
539
+ // state. A parent DELETION (null) while the child edited the file is a
540
+ // conflict — the child's edit must not resurrect a parent-deleted file.
541
+ // null === null via entryEquals; both absent is NOT a conflict.
542
+ const parentCapture = captureInventory(this.source);
543
+ const parent = parentCapture.entries;
544
+ const entryEquals = (a, b) => (!a && !b) || (!!a && !!b && a.equals(b));
545
+ const conflicts = [];
546
+ for (const path of changed) {
547
+ if (!entryEquals(parent[path], baseline[path]) && !entryEquals(parent[path], proposal[path]))
548
+ conflicts.push(path);
549
+ }
550
+ for (const path of changedDirectories) {
551
+ if (!entryEquals(parent[path], baseline[path]) && !entryEquals(parent[path], proposal[path]))
552
+ conflicts.push(path);
553
+ }
554
+ // A late parent file added inside a removed directory must be preserved.
555
+ for (const [path] of Object.entries(parent)) {
556
+ if (!baseline[path]) {
557
+ for (const dir of removedDirectories) {
558
+ if (path.startsWith(dir + '/')) { conflicts.push(path); break; }
559
+ }
560
+ }
561
+ }
562
+ if (conflicts.length) {
563
+ return {
564
+ ok: false, error_code: 'child_workspace_merge_conflict',
565
+ conflicts: [...new Set(conflicts)].sort(), applied: [],
566
+ comparison_revision: usedPrevious ? previous.revision : baseCapture.revision,
567
+ instruction: 'Read the current parent file and resolve the conflict with its ordinary authorized editing tools. Nothing was merged.',
568
+ };
569
+ }
570
+ guard();
571
+ // Save the immutable publication inventory BEFORE applying, so a crash
572
+ // after the save but before a partial apply leaves a verifiable receipt.
573
+ const publication = this._savePublication(workspace, childCapture.revision, proposal);
574
+ const payloads = Object.create(null);
575
+ for (const path of changed) {
576
+ if (proposal[path]) payloads[path] = readPayload(workspace.root, path, proposal[path]);
577
+ }
578
+ const alreadyPresent = [];
579
+ // Create/mode directories first (shallow-to-deep), then files, then remove
580
+ // directories (deep-to-shallow) so an empty directory's children are gone.
581
+ const createOrMode = changedDirectories.filter(d => !removedDirectories.has(d))
582
+ .sort((a, b) => (a.split('/').length - b.split('/').length) || (a < b ? -1 : a > b ? 1 : 0));
583
+ const remove = [...removedDirectories].sort((a, b) => (b.split('/').length - a.split('/').length) || (a < b ? -1 : a > b ? 1 : 0));
584
+ const order = [...createOrMode, ...changed, ...remove];
585
+ for (const path of order) {
586
+ // Both absent (parent and proposal both deleted) is already-present
587
+ // (nothing to do); both present and equal is already-present. Use the
588
+ // same entryEquals as the conflict check so null is a real state.
589
+ if (entryEquals(parent[path], proposal[path])) { alreadyPresent.push(path); continue; }
590
+ const target = join(this.source, path);
591
+ guard();
592
+ try {
593
+ if (changedDirectories.includes(path) || removedDirectories.has(path)) {
594
+ if (!proposal[path]) {
595
+ // Never recursively delete: only remove an empty directory.
596
+ rmdirSync(target);
597
+ } else if (!parent[path]) {
598
+ mkdirSync(target, { recursive: true });
599
+ }
600
+ applied.push(path);
601
+ if (proposal[path]) chmodSync(target, proposal[path].mode);
602
+ } else if (proposal[path]) {
603
+ publishWorkspaceBytes(target, payloads[path], { createOnly: !baseline[path] });
604
+ applied.push(path);
605
+ chmodSync(target, proposal[path].mode);
606
+ } else {
607
+ unlinkSync(target);
608
+ applied.push(path);
609
+ }
610
+ } catch (error) {
611
+ if (error instanceof ChildWorkspaceError) throw error;
612
+ return {
613
+ ok: false, error_code: 'child_workspace_publish_failed',
614
+ applied, failed_path: path,
615
+ instruction: 'Inspect the reported files before continuing; this is not an atomic multi-file transaction.',
616
+ };
617
+ }
618
+ }
619
+ return {
620
+ ok: true, applied, already_present: alreadyPresent,
621
+ base_revision: baseCapture.revision, child_revision: childCapture.revision,
622
+ comparison_revision: usedPrevious ? previous.revision : baseCapture.revision,
623
+ publication,
624
+ };
625
+ } finally { release(); }
626
+ }
627
+ }
628
+
629
+ // ---- helpers ----------------------------------------------------------------
630
+
631
+ /** Canonicalize a root path: resolve symlinks for the existing portion (like
632
+ * Python's Path.resolve()), normalizing macOS /var → /private/var aliases.
633
+ * For a not-yet-created path, resolve the closest existing ancestor and
634
+ * append the non-existent tail lexically. This is NOT a substitute for a
635
+ * trusted directory fd, but it detects a swap visible at the path string. */
636
+ function canonicalRoot(p) {
637
+ const resolved = resolve(p);
638
+ if (existsSync(resolved)) {
639
+ try { return realpathSync(resolved); } catch { return resolved; }
640
+ }
641
+ return realpathExisting(resolved);
642
+ }
643
+
644
+ function isSymlink(p) { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } }
645
+ function isDir(p) { try { return lstatSync(p).isDirectory(); } catch { return false; } }
646
+ function rmdirRecursive(p) {
647
+ let dirents;
648
+ try { dirents = readdirSync(p, { withFileTypes: true }); } catch { return; }
649
+ for (const d of dirents) {
650
+ const abs = join(p, d.name);
651
+ if (d.isDirectory()) rmdirRecursive(abs);
652
+ else try { unlinkSync(abs); } catch { /* gone */ }
653
+ }
654
+ try { rmdirSync(p); } catch { /* not empty */ }
655
+ }