agentlas 1.0.65 → 1.0.67

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 (163) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +12 -4
  3. package/bin/agentlas.cjs +354 -16
  4. package/engine/acp/server.cjs +34 -3
  5. package/engine/agentlas-api-agent.cjs +151 -120
  6. package/engine/agentlas-banner.cjs +6 -2
  7. package/engine/agentlas-capabilities.cjs +17 -10
  8. package/engine/agentlas-cloud-runtime.cjs +4 -5
  9. package/engine/agentlas-config.cjs +19 -54
  10. package/engine/agentlas-core-harness.cjs +59 -5
  11. package/engine/agentlas-desktop-loadout.cjs +17 -3
  12. package/engine/agentlas-evolution.cjs +75 -18
  13. package/engine/agentlas-experience-exchange.cjs +145 -14
  14. package/engine/agentlas-experience-intake.cjs +2 -2
  15. package/engine/agentlas-i18n.cjs +0 -2
  16. package/engine/agentlas-input.cjs +273 -20
  17. package/engine/agentlas-judgment.cjs +0 -0
  18. package/engine/agentlas-mcp-env.cjs +84 -23
  19. package/engine/agentlas-mcp-wrapper.cjs +36 -4
  20. package/engine/agentlas-memory-governance.cjs +2 -1
  21. package/engine/agentlas-memory-import.cjs +49 -12
  22. package/engine/agentlas-native-host.cjs +279 -44
  23. package/engine/agentlas-onboard.cjs +23 -1
  24. package/engine/agentlas-permissions.cjs +5 -1
  25. package/engine/agentlas-sqlite-policy.cjs +40 -2
  26. package/engine/agentlas-tasks.cjs +7 -0
  27. package/engine/agentlas-tools.cjs +37 -10
  28. package/engine/agentlas-ui.cjs +26 -4
  29. package/engine/agentlas-workforce.cjs +30 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +21 -4
  32. package/engine/agents/builder.cjs +35 -10
  33. package/engine/agents/files.cjs +81 -10
  34. package/engine/agents/import-local.cjs +10 -2
  35. package/engine/agents/registry.cjs +10 -3
  36. package/engine/agents/router.cjs +7 -7
  37. package/engine/agents/routes.cjs +31 -9
  38. package/engine/automation/daemon.cjs +154 -40
  39. package/engine/automation/launchd.cjs +395 -16
  40. package/engine/automation/schedule.cjs +90 -3
  41. package/engine/automation/store.cjs +163 -59
  42. package/engine/browser/cdp.cjs +190 -17
  43. package/engine/browser/vault.cjs +1 -0
  44. package/engine/cli-output.cjs +96 -30
  45. package/engine/cloud/auth.cjs +131 -13
  46. package/engine/cloud/hub-client.cjs +41 -16
  47. package/engine/cloud-assets/cas.cjs +18 -9
  48. package/engine/cloud-assets/commands.cjs +108 -30
  49. package/engine/cloud-assets/package.cjs +66 -3
  50. package/engine/cloud-assets/restore.cjs +12 -9
  51. package/engine/cloud-assets/state.cjs +609 -116
  52. package/engine/commands/acp.cjs +13 -2
  53. package/engine/commands/automation.cjs +75 -24
  54. package/engine/commands/billing.cjs +6 -1
  55. package/engine/commands/browser.cjs +61 -29
  56. package/engine/commands/build.cjs +72 -14
  57. package/engine/commands/call.cjs +1 -1
  58. package/engine/commands/cd.cjs +1 -1
  59. package/engine/commands/cloud.cjs +2 -1
  60. package/engine/commands/connect.cjs +38 -13
  61. package/engine/commands/creds.cjs +74 -29
  62. package/engine/commands/doctor.cjs +17 -1
  63. package/engine/commands/document.cjs +38 -10
  64. package/engine/commands/env.cjs +6 -1
  65. package/engine/commands/firm.cjs +42 -12
  66. package/engine/commands/graph.cjs +36 -32
  67. package/engine/commands/help.cjs +11 -1
  68. package/engine/commands/hep.cjs +1 -1
  69. package/engine/commands/import.cjs +5 -4
  70. package/engine/commands/index.cjs +1 -1
  71. package/engine/commands/install.cjs +6 -4
  72. package/engine/commands/list.cjs +56 -16
  73. package/engine/commands/login.cjs +5 -0
  74. package/engine/commands/logout.cjs +6 -1
  75. package/engine/commands/mcp.cjs +150 -37
  76. package/engine/commands/memory.cjs +3 -2
  77. package/engine/commands/multimodal.cjs +12 -0
  78. package/engine/commands/native.cjs +5 -1
  79. package/engine/commands/one.cjs +78 -33
  80. package/engine/commands/plugin.cjs +13 -2
  81. package/engine/commands/project.cjs +15 -12
  82. package/engine/commands/research.cjs +18 -7
  83. package/engine/commands/roles.cjs +104 -28
  84. package/engine/commands/route.cjs +2 -2
  85. package/engine/commands/run.cjs +42 -9
  86. package/engine/commands/search.cjs +23 -2
  87. package/engine/commands/setup.cjs +6 -1
  88. package/engine/commands/storm.cjs +44 -6
  89. package/engine/commands/swarm.cjs +47 -9
  90. package/engine/commands/telegram.cjs +6 -1
  91. package/engine/commands/uninstall.cjs +12 -6
  92. package/engine/commands/update.cjs +5 -0
  93. package/engine/commands/upload.cjs +2 -1
  94. package/engine/commands/usage.cjs +6 -1
  95. package/engine/commands/version.cjs +6 -1
  96. package/engine/commands/whoami.cjs +6 -1
  97. package/engine/commands/workforce.cjs +60 -16
  98. package/engine/core/capability-grants.cjs +7 -2
  99. package/engine/core/daemon-client.cjs +36 -7
  100. package/engine/core/db.cjs +10 -2
  101. package/engine/core/desktop-core-fetch.cjs +477 -77
  102. package/engine/core/schema-ensure.cjs +23 -11
  103. package/engine/experience/build.cjs +5 -1
  104. package/engine/experience/intents.cjs +35 -82
  105. package/engine/experience/runtime.cjs +7 -1
  106. package/engine/experience/variant.cjs +58 -4
  107. package/engine/firms/orchestrate.cjs +9 -6
  108. package/engine/graph/ask-model.cjs +22 -11
  109. package/engine/graph/interview.cjs +74 -23
  110. package/engine/graph/package.cjs +63 -8
  111. package/engine/hephaestus/local-core.cjs +18 -5
  112. package/engine/hephaestus/runtime.cjs +48 -43
  113. package/engine/hub/install.cjs +484 -63
  114. package/engine/hub/plugins.cjs +391 -29
  115. package/engine/mcp/consent.cjs +25 -17
  116. package/engine/mcp/contract.cjs +229 -24
  117. package/engine/mcp/inventory.cjs +46 -10
  118. package/engine/mcp/plan.cjs +10 -1
  119. package/engine/mcp/probe.cjs +24 -9
  120. package/engine/memory-cli/curate.cjs +74 -16
  121. package/engine/oberon/common.cjs +37 -12
  122. package/engine/oberon/manifest.cjs +25 -2
  123. package/engine/oberon/outputs.cjs +49 -19
  124. package/engine/oberon/render.cjs +665 -40
  125. package/engine/project/career-graph.cjs +15 -2
  126. package/engine/project/controller.cjs +133 -15
  127. package/engine/project/credentials.cjs +382 -34
  128. package/engine/project/env-file.cjs +93 -8
  129. package/engine/project/memory-context.cjs +11 -57
  130. package/engine/project/ontology.cjs +66 -25
  131. package/engine/project/paths.cjs +42 -8
  132. package/engine/project/seed.cjs +88 -51
  133. package/engine/project/state.cjs +41 -16
  134. package/engine/project/team.cjs +26 -1
  135. package/engine/runtime-refusal.cjs +4 -1
  136. package/engine/runtimes/acp-driver.cjs +56 -9
  137. package/engine/runtimes/auth-evidence.cjs +21 -2
  138. package/engine/runtimes/detect.cjs +28 -8
  139. package/engine/runtimes/kinds.cjs +12 -4
  140. package/engine/runtimes/overrides.cjs +4 -3
  141. package/engine/runtimes/resolve.cjs +8 -7
  142. package/engine/runtimes/roles.cjs +7 -5
  143. package/engine/sessions/apply-fences.cjs +396 -56
  144. package/engine/sessions/fences.cjs +111 -27
  145. package/engine/sessions/memory-turn.cjs +62 -3
  146. package/engine/sessions/orchestrator.cjs +11 -1
  147. package/engine/sessions/prompt.cjs +66 -9
  148. package/engine/sessions/session.cjs +70 -10
  149. package/engine/sessions/sink.cjs +78 -12
  150. package/engine/sessions/store.cjs +29 -7
  151. package/engine/storm/storm.cjs +26 -3
  152. package/engine/storm/swarm.cjs +31 -3
  153. package/engine/telegram/connect.cjs +185 -22
  154. package/engine/ui/commands-catalog.cjs +1 -1
  155. package/engine/ui/palette.cjs +12 -2
  156. package/engine/ui/repl.cjs +57 -9
  157. package/engine/ui/screens.cjs +2 -4
  158. package/engine/vendor/desktop-core.manifest.json +5 -5
  159. package/engine/workforce/capture.cjs +9 -27
  160. package/engine/workforce/deps.cjs +18 -6
  161. package/engine/workforce/local-core-transport.cjs +68 -5
  162. package/package.json +1 -1
  163. package/engine/commands/career-graph.cjs +0 -51
