@ouro.bot/cli 0.1.0-alpha.263 → 0.1.0-alpha.265

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.
package/changelog.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.265",
6
+ "changes": [
7
+ "fix(daemon): bulletproof vitest socket leak + SIGTERM forensics. Hardens the socket-client vitest guard so production daemon socket calls (DEFAULT_DAEMON_SOCKET_PATH = /tmp/ouroboros-daemon.sock) are unconditionally blocked under vitest, regardless of __bypassVitestGuardForTests state. Cross-file leaks via the globalThis bypass flag (process-wide, leaks across concurrent test files in the same vitest worker) can no longer reach the production daemon. Test files that legitimately exercise the real socket-client transport against synthetic test socket paths (/tmp/daemon.sock) continue to work. Background: a daemon outage on 2026-04-08 was traced to a 14-call burst of leaked `inner.wake testagent` errors, signature of vitest test runs hammering the production socket — same pattern as 1,460 historical daemon log entries.",
8
+ "feat(daemon): SIGTERM/SIGINT tombstone forensics. New writeDaemonTombstone path captures process.ppid, parent command via `ps -p <ppid> -o command=`, and a filtered process snapshot (only node/vitest/ouro/kill lines) at signal-driven death time. Adds a killerHint heuristic: launchd parent reparenting suggests `launchctl bootout`/KeepAlive thrash; vitest worker presence suggests test cleanup; pkill/killall presence is an explicit kill. Forensics field is parsed by readDaemonTombstone and included in the daemon.tombstone_written nerves event meta. Also caps recentCrashes at 100 entries (was 12,265 from a March 31 thrash loop).",
9
+ "test(daemon): contract test BYPASS_USE_ALLOWLIST flags any new file calling __bypassVitestGuardForTests outside the two known-good test files (socket-client.test.ts and daemon-cli-defaults.test.ts). Prevents future regressions of the cross-file leak vector."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.264",
14
+ "changes": [
15
+ "fix(bluebubbles): dedup `updated-message` webhooks BEFORE running repair+hydrate+VLM. BlueBubbles routinely sends a `new-message` webhook for a fresh message, then follows up seconds later with one or more `updated-message` webhooks for delivery/read status. The BB sense's `repairEvent` path promotes updated-message events with recoverable content back to `message` kind, which re-runs the full hydration pipeline \u2014 including a second MiniMax VLM describe call on the same image. Verified live on 2026-04-08T00:58Z: two sequential VLM describes for attachment guid 317E37EB-..., 13.7s + 14.0s each, for the exact same 291KB JPEG, triggered by a `new-message` followed 3s later by an `updated-message` for the same guid. The downstream `handleBlueBubblesNormalizedEvent` dedup check was firing correctly but too late (after the expensive VLM round-trip). Fix: add a pre-repair dedup check in `handleBlueBubblesEvent` that consults the inbound sidecar by messageGuid and short-circuits before calling `client.repairEvent(...)`. New nerves event `senses.bluebubbles_repair_skipped_duplicate` at warn level for observability."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.1.0-alpha.263",
6
20
  "changes": [
@@ -33,14 +33,19 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.RECENT_CRASHES_MAX = void 0;
36
37
  exports.getTombstonePath = getTombstonePath;
37
38
  exports.setTombstonePath = setTombstonePath;
39
+ exports.captureDeathForensics = captureDeathForensics;
38
40
  exports.writeDaemonTombstone = writeDaemonTombstone;
39
41
  exports.readDaemonTombstone = readDaemonTombstone;
42
+ const child_process_1 = require("child_process");
40
43
  const fs = __importStar(require("fs"));
41
44
  const os = __importStar(require("os"));
42
45
  const path = __importStar(require("path"));
43
46
  const runtime_1 = require("../../nerves/runtime");
47
+ /** Maximum number of historical crash timestamps to retain in the tombstone. */
48
+ exports.RECENT_CRASHES_MAX = 100;
44
49
  let _tombstonePath = null;
45
50
  function getTombstonePath() {
46
51
  if (!_tombstonePath) {
@@ -52,7 +57,84 @@ function getTombstonePath() {
52
57
  function setTombstonePath(p) {
53
58
  _tombstonePath = p;
54
59
  }
55
- function writeDaemonTombstone(reason, error) {
60
+ /* v8 ignore start -- shells out to ps; covered via injected runPs in unit tests @preserve */
61
+ function defaultRunPs(args) {
62
+ try {
63
+ return (0, child_process_1.execSync)(`ps ${args.join(" ")}`, { encoding: "utf-8", timeout: 2000 });
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /* v8 ignore stop */
70
+ const DEFAULT_FORENSICS_DEPS = {
71
+ /* v8 ignore next -- defensive: process.ppid always exists in node @preserve */
72
+ ppid: () => (typeof process !== "undefined" && typeof process.ppid === "number" ? process.ppid : 0),
73
+ runPs: defaultRunPs,
74
+ };
75
+ /**
76
+ * Capture forensic data about who is likely to have killed the daemon.
77
+ * Synchronous and best-effort: never throws, always returns a structured
78
+ * record (with nulls if anything failed). Called from inside SIGTERM/SIGINT
79
+ * handlers, so it must be fast and have no async dependencies.
80
+ */
81
+ function captureDeathForensics(deps = DEFAULT_FORENSICS_DEPS) {
82
+ const parentPid = (() => {
83
+ try {
84
+ const p = deps.ppid();
85
+ return typeof p === "number" && p > 0 ? p : null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ })();
91
+ let parentCommand = null;
92
+ if (parentPid !== null) {
93
+ const psParent = deps.runPs(["-p", String(parentPid), "-o", "command="]);
94
+ if (psParent !== null) {
95
+ const trimmed = psParent.trim();
96
+ parentCommand = trimmed.length > 0 ? trimmed : null;
97
+ }
98
+ }
99
+ const processSnapshot = (() => {
100
+ const psAll = deps.runPs(["-eo", "pid,ppid,command"]);
101
+ if (psAll === null)
102
+ return null;
103
+ const lines = psAll.split("\n");
104
+ // Filter for relevant processes only — node, vitest, ouro, kill commands.
105
+ // Keeps the snapshot small and human-scannable.
106
+ const relevant = lines.filter((line) => {
107
+ const lower = line.toLowerCase();
108
+ return (lower.includes("node")
109
+ || lower.includes("vitest")
110
+ || lower.includes("ouro")
111
+ || lower.includes("/kill ")
112
+ || lower.includes("pkill")
113
+ || lower.includes("killall"));
114
+ });
115
+ if (relevant.length === 0)
116
+ return null;
117
+ return relevant.join("\n");
118
+ })();
119
+ const killerHint = deriveKillerHint(parentCommand, processSnapshot);
120
+ return { parentPid, parentCommand, processSnapshot, killerHint };
121
+ }
122
+ function deriveKillerHint(parentCommand, snapshot) {
123
+ if (parentCommand !== null && parentCommand.toLowerCase().includes("launchd")) {
124
+ return "process was reparented to launchd — likely killed by launchctl bootout, KeepAlive thrash, or memory pressure";
125
+ }
126
+ if (snapshot !== null) {
127
+ const lower = snapshot.toLowerCase();
128
+ if (lower.includes("vitest")) {
129
+ return "vitest worker is running — possible test cleanup killing detached processes";
130
+ }
131
+ if (lower.includes("pkill") || lower.includes("killall")) {
132
+ return "saw pkill/killall in process list — explicit kill command";
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+ function writeDaemonTombstone(reason, error, forensicsDeps) {
56
138
  const now = new Date().toISOString();
57
139
  const filePath = getTombstonePath();
58
140
  // Read existing recentCrashes from previous tombstone (best-effort)
@@ -67,6 +149,17 @@ function writeDaemonTombstone(reason, error) {
67
149
  catch {
68
150
  // No existing tombstone or unreadable — start fresh
69
151
  }
152
+ // Append the new crash and cap at the most recent RECENT_CRASHES_MAX entries
153
+ // so the tombstone doesn't grow without bound (we saw it hit 12,265 entries
154
+ // after a March 31 crash-restart thrash loop).
155
+ const recentCrashes = [...existingCrashes, now].slice(-exports.RECENT_CRASHES_MAX);
156
+ // Forensics: only meaningful for signal-driven deaths (sigterm/sigint).
157
+ // For uncaughtException etc. we skip the snapshot to keep tombstone writes
158
+ // fast on the unhappy path.
159
+ const shouldCaptureForensics = reason === "sigterm" || reason === "sigint";
160
+ const forensics = shouldCaptureForensics
161
+ ? captureDeathForensics(forensicsDeps)
162
+ : undefined;
70
163
  const tombstone = {
71
164
  reason,
72
165
  message: error?.message ?? reason,
@@ -74,7 +167,8 @@ function writeDaemonTombstone(reason, error) {
74
167
  timestamp: now,
75
168
  pid: process.pid,
76
169
  uptimeSeconds: Math.floor(process.uptime()),
77
- recentCrashes: [...existingCrashes, now],
170
+ recentCrashes,
171
+ ...(forensics ? { forensics } : {}),
78
172
  };
79
173
  try {
80
174
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -88,7 +182,13 @@ function writeDaemonTombstone(reason, error) {
88
182
  component: "daemon",
89
183
  event: "daemon.tombstone_written",
90
184
  message: `daemon tombstone written: ${reason}`,
91
- meta: { reason, errorMessage: error?.message ?? null, filePath },
185
+ meta: {
186
+ reason,
187
+ errorMessage: error?.message ?? null,
188
+ filePath,
189
+ parentPid: forensics?.parentPid ?? null,
190
+ killerHint: forensics?.killerHint ?? null,
191
+ },
92
192
  });
93
193
  }
94
194
  function readDaemonTombstone() {
@@ -105,6 +205,7 @@ function readDaemonTombstone() {
105
205
  message: "read daemon tombstone",
106
206
  meta: { filePath },
107
207
  });
208
+ const forensics = parseForensics(parsed.forensics);
108
209
  return {
109
210
  reason: parsed.reason,
110
211
  message: typeof parsed.message === "string" ? parsed.message : String(parsed.reason),
@@ -115,9 +216,21 @@ function readDaemonTombstone() {
115
216
  recentCrashes: Array.isArray(parsed.recentCrashes)
116
217
  ? parsed.recentCrashes.filter((e) => typeof e === "string")
117
218
  : [],
219
+ ...(forensics ? { forensics } : {}),
118
220
  };
119
221
  }
120
222
  catch {
121
223
  return null;
122
224
  }
123
225
  }
226
+ function parseForensics(value) {
227
+ if (value === null || typeof value !== "object")
228
+ return null;
229
+ const v = value;
230
+ return {
231
+ parentPid: typeof v.parentPid === "number" ? v.parentPid : null,
232
+ parentCommand: typeof v.parentCommand === "string" ? v.parentCommand : null,
233
+ processSnapshot: typeof v.processSnapshot === "string" ? v.processSnapshot : null,
234
+ killerHint: typeof v.killerHint === "string" ? v.killerHint : null,
235
+ };
236
+ }
@@ -60,6 +60,23 @@ exports.DEFAULT_DAEMON_SOCKET_PATH = "/tmp/ouroboros-daemon.sock";
60
60
  * test socket) call `__bypassVitestGuardForTests(true)` to opt out of the
61
61
  * guard. We persist that flag on globalThis so it survives `vi.resetModules`
62
62
  * (which clears the module cache between tests).
63
+ *
64
+ * HARDENING (cross-file leak protection): the bypass flag lives on globalThis
65
+ * and is process-wide. With vitest's default `fileParallelism: true`,
66
+ * concurrently-running test files in the same worker can have the bypass on
67
+ * even though they didn't ask for it — and any test that uses
68
+ * `name: "testagent"` and exercises a code path through this module will
69
+ * leak `inner.wake testagent` to the production daemon socket. We saw this
70
+ * cause a real outage on 2026-04-08 (daemon SIGTERMed mid-validation).
71
+ *
72
+ * Defense: even when the bypass flag is set, we ALWAYS hard-block calls that
73
+ * target the production daemon socket path. Test files that legitimately
74
+ * exercise the real socket-client code paths use a synthetic socket path like
75
+ * `/tmp/daemon.sock` or `/tmp/some-real-daemon.sock` — those keep working.
76
+ * Calls to `/tmp/ouroboros-daemon.sock` (DEFAULT_DAEMON_SOCKET_PATH) under
77
+ * vitest are unconditionally blocked, regardless of bypass state. This makes
78
+ * the cross-file leak vector impossible without restricting legitimate
79
+ * socket-client unit tests.
63
80
  */
64
81
  const BYPASS_KEY = "__ouro_socket_client_bypass_vitest_guard__";
65
82
  /** ONLY for socket-client.test.ts. Do not call from any other test or production code. */
@@ -67,22 +84,36 @@ function __bypassVitestGuardForTests(bypass) {
67
84
  ;
68
85
  globalThis[BYPASS_KEY] = bypass;
69
86
  }
70
- function isUnderVitest() {
71
- if (globalThis[BYPASS_KEY] === true)
72
- return false;
87
+ function isVitestProcess() {
73
88
  /* v8 ignore next -- defensive: process and process.argv always exist in node @preserve */
74
89
  if (typeof process === "undefined" || !Array.isArray(process.argv))
75
90
  return false;
76
91
  return process.argv.some((arg) => typeof arg === "string" && arg.includes("vitest"));
77
92
  }
93
+ function isProductionDaemonSocket(socketPath) {
94
+ return socketPath === exports.DEFAULT_DAEMON_SOCKET_PATH;
95
+ }
96
+ /**
97
+ * Returns true when this socket call should be converted into a safe no-op
98
+ * because we're under vitest and the call would otherwise leak into a real
99
+ * daemon. The bypass flag short-circuits the guard for non-production socket
100
+ * paths only — production socket paths are ALWAYS blocked under vitest.
101
+ */
102
+ function shouldSuppressSocketCall(socketPath) {
103
+ if (!isVitestProcess())
104
+ return false;
105
+ if (isProductionDaemonSocket(socketPath))
106
+ return true;
107
+ return globalThis[BYPASS_KEY] !== true;
108
+ }
78
109
  function sendDaemonCommand(socketPath, command) {
79
- if (isUnderVitest()) {
110
+ if (shouldSuppressSocketCall(socketPath)) {
80
111
  (0, runtime_1.emitNervesEvent)({
81
112
  level: "warn",
82
113
  component: "daemon",
83
114
  event: "daemon.socket_command_test_blocked",
84
115
  message: "blocked socket command from leaking into real daemon under vitest",
85
- meta: { socketPath, kind: command.kind },
116
+ meta: { socketPath, kind: command.kind, isProductionSocket: isProductionDaemonSocket(socketPath) },
86
117
  });
87
118
  return Promise.resolve({ ok: true, message: "test mode: socket call suppressed" });
88
119
  }
@@ -191,7 +222,7 @@ function sendDaemonCommand(socketPath, command) {
191
222
  });
192
223
  }
193
224
  function checkDaemonSocketAlive(socketPath) {
194
- if (isUnderVitest()) {
225
+ if (shouldSuppressSocketCall(socketPath)) {
195
226
  return Promise.resolve(false);
196
227
  }
197
228
  (0, runtime_1.emitNervesEvent)({
@@ -248,13 +279,13 @@ function checkDaemonSocketAlive(socketPath) {
248
279
  });
249
280
  }
250
281
  async function requestInnerWake(agent, socketPath = exports.DEFAULT_DAEMON_SOCKET_PATH) {
251
- if (isUnderVitest()) {
282
+ if (shouldSuppressSocketCall(socketPath)) {
252
283
  (0, runtime_1.emitNervesEvent)({
253
284
  level: "warn",
254
285
  component: "daemon",
255
286
  event: "daemon.inner_wake_test_blocked",
256
287
  message: "blocked inner wake from leaking into real daemon under vitest",
257
- meta: { agent, socketPath },
288
+ meta: { agent, socketPath, isProductionSocket: isProductionDaemonSocket(socketPath) },
258
289
  });
259
290
  return null;
260
291
  }
@@ -859,7 +859,43 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
859
859
  async function handleBlueBubblesEvent(payload, deps = {}) {
860
860
  const resolvedDeps = { ...defaultDeps, ...deps };
861
861
  const client = resolvedDeps.createClient();
862
- const event = await client.repairEvent((0, model_1.normalizeBlueBubblesEvent)(payload));
862
+ const normalized = (0, model_1.normalizeBlueBubblesEvent)(payload);
863
+ // Pre-repair dedup: if we've already processed this messageGuid, skip the
864
+ // repair+hydrate path entirely. Applies to BOTH `kind: "message"` AND
865
+ // `kind: "mutation"` events — BlueBubbles often sends a `new-message`
866
+ // webhook for a fresh message AND one or more follow-up `updated-message`
867
+ // webhooks for delivery/read status. The mutation path (inside repairEvent)
868
+ // can promote an updated-message back to a message if it has recoverable
869
+ // content, which then re-runs the full VLM-describe pipeline on the same
870
+ // attachment.
871
+ //
872
+ // Without this early check, we paid DOUBLE latency and double tokens on
873
+ // every image-bearing message. Verified live on 2026-04-08T00:58Z: two
874
+ // sequential VLM describes for attachment guid 317E37EB-..., 13.7s +
875
+ // 14.0s each, for the exact same 291KB JPEG — triggered by a sequence of
876
+ // `new-message` followed ~3s later by `updated-message` for the same guid.
877
+ //
878
+ // We still route the skip through `handleBlueBubblesNormalizedEvent` so
879
+ // the downstream `already_processed` path fires its observability events
880
+ // and the caller sees a consistent return shape.
881
+ const agentName = resolvedDeps.getAgentName();
882
+ if (normalized.messageGuid
883
+ && (0, inbound_log_1.hasRecordedBlueBubblesInbound)(agentName, normalized.chat.sessionKey, normalized.messageGuid)) {
884
+ (0, runtime_1.emitNervesEvent)({
885
+ level: "warn",
886
+ component: "senses",
887
+ event: "senses.bluebubbles_repair_skipped_duplicate",
888
+ message: "skipped repair+hydrate for already-processed bluebubbles messageGuid",
889
+ meta: {
890
+ messageGuid: normalized.messageGuid,
891
+ sessionKey: normalized.chat.sessionKey,
892
+ eventType: normalized.eventType,
893
+ normalizedKind: normalized.kind,
894
+ },
895
+ });
896
+ return handleBlueBubblesNormalizedEvent(normalized, resolvedDeps, "webhook");
897
+ }
898
+ const event = await client.repairEvent(normalized);
863
899
  return handleBlueBubblesNormalizedEvent(event, resolvedDeps, "webhook");
864
900
  }
865
901
  function countPendingRecoveryCandidates(agentName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.263",
3
+ "version": "0.1.0-alpha.265",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",