@ouro.bot/cli 0.1.0-alpha.990 → 0.1.0-alpha.991

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 (37) hide show
  1. package/changelog.json +6 -0
  2. package/deploy/unraid/Dockerfile +15 -2
  3. package/deploy/unraid/README.txt +44 -9
  4. package/deploy/unraid/github_known_hosts +3 -0
  5. package/deploy/unraid/ouro-sync-ssh +98 -0
  6. package/deploy/unraid/ouro-sync.sudoers +3 -0
  7. package/deploy/unraid/sanctuary-butler-upgrade.mjs +26 -2
  8. package/deploy/unraid/sanctuary-replay-gate.mjs +361 -8
  9. package/deploy/unraid/sanctuary-sync-cutover.sh +1039 -0
  10. package/deploy/unraid/sanctuary.ouro/agent.json +2 -1
  11. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  12. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +3 -1
  13. package/deploy/unraid/sanctuary.xml +2 -1
  14. package/dist/heart/config.js +2 -1
  15. package/dist/heart/daemon/agent-discovery.js +5 -1
  16. package/dist/heart/daemon/agent-home.js +67 -31
  17. package/dist/heart/daemon/boot-sync-probe.js +36 -10
  18. package/dist/heart/daemon/cli-exec.js +79 -20
  19. package/dist/heart/daemon/cli-help.js +12 -0
  20. package/dist/heart/daemon/cli-parse.js +14 -0
  21. package/dist/heart/daemon/container-spec-auditor-main.js +22 -6
  22. package/dist/heart/daemon/container-spec-auditor.js +18 -2
  23. package/dist/heart/daemon/daemon-entry.js +22 -0
  24. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
  25. package/dist/heart/daemon/sanctuary-bundle-migration.js +97 -1
  26. package/dist/heart/daemon/sanctuary-package-management.js +10 -0
  27. package/dist/heart/sync-lock.js +431 -0
  28. package/dist/heart/sync.js +1252 -399
  29. package/dist/mind/prompt.js +92 -19
  30. package/dist/repertoire/guardrails.js +285 -3
  31. package/dist/repertoire/tools-bundle.js +107 -185
  32. package/dist/repertoire/tools.js +5 -1
  33. package/dist/senses/pipeline.js +37 -3
  34. package/dist/senses/private-runtime.js +1 -6
  35. package/dist/senses/voice/twilio-phone.js +16 -16
  36. package/npm-shrinkwrap.json +2 -2
  37. package/package.json +1 -1
@@ -98,6 +98,16 @@ function prepareSanctuaryPackageManagedBundle(decision, deps = {}) {
98
98
  const action = inspection.ok ? inspection.data.repair.action : inspection.error.repair.action;
99
99
  throw (0, daemon_bootstrap_startup_1.createSanctuaryBundlePreparationFailure)(action === "none" ? "run_verified_update_recovery" : action);
100
100
  }
101
+ // With sync on, a missing exclude would commit secrets and package-managed files, so that bundle does not boot.
102
+ // With sync off nothing is committed, so the failure is logged and the boot goes on.
103
+ try {
104
+ (deps.writeGitExclude ?? sanctuary_bundle_migration_1.writeSanctuaryGitExclude)(decision.agentRoot);
105
+ }
106
+ catch (error) {
107
+ if ((deps.syncEnabled ?? sanctuary_bundle_migration_1.bundleSyncEnabled)(decision.agentRoot))
108
+ throw (0, daemon_bootstrap_startup_1.createSanctuaryBundlePreparationFailure)("run_verified_update_recovery");
109
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "daemon", event: "daemon.sanctuary_git_exclude_failed", message: "could not write the bundle's git exclude; sync is off, so the boot continues", meta: { reason: error instanceof Error ? error.message : String(error) } });
110
+ }
101
111
  (0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_package_management_prepared", message: "prepared Sanctuary package-managed bundle", meta: { runtimePackageVersion: inspection.data.runtimePackageVersion, journalState: inspection.data.journalState } });
102
112
  return inspection;
103
113
  }
