hilos-agent 0.10.1 → 0.11.2

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.
@@ -0,0 +1,574 @@
1
+ // Durable recovery records for local-daemon Iterate claims. A claim changes the
2
+ // canonical run generation before its HTTP response reaches this machine, so a
3
+ // response-lost process must retain the exact owner token across restarts.
4
+
5
+ import * as nodeFs from "node:fs";
6
+ import { execFileSync } from "node:child_process";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { homedir, platform } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ export const ITERATE_CLAIM_RECOVERY_DIR = join(homedir(), ".hilos", "iterate-claims");
12
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
13
+ const INTRINSIC_SELF_ID_PREFIX = "intrinsic-self:";
14
+ const SELF_PROCESS_INSTANCE_ID = `${INTRINSIC_SELF_ID_PREFIX}${platform()}:${Math.round(
15
+ Date.now() - process.uptime() * 1000,
16
+ )}:${randomUUID()}`;
17
+
18
+ function safeSegment(value) {
19
+ return String(value || "").replace(/[^a-zA-Z0-9-]/g, "");
20
+ }
21
+
22
+ export function canonicalIterateClaimServerIdentity(url) {
23
+ try {
24
+ const parsed = new URL(String(url || "").trim());
25
+ parsed.hash = "";
26
+ parsed.username = "";
27
+ parsed.password = "";
28
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/";
29
+ parsed.searchParams.sort();
30
+ // URL normalizes scheme/host casing for us. Preserve the path's casing:
31
+ // unlike hostnames, HTTP paths can name different resources by case.
32
+ return parsed.toString().replace(/\/$/, "");
33
+ } catch {
34
+ return String(url || "").trim().replace(/\/+$/, "").toLowerCase();
35
+ }
36
+ }
37
+
38
+ /** Stable across bearer rotation; the bearer token is never hashed or written. */
39
+ export function iterateClaimConnectionFingerprint({ url }) {
40
+ return createHash("sha256")
41
+ .update(canonicalIterateClaimServerIdentity(url))
42
+ .digest("hex")
43
+ .slice(0, 24);
44
+ }
45
+
46
+ function validRecord(value) {
47
+ return Boolean(
48
+ value &&
49
+ typeof value.runId === "string" &&
50
+ UUID_RE.test(value.runId) &&
51
+ Number.isSafeInteger(value.expectedIterateTurn) &&
52
+ value.expectedIterateTurn >= 0 &&
53
+ typeof value.claimId === "string" &&
54
+ UUID_RE.test(value.claimId),
55
+ );
56
+ }
57
+
58
+ function defaultProcessAlive(pid) {
59
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
60
+ try {
61
+ process.kill(pid, 0);
62
+ return true;
63
+ } catch (error) {
64
+ return error?.code !== "ESRCH";
65
+ }
66
+ }
67
+
68
+ // A PID only identifies a process until the OS reuses it. Persist an identity
69
+ // tied to the process incarnation as well, so an owner left by a crash or
70
+ // reboot cannot be mistaken for the later process that inherited its PID.
71
+ // Linux exposes a boot UUID plus the process start tick without spawning. The
72
+ // supported desktop platforms expose an absolute process start time through
73
+ // their standard process tools.
74
+ export function defaultProcessInstanceIdentity(
75
+ pid,
76
+ {
77
+ readFileSync = nodeFs.readFileSync,
78
+ execute = execFileSync,
79
+ currentPid = process.pid,
80
+ selfIdentity = SELF_PROCESS_INSTANCE_ID,
81
+ platformName = platform(),
82
+ } = {},
83
+ ) {
84
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
85
+ try {
86
+ if (platformName === "linux") {
87
+ try {
88
+ const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
89
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
90
+ const commandEnd = stat.lastIndexOf(")");
91
+ if (bootId && commandEnd >= 0) {
92
+ // After the command's closing parenthesis, index 0 is field 3
93
+ // (state), making index 19 field 22 (start tick since boot).
94
+ const startTick = stat.slice(commandEnd + 1).trim().split(/\s+/)[19];
95
+ if (startTick) return `linux:${bootId}:${startTick}`;
96
+ }
97
+ } catch {
98
+ // Sandboxed Linux environments may not mount procfs. Fall through to
99
+ // the same POSIX process-start query used by macOS/BSD.
100
+ }
101
+ }
102
+ if (platformName === "win32") {
103
+ const ticks = execute(
104
+ "powershell.exe",
105
+ [
106
+ "-NoProfile",
107
+ "-NonInteractive",
108
+ "-Command",
109
+ `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
110
+ ],
111
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
112
+ ).trim();
113
+ return ticks ? `win32:${ticks}` : null;
114
+ }
115
+ const started = execute("ps", ["-o", "lstart=", "-p", String(pid)], {
116
+ encoding: "utf8",
117
+ stdio: ["ignore", "pipe", "ignore"],
118
+ }).trim();
119
+ if (!started) return null;
120
+ if (["darwin", "freebsd", "openbsd", "netbsd"].includes(platformName)) {
121
+ const boot = execute("sysctl", ["-n", "kern.boottime"], {
122
+ encoding: "utf8",
123
+ stdio: ["ignore", "pipe", "ignore"],
124
+ }).trim();
125
+ // BSD ps exposes lstart only to the second. Mark that precision in the
126
+ // durable identity: unequal values prove reuse, while equal values are
127
+ // intentionally treated as ambiguous/live rather than risking two
128
+ // daemons when a PID is recycled inside the same second.
129
+ return boot ? `${platformName}:coarse-second:${boot}:${started}` : null;
130
+ }
131
+ return `${platformName}:coarse-second:${started}`;
132
+ } catch {
133
+ // This remains available in restricted runtimes that deny procfs/ps
134
+ // access, and is shared by every store created in this process. A
135
+ // different process that cannot inspect it will conservatively fail
136
+ // closed rather than preempting a possibly-live owner.
137
+ return pid === currentPid ? selfIdentity : null;
138
+ }
139
+ }
140
+
141
+ function syncDirectory(fs, path) {
142
+ const fd = fs.openSync(path, "r");
143
+ try {
144
+ fs.fsyncSync(fd);
145
+ } finally {
146
+ fs.closeSync(fd);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * One file per claim plus one exclusive agent/server scope owner.
152
+ * @param {{ agentId: string, url: string, token?: string, dir?: string,
153
+ * log?: { error?: (message: string) => void }, fs?: any,
154
+ * isProcessAlive?: (pid: number) => boolean,
155
+ * getProcessInstanceIdentity?: (pid: number) => string | null,
156
+ * pid?: number, ownerId?: string }} options
157
+ */
158
+ export function createIterateClaimRecoveryStore({
159
+ agentId,
160
+ url,
161
+ // Accepted for call-site compatibility only. Deliberately unused.
162
+ token: _token,
163
+ dir = ITERATE_CLAIM_RECOVERY_DIR,
164
+ log,
165
+ fs = nodeFs,
166
+ isProcessAlive = defaultProcessAlive,
167
+ getProcessInstanceIdentity = defaultProcessInstanceIdentity,
168
+ pid = process.pid,
169
+ ownerId = randomUUID(),
170
+ } = {}) {
171
+ const scope = `${safeSegment(agentId)}-${iterateClaimConnectionFingerprint({ url })}`;
172
+ const scopeDir = join(dir, scope);
173
+ const ownerKey = safeSegment(ownerId);
174
+ const lockName = `.owner.${ownerKey}.lock`;
175
+ const lockPath = join(scopeDir, lockName);
176
+ const electionPath = join(scopeDir, ".owner-election.lock");
177
+ const recordPath = (claimId) => join(scopeDir, `${safeSegment(claimId)}.json`);
178
+ const processInstanceIdentity = getProcessInstanceIdentity(pid);
179
+ let lockHeld = false;
180
+ let heldElectionPath = null;
181
+
182
+ function ensureScope() {
183
+ fs.mkdirSync(scopeDir, { recursive: true, mode: 0o700 });
184
+ fs.chmodSync(scopeDir, 0o700);
185
+ }
186
+
187
+ function durableWrite(path, value, { exclusive = false } = {}) {
188
+ const fd = fs.openSync(path, exclusive ? "wx" : "w", 0o600);
189
+ try {
190
+ fs.writeFileSync(fd, value, { encoding: "utf8" });
191
+ fs.chmodSync(path, 0o600);
192
+ fs.fsyncSync(fd);
193
+ } finally {
194
+ fs.closeSync(fd);
195
+ }
196
+ }
197
+
198
+ function readLock(path = lockPath) {
199
+ try {
200
+ return JSON.parse(fs.readFileSync(path, "utf8"));
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ function matchesOwner(value) {
207
+ return Boolean(
208
+ value?.ownerId === ownerId &&
209
+ value?.pid === pid &&
210
+ value?.processInstanceIdentity === processInstanceIdentity,
211
+ );
212
+ }
213
+
214
+ function validLock(value, name) {
215
+ return Boolean(
216
+ value &&
217
+ Number.isSafeInteger(value.pid) &&
218
+ value.pid > 0 &&
219
+ typeof value.ownerId === "string" &&
220
+ safeSegment(value.ownerId) &&
221
+ (value.processInstanceIdentity === undefined ||
222
+ (typeof value.processInstanceIdentity === "string" &&
223
+ value.processInstanceIdentity.length > 0)) &&
224
+ name === `.owner.${safeSegment(value.ownerId)}.lock`,
225
+ );
226
+ }
227
+
228
+ function ownerIsLive(owner) {
229
+ if (!isProcessAlive(owner.pid)) return false;
230
+ // Locks written by the previous daemon version have no incarnation
231
+ // identity. During a rolling upgrade, numeric liveness is the only safe
232
+ // evidence available: never preempt a live legacy owner, but reclaim it
233
+ // once that PID is gone.
234
+ if (owner.processInstanceIdentity === undefined) return true;
235
+ // This marker is written only when the owner could not inspect even its
236
+ // own OS process identity. It is deliberately not cross-process
237
+ // comparable: a later contender may have broader procfs/ps permissions,
238
+ // but its different real identity does not prove that the live fallback
239
+ // owner was replaced.
240
+ if (owner.processInstanceIdentity.startsWith(INTRINSIC_SELF_ID_PREFIX)) return true;
241
+ let currentIdentity;
242
+ try {
243
+ currentIdentity = getProcessInstanceIdentity(owner.pid);
244
+ } catch {
245
+ // A live-looking process whose identity cannot be inspected is not safe
246
+ // to preempt. A later poll can recover it once liveness is knowable.
247
+ return true;
248
+ }
249
+ if (!currentIdentity) return true;
250
+ // The reverse transition is equally non-comparable: this process may have
251
+ // published a durable OS identity while inspection worked, then fallen
252
+ // back later (sandbox/profile change, transient procfs denial). Numeric
253
+ // liveness plus an intrinsic current-process marker is ambiguous, not
254
+ // evidence that the published owner was replaced.
255
+ if (currentIdentity.startsWith(INTRINSIC_SELF_ID_PREFIX)) return true;
256
+ return currentIdentity === owner.processInstanceIdentity;
257
+ }
258
+
259
+ function ownsPublishedLock() {
260
+ if (!lockHeld) return false;
261
+ const owner = readLock();
262
+ const elected = heldElectionPath ? readLock(heldElectionPath) : null;
263
+ if (matchesOwner(owner) && matchesOwner(elected)) {
264
+ return true;
265
+ }
266
+ lockHeld = false;
267
+ return false;
268
+ }
269
+
270
+ function removeOwnPublishedLock() {
271
+ const owner = readLock();
272
+ const elected = heldElectionPath ? readLock(heldElectionPath) : null;
273
+ if (!matchesOwner(owner) || !matchesOwner(elected)) return false;
274
+ try {
275
+ fs.unlinkSync(heldElectionPath);
276
+ fs.unlinkSync(lockPath);
277
+ syncDirectory(fs, scopeDir);
278
+ lockHeld = false;
279
+ heldElectionPath = null;
280
+ return true;
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
285
+
286
+ function removeOwnCandidate() {
287
+ if (!matchesOwner(readLock())) return false;
288
+ try {
289
+ fs.unlinkSync(lockPath);
290
+ syncDirectory(fs, scopeDir);
291
+ return true;
292
+ } catch {
293
+ return false;
294
+ }
295
+ }
296
+
297
+ function acquireElection() {
298
+ let candidatePath = electionPath;
299
+ let traversalBudget;
300
+ try {
301
+ // A valid chain with N existing anchors reaches either a live owner or
302
+ // its first missing successor in at most N + 1 hops. Deriving the bound
303
+ // from durable state avoids a lifetime crash-generation ceiling while
304
+ // still bounding work on corrupt or adversarial directories.
305
+ traversalBudget =
306
+ fs
307
+ .readdirSync(scopeDir)
308
+ .filter(
309
+ (name) =>
310
+ name === ".owner-election.lock" ||
311
+ (name.startsWith(".owner-election-after-") && name.endsWith(".lock")),
312
+ ).length + 1;
313
+ } catch {
314
+ return false;
315
+ }
316
+ const visited = new Set();
317
+ for (let attempt = 0; attempt < traversalBudget; attempt += 1) {
318
+ if (visited.has(candidatePath)) return false;
319
+ visited.add(candidatePath);
320
+ try {
321
+ fs.linkSync(lockPath, candidatePath);
322
+ syncDirectory(fs, scopeDir);
323
+ heldElectionPath = candidatePath;
324
+ return true;
325
+ } catch (error) {
326
+ if (error?.code !== "EEXIST") return false;
327
+ }
328
+ const elected = readLock(candidatePath);
329
+ if (matchesOwner(elected)) {
330
+ heldElectionPath = candidatePath;
331
+ return true;
332
+ }
333
+ if (elected && validLock(elected, `.owner.${safeSegment(elected.ownerId)}.lock`) && ownerIsLive(elected)) {
334
+ return false;
335
+ }
336
+ // Never replace or unlink a stale anchor: path-based cleanup cannot be
337
+ // conditional on the inode inspected above, so a delayed contender
338
+ // could otherwise remove a newer winner. Every contender instead walks
339
+ // the same immutable successor chain and atomically races to publish the
340
+ // first missing link.
341
+ try {
342
+ const stat = fs.statSync(candidatePath);
343
+ const staleKey = createHash("sha256")
344
+ .update(`${stat.dev}:${stat.ino}:${stat.size}:${safeSegment(elected?.ownerId)}`)
345
+ .digest("hex")
346
+ .slice(0, 24);
347
+ candidatePath = join(scopeDir, `.owner-election-after-${staleKey}.lock`);
348
+ } catch (error) {
349
+ if (error?.code === "ENOENT") {
350
+ candidatePath = electionPath;
351
+ continue;
352
+ }
353
+ return false;
354
+ }
355
+ }
356
+ return false;
357
+ }
358
+
359
+ return {
360
+ acquireLock() {
361
+ if (lockHeld) return true;
362
+ if (!ownerKey || !processInstanceIdentity) return false;
363
+ try {
364
+ ensureScope();
365
+ } catch (error) {
366
+ log?.error?.(`iterate claim scope unavailable: ${error?.message || error}`);
367
+ return false;
368
+ }
369
+ // Publish through an fsynced private file and an atomic hard link. Readers
370
+ // can therefore see either no contender or a complete contender, never a
371
+ // partially-written lock. Every process has an immutable, unique path;
372
+ // stale cleanup can never unlink a later contender at another path.
373
+ let temp = join(scopeDir, `.owner-pending.${ownerKey}.${pid}.${randomUUID()}.tmp`);
374
+ try {
375
+ durableWrite(temp, JSON.stringify({
376
+ pid,
377
+ ownerId,
378
+ processInstanceIdentity,
379
+ lockProtocol: 2,
380
+ state: "contender",
381
+ }), { exclusive: true });
382
+ fs.linkSync(temp, lockPath);
383
+ fs.unlinkSync(temp);
384
+ temp = null;
385
+ syncDirectory(fs, scopeDir);
386
+ } catch (error) {
387
+ if (temp) {
388
+ try {
389
+ fs.unlinkSync(temp);
390
+ } catch {
391
+ /* best-effort cleanup of an unpublished contender */
392
+ }
393
+ }
394
+ removeOwnCandidate();
395
+ log?.error?.(`iterate claim scope not locked: ${error?.message || error}`);
396
+ return false;
397
+ }
398
+
399
+ if (!acquireElection()) {
400
+ removeOwnCandidate();
401
+ return false;
402
+ }
403
+
404
+ let names;
405
+ try {
406
+ names = fs
407
+ .readdirSync(scopeDir)
408
+ .filter((name) => name.startsWith(".owner.") && name.endsWith(".lock"));
409
+ } catch (error) {
410
+ removeOwnPublishedLock();
411
+ log?.error?.(`iterate claim scope not inspected: ${error?.message || error}`);
412
+ return false;
413
+ }
414
+
415
+ let ownSeen = false;
416
+ let liveContender = false;
417
+ const stalePaths = [];
418
+ for (const name of names) {
419
+ const path = join(scopeDir, name);
420
+ const owner = readLock(path);
421
+ if (
422
+ name === lockName &&
423
+ owner?.ownerId === ownerId &&
424
+ owner?.pid === pid &&
425
+ owner?.processInstanceIdentity === processInstanceIdentity
426
+ ) {
427
+ ownSeen = true;
428
+ continue;
429
+ }
430
+ if (owner?.lockProtocol === 2 && owner?.state === "contender") {
431
+ if (!ownerIsLive(owner)) stalePaths.push(path);
432
+ continue;
433
+ }
434
+ if (!validLock(owner, name) || !ownerIsLive(owner)) {
435
+ stalePaths.push(path);
436
+ continue;
437
+ }
438
+ liveContender = true;
439
+ }
440
+
441
+ if (!ownSeen || liveContender) {
442
+ removeOwnPublishedLock();
443
+ return false;
444
+ }
445
+
446
+ // These paths are immutable per owner UUID. A newly-published contender
447
+ // necessarily has a different path and is left untouched; if it appears
448
+ // after our scan it will observe this live owner and withdraw itself.
449
+ for (const path of stalePaths) {
450
+ try {
451
+ fs.unlinkSync(path);
452
+ syncDirectory(fs, scopeDir);
453
+ } catch (error) {
454
+ if (error?.code !== "ENOENT") {
455
+ log?.error?.(`stale iterate claim lock not cleared: ${error?.message || error}`);
456
+ }
457
+ }
458
+ }
459
+ lockHeld = true;
460
+ return true;
461
+ },
462
+
463
+ releaseLock() {
464
+ if (!ownsPublishedLock()) return false;
465
+ if (!removeOwnPublishedLock()) {
466
+ const error = new Error("published owner changed or could not be removed");
467
+ log?.error?.(`iterate claim scope lock not released: ${error?.message || error}`);
468
+ return false;
469
+ }
470
+ return true;
471
+ },
472
+
473
+ holdsLock() {
474
+ return ownsPublishedLock();
475
+ },
476
+
477
+ persist(record) {
478
+ if (!ownsPublishedLock() || !validRecord(record) || !safeSegment(record.claimId)) return false;
479
+ let temp = null;
480
+ try {
481
+ ensureScope();
482
+ temp = join(scopeDir, `.${safeSegment(record.claimId)}.${pid}.${randomUUID()}.tmp`);
483
+ durableWrite(temp, JSON.stringify(record), { exclusive: true });
484
+ fs.renameSync(temp, recordPath(record.claimId));
485
+ fs.chmodSync(recordPath(record.claimId), 0o600);
486
+ syncDirectory(fs, scopeDir);
487
+ return true;
488
+ } catch (error) {
489
+ if (temp) {
490
+ try {
491
+ fs.unlinkSync(temp);
492
+ } catch {
493
+ /* best-effort cleanup of an incomplete temp file */
494
+ }
495
+ }
496
+ log?.error?.(`iterate claim recovery not persisted: ${error?.message || error}`);
497
+ return false;
498
+ }
499
+ },
500
+
501
+ remove(claimId) {
502
+ if (!ownsPublishedLock()) return false;
503
+ try {
504
+ fs.unlinkSync(recordPath(claimId));
505
+ syncDirectory(fs, scopeDir);
506
+ return true;
507
+ } catch (error) {
508
+ if (error?.code === "ENOENT" && ownsPublishedLock()) return true;
509
+ log?.error?.(`iterate claim recovery not cleared: ${error?.message || error}`);
510
+ return false;
511
+ }
512
+ },
513
+
514
+ list() {
515
+ if (!ownsPublishedLock()) return { records: [], unresolved: 1 };
516
+ let names;
517
+ try {
518
+ names = fs.readdirSync(scopeDir).filter((name) => name.endsWith(".json"));
519
+ } catch (error) {
520
+ log?.error?.(`iterate claim recovery not read: ${error?.message || error}`);
521
+ return { records: [], unresolved: 1 };
522
+ }
523
+ const records = [];
524
+ let unresolved = 0;
525
+ for (const name of names) {
526
+ try {
527
+ const value = JSON.parse(fs.readFileSync(join(scopeDir, name), "utf8"));
528
+ if (validRecord(value)) records.push(value);
529
+ else unresolved += 1;
530
+ } catch (error) {
531
+ unresolved += 1;
532
+ log?.error?.(`iterate claim recovery record unreadable (${name}): ${error?.message || error}`);
533
+ }
534
+ }
535
+ return { records, unresolved };
536
+ },
537
+ };
538
+ }
539
+
540
+ export const TERMINAL_ITERATE_RELEASE_KINDS = new Set([
541
+ "released",
542
+ "already-released",
543
+ "not-owned",
544
+ ]);
545
+
546
+ export async function releaseDaemonIterateClaim({ tool, recoveryStore, record }) {
547
+ const result = await tool("update_run", {
548
+ runId: record.runId,
549
+ releaseIterateClaim: true,
550
+ expectedIterateTurn: record.expectedIterateTurn,
551
+ claimId: record.claimId,
552
+ });
553
+ if (!TERMINAL_ITERATE_RELEASE_KINDS.has(result?.releaseKind)) return result;
554
+ if (!recoveryStore?.remove(record.claimId)) {
555
+ throw new Error("the Iterate claim recovery record could not be cleared");
556
+ }
557
+ return result;
558
+ }
559
+
560
+ export async function reconcileDaemonIterateClaims({ tool, recoveryStore, log = console }) {
561
+ if (!recoveryStore) return { unresolved: 0 };
562
+ const listed = recoveryStore.list();
563
+ let unresolved = listed.unresolved;
564
+ for (const record of listed.records) {
565
+ try {
566
+ const result = await releaseDaemonIterateClaim({ tool, recoveryStore, record });
567
+ if (!TERMINAL_ITERATE_RELEASE_KINDS.has(result?.releaseKind)) unresolved += 1;
568
+ } catch (error) {
569
+ unresolved += 1;
570
+ log?.error?.(`iterate claim recovery deferred: ${error?.message || error}`);
571
+ }
572
+ }
573
+ return { unresolved };
574
+ }