@@ -24,12 +24,430 @@ const {
24
24
  CLOUD_PACKAGE_HASH_V1,
25
25
  CLOUD_RESTORE_MARKER_PATH,
26
26
  cloudSlug,
27
- cloudApplyPortableFileMode,
28
27
  cloudFsyncDirectory,
29
28
  normalizeCloudAssetDescriptor,
30
29
  } = require("../hub/install.cjs");
31
30
 
32
31
  const CLOUD_ASSET_STATE_FILE = "cloud-asset-state.v1.json";
32
+ const CLOUD_ASSET_STATE_MAX_BYTES = 1024 * 1024;
33
+ const CLOUD_ASSET_LOCK_STALE_MS = 30_000;
34
+ const CLOUD_ASSET_LOCK_WAIT_MS = 10_000;
35
+
36
+ function waitSync(milliseconds) {
37
+ const signal = new Int32Array(new SharedArrayBuffer(4));
38
+ Atomics.wait(signal, 0, 0, milliseconds);
39
+ }
40
+
41
+ const STATE_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
42
+
43
+ function stateSameDirectoryIdentity(left, right) {
44
+ return Boolean(
45
+ left && right && left.isDirectory() && right.isDirectory() &&
46
+ !left.isSymbolicLink() && !right.isSymbolicLink() &&
47
+ left.dev === right.dev && left.ino === right.ino,
48
+ );
49
+ }
50
+
51
+ function stateDirectoryAnchor(target, label, { allowMissing = false, containedBy = null } = {}) {
52
+ let stat;
53
+ try { stat = fs.lstatSync(target); }
54
+ catch (error) {
55
+ if (allowMissing && error && error.code === "ENOENT") return null;
56
+ throw error;
57
+ }
58
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
59
+ throw new Error(`${label} is not a safe managed directory`);
60
+ }
61
+ let realpath;
62
+ try { realpath = fs.realpathSync.native(target); }
63
+ catch (error) { throw new Error(`${label} could not be canonicalized: ${error.message || error}`); }
64
+ if (containedBy && !(
65
+ realpath === containedBy.realpath || realpath.startsWith(`${containedBy.realpath}${path.sep}`)
66
+ )) {
67
+ throw new Error(`${label} escapes its managed root`);
68
+ }
69
+ return { path: target, realpath, dev: stat.dev, ino: stat.ino, stat };
70
+ }
71
+
72
+ function stateAssertDirectoryAnchor(anchor, label, containedBy = null) {
73
+ const current = stateDirectoryAnchor(anchor.path, label, { allowMissing: false, containedBy });
74
+ if (
75
+ !stateSameDirectoryIdentity(anchor.stat || anchor, current.stat || current) ||
76
+ current.realpath !== anchor.realpath
77
+ ) {
78
+ throw new Error(`${label} changed while it was being used`);
79
+ }
80
+ return current;
81
+ }
82
+
83
+ function stateEnsureDirectory(target, label) {
84
+ fs.mkdirSync(target, { recursive: true, mode: 0o700 });
85
+ const anchor = stateDirectoryAnchor(target, label);
86
+ try { fs.chmodSync(target, 0o700); } catch { /* Windows/best-effort */ }
87
+ stateAssertDirectoryAnchor(anchor, label);
88
+ return anchor;
89
+ }
90
+
91
+ function stateSameFileIdentity(left, right) {
92
+ return Boolean(
93
+ left && right && left.isFile() && right.isFile() &&
94
+ !left.isSymbolicLink() && !right.isSymbolicLink() &&
95
+ left.dev === right.dev && left.ino === right.ino,
96
+ );
97
+ }
98
+
99
+ function stateSameFileSnapshot(left, right) {
100
+ return stateSameFileIdentity(left, right) && left.nlink === right.nlink &&
101
+ left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
102
+ }
103
+
104
+ function stateFileSnapshot(file, label, { allowMissing = false, maxBytes = CLOUD_ASSET_STATE_MAX_BYTES, allowHardLinks = false } = {}) {
105
+ let stat;
106
+ try { stat = fs.lstatSync(file); }
107
+ catch (error) {
108
+ if (allowMissing && error && error.code === "ENOENT") return null;
109
+ throw error;
110
+ }
111
+ if (!stat.isFile() || stat.isSymbolicLink() || (!allowHardLinks && stat.nlink !== 1) || stat.size > maxBytes) {
112
+ throw new Error(`${label} is not a bounded private file`);
113
+ }
114
+ return stat;
115
+ }
116
+
117
+ function stateRemoveOwnedFile(file, expected, { allowLinked = false } = {}) {
118
+ try {
119
+ const current = fs.lstatSync(file);
120
+ if (stateSameFileIdentity(current, expected) && (current.nlink === 1 || (allowLinked && current.nlink >= 2))) {
121
+ fs.unlinkSync(file);
122
+ }
123
+ } catch { /* leave unknown successors and recovery artifacts untouched */ }
124
+ }
125
+
126
+ function stateWriteTemp(directory, name, payload, label) {
127
+ stateAssertDirectoryAnchor(directory, `${label} directory`);
128
+ const file = path.join(directory.realpath, name);
129
+ let fd;
130
+ let created;
131
+ try {
132
+ fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | STATE_NOFOLLOW, 0o600);
133
+ created = fs.fstatSync(fd);
134
+ if (!created.isFile() || created.isSymbolicLink() || created.nlink !== 1) {
135
+ throw new Error(`${label} temporary file is unsafe`);
136
+ }
137
+ const bytes = Buffer.from(payload, "utf8");
138
+ let offset = 0;
139
+ while (offset < bytes.length) {
140
+ const written = fs.writeSync(fd, bytes, offset, bytes.length - offset, null);
141
+ if (!Number.isInteger(written) || written <= 0) throw new Error(`${label} temporary file write stalled`);
142
+ offset += written;
143
+ }
144
+ try { fs.fchmodSync(fd, 0o600); } catch { /* Windows/best-effort */ }
145
+ fs.fsyncSync(fd);
146
+ const written = fs.fstatSync(fd);
147
+ if (!stateSameFileIdentity(created, written) || written.nlink !== 1 || written.size !== bytes.length) {
148
+ throw new Error(`${label} temporary file changed while writing`);
149
+ }
150
+ return { path: file, stat: written };
151
+ } catch (error) {
152
+ if (created) stateRemoveOwnedFile(file, created);
153
+ throw error;
154
+ } finally {
155
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* preserve original failure */ }
156
+ }
157
+ }
158
+
159
+ function stateRestoreBackup(backup, target, expected, label) {
160
+ try {
161
+ if (stateFileSnapshot(target, `${label} successor`, { allowMissing: true })) return false;
162
+ const current = stateFileSnapshot(backup, `${label} backup`);
163
+ if (!current || !stateSameFileIdentity(current, expected) || current.nlink !== 1) return false;
164
+ fs.linkSync(backup, target);
165
+ const restored = stateFileSnapshot(target, `${label} restored`);
166
+ if (!restored || !stateSameFileIdentity(restored, expected) || restored.nlink < 2) return false;
167
+ fs.unlinkSync(backup);
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Publish a state/marker file only after the managed directory and the
176
+ * previously observed target have remained the same. Existing targets are
177
+ * quarantined first, then the new file is linked with no-replace semantics;
178
+ * an unexpected successor is never overwritten.
179
+ */
180
+ function statePublishFile(directory, targetName, temporary, expected, label) {
181
+ stateAssertDirectoryAnchor(directory, `${label} directory`);
182
+ const target = path.join(directory.realpath, targetName);
183
+ const current = stateFileSnapshot(target, `${label} target`, { allowMissing: true });
184
+ if ((expected && (!current || !stateSameFileSnapshot(current, expected))) || (!expected && current)) {
185
+ throw new Error(`${label} changed before publication`);
186
+ }
187
+ let backup = null;
188
+ let linked = false;
189
+ try {
190
+ if (expected) {
191
+ backup = `${target}.previous-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
192
+ fs.renameSync(target, backup);
193
+ const moved = stateFileSnapshot(backup, `${label} backup`);
194
+ if (!moved || !stateSameFileIdentity(moved, expected) || moved.nlink !== 1) {
195
+ stateRestoreBackup(backup, target, expected, label);
196
+ throw new Error(`${label} target changed before publication`);
197
+ }
198
+ stateAssertDirectoryAnchor(directory, `${label} directory`);
199
+ if (stateFileSnapshot(target, `${label} successor`, { allowMissing: true })) {
200
+ throw new Error(`${label} successor appeared during publication`);
201
+ }
202
+ }
203
+ stateAssertDirectoryAnchor(directory, `${label} directory`);
204
+ if (stateFileSnapshot(target, `${label} successor`, { allowMissing: true })) {
205
+ throw new Error(`${label} successor appeared during publication`);
206
+ }
207
+ fs.linkSync(temporary.path, target);
208
+ linked = true;
209
+ const linkedTarget = stateFileSnapshot(target, `${label} target`, { allowHardLinks: true });
210
+ if (!linkedTarget || !stateSameFileIdentity(linkedTarget, temporary.stat) || linkedTarget.nlink < 2) {
211
+ throw new Error(`${label} publication produced an unsafe target`);
212
+ }
213
+ stateAssertDirectoryAnchor(directory, `${label} directory`);
214
+ stateRemoveOwnedFile(temporary.path, temporary.stat, { allowLinked: true });
215
+ const installed = stateFileSnapshot(target, `${label} target`);
216
+ if (!installed || !stateSameFileIdentity(installed, temporary.stat) || installed.nlink !== 1) {
217
+ throw new Error(`${label} identity changed after publication`);
218
+ }
219
+ try { fs.chmodSync(target, 0o600); } catch { /* Windows/best-effort */ }
220
+ const final = stateFileSnapshot(target, `${label} target`);
221
+ if (!final || !stateSameFileIdentity(final, temporary.stat) || final.nlink !== 1 ||
222
+ (process.platform !== "win32" && (final.mode & 0o777) !== 0o600)) {
223
+ throw new Error(`${label} mode or identity changed after publication`);
224
+ }
225
+ stateAssertDirectoryAnchor(directory, `${label} directory`);
226
+ if (backup) {
227
+ const backupStat = stateFileSnapshot(backup, `${label} backup`, { allowMissing: true });
228
+ if (backupStat && stateSameFileIdentity(backupStat, expected) && backupStat.nlink === 1) {
229
+ fs.unlinkSync(backup);
230
+ }
231
+ }
232
+ return target;
233
+ } catch (error) {
234
+ if (linked) stateRemoveOwnedFile(target, temporary.stat);
235
+ stateRemoveOwnedFile(temporary.path, temporary.stat);
236
+ if (backup) stateRestoreBackup(backup, target, expected, label);
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ function stateWriteLockOwner(lockParent, lock, owner) {
242
+ stateAssertDirectoryAnchor(lockParent, "Cloud asset lock parent");
243
+ stateAssertDirectoryAnchor(lock, "Cloud asset lock", lockParent);
244
+ const ownerPath = path.join(lock.realpath, "owner.json");
245
+ let fd;
246
+ let created;
247
+ try {
248
+ fd = fs.openSync(ownerPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | STATE_NOFOLLOW, 0o600);
249
+ created = fs.fstatSync(fd);
250
+ if (!created.isFile() || created.isSymbolicLink() || created.nlink !== 1) {
251
+ throw new Error("lock owner is not a bounded private file");
252
+ }
253
+ const payload = Buffer.from(JSON.stringify(owner) + "\n", "utf8");
254
+ let offset = 0;
255
+ while (offset < payload.length) {
256
+ const written = fs.writeSync(fd, payload, offset, payload.length - offset, null);
257
+ if (!Number.isInteger(written) || written <= 0) throw new Error("lock owner write stalled");
258
+ offset += written;
259
+ }
260
+ try { fs.fchmodSync(fd, 0o600); } catch { /* Windows/best-effort */ }
261
+ fs.fsyncSync(fd);
262
+ const written = fs.fstatSync(fd);
263
+ if (!stateSameFileIdentity(created, written) || written.nlink !== 1 || written.size !== payload.length) {
264
+ throw new Error("lock owner changed while it was written");
265
+ }
266
+ } catch (error) {
267
+ if (created) stateRemoveOwnedFile(ownerPath, created);
268
+ throw error;
269
+ } finally {
270
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* preserve original failure */ }
271
+ }
272
+ stateAssertDirectoryAnchor(lockParent, "Cloud asset lock parent");
273
+ stateAssertDirectoryAnchor(lock, "Cloud asset lock", lockParent);
274
+ const written = stateFileSnapshot(ownerPath, "Cloud asset lock owner", { maxBytes: 512 });
275
+ if (!written || !stateSameFileIdentity(written, created) || written.nlink !== 1 || (written.mode & 0o777) !== 0o600) {
276
+ throw new Error("lock owner changed while it was written");
277
+ }
278
+ }
279
+
280
+ function stateRemoveOwnedLockDirectory(directoryPath, expected) {
281
+ let current;
282
+ try { current = stateDirectoryAnchor(directoryPath, "Cloud asset lock cleanup"); }
283
+ catch { return false; }
284
+ if (!stateSameDirectoryIdentity(current.stat, expected.stat || expected)) return false;
285
+ const ownerPath = path.join(current.realpath, "owner.json");
286
+ const owner = stateFileSnapshot(ownerPath, "Cloud asset lock owner", { allowMissing: true, maxBytes: 512 });
287
+ if (owner) {
288
+ try { fs.unlinkSync(ownerPath); } catch { return false; }
289
+ }
290
+ try {
291
+ fs.rmdirSync(current.realpath);
292
+ return true;
293
+ } catch {
294
+ // Never recursively delete a lock directory after its identity is no
295
+ // longer provable; leave the artifact for bounded stale-lock recovery.
296
+ return false;
297
+ }
298
+ }
299
+
300
+ function processIsAlive(pid) {
301
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
302
+ try {
303
+ process.kill(pid, 0);
304
+ return true;
305
+ } catch (error) {
306
+ if (error && error.code === "ESRCH") return false;
307
+ // EPERM means the process exists but belongs to another user. Unknown
308
+ // failures also stay fail-safe: never steal a lock from a possibly live owner.
309
+ return true;
310
+ }
311
+ }
312
+
313
+ function readLockOwner(lockPath, lockAnchor = null) {
314
+ if (lockAnchor) stateAssertDirectoryAnchor(lockAnchor, "Cloud asset lock");
315
+ const ownerPath = path.join(lockAnchor ? lockAnchor.realpath : lockPath, "owner.json");
316
+ let fd;
317
+ try {
318
+ const listed = stateFileSnapshot(ownerPath, "Cloud asset lock owner", { allowMissing: true, maxBytes: 512 });
319
+ if (!listed) return null;
320
+ fd = fs.openSync(ownerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
321
+ const before = fs.fstatSync(fd);
322
+ if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > 512 || !stateSameFileIdentity(before, listed)) {
323
+ throw new Error("lock owner is not a bounded private file");
324
+ }
325
+ const raw = fs.readFileSync(fd, "utf8");
326
+ const after = fs.fstatSync(fd);
327
+ if (
328
+ Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
329
+ after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
330
+ ) {
331
+ throw new Error("lock owner changed while it was read");
332
+ }
333
+ const parsed = JSON.parse(raw);
334
+ if (!Number.isSafeInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.nonce !== "string" || !/^[a-f0-9]{32}$/.test(parsed.nonce)) {
335
+ throw new Error("lock owner identity is invalid");
336
+ }
337
+ return parsed;
338
+ } catch (error) {
339
+ if (error && error.code === "ENOENT") return null;
340
+ throw error;
341
+ } finally {
342
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ }
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Directory rename is the lock hand-off boundary. A stale lock is moved to a
348
+ * unique quarantine path before cleanup, so a new owner's lock can never be
349
+ * removed by the old cleanup path.
350
+ */
351
+ function withCloudAssetLock(targetPath, label, action) {
352
+ const lockPath = `${targetPath}.lock`;
353
+ const lockParent = path.dirname(lockPath);
354
+ const lockParentAnchor = stateEnsureDirectory(lockParent, `${label} lock parent`);
355
+ const lockName = path.basename(lockPath);
356
+ const lockActualPath = path.join(lockParentAnchor.realpath, lockName);
357
+ const deadline = Date.now() + CLOUD_ASSET_LOCK_WAIT_MS;
358
+ let acquired = false;
359
+ let lockAnchor = null;
360
+ while (!acquired) {
361
+ let createdLock = false;
362
+ try {
363
+ stateAssertDirectoryAnchor(lockParentAnchor, `${label} lock parent`);
364
+ fs.mkdirSync(lockActualPath, { mode: 0o700 });
365
+ createdLock = true;
366
+ lockAnchor = stateDirectoryAnchor(lockActualPath, `${label} lock`, { containedBy: lockParentAnchor });
367
+ try { fs.chmodSync(lockActualPath, 0o700); } catch { /* Windows/best-effort */ }
368
+ stateAssertDirectoryAnchor(lockParentAnchor, `${label} lock parent`);
369
+ stateAssertDirectoryAnchor(lockAnchor, `${label} lock`, lockParentAnchor);
370
+ const owner = {
371
+ pid: process.pid,
372
+ nonce: crypto.randomBytes(16).toString("hex"),
373
+ createdAt: new Date().toISOString(),
374
+ };
375
+ stateWriteLockOwner(lockParentAnchor, lockAnchor, owner);
376
+ acquired = true;
377
+ } catch (error) {
378
+ if (!createdLock && error && (error.code === "EEXIST" || error.code === "ENOENT")) {
379
+ if (error.code === "ENOENT") continue;
380
+ let lock = null;
381
+ try {
382
+ lock = stateDirectoryAnchor(lockActualPath, `${label} lock`, {
383
+ allowMissing: true,
384
+ containedBy: lockParentAnchor,
385
+ });
386
+ } catch (statError) {
387
+ if (statError && statError.code === "ENOENT") continue;
388
+ throw statError;
389
+ }
390
+ if (!lock) continue;
391
+ let owner = null;
392
+ try { owner = readLockOwner(lockActualPath, lock); } catch { owner = null; }
393
+ if (Date.now() - lock.stat.mtimeMs > CLOUD_ASSET_LOCK_STALE_MS && (!owner || !processIsAlive(owner.pid))) {
394
+ stateAssertDirectoryAnchor(lockParentAnchor, `${label} lock parent`);
395
+ stateAssertDirectoryAnchor(lock, `${label} lock`, lockParentAnchor);
396
+ const quarantine = path.join(
397
+ lockParentAnchor.realpath,
398
+ `${lockName}.stale-${process.pid}-${crypto.randomBytes(6).toString("hex")}`,
399
+ );
400
+ try {
401
+ fs.renameSync(lockActualPath, quarantine);
402
+ const moved = stateDirectoryAnchor(quarantine, `${label} stale lock`, { containedBy: lockParentAnchor });
403
+ if (stateSameDirectoryIdentity(moved.stat, lock.stat)) stateRemoveOwnedLockDirectory(quarantine, moved);
404
+ } catch (reclaimError) {
405
+ if (reclaimError && reclaimError.code === "ENOENT") continue;
406
+ throw reclaimError;
407
+ }
408
+ continue;
409
+ }
410
+ if (Date.now() >= deadline) throw new Error(`${label} is busy; retry after the active operation finishes`);
411
+ waitSync(25);
412
+ continue;
413
+ }
414
+ if (lockAnchor && !acquired) {
415
+ const abandoned = path.join(
416
+ lockParentAnchor.realpath,
417
+ `${lockName}.abandoned-${process.pid}-${crypto.randomBytes(6).toString("hex")}`,
418
+ );
419
+ try {
420
+ stateAssertDirectoryAnchor(lockParentAnchor, `${label} lock parent`);
421
+ stateAssertDirectoryAnchor(lockAnchor, `${label} lock`, lockParentAnchor);
422
+ fs.renameSync(lockActualPath, abandoned);
423
+ const moved = stateDirectoryAnchor(abandoned, `${label} abandoned lock`, { containedBy: lockParentAnchor });
424
+ if (stateSameDirectoryIdentity(moved.stat, lockAnchor.stat)) stateRemoveOwnedLockDirectory(abandoned, moved);
425
+ } catch { /* original owner-write error remains authoritative */ }
426
+ }
427
+ throw error;
428
+ }
429
+ }
430
+ try {
431
+ return action();
432
+ } finally {
433
+ const cleanup = path.join(
434
+ lockParentAnchor.realpath,
435
+ `${lockName}.done-${process.pid}-${crypto.randomBytes(6).toString("hex")}`,
436
+ );
437
+ try {
438
+ stateAssertDirectoryAnchor(lockParentAnchor, `${label} lock parent`);
439
+ stateAssertDirectoryAnchor(lockAnchor, `${label} lock`, lockParentAnchor);
440
+ fs.renameSync(lockActualPath, cleanup);
441
+ const moved = stateDirectoryAnchor(cleanup, `${label} completed lock`, { containedBy: lockParentAnchor });
442
+ if (!stateSameDirectoryIdentity(moved.stat, lockAnchor.stat)) {
443
+ throw new Error(`${label} lock changed while it was being released`);
444
+ }
445
+ stateRemoveOwnedLockDirectory(cleanup, moved);
446
+ } catch (error) {
447
+ if (!error || error.code !== "ENOENT") throw error;
448
+ }
449
+ }
450
+ }
33
451
 
34
452
  function normalizeCloudScopeFlag(value) {
35
453
  if (value === "owner-private" || value === "private" || value === "private-link") return "owner-private";
@@ -45,45 +463,68 @@ function cloudAssetStatePath() {
45
463
  return path.join(userDataDir(), CLOUD_ASSET_STATE_FILE);
46
464
  }
47
465
 
466
+ function normalizeCloudAssetState(parsed) {
467
+ if (!parsed || parsed.schemaVersion !== 1 || !parsed.assets || typeof parsed.assets !== "object" || Array.isArray(parsed.assets)) {
468
+ throw new Error("state schema is invalid");
469
+ }
470
+ const assets = {};
471
+ for (const [key, raw] of Object.entries(parsed.assets)) {
472
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`state entry ${key} is invalid`);
473
+ const descriptor = normalizeCloudAssetDescriptor(raw.descriptor, `state entry ${key}`);
474
+ if (key !== cloudDescriptorKey(descriptor)) throw new Error(`state entry ${key} key is invalid`);
475
+ if (!Array.isArray(raw.sourceRoots)) throw new Error(`state entry ${key} sourceRoots is invalid`);
476
+ for (const item of raw.sourceRoots) {
477
+ if (typeof item !== "string" || !path.isAbsolute(item)) throw new Error(`state entry ${key} sourceRoots is invalid`);
478
+ }
479
+ const sourceRoots = [...new Set(raw.sourceRoots.map((item) => path.resolve(item)))].slice(0, 32);
480
+ assets[key] = { descriptor, sourceRoots };
481
+ }
482
+ if (!Array.isArray(parsed.deletedBases)) throw new Error("state deletedBases is invalid");
483
+ const deletedBases = parsed.deletedBases.map((item, index) => {
484
+ if (
485
+ !item || typeof item !== "object" || Array.isArray(item) ||
486
+ typeof item.rootPath !== "string" || !path.isAbsolute(item.rootPath) ||
487
+ typeof item.slug !== "string" || cloudSlug(item.slug) !== item.slug ||
488
+ !CLOUD_ASSET_SCOPES.has(item.scope) || !/^[A-Za-z0-9_-]{8,128}$/.test(String(item.cloudId || "")) ||
489
+ typeof item.revision !== "string" || !item.revision || item.revision.length > 512 || /["\\\u0000-\u001f\u007f]/.test(item.revision)
490
+ ) {
491
+ throw new Error(`state deletedBases[${index}] is invalid`);
492
+ }
493
+ return {
494
+ rootPath: path.resolve(item.rootPath),
495
+ slug: item.slug,
496
+ scope: item.scope,
497
+ cloudId: item.cloudId,
498
+ revision: item.revision,
499
+ };
500
+ }).slice(-256);
501
+ return { schemaVersion: 1, assets, deletedBases };
502
+ }
503
+
48
504
  function readCloudAssetState() {
49
505
  const statePath = cloudAssetStatePath();
50
- if (!fs.existsSync(statePath)) return { schemaVersion: 1, assets: {}, deletedBases: [] };
51
506
  let fd;
52
507
  try {
53
508
  // O_NOFOLLOW: 상태 파일이 심링크로 바꿔치기되면 읽지 않는다 (로컬 상태 위조 방어).
54
- fd = fs.openSync(statePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
55
- const stat = fs.fstatSync(fd);
56
- if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("state file is not a bounded regular file");
57
- const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
58
- if (!parsed || parsed.schemaVersion !== 1 || !parsed.assets || typeof parsed.assets !== "object" || Array.isArray(parsed.assets)) {
59
- throw new Error("state schema is invalid");
509
+ try {
510
+ fd = fs.openSync(statePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
511
+ } catch (error) {
512
+ if (error && error.code === "ENOENT") return { schemaVersion: 1, assets: {}, deletedBases: [] };
513
+ throw error;
60
514
  }
61
- const assets = {};
62
- for (const [key, raw] of Object.entries(parsed.assets)) {
63
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`state entry ${key} is invalid`);
64
- const descriptor = normalizeCloudAssetDescriptor(raw.descriptor, `state entry ${key}`);
65
- if (key !== cloudDescriptorKey(descriptor)) throw new Error(`state entry ${key} key is invalid`);
66
- const sourceRoots = Array.isArray(raw.sourceRoots)
67
- ? [...new Set(raw.sourceRoots.filter((item) => typeof item === "string" && path.isAbsolute(item)).map((item) => path.resolve(item)))].slice(0, 32)
68
- : [];
69
- assets[key] = { descriptor, sourceRoots };
515
+ const before = fs.fstatSync(fd);
516
+ if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > CLOUD_ASSET_STATE_MAX_BYTES) {
517
+ throw new Error("state file is not a bounded private file");
70
518
  }
71
- const deletedBases = Array.isArray(parsed.deletedBases)
72
- ? parsed.deletedBases.filter((item) =>
73
- item && typeof item === "object" && !Array.isArray(item) &&
74
- typeof item.rootPath === "string" && path.isAbsolute(item.rootPath) &&
75
- typeof item.slug === "string" && cloudSlug(item.slug) === item.slug &&
76
- CLOUD_ASSET_SCOPES.has(item.scope) && typeof item.cloudId === "string" &&
77
- typeof item.revision === "string"
78
- ).map((item) => ({
79
- rootPath: path.resolve(item.rootPath),
80
- slug: item.slug,
81
- scope: item.scope,
82
- cloudId: item.cloudId,
83
- revision: item.revision,
84
- })).slice(-256)
85
- : [];
86
- return { schemaVersion: 1, assets, deletedBases };
519
+ const raw = fs.readFileSync(fd, "utf8");
520
+ const after = fs.fstatSync(fd);
521
+ if (
522
+ Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
523
+ after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
524
+ ) {
525
+ throw new Error("state file changed while it was read");
526
+ }
527
+ return normalizeCloudAssetState(JSON.parse(raw));
87
528
  } catch (error) {
88
529
  // 깨진 상태 파일을 빈 상태로 위장하면 stale 베이스로 원격 리비전을 덮어쓸 수 있다.
89
530
  throw new Error(`Agent Cloud local revision state is unreadable: ${error.message || error}`);
@@ -92,40 +533,64 @@ function readCloudAssetState() {
92
533
  }
93
534
  }
94
535
 
95
- function writeCloudAssetState(state) {
536
+ function writeCloudAssetStateUnlocked(state) {
96
537
  const statePath = cloudAssetStatePath();
97
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
98
- const temp = `${statePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
99
- const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
538
+ const normalized = normalizeCloudAssetState(state);
539
+ const directory = stateEnsureDirectory(path.dirname(statePath), "Cloud asset state directory");
540
+ const targetName = path.basename(statePath);
541
+ const target = path.join(directory.realpath, targetName);
542
+ const expected = stateFileSnapshot(target, "Cloud asset state", { allowMissing: true });
543
+ const payload = JSON.stringify(normalized, null, 2) + "\n";
544
+ if (Buffer.byteLength(payload, "utf8") > CLOUD_ASSET_STATE_MAX_BYTES) {
545
+ throw new Error("Cloud asset state exceeds its safety limit");
546
+ }
547
+ const temporary = stateWriteTemp(
548
+ directory,
549
+ `.${targetName}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`,
550
+ payload,
551
+ "Cloud asset state",
552
+ );
100
553
  try {
101
- fs.writeFileSync(fd, JSON.stringify(state, null, 2) + "\n", "utf8");
102
- fs.fsyncSync(fd);
554
+ statePublishFile(directory, targetName, temporary, expected, "Cloud asset state");
555
+ cloudFsyncDirectory(directory.realpath);
103
556
  } finally {
104
- fs.closeSync(fd);
557
+ stateRemoveOwnedFile(temporary.path, temporary.stat);
105
558
  }
106
- fs.renameSync(temp, statePath);
107
- cloudApplyPortableFileMode(statePath, 0o600);
108
- cloudFsyncDirectory(path.dirname(statePath));
559
+ return normalized;
560
+ }
561
+
562
+ function writeCloudAssetState(state) {
563
+ return withCloudAssetLock(cloudAssetStatePath(), "Agent Cloud local revision state", () => writeCloudAssetStateUnlocked(state));
564
+ }
565
+
566
+ function updateCloudAssetState(mutator) {
567
+ if (typeof mutator !== "function") throw new TypeError("Cloud asset state mutator must be a function");
568
+ return withCloudAssetLock(cloudAssetStatePath(), "Agent Cloud local revision state", () => {
569
+ const state = readCloudAssetState();
570
+ const result = mutator(state);
571
+ writeCloudAssetStateUnlocked(state);
572
+ return result;
573
+ });
109
574
  }
110
575
 
111
576
  /** 서버가 준 리비전 영수증을 관측 상태로 승격. sourceRoot가 있으면 톰스톤도 해제한다. */
112
577
  function rememberCloudAssetDescriptor(value, options = {}) {
113
578
  const descriptor = normalizeCloudAssetDescriptor(value);
114
- const state = readCloudAssetState();
115
- const key = cloudDescriptorKey(descriptor);
116
- const previous = state.assets[key];
117
- const sameRevision = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
118
- const roots = sameRevision ? [...previous.sourceRoots] : [];
119
- if (options.sourceRoot) {
120
- const sourceRoot = path.resolve(options.sourceRoot);
121
- roots.push(sourceRoot);
122
- state.deletedBases = state.deletedBases.filter(
123
- (item) => !(item.rootPath === sourceRoot && item.slug === descriptor.slug && item.scope === descriptor.scope),
124
- );
125
- }
126
- state.assets[key] = { descriptor, sourceRoots: [...new Set(roots)].slice(0, 32) };
127
- writeCloudAssetState(state);
128
- return descriptor;
579
+ return updateCloudAssetState((state) => {
580
+ const key = cloudDescriptorKey(descriptor);
581
+ const previous = state.assets[key];
582
+ const sameRevision = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
583
+ const roots = sameRevision ? [...previous.sourceRoots] : [];
584
+ if (options.sourceRoot) {
585
+ const sourceRoot = path.resolve(options.sourceRoot);
586
+ roots.push(sourceRoot);
587
+ state.deletedBases = state.deletedBases.filter(
588
+ (item) => !(item.rootPath === sourceRoot && item.slug === descriptor.slug && item.scope === descriptor.scope),
589
+ );
590
+ }
591
+ state.assets[key] = { descriptor, sourceRoots: [...new Set(roots)].slice(0, 32) };
592
+ return descriptor;
593
+ });
129
594
  }
130
595
 
131
596
  function findCloudAssetDescriptor(slug, scope) {
@@ -193,18 +658,18 @@ function cloudUnboundDescriptorForSource(rootPath, slug, scope) {
193
658
 
194
659
  /** 이 소스 루트를 이미 관측된 클라우드 자산에 명시적으로 연결한다. */
195
660
  function bindCloudAssetSourceRoot(rootPath, slug, scope) {
196
- const state = readCloudAssetState();
197
- const key = `${scope}:${slug}`;
198
- const entry = state.assets[key];
199
- if (!entry) throw new Error(`No observed Cloud revision for ${key} to bind.`);
200
- const normalizedRoot = path.resolve(rootPath);
201
- const roots = [...new Set([...entry.sourceRoots, normalizedRoot])].slice(0, 32);
202
- state.assets[key] = { descriptor: entry.descriptor, sourceRoots: roots };
203
- state.deletedBases = state.deletedBases.filter(
204
- (item) => !(item.rootPath === normalizedRoot && item.slug === slug && item.scope === scope),
205
- );
206
- writeCloudAssetState(state);
207
- return entry.descriptor;
661
+ return updateCloudAssetState((state) => {
662
+ const key = `${scope}:${slug}`;
663
+ const entry = state.assets[key];
664
+ if (!entry) throw new Error(`No observed Cloud revision for ${key} to bind.`);
665
+ const normalizedRoot = path.resolve(rootPath);
666
+ const roots = [...new Set([...entry.sourceRoots, normalizedRoot])].slice(0, 32);
667
+ state.assets[key] = { descriptor: entry.descriptor, sourceRoots: roots };
668
+ state.deletedBases = state.deletedBases.filter(
669
+ (item) => !(item.rootPath === normalizedRoot && item.slug === slug && item.scope === scope),
670
+ );
671
+ return entry.descriptor;
672
+ });
208
673
  }
209
674
 
210
675
  function cloudBaseDescriptorForSource(marker, rootPath, slug, scope) {
@@ -221,64 +686,91 @@ function cloudBaseDescriptorForSource(marker, rootPath, slug, scope) {
221
686
  const stateDescriptor = entry && entry.sourceRoots.includes(normalizedRoot) ? entry.descriptor : null;
222
687
  if (!markerDescriptor) return stateDescriptor;
223
688
  if (!stateDescriptor) return markerDescriptor;
224
- return stateDescriptor.cloudId === markerDescriptor.cloudId && stateDescriptor.updatedAt >= markerDescriptor.updatedAt
689
+ return stateDescriptor.cloudId === markerDescriptor.cloudId && Date.parse(stateDescriptor.updatedAt) >= Date.parse(markerDescriptor.updatedAt)
225
690
  ? stateDescriptor
226
691
  : markerDescriptor;
227
692
  }
228
693
 
229
694
  function writeCloudSourceMarker(rootPath, scan, descriptor, options = {}) {
230
- const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
231
- if (fs.existsSync(markerPath)) {
232
- const stat = fs.lstatSync(markerPath);
233
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Agent Cloud revision marker is not a regular file");
234
- }
235
- const descriptors = cloudMarkerDescriptors(options.previousMarker);
236
- if (descriptor) descriptors[descriptor.scope] = descriptor;
237
- if (options.removeDescriptor) {
238
- const current = descriptors[options.removeDescriptor.scope];
239
- if (current && current.cloudId === options.removeDescriptor.cloudId && current.revision === options.removeDescriptor.revision) {
240
- delete descriptors[options.removeDescriptor.scope];
695
+ const lockTarget = path.join(
696
+ userDataDir(),
697
+ "cloud-source-marker-locks",
698
+ crypto.createHash("sha256").update(path.resolve(rootPath)).digest("hex"),
699
+ );
700
+ return withCloudAssetLock(lockTarget, "Agent Cloud source marker", () => {
701
+ const rootAnchor = stateDirectoryAnchor(rootPath, "Cloud source marker root");
702
+ const markerPath = path.join(rootAnchor.realpath, CLOUD_RESTORE_MARKER_PATH);
703
+ const expectedMarker = stateFileSnapshot(markerPath, "Cloud source marker", { allowMissing: true });
704
+ const previousMarker = readCloudSourceMarker(rootAnchor.realpath);
705
+ stateAssertDirectoryAnchor(rootAnchor, "Cloud source marker root");
706
+ const currentMarker = stateFileSnapshot(markerPath, "Cloud source marker", { allowMissing: true });
707
+ if ((expectedMarker && (!currentMarker || !stateSameFileSnapshot(currentMarker, expectedMarker))) ||
708
+ (!expectedMarker && currentMarker)) {
709
+ throw new Error("Cloud source marker changed while it was read");
241
710
  }
242
- }
243
- const latest = descriptor || Object.values(descriptors)[0] || null;
244
- const marker = {
245
- schemaVersion: 1,
246
- source: "agentlas-cloud",
247
- slug: latest?.slug || options.removeDescriptor?.slug || cloudSlug(path.basename(rootPath)),
248
- packageHash: descriptor?.packageHash || options.packageHash || options.previousMarker?.packageHash || "",
249
- packageHashVersion: descriptor?.packageHashVersion || options.packageHashVersion || options.previousMarker?.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
250
- fileCount: Number.isSafeInteger(options.fileCount) ? options.fileCount : (options.previousMarker?.fileCount || 0),
251
- totalBytes: Number.isSafeInteger(options.totalBytes) ? options.totalBytes : (options.previousMarker?.totalBytes || 0),
252
- executablePaths: Array.isArray(options.executablePaths) ? options.executablePaths : options.previousMarker?.executablePaths,
253
- cloudAssets: descriptors,
254
- ...(latest ? latest : {}),
255
- restoredAt: options.previousMarker?.restoredAt,
256
- savedAt: new Date().toISOString(),
257
- };
258
- for (const key of Object.keys(marker)) if (marker[key] === undefined) delete marker[key];
259
- const temp = path.join(rootPath, `.${CLOUD_RESTORE_MARKER_PATH}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
260
- const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
261
- try {
262
- fs.writeFileSync(fd, JSON.stringify(marker, null, 2) + "\n", "utf8");
263
- fs.fsyncSync(fd);
264
- } finally {
265
- fs.closeSync(fd);
266
- }
267
- fs.renameSync(temp, markerPath);
268
- cloudApplyPortableFileMode(markerPath, 0o600);
269
- cloudFsyncDirectory(rootPath);
270
- return marker;
711
+ const descriptors = cloudMarkerDescriptors(previousMarker);
712
+ if (descriptor) descriptors[descriptor.scope] = descriptor;
713
+ if (options.removeDescriptor) {
714
+ const current = descriptors[options.removeDescriptor.scope];
715
+ if (current && current.cloudId === options.removeDescriptor.cloudId && current.revision === options.removeDescriptor.revision) {
716
+ delete descriptors[options.removeDescriptor.scope];
717
+ }
718
+ }
719
+ const latest = descriptor || Object.values(descriptors)[0] || null;
720
+ const marker = {
721
+ schemaVersion: 1,
722
+ source: "agentlas-cloud",
723
+ slug: latest?.slug || options.removeDescriptor?.slug || cloudSlug(path.basename(rootPath)),
724
+ packageHash: descriptor?.packageHash || options.packageHash || previousMarker?.packageHash || "",
725
+ packageHashVersion: descriptor?.packageHashVersion || options.packageHashVersion || previousMarker?.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
726
+ fileCount: Number.isSafeInteger(options.fileCount) ? options.fileCount : (previousMarker?.fileCount || 0),
727
+ totalBytes: Number.isSafeInteger(options.totalBytes) ? options.totalBytes : (previousMarker?.totalBytes || 0),
728
+ executablePaths: Array.isArray(options.executablePaths) ? options.executablePaths : previousMarker?.executablePaths,
729
+ cloudAssets: descriptors,
730
+ ...(latest ? latest : {}),
731
+ restoredAt: previousMarker?.restoredAt,
732
+ savedAt: new Date().toISOString(),
733
+ };
734
+ for (const key of Object.keys(marker)) if (marker[key] === undefined) delete marker[key];
735
+ const temporary = stateWriteTemp(
736
+ rootAnchor,
737
+ `.${CLOUD_RESTORE_MARKER_PATH}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`,
738
+ JSON.stringify(marker, null, 2) + "\n",
739
+ "Cloud source marker",
740
+ );
741
+ try {
742
+ statePublishFile(rootAnchor, CLOUD_RESTORE_MARKER_PATH, temporary, expectedMarker, "Cloud source marker");
743
+ cloudFsyncDirectory(rootAnchor.realpath);
744
+ } finally {
745
+ stateRemoveOwnedFile(temporary.path, temporary.stat);
746
+ }
747
+ return marker;
748
+ });
271
749
  }
272
750
 
273
751
  function readCloudSourceMarker(rootPath) {
274
752
  const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
275
- if (!fs.existsSync(markerPath)) return null;
276
753
  let fd;
277
754
  try {
278
- fd = fs.openSync(markerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
279
- const stat = fs.fstatSync(fd);
280
- if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("marker is not a bounded regular file");
281
- return JSON.parse(fs.readFileSync(fd, "utf8"));
755
+ try {
756
+ fd = fs.openSync(markerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
757
+ } catch (error) {
758
+ if (error && error.code === "ENOENT") return null;
759
+ throw error;
760
+ }
761
+ const before = fs.fstatSync(fd);
762
+ if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > CLOUD_ASSET_STATE_MAX_BYTES) {
763
+ throw new Error("marker is not a bounded private file");
764
+ }
765
+ const raw = fs.readFileSync(fd, "utf8");
766
+ const after = fs.fstatSync(fd);
767
+ if (
768
+ Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
769
+ after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
770
+ ) {
771
+ throw new Error("marker changed while it was read");
772
+ }
773
+ return JSON.parse(raw);
282
774
  } finally {
283
775
  if (fd !== undefined) fs.closeSync(fd);
284
776
  }
@@ -291,6 +783,7 @@ module.exports = {
291
783
  cloudAssetStatePath,
292
784
  readCloudAssetState,
293
785
  writeCloudAssetState,
786
+ updateCloudAssetState,
294
787
  rememberCloudAssetDescriptor,
295
788
  findCloudAssetDescriptor,
296
789
  cloudMarkerDescriptors,