@@ -0,0 +1,431 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.__testHooks = exports.SYNC_LOCK_FILE = void 0;
37
+ exports.tryAcquireSyncLockSync = tryAcquireSyncLockSync;
38
+ exports.acquireSyncLock = acquireSyncLock;
39
+ exports.clearOwnStaleSyncLock = clearOwnStaleSyncLock;
40
+ const child_process_1 = require("child_process");
41
+ const crypto_1 = require("crypto");
42
+ const fs = __importStar(require("fs"));
43
+ const path = __importStar(require("path"));
44
+ const runtime_1 = require("../nerves/runtime");
45
+ /** File name of the per-bundle sync lock, kept under `.git/`. */
46
+ exports.SYNC_LOCK_FILE = "ouro-sync.lock";
47
+ const RECLAIM_STALE_MS = 30_000;
48
+ const POLL_MS = 100;
49
+ /**
50
+ * Test seams.
51
+ * @internal Not part of the public API; production code must not set these.
52
+ */
53
+ exports.__testHooks = {
54
+ resetOwnStartTime() {
55
+ ownStartTime = undefined;
56
+ },
57
+ };
58
+ const heldTokens = new Set();
59
+ function lockFilePath(agentRoot) {
60
+ return path.join(agentRoot, ".git", exports.SYNC_LOCK_FILE);
61
+ }
62
+ const PS_ENV_OVERRIDES = { LC_ALL: "C", TZ: "UTC" };
63
+ function readProcStat(pid) {
64
+ try {
65
+ return exports.__testHooks.readProcStat ? exports.__testHooks.readProcStat(pid) : fs.readFileSync(`/proc/${pid}/stat`, "utf8");
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ function readPsStart(pid) {
72
+ try {
73
+ const out = exports.__testHooks.readPsStart
74
+ ? exports.__testHooks.readPsStart(pid)
75
+ : (0, child_process_1.execFileSync)("ps", ["-o", "lstart=", "-p", String(pid)], {
76
+ encoding: "utf8",
77
+ timeout: 2000,
78
+ stdio: ["ignore", "pipe", "ignore"],
79
+ // lstart prints local time in the caller's locale; pin both so every process sees one string.
80
+ env: { ...process.env, ...PS_ENV_OVERRIDES },
81
+ });
82
+ const trimmed = out === null ? "" : out.trim();
83
+ return trimmed === "" ? null : trimmed;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /**
90
+ * Process start time as an opaque string, or null when unknown. Linux: field 22
91
+ * of `/proc/<pid>/stat` (the command name, field 2, may hold spaces and parens,
92
+ * so parse past the last `)`). Elsewhere, or when /proc is unreadable: the
93
+ * trimmed output of `ps -o lstart=` under a pinned locale and time zone.
94
+ */
95
+ function processStartTime(pid) {
96
+ if ((exports.__testHooks.platform?.() ?? process.platform) === "linux") {
97
+ const stat = readProcStat(pid);
98
+ if (stat !== null) {
99
+ // `rest` begins at field 3.
100
+ const field = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[22 - 3];
101
+ if (field !== undefined)
102
+ return field;
103
+ }
104
+ }
105
+ return readPsStart(pid);
106
+ }
107
+ /** This process's own start time cannot change, so it is read once, lazily, when the read succeeds. */
108
+ let ownStartTime;
109
+ function ownProcessStartTime() {
110
+ if (ownStartTime !== undefined)
111
+ return ownStartTime;
112
+ // Cache only a successful read; a failed one is retried on the next attempt.
113
+ const read = processStartTime(process.pid);
114
+ if (read !== null)
115
+ ownStartTime = read;
116
+ return read;
117
+ }
118
+ /** /proc ticks are all digits; `ps` lstart never is. Values of different formats cannot be compared. */
119
+ const isProcFormat = (value) => /^\d+$/.test(value);
120
+ function pidAlive(pid) {
121
+ try {
122
+ process.kill(pid, 0);
123
+ return true;
124
+ }
125
+ catch (err) {
126
+ return err.code !== "ESRCH";
127
+ }
128
+ }
129
+ function isStale(content) {
130
+ if (!pidAlive(content.pid))
131
+ return true;
132
+ if (content.procStart !== null) {
133
+ // Stale only when both start times are known, in the same format, and differ.
134
+ // An unreadable start time means unknown, and a live pid with unknown start is live.
135
+ const now = content.pid === process.pid ? ownProcessStartTime() : processStartTime(content.pid);
136
+ if (now !== null && isProcFormat(now) === isProcFormat(content.procStart) && now !== content.procStart)
137
+ return true;
138
+ }
139
+ return content.pid === process.pid && !heldTokens.has(content.token);
140
+ }
141
+ function readLock(lockPath) {
142
+ exports.__testHooks.beforeRead?.();
143
+ let fd;
144
+ try {
145
+ fd = fs.openSync(lockPath, "r");
146
+ }
147
+ catch (err) {
148
+ if (err.code === "ENOENT")
149
+ return { kind: "missing" };
150
+ throw err;
151
+ }
152
+ let raw;
153
+ let ageMs;
154
+ try {
155
+ // Content and mtime come from one descriptor, so the file cannot vanish between them.
156
+ raw = fs.readFileSync(fd, "utf8");
157
+ ageMs = Date.now() - fs.fstatSync(fd).mtimeMs;
158
+ }
159
+ finally {
160
+ fs.closeSync(fd);
161
+ }
162
+ try {
163
+ const parsed = JSON.parse(raw);
164
+ if (typeof parsed.pid === "number" && typeof parsed.token === "string") {
165
+ return { kind: "ok", raw, content: { pid: parsed.pid, token: parsed.token, procStart: parsed.procStart ?? null } };
166
+ }
167
+ }
168
+ catch {
169
+ // fall through to malformed
170
+ }
171
+ // Corrupt-lock rule: a lock that does not parse is busy while fresh, because a writer may be
172
+ // between create and write. Once its mtime is older than 30 s it is stale and may be reclaimed.
173
+ return { kind: "malformed", raw, ageMs };
174
+ }
175
+ function judge(result) {
176
+ if (result.kind === "ok")
177
+ return { stale: isStale(result.content), holderPid: result.content.pid };
178
+ return { stale: result.ageMs > RECLAIM_STALE_MS, holderPid: null };
179
+ }
180
+ function makeHandle(lockPath, token) {
181
+ return {
182
+ release() {
183
+ heldTokens.delete(token);
184
+ const current = readLock(lockPath);
185
+ if (current.kind === "ok" && current.content.token === token)
186
+ fs.rmSync(lockPath, { force: true });
187
+ },
188
+ };
189
+ }
190
+ /** Exclusive create. Returns null if the lock already exists. */
191
+ function createLock(lockPath) {
192
+ const token = (0, crypto_1.randomBytes)(16).toString("hex");
193
+ const content = { pid: process.pid, token, procStart: ownProcessStartTime() };
194
+ let fd;
195
+ try {
196
+ fd = fs.openSync(lockPath, "wx");
197
+ }
198
+ catch (err) {
199
+ if (err.code === "EEXIST")
200
+ return null;
201
+ throw err;
202
+ }
203
+ try {
204
+ exports.__testHooks.beforeLockWrite?.();
205
+ fs.writeSync(fd, JSON.stringify(content));
206
+ }
207
+ catch (err) {
208
+ // Do not leave an empty lock that would read as busy until it ages out.
209
+ fs.closeSync(fd);
210
+ fs.rmSync(lockPath, { force: true });
211
+ throw err;
212
+ }
213
+ fs.closeSync(fd);
214
+ heldTokens.add(token);
215
+ return makeHandle(lockPath, token);
216
+ }
217
+ const guardPathOf = (lockPath) => `${lockPath}.reclaim`;
218
+ function guardOwned(guardPath, token) {
219
+ try {
220
+ return fs.readFileSync(guardPath, "utf8") === token;
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ }
226
+ /** Create the guard with O_EXCL, holding a random token, and fsync it. Returns null if it exists. */
227
+ function createGuard(guardPath) {
228
+ const token = (0, crypto_1.randomBytes)(16).toString("hex");
229
+ let fd;
230
+ try {
231
+ fd = fs.openSync(guardPath, "wx");
232
+ }
233
+ catch (err) {
234
+ /* v8 ignore next -- only a collision is expected; other errors are real faults @preserve */
235
+ if (err.code !== "EEXIST")
236
+ throw err;
237
+ return null;
238
+ }
239
+ try {
240
+ fs.writeSync(fd, token);
241
+ fs.fsyncSync(fd);
242
+ }
243
+ finally {
244
+ fs.closeSync(fd);
245
+ }
246
+ return token;
247
+ }
248
+ /**
249
+ * Clear a guard older than 30 s without ever deleting a guard that is not the one judged stale:
250
+ * rename it aside, read the renamed file's token, and delete only on a match. On a mismatch it is
251
+ * not restored; the call reports busy.
252
+ */
253
+ function clearStaleGuard(guardPath) {
254
+ let judgedToken;
255
+ let ageMs;
256
+ try {
257
+ exports.__testHooks.beforeReclaimStat?.();
258
+ ageMs = Date.now() - fs.statSync(guardPath).mtimeMs;
259
+ judgedToken = fs.readFileSync(guardPath, "utf8");
260
+ }
261
+ catch {
262
+ return "gone";
263
+ }
264
+ if (ageMs <= RECLAIM_STALE_MS)
265
+ return "busy";
266
+ const moved = `${guardPath}.stale.${(0, crypto_1.randomBytes)(8).toString("hex")}`;
267
+ exports.__testHooks.guardRace?.("beforeRename");
268
+ try {
269
+ fs.renameSync(guardPath, moved);
270
+ }
271
+ catch {
272
+ return "gone";
273
+ }
274
+ exports.__testHooks.guardRace?.("afterRename");
275
+ if (fs.readFileSync(moved, "utf8") === judgedToken) {
276
+ fs.rmSync(moved, { force: true });
277
+ return "cleared";
278
+ }
279
+ // We moved a guard that is not the one we judged. Never put it back: restoring a guard that
280
+ // another reclaimer may already have outlived is how two processes end up holding the lock.
281
+ // Its owner's ownership re-check finds the guard gone and gives up. We delete only our aside copy.
282
+ fs.rmSync(moved, { force: true });
283
+ return "busy";
284
+ }
285
+ /**
286
+ * Take over a stale lock. One guard file (O_EXCL, holding our token) guards the whole
287
+ * sequence, so two reclaimers cannot both move a lock. Ownership of the guard is checked
288
+ * before the unlink and after the create; a reclaimer that lost its guard gives up.
289
+ */
290
+ function reclaim(lockPath, staleRaw) {
291
+ const guardPath = guardPathOf(lockPath);
292
+ let guard = createGuard(guardPath);
293
+ if (guard === null) {
294
+ const cleared = clearStaleGuard(guardPath);
295
+ if (cleared === "gone")
296
+ return "retry";
297
+ if (cleared === "busy")
298
+ return { busy: true, holderPid: null };
299
+ guard = createGuard(guardPath);
300
+ if (guard === null)
301
+ return { busy: true, holderPid: null };
302
+ }
303
+ const token = guard;
304
+ try {
305
+ const current = readLock(lockPath);
306
+ if (current.kind === "missing")
307
+ return "retry";
308
+ const verdict = judge(current);
309
+ if (!verdict.stale || current.raw !== staleRaw)
310
+ return { busy: true, holderPid: verdict.holderPid };
311
+ exports.__testHooks.beforeUnlink?.();
312
+ // Accepted residual (M14): between this ownership check and the unlink below, a guard that was
313
+ // judged stale and cleared by another reclaimer can still let a third contender reclaim the
314
+ // lock in the same gap, so two holders are possible when three reclaimers meet a guard left by
315
+ // a crashed one. It needs a crash plus a 30 s pause plus three contenders in one window, and
316
+ // every closing option (a lock-file lease, a held flock) costs more than the exposure.
317
+ if (!guardOwned(guardPath, token))
318
+ return { busy: true, holderPid: null };
319
+ fs.rmSync(lockPath, { force: true });
320
+ exports.__testHooks.afterUnlink?.();
321
+ const handle = createLock(lockPath);
322
+ // Only a fresh acquirer (not a reclaimer) can have created the lock since our unlink.
323
+ if (!handle)
324
+ return "retry";
325
+ if (!guardOwned(guardPath, token)) {
326
+ handle.release();
327
+ return { busy: true, holderPid: null };
328
+ }
329
+ (0, runtime_1.emitNervesEvent)({
330
+ component: "heart",
331
+ event: "heart.sync_lock_reclaimed",
332
+ message: "reclaimed a stale bundle sync lock",
333
+ meta: { lockPath, previousPid: verdict.holderPid },
334
+ });
335
+ return handle;
336
+ }
337
+ finally {
338
+ if (guardOwned(guardPath, token))
339
+ fs.rmSync(guardPath, { force: true });
340
+ }
341
+ }
342
+ function attemptOnce(lockPath) {
343
+ let busy = { busy: true, holderPid: null };
344
+ for (let i = 0; i < 3; i++) {
345
+ const created = createLock(lockPath);
346
+ if (created)
347
+ return created;
348
+ const existing = readLock(lockPath);
349
+ if (existing.kind === "missing")
350
+ continue;
351
+ const verdict = judge(existing);
352
+ busy = { busy: true, holderPid: verdict.holderPid };
353
+ if (!verdict.stale)
354
+ break;
355
+ const outcome = reclaim(lockPath, existing.raw);
356
+ if (outcome === "retry")
357
+ continue;
358
+ if ("busy" in outcome) {
359
+ busy = outcome;
360
+ break;
361
+ }
362
+ return outcome;
363
+ }
364
+ return busy;
365
+ }
366
+ function emitBusy(lockPath, busy) {
367
+ (0, runtime_1.emitNervesEvent)({
368
+ level: "warn",
369
+ component: "heart",
370
+ event: "heart.sync_lock_busy",
371
+ message: "bundle sync lock is held",
372
+ meta: { lockPath, holderPid: busy.holderPid },
373
+ });
374
+ }
375
+ /** One synchronous attempt. */
376
+ function tryAcquireSyncLockSync(agentRoot) {
377
+ const lockPath = lockFilePath(agentRoot);
378
+ const result = attemptOnce(lockPath);
379
+ if ("busy" in result)
380
+ emitBusy(lockPath, result);
381
+ return result;
382
+ }
383
+ /** The signal's own reason, or an AbortError when it has none. */
384
+ function abortReason(signal) {
385
+ return signal.reason ?? new DOMException("The sync lock wait was aborted", "AbortError");
386
+ }
387
+ function pause(ms, signal) {
388
+ return new Promise((resolve, reject) => {
389
+ const onAbort = () => {
390
+ clearTimeout(timer);
391
+ reject(abortReason(signal));
392
+ };
393
+ const timer = setTimeout(() => {
394
+ signal?.removeEventListener("abort", onAbort);
395
+ resolve();
396
+ }, ms);
397
+ // The timer stays ref'd on purpose: a one-shot caller (`ouro sync`) that waits on a busy lock
398
+ // must keep the process alive until it gets the lock or times out.
399
+ signal?.addEventListener("abort", onAbort, { once: true });
400
+ });
401
+ }
402
+ /**
403
+ * Polls every 100 ms for up to `waitMs`. `Infinity` waits until the lock is acquired; NaN or a
404
+ * negative value is a single attempt. An aborted `signal` rejects with its reason (an AbortError by default).
405
+ */
406
+ async function acquireSyncLock(agentRoot, waitMs, signal) {
407
+ const lockPath = lockFilePath(agentRoot);
408
+ const deadline = Date.now() + (waitMs > 0 ? waitMs : 0);
409
+ for (;;) {
410
+ if (signal?.aborted)
411
+ throw abortReason(signal);
412
+ const result = attemptOnce(lockPath);
413
+ if (!("busy" in result))
414
+ return result;
415
+ if (Date.now() >= deadline) {
416
+ emitBusy(lockPath, result);
417
+ return result;
418
+ }
419
+ await pause(Math.min(POLL_MS, Math.max(1, deadline - Date.now())), signal);
420
+ }
421
+ }
422
+ /** Daemon boot: drop a lock this pid left behind in an earlier life. */
423
+ function clearOwnStaleSyncLock(agentRoot) {
424
+ const lockPath = lockFilePath(agentRoot);
425
+ const existing = readLock(lockPath);
426
+ if (existing.kind !== "ok")
427
+ return;
428
+ if (existing.content.pid === process.pid && !heldTokens.has(existing.content.token)) {
429
+ fs.rmSync(lockPath, { force: true });
430
+ }
431
+ }