@bli-cockpit/cli 0.2.280 → 0.2.282

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.
@@ -16,11 +16,21 @@ export const DEFAULT_AUTOSTART_INTERVAL_SECONDS = 15 * 60;
16
16
  export const REMIND_AUTOSTART_LABEL = "com.bli.cockpit.remind";
17
17
  export const WINDOWS_REMIND_TASK_NAME = "BLI Cockpit Remind";
18
18
  export const REMIND_INTERVAL_SECONDS = 60;
19
+ /**
20
+ * BLI-23571: the THIRD job of the one autostart contract, and the only long-lived one. A resident shell
21
+ * guard that kills a runaway `node` before it freezes the machine. See `memory-guard-contract.ts`.
22
+ */
23
+ export const MEMORY_GUARD_AUTOSTART_LABEL = "com.bli.cockpit.memguard";
24
+ export const WINDOWS_MEMORY_GUARD_TASK_NAME = "BLI Cockpit Memory Guard";
19
25
  export const UNSUPPORTED_MESSAGE = "autostart is supported on macOS and Windows only";
20
26
  /** Where the reminder LaunchAgent lives for a given home. */
21
27
  export function remindPlistPathFor(homeDir) {
22
28
  return path.join(homeDir, "Library", "LaunchAgents", `${REMIND_AUTOSTART_LABEL}.plist`);
23
29
  }
30
+ /** Where the memory guard LaunchAgent lives for a given home. */
31
+ export function memoryGuardPlistPathFor(homeDir) {
32
+ return path.join(homeDir, "Library", "LaunchAgents", `${MEMORY_GUARD_AUTOSTART_LABEL}.plist`);
33
+ }
24
34
  /** Where the LaunchAgent lives for a given home; also the `plist_path` every
25
35
  * result carries, including the unsupported-platform one. */
26
36
  export function plistPathFor(homeDir) {
@@ -0,0 +1,275 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileExists, MEMORY_GUARD_AUTOSTART_LABEL, memoryGuardPlistPathFor, WINDOWS_MEMORY_GUARD_TASK_NAME, } from "./autostart-contract.js";
5
+ import { describeDifference, renderedFileDifference, renderWindowsLauncherScript, UTF8_BOM, windowsPowerShellPath, windowsWScriptPath, } from "./autostart-windows-scripts.js";
6
+ import { xmlEscape, decodeXmlEntities } from "./autostart-xml.js";
7
+ import { lastKillIn, MEMORY_GUARD_DISABLED_MARKER, MEMORY_GUARD_LOG, memoryGuardLimitsFor, memoryGuardStateFile, memoryGuardTurnedOff, memoryGuardTurnedOn, } from "./memory-guard-contract.js";
8
+ import { renderDarwinMemoryGuardScript } from "./memory-guard-script-darwin.js";
9
+ import { renderWindowsMemoryGuardRegistration, renderWindowsMemoryGuardScript } from "./memory-guard-script-windows.js";
10
+ /**
11
+ * The memory guard: the THIRD job of the one autostart contract (BLI-23571), installed, repaired and removed
12
+ * with the sync and reminder jobs from the same front doors in `autostart.ts`, and honouring the same
13
+ * opt-out marker (an operator uninstall removes it). Unlike the reminder it is a RESIDENT process:
14
+ * launchd `KeepAlive` on macOS, a logon task that restarts on failure with no time limit on Windows.
15
+ * `COCKPIT_MEMORY_GUARD=off` at install time turns only the guard off and leaves a marker the tick respects.
16
+ *
17
+ * Nothing here throws for a host problem: every function answers with a named status and a message, and
18
+ * every deciding branch logs its reason (metadata only, never a path).
19
+ */
20
+ const TAG = "[autostart-memory-guard]";
21
+ const GUARD_SCRIPT_DARWIN = "memory-guard.sh";
22
+ const GUARD_OUT_LOG = "memory-guard.out.log";
23
+ const GUARD_ERR_LOG = "memory-guard.err.log";
24
+ const GUARD_SCRIPT_WINDOWS = "autostart-memory-guard.ps1";
25
+ const GUARD_LAUNCHER = "autostart-memory-guard.vbs";
26
+ const GUARD_REGISTRATION = "autostart-memory-guard-register.ps1";
27
+ const LOG_TAIL_BYTES = 64 * 1024;
28
+ function stateFile(options, name) {
29
+ return memoryGuardStateFile(options.homeDir, name);
30
+ }
31
+ function result(options, platform, base) {
32
+ return { label: MEMORY_GUARD_AUTOSTART_LABEL, limits_mb: limitsOf(options), ...base, ...(platform === "win32" ? { task_name: WINDOWS_MEMORY_GUARD_TASK_NAME } : {}) };
33
+ }
34
+ function limitsOf(options) {
35
+ return memoryGuardLimitsFor({ ...(options.env ? { env: options.env } : {}), ...(options.totalMemoryBytes ? { totalMemoryBytes: options.totalMemoryBytes } : {}) });
36
+ }
37
+ /** launchd's guard plist: resident, restarted by launchd within 5 s of any exit, interactive priority. */
38
+ export function renderMemoryGuardPlist(options) {
39
+ return [
40
+ '<?xml version="1.0" encoding="UTF-8"?>',
41
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
42
+ '<plist version="1.0">',
43
+ "<dict>",
44
+ " <key>Label</key>",
45
+ ` <string>${xmlEscape(MEMORY_GUARD_AUTOSTART_LABEL)}</string>`,
46
+ " <key>ProgramArguments</key>",
47
+ " <array>",
48
+ " <string>/bin/bash</string>",
49
+ ` <string>${xmlEscape(options.scriptPath)}</string>`,
50
+ " </array>",
51
+ " <key>RunAtLoad</key>",
52
+ " <true/>",
53
+ " <key>KeepAlive</key>",
54
+ " <true/>",
55
+ " <key>ProcessType</key>",
56
+ " <string>Interactive</string>",
57
+ " <key>ThrottleInterval</key>",
58
+ " <integer>5</integer>",
59
+ " <key>StandardOutPath</key>",
60
+ ` <string>${xmlEscape(options.stdoutPath)}</string>`,
61
+ " <key>StandardErrorPath</key>",
62
+ ` <string>${xmlEscape(options.stderrPath)}</string>`,
63
+ "</dict>",
64
+ "</plist>",
65
+ "",
66
+ ].join("\n");
67
+ }
68
+ function darwinFiles(options) {
69
+ const scriptPath = stateFile(options, GUARD_SCRIPT_DARWIN);
70
+ return {
71
+ scriptPath,
72
+ plistPath: memoryGuardPlistPathFor(options.homeDir ?? os.homedir()),
73
+ script: renderDarwinMemoryGuardScript({ limits: limitsOf(options), logPath: stateFile(options, MEMORY_GUARD_LOG) }),
74
+ plist: renderMemoryGuardPlist({ scriptPath, stdoutPath: stateFile(options, GUARD_OUT_LOG), stderrPath: stateFile(options, GUARD_ERR_LOG) }),
75
+ };
76
+ }
77
+ function windowsFiles(options) {
78
+ const paths = {
79
+ script: stateFile(options, GUARD_SCRIPT_WINDOWS),
80
+ launcher: stateFile(options, GUARD_LAUNCHER),
81
+ registration: stateFile(options, GUARD_REGISTRATION),
82
+ };
83
+ return {
84
+ paths,
85
+ script: `${UTF8_BOM}${renderWindowsMemoryGuardScript({ limits: limitsOf(options), logPath: stateFile(options, MEMORY_GUARD_LOG) })}`,
86
+ launcher: renderWindowsLauncherScript({ powershellPath: windowsPowerShellPath(), scriptPath: paths.script }),
87
+ registration: `${UTF8_BOM}${renderWindowsMemoryGuardRegistration({ taskName: WINDOWS_MEMORY_GUARD_TASK_NAME, launcherPath: paths.launcher, scriptName: GUARD_SCRIPT_WINDOWS })}`,
88
+ };
89
+ }
90
+ /** Writes the guard script and plist without touching launchd (the detached healer registers it). */
91
+ export async function writeDarwinMemoryGuardPlist(options) {
92
+ const files = darwinFiles(options);
93
+ await mkdir(path.dirname(files.plistPath), { recursive: true });
94
+ await mkdir(path.dirname(files.scriptPath), { recursive: true });
95
+ await writeFile(files.scriptPath, files.script, { encoding: "utf8", mode: 0o755 });
96
+ await chmod(files.scriptPath, 0o755);
97
+ await writeFile(files.plistPath, files.plist, "utf8");
98
+ return { plist_path: files.plistPath };
99
+ }
100
+ /** True when the operator turned only the guard off (`COCKPIT_MEMORY_GUARD=off` at an earlier install). */
101
+ export async function memoryGuardDisabled(options) {
102
+ return fileExists(stateFile(options, MEMORY_GUARD_DISABLED_MARKER));
103
+ }
104
+ function platformPath(options, platform) {
105
+ return platform === "win32" ? stateFile(options, GUARD_SCRIPT_WINDOWS) : memoryGuardPlistPathFor(options.homeDir ?? os.homedir());
106
+ }
107
+ /** Applies the install-time switch. Returns a finished result when the guard must stay off, else null. */
108
+ async function applyInstallSwitch(options, platform) {
109
+ const marker = stateFile(options, MEMORY_GUARD_DISABLED_MARKER);
110
+ const env = options.env ?? process.env;
111
+ if (memoryGuardTurnedOff(env)) {
112
+ await mkdir(path.dirname(marker), { recursive: true });
113
+ await writeFile(marker, new Date().toISOString(), "utf8");
114
+ await uninstallMemoryGuard(options);
115
+ console.error(`${TAG} not installed`, JSON.stringify({ reason: "turned_off_by_env", env: "COCKPIT_MEMORY_GUARD=off", platform }));
116
+ return result(options, platform, { status: "absent", path: platformPath(options, platform), disabled: true, loaded: false, message: "memory guard turned off by COCKPIT_MEMORY_GUARD=off; set COCKPIT_MEMORY_GUARD=on and re-run `cockpit autostart install` to bring it back" });
117
+ }
118
+ if (memoryGuardTurnedOn(env)) {
119
+ await rm(marker, { force: true });
120
+ return null;
121
+ }
122
+ if (await fileExists(marker)) {
123
+ console.error(`${TAG} not installed`, JSON.stringify({ reason: "disabled_marker_stands", platform }));
124
+ return result(options, platform, { status: "absent", path: platformPath(options, platform), disabled: true, loaded: false, message: "memory guard stays off (COCKPIT_MEMORY_GUARD=off was chosen at an earlier install)" });
125
+ }
126
+ return null;
127
+ }
128
+ /** Installs or repairs the memory guard for this host. Never throws. */
129
+ export async function installMemoryGuard(options) {
130
+ const platform = options.platform ?? process.platform;
131
+ try {
132
+ if (platform !== "darwin" && platform !== "win32")
133
+ return result(options, platform, { status: "unsupported", path: platformPath(options, platform) });
134
+ const switched = await applyInstallSwitch(options, platform);
135
+ if (switched)
136
+ return switched;
137
+ if (platform === "darwin") {
138
+ const written = await writeDarwinMemoryGuardPlist(options);
139
+ await options.exec("launchctl", ["unload", written.plist_path]).catch(() => undefined);
140
+ const load = await options.exec("launchctl", ["load", written.plist_path]);
141
+ console.error(`${TAG} ${load.code === 0 ? "launchd job loaded" : "launchd job not loaded"}`, JSON.stringify({ label: MEMORY_GUARD_AUTOSTART_LABEL, exit_code: load.code, hard_limit_mb: limitsOf(options).hard }));
142
+ return result(options, platform, { status: "installed", path: written.plist_path, loaded: load.code === 0, ...(load.code === 0 ? {} : { message: `launchctl load exited ${load.code} for the memory guard` }) });
143
+ }
144
+ return await installWindowsGuard(options);
145
+ }
146
+ catch (error) {
147
+ console.error(`${TAG} install failed`, JSON.stringify({ reason: "install_threw", platform, error_name: error instanceof Error ? error.name : typeof error }));
148
+ return result(options, platform, { status: "not_loaded", path: platformPath(options, platform), loaded: false, message: "the memory guard could not be written" });
149
+ }
150
+ }
151
+ async function installWindowsGuard(options) {
152
+ const files = windowsFiles(options);
153
+ await mkdir(path.dirname(files.paths.script), { recursive: true });
154
+ await writeFile(files.paths.script, files.script, "utf8");
155
+ await writeFile(files.paths.launcher, files.launcher, "utf8");
156
+ await writeFile(files.paths.registration, files.registration, "utf8");
157
+ const created = await options.exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", files.paths.registration]);
158
+ console.error(`${TAG} ${created.code === 0 ? "scheduled task registered and started" : "scheduled task registration failed"}`, JSON.stringify({ task_name: WINDOWS_MEMORY_GUARD_TASK_NAME, exit_code: created.code, hard_limit_mb: limitsOf(options).hard }));
159
+ return result(options, "win32", { status: "installed", path: files.paths.script, loaded: created.code === 0, ...(created.code === 0 ? {} : { message: `PowerShell registration of the memory guard exited ${created.code}` }) });
160
+ }
161
+ /** Removes the guard and its files (the log stays: it is the history of what was killed). Safe to repeat. */
162
+ export async function uninstallMemoryGuard(options) {
163
+ const platform = options.platform ?? process.platform;
164
+ if (platform === "darwin") {
165
+ const plistPath = memoryGuardPlistPathFor(options.homeDir ?? os.homedir());
166
+ if (!(await fileExists(plistPath)))
167
+ return result(options, platform, { status: "absent", path: plistPath });
168
+ await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
169
+ await rm(plistPath, { force: true });
170
+ await rm(stateFile(options, GUARD_SCRIPT_DARWIN), { force: true });
171
+ console.error(`${TAG} launchd job removed`, JSON.stringify({ label: MEMORY_GUARD_AUTOSTART_LABEL }));
172
+ return result(options, platform, { status: "uninstalled", path: plistPath });
173
+ }
174
+ if (platform === "win32") {
175
+ const files = windowsFiles(options);
176
+ const query = await options.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_MEMORY_GUARD_TASK_NAME]);
177
+ const known = query.code === 0;
178
+ if (known)
179
+ await options.exec("schtasks.exe", ["/End", "/TN", WINDOWS_MEMORY_GUARD_TASK_NAME]).catch(() => undefined);
180
+ // The resident loop is a child PowerShell; ending the task does not always take it down.
181
+ if (known)
182
+ await options.exec("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | Where-Object { $_.CommandLine -like '*${GUARD_SCRIPT_WINDOWS}*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`]).catch(() => undefined);
183
+ const removed = known ? await options.exec("schtasks.exe", ["/Delete", "/TN", WINDOWS_MEMORY_GUARD_TASK_NAME, "/F"]) : null;
184
+ if (removed && removed.code !== 0) {
185
+ console.error(`${TAG} scheduled task not removed`, JSON.stringify({ task_name: WINDOWS_MEMORY_GUARD_TASK_NAME, exit_code: removed.code }));
186
+ return result(options, platform, { status: "not_loaded", path: files.paths.script, message: `schtasks /Delete exited ${removed.code} for the memory guard` });
187
+ }
188
+ for (const file of Object.values(files.paths))
189
+ await rm(file, { force: true });
190
+ console.error(`${TAG} scheduled task ${known ? "removed" : "was not registered"}`, JSON.stringify({ task_name: WINDOWS_MEMORY_GUARD_TASK_NAME }));
191
+ return result(options, platform, { status: known ? "uninstalled" : "absent", path: files.paths.script });
192
+ }
193
+ return result(options, platform, { status: "unsupported", path: platformPath(options, platform) });
194
+ }
195
+ async function lastKill(options) {
196
+ const text = await readFile(stateFile(options, MEMORY_GUARD_LOG), "utf8").then((full) => full.slice(-LOG_TAIL_BYTES), () => "");
197
+ const kill = lastKillIn(text);
198
+ return kill ? { last_kill: kill } : {};
199
+ }
200
+ /**
201
+ * Whether the guard is registered, loaded and still says what this build would write. Content is only
202
+ * compared when the caller supplied roots, exactly like the sync agent (a bare `autostart status` stays permissive).
203
+ */
204
+ export async function memoryGuardStatus(options) {
205
+ const platform = options.platform ?? process.platform;
206
+ if (platform !== "darwin" && platform !== "win32")
207
+ return result(options, platform, { status: "unsupported", path: platformPath(options, platform) });
208
+ if (await memoryGuardDisabled(options)) {
209
+ return result(options, platform, { status: "absent", path: platformPath(options, platform), disabled: true, loaded: false, message: "memory guard is turned off (COCKPIT_MEMORY_GUARD=off at install)" });
210
+ }
211
+ const compare = Boolean(options.repoRoots && options.repoRoots.length > 0);
212
+ const reading = platform === "darwin" ? await darwinGuardStatus(options, compare) : await windowsGuardStatus(options, compare);
213
+ return { ...reading, ...(await lastKill(options)) };
214
+ }
215
+ async function darwinGuardStatus(options, compare) {
216
+ const files = darwinFiles(options);
217
+ const onDisk = await readFile(files.plistPath, "utf8").catch(() => null);
218
+ if (onDisk === null)
219
+ return result(options, "darwin", { status: "absent", path: files.plistPath });
220
+ const problems = [];
221
+ if (compare && onDisk !== files.plist)
222
+ problems.push("memory guard plist does not match the current Tower runtime");
223
+ const scriptOnDisk = await readFile(files.scriptPath, "utf8").catch(() => null);
224
+ if (scriptOnDisk === null)
225
+ problems.push("memory guard script is missing");
226
+ else if (compare && scriptOnDisk !== files.script)
227
+ problems.push("memory guard script does not match the current Tower runtime");
228
+ const list = await options.exec("launchctl", ["list", MEMORY_GUARD_AUTOSTART_LABEL]);
229
+ if (list.code !== 0)
230
+ problems.unshift("memory guard is not loaded");
231
+ console.error(`${TAG} status`, JSON.stringify({ label: MEMORY_GUARD_AUTOSTART_LABEL, problems: problems.length }));
232
+ return result(options, "darwin", { status: problems.length === 0 ? "loaded" : "not_loaded", path: files.plistPath, loaded: problems.length === 0, ...(problems.length ? { message: problems.join("; ") } : {}) });
233
+ }
234
+ /** What Task Scheduler stored for the guard: a logon trigger, our launcher, restart on failure, no time limit. */
235
+ export function memoryGuardTaskProblems(taskXml, expected) {
236
+ const xml = decodeXmlEntities(taskXml);
237
+ const lower = xml.toLowerCase();
238
+ const problems = [];
239
+ if (/<Enabled>\s*false\s*<\/Enabled>/iu.test(xml))
240
+ problems.push("memory guard task is disabled");
241
+ if (!/<LogonTrigger[\s/>]/iu.test(xml))
242
+ problems.push("memory guard task has no logon trigger");
243
+ if (!/<RestartOnFailure>/iu.test(xml))
244
+ problems.push("memory guard task does not restart on failure");
245
+ if (!/<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/iu.test(xml))
246
+ problems.push("memory guard task has an execution time limit");
247
+ if (!lower.includes(expected.launcherPath.toLowerCase()))
248
+ problems.push("memory guard task does not run the current launcher");
249
+ if (!lower.includes(path.win32.basename(expected.wscriptPath).toLowerCase()))
250
+ problems.push("memory guard task does not run through wscript");
251
+ return problems;
252
+ }
253
+ async function windowsGuardStatus(options, compare) {
254
+ const files = windowsFiles(options);
255
+ const query = await options.exec("schtasks.exe", ["/Query", "/TN", WINDOWS_MEMORY_GUARD_TASK_NAME, "/XML"]);
256
+ if (query.code !== 0) {
257
+ const absent = query.code === 1 && /cannot find|not found|does not exist/iu.test(`${query.stderr}\n${query.stdout}`);
258
+ console.error(`${TAG} task query failed`, JSON.stringify({ task_name: WINDOWS_MEMORY_GUARD_TASK_NAME, exit_code: query.code, status: absent ? "absent" : "not_loaded" }));
259
+ return result(options, "win32", { status: absent ? "absent" : "not_loaded", path: files.paths.script });
260
+ }
261
+ const problems = memoryGuardTaskProblems(query.stdout, { launcherPath: files.paths.launcher, wscriptPath: windowsWScriptPath() });
262
+ if (!(await fileExists(files.paths.script)))
263
+ problems.push("memory guard script is missing");
264
+ else if (!(await fileExists(files.paths.launcher)))
265
+ problems.push("memory guard launcher is missing");
266
+ else if (compare) {
267
+ for (const [label, file, text] of [["script", files.paths.script, files.script], ["launcher", files.paths.launcher, files.launcher]]) {
268
+ const difference = renderedFileDifference(await readFile(file, "utf8").catch(() => null), text);
269
+ if (difference)
270
+ problems.push(`memory guard ${label} does not match the current Tower runtime (${describeDifference(difference)})`);
271
+ }
272
+ }
273
+ console.error(`${TAG} status`, JSON.stringify({ task_name: WINDOWS_MEMORY_GUARD_TASK_NAME, problems: problems.length }));
274
+ return result(options, "win32", { status: problems.length === 0 ? "loaded" : "not_loaded", path: files.paths.script, loaded: problems.length === 0, ...(problems.length ? { message: problems.join("; ") } : {}) });
275
+ }
@@ -67,7 +67,12 @@ async function readAutostartProblem(paths, options, platform) {
67
67
  const remindProblem = status.status === "loaded" && status.remind && status.remind.status !== "loaded" && status.remind.status !== "unsupported"
68
68
  ? [`reminder job ${status.remind.status === "absent" ? "absent" : status.remind.message ?? "not loaded"}`]
69
69
  : [];
70
- if (!absent && status.status !== "not_loaded" && runtimeProblems.length === 0 && upgradeProblems.length === 0 && remindProblem.length === 0) {
70
+ // BLI-23571: the memory guard is repaired the same way. A guard the operator turned off stays off.
71
+ const guard = status.memory_guard;
72
+ const guardProblem = status.status === "loaded" && guard && !guard.disabled && guard.status !== "loaded" && guard.status !== "unsupported"
73
+ ? [`memory guard ${guard.status === "absent" ? "absent" : guard.message ?? "not loaded"}`]
74
+ : [];
75
+ if (!absent && status.status !== "not_loaded" && runtimeProblems.length === 0 && upgradeProblems.length === 0 && remindProblem.length === 0 && guardProblem.length === 0) {
71
76
  // Healthy is the steady state and stays silent. A marker left by an
72
77
  // earlier attempt is spent: that repair worked, so the next break starts
73
78
  // fresh instead of waiting out a retry window.
@@ -80,6 +85,7 @@ async function readAutostartProblem(paths, options, platform) {
80
85
  ...runtimeProblems,
81
86
  ...upgradeProblems,
82
87
  ...remindProblem,
88
+ ...guardProblem,
83
89
  ]
84
90
  .join("; ")
85
91
  .slice(0, DETAIL_MAX_CHARS) || "registration not loaded";
@@ -135,6 +141,14 @@ async function repairWindowsAutostart(options, platform, absent, problem) {
135
141
  step: "autostart_repair",
136
142
  };
137
143
  }
144
+ if (repaired.loaded && repaired.memory_guard?.loaded === false && !repaired.memory_guard.disabled) {
145
+ return {
146
+ status: "fail",
147
+ reason: "memory_guard_repair_failed",
148
+ detail: (repaired.memory_guard.message ?? problem).slice(0, DETAIL_MAX_CHARS),
149
+ step: "autostart_repair",
150
+ };
151
+ }
138
152
  if (repaired.loaded) {
139
153
  return {
140
154
  status: "ok",
package/dist/autostart.js CHANGED
@@ -5,6 +5,7 @@ import { readRegisteredDarwinRuntimePaths } from "./autostart-darwin-plist.js";
5
5
  import { installWindowsTask, uninstallWindowsTask, windowsTaskStatus, } from "./autostart-windows.js";
6
6
  import { readRegisteredWindowsRuntimePaths } from "./autostart-windows-scripts.js";
7
7
  import { installRemindAgent, remindAgentStatus, uninstallRemindAgent } from "./autostart-remind.js";
8
+ import { installMemoryGuard, memoryGuardStatus, uninstallMemoryGuard } from "./autostart-memory-guard.js";
8
9
  /**
9
10
  * Background collection's front door: install, uninstall, and status for the
10
11
  * OS scheduler, whichever OS this is.
@@ -21,7 +22,7 @@ import { installRemindAgent, remindAgentStatus, uninstallRemindAgent } from "./a
21
22
  * autostart-contract.ts and is re-exported here because this module is the
22
23
  * import site the rest of the collector already knows (BLI-3571).
23
24
  */
24
- export { AUTOSTART_LABEL, DEFAULT_AUTOSTART_INTERVAL_SECONDS, WINDOWS_AUTOSTART_TASK_NAME, REMIND_AUTOSTART_LABEL, WINDOWS_REMIND_TASK_NAME, } from "./autostart-contract.js";
25
+ export { AUTOSTART_LABEL, DEFAULT_AUTOSTART_INTERVAL_SECONDS, WINDOWS_AUTOSTART_TASK_NAME, REMIND_AUTOSTART_LABEL, WINDOWS_REMIND_TASK_NAME, MEMORY_GUARD_AUTOSTART_LABEL, WINDOWS_MEMORY_GUARD_TASK_NAME, } from "./autostart-contract.js";
25
26
  export { DARWIN_MIN_SECONDS_BETWEEN_TICKS, DARWIN_NETWORK_CHANGE_SIGNAL, DARWIN_NODE_MISSING_MESSAGE, writeDarwinAutostartPlist, } from "./autostart-darwin-plist.js";
26
27
  export { launchdUserDomain, reregisterDarwinAgent, } from "./autostart-darwin.js";
27
28
  /**
@@ -35,7 +36,9 @@ export async function installAutostartAgent(options) {
35
36
  return unsupportedResult(options.homeDir);
36
37
  const sync = platform === "win32" ? await installWindowsTask(options) : await installDarwinAgent(options);
37
38
  // BLI-21564: the reminder job is the second job of the same contract, installed and repaired with the sync agent.
38
- return { ...sync, remind: await installRemindAgent(options) };
39
+ const remind = await installRemindAgent(options);
40
+ // BLI-23571: the memory guard is the third job; `COCKPIT_MEMORY_GUARD=off` at install skips only this one.
41
+ return { ...sync, remind, memory_guard: await installMemoryGuard(options) };
39
42
  }
40
43
  /**
41
44
  * Removes the scheduled sync and the reminder job. Reports `absent` when there
@@ -46,7 +49,8 @@ export async function uninstallAutostartAgent(options) {
46
49
  if (platform !== "win32" && platform !== "darwin")
47
50
  return unsupportedResult(options.homeDir);
48
51
  const sync = platform === "win32" ? await uninstallWindowsTask(options) : await uninstallDarwinAgent(options);
49
- return { ...sync, remind: await uninstallRemindAgent(options) };
52
+ const remind = await uninstallRemindAgent(options);
53
+ return { ...sync, remind, memory_guard: await uninstallMemoryGuard(options) };
50
54
  }
51
55
  /**
52
56
  * Reports whether the scheduled sync is installed, registered, and still
@@ -59,7 +63,8 @@ export async function autostartStatus(options) {
59
63
  if (platform !== "win32" && platform !== "darwin")
60
64
  return unsupportedResult(options.homeDir);
61
65
  const sync = platform === "win32" ? await windowsTaskStatus(options) : await darwinAgentStatus(options);
62
- return { ...sync, remind: await remindAgentStatus(options) };
66
+ const remind = await remindAgentStatus(options);
67
+ return { ...sync, remind, memory_guard: await memoryGuardStatus(options) };
63
68
  }
64
69
  /**
65
70
  * What the platform says it will run, parsed out of the registration itself
@@ -53,6 +53,7 @@ export async function runAutostart(command, io) {
53
53
  }
54
54
  writeAutostartResult(io, result);
55
55
  writeRemindLine(io, result);
56
+ writeMemoryGuardLine(io, result);
56
57
  return autostartExitCode(command.action, result);
57
58
  }
58
59
  /** BLI-21564: the reminder job's own line beside the sync agent's, with its message when it needs attention. */
@@ -64,6 +65,20 @@ function writeRemindLine(io, result) {
64
65
  if (remind.message)
65
66
  writeLine(io.stderr, remind.message);
66
67
  }
68
+ /** BLI-23571: the memory guard's line: loaded or not, its limits, and the last thing it killed. */
69
+ function writeMemoryGuardLine(io, result) {
70
+ const guard = result.memory_guard;
71
+ if (!guard || guard.status === "unsupported")
72
+ return;
73
+ const state = guard.disabled
74
+ ? "turned off"
75
+ : guard.status === "installed" ? (guard.loaded ? "installed and loaded" : "installed, not loaded") : guard.status.replace("_", " ");
76
+ const limits = guard.limits_mb ? `; kills node over ${guard.limits_mb.hard} MB (${guard.limits_mb.warning} under memory pressure, ${guard.limits_mb.critical} when critical)` : "";
77
+ const kill = guard.last_kill ? `; last kill ${guard.last_kill.ts} (${guard.last_kill.kind}, ${guard.last_kill.footprint_mb} MB, ${guard.last_kill.reason})` : "";
78
+ writeLine(io.stdout, `Memory guard (${guard.task_name ?? guard.label}): ${state}${guard.disabled ? "" : limits}${kill}`);
79
+ if (guard.message)
80
+ writeLine(io.stderr, guard.message);
81
+ }
67
82
  function autostartExitCode(action, result) {
68
83
  if (result.status === "unsupported")
69
84
  return 1;
@@ -1,8 +1,9 @@
1
1
  import path from "node:path";
2
2
  import { appendFile } from "node:fs/promises";
3
- import { AUTOSTART_LABEL, autostartStatus, REMIND_AUTOSTART_LABEL, reregisterDarwinAgent, writeDarwinAutostartPlist, } from "../autostart.js";
3
+ import { AUTOSTART_LABEL, autostartStatus, MEMORY_GUARD_AUTOSTART_LABEL, REMIND_AUTOSTART_LABEL, reregisterDarwinAgent, writeDarwinAutostartPlist, } from "../autostart.js";
4
4
  import { isAutostartOptedOut } from "../autostart-opt-out.js";
5
5
  import { writeDarwinRemindPlist } from "../autostart-remind.js";
6
+ import { memoryGuardDisabled, writeDarwinMemoryGuardPlist } from "../autostart-memory-guard.js";
6
7
  import { redactedHealthDetail } from "../health-detail.js";
7
8
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
8
9
  import { normalizeCollectionRoots } from "../root-normalization.js";
@@ -138,8 +139,35 @@ async function writeAndVerifyRegistration(command, exec, platform, roots, before
138
139
  detail: redactedHealthDetail(remindRegistered.detail ?? "").slice(0, DETAIL_MAX_CHARS),
139
140
  };
140
141
  }
142
+ const guardFailure = await healMemoryGuard(command, exec, platform, roots);
143
+ if (guardFailure)
144
+ return guardFailure;
141
145
  return verifyRegistration(command, exec, platform, roots, beforeStatus, written.node_path_reason);
142
146
  }
147
+ /** BLI-23571: the memory guard is healed with the sync agent by the same two steps, unless the operator turned it off. */
148
+ async function healMemoryGuard(command, exec, platform, roots) {
149
+ const guardOptions = { homeDir: command.homeDir, repoRoot: roots[0], repoRoots: roots, dashboardUrl: command.dashboardUrl, exec, platform };
150
+ if (await memoryGuardDisabled(guardOptions)) {
151
+ console.error("[autostart-heal] memory guard left off", JSON.stringify({ reason: "turned_off_by_operator", platform }));
152
+ return null;
153
+ }
154
+ let guardPlist;
155
+ try {
156
+ guardPlist = await writeDarwinMemoryGuardPlist(guardOptions);
157
+ }
158
+ catch (error) {
159
+ return {
160
+ status: "fail",
161
+ reason: "memory_guard_plist_write_failed",
162
+ detail: redactedHealthDetail(error instanceof Error ? error.message : String(error)).slice(0, DETAIL_MAX_CHARS),
163
+ };
164
+ }
165
+ const registered = await reregisterDarwinAgent({ exec, plistPath: guardPlist.plist_path, label: MEMORY_GUARD_AUTOSTART_LABEL });
166
+ if (!registered.ok) {
167
+ return { status: "fail", reason: `memory_guard_${registered.reason}`, detail: redactedHealthDetail(registered.detail ?? "").slice(0, DETAIL_MAX_CHARS) };
168
+ }
169
+ return null;
170
+ }
143
171
  async function verifyRegistration(command, exec, platform, roots, beforeStatus, nodePathReason) {
144
172
  // Verify against what launchd returns, not against what we sent it
145
173
  // (BLI-2541). A bootstrap that exits 0 and a registration that reads back
@@ -159,6 +187,13 @@ async function verifyRegistration(command, exec, platform, roots, beforeStatus,
159
187
  detail: redactedHealthDetail(after.remind.message ?? after.remind.status).slice(0, DETAIL_MAX_CHARS),
160
188
  };
161
189
  }
190
+ if (after.status === "loaded" && after.memory_guard && !after.memory_guard.disabled && after.memory_guard.status !== "loaded" && after.memory_guard.status !== "unsupported") {
191
+ return {
192
+ status: "fail",
193
+ reason: "memory_guard_reregistered_but_still_broken",
194
+ detail: redactedHealthDetail(after.memory_guard.message ?? after.memory_guard.status).slice(0, DETAIL_MAX_CHARS),
195
+ };
196
+ }
162
197
  if (after.status !== "loaded") {
163
198
  return {
164
199
  status: "fail",
@@ -117,9 +117,9 @@ async function stateFor(result, roots, options) {
117
117
  ...detail,
118
118
  };
119
119
  }
120
- return readRegisteredRuntime(roots, options);
120
+ return readRegisteredRuntime(roots, options, result.memory_guard);
121
121
  }
122
- async function readRegisteredRuntime(roots, options) {
122
+ async function readRegisteredRuntime(roots, options, guard) {
123
123
  // BLI-3553: "loaded" only means the scheduler accepted the registration. It
124
124
  // says nothing about whether the binary that registration names still exists
125
125
  // — and `brew upgrade node` deletes exactly that. Ask the filesystem about
@@ -142,10 +142,24 @@ async function readRegisteredRuntime(roots, options) {
142
142
  return {
143
143
  state: "loaded",
144
144
  reason: "autostart_loaded",
145
- message: "background sync is running",
145
+ message: `background sync is running; ${memoryGuardWords(guard)}`,
146
146
  roots,
147
147
  };
148
148
  }
149
+ /**
150
+ * BLI-23571: the memory guard rides on the `autostart-alive` row's words rather than a row of its own
151
+ * (a new doctor step id would reach the server schema). Path-free: it is logged and uploaded.
152
+ */
153
+ function memoryGuardWords(guard) {
154
+ if (!guard || guard.status === "unsupported")
155
+ return "memory guard not applicable on this host";
156
+ if (guard.disabled)
157
+ return "memory guard turned off";
158
+ const kill = guard.last_kill ? `, last kill ${guard.last_kill.ts} (${guard.last_kill.kind})` : "";
159
+ if (guard.status !== "loaded")
160
+ return `memory guard ${guard.status === "absent" ? "absent" : "not loaded"} (the next sync tick repairs it)`;
161
+ return `memory guard loaded (limit ${guard.limits_mb?.hard ?? "?"} MB${kill})`;
162
+ }
149
163
  /**
150
164
  * The setup receipt's `collector autostart` word (BLI-3731).
151
165
  *
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.280");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.282");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,13 @@
1
+ import { reportInstallEventsBestEffort } from "./install-receipts.js";
2
+ import { forwardMemoryGuardReceipts } from "../memory-guard-forward.js";
3
+ export async function runMemoryGuardForwardAfterSync(command, io, dashboardUrl) {
4
+ try {
5
+ await forwardMemoryGuardReceipts({
6
+ homeDir: command.homeDir,
7
+ report: (events) => reportInstallEventsBestEffort({ homeDir: command.homeDir, dashboardUrl, command: "sync", events, json: command.json, io }),
8
+ });
9
+ }
10
+ catch (error) {
11
+ console.error("[memory-guard-forward] forwarding threw; collection is unaffected", JSON.stringify({ reason: "forward_threw", error_name: error instanceof Error ? error.name : typeof error }));
12
+ }
13
+ }
@@ -41,6 +41,7 @@ import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
41
41
  import { runMemoryExperienceAfterSync } from "./memory-log.js";
42
42
  import { classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
43
43
  import { runMachineHealAfterSync } from "./sync-heal.js";
44
+ import { runMemoryGuardForwardAfterSync } from "./sync-followups-memory-guard.js";
44
45
  import { sendSyncHeartbeat } from "./sync-heartbeat.js";
45
46
  import { runSyncWithHealthReceipt } from "./sync-receipt.js";
46
47
  import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledSelfUpdateAfterSync, runStagingPruneAfterSync, } from "./sync-followups.js";
@@ -211,6 +212,8 @@ async function reportTickThrew(command, io, dashboardUrl, error, errorCode) {
211
212
  async function convergeAfterTick(command, io, dashboardUrl, facts) {
212
213
  await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
213
214
  await runMemoryInstallAfterSync(command, io, dashboardUrl);
215
+ // BLI-23571: carry the resident memory guard's kills to the fleet.
216
+ await runMemoryGuardForwardAfterSync(command, io, dashboardUrl);
214
217
  // BLI-12828: the repairs `cockpit doctor` also makes (agent rules, a lapsed
215
218
  // sign-in), so nobody has to be told to run it.
216
219
  await runMachineHealAfterSync(command, io, dashboardUrl);
@@ -0,0 +1,121 @@
1
+ import crypto from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { getCollectorRuntimePaths } from "./local-state.js";
5
+ /**
6
+ * The resident memory guard's vocabulary (BLI-23571): limits, the log line, the process kinds.
7
+ *
8
+ * Why it exists: on 2026-10-28 a `node (vitest 2)` worker reached 166 GB of macOS footprint
9
+ * (compressed + swap, which `ps` RSS does not show) and froze a 16 GB Mac. `command-guard.ts` only caps
10
+ * the collector's own commands; nothing protected the machine from any OTHER runaway node (test workers,
11
+ * dev servers, agent tools). The guard is a SHELL script on purpose: it keeps running while node is
12
+ * wedged, costs about 2 MB, and spawns no node per tick.
13
+ *
14
+ * What it kills: only a process whose executable is `node`, never itself, never anything else.
15
+ */
16
+ export const MEMORY_GUARD_LOG = "memory-guard.log";
17
+ export const MEMORY_GUARD_CURSOR = "memory-guard.cursor.json";
18
+ export const MEMORY_GUARD_DISABLED_MARKER = "memory-guard.disabled";
19
+ export const MEMORY_GUARD_LOG_LIMIT_BYTES = 1024 * 1024;
20
+ /** Lines kept when the log is trimmed past its cap. */
21
+ export const MEMORY_GUARD_LOG_KEEP_LINES = 200;
22
+ export const MEMORY_GUARD_ORPHAN_MIN_AGE_SECONDS = 300;
23
+ export const MEMORY_GUARD_STEP = "memory_guard";
24
+ export const MEMORY_GUARD_REASONS = [
25
+ "footprint_over_limit",
26
+ "rss_over_limit",
27
+ "pressure_warning",
28
+ "pressure_critical",
29
+ "orphan_vitest_worker",
30
+ ];
31
+ export const MEMORY_GUARD_EVENTS = ["killed", "kill_failed", "orphan_reaped", "started"];
32
+ const MB = 1024 * 1024;
33
+ /**
34
+ * hard = min(6144 MB, 40% of RAM); warning tier = min(4096, 25%); critical tier = min(2048, 12.5%).
35
+ * `COCKPIT_MEMORY_GUARD_LIMIT_MB` replaces the hard limit and can only pull the other tiers down with it.
36
+ */
37
+ export function resolveMemoryGuardLimits(totalMemoryBytes, env = {}) {
38
+ const totalMb = Math.max(1, Math.floor(totalMemoryBytes / MB));
39
+ const base = {
40
+ hard: Math.min(6144, Math.floor(totalMb * 0.4)),
41
+ warning: Math.min(4096, Math.floor(totalMb * 0.25)),
42
+ critical: Math.min(2048, Math.floor(totalMb * 0.125)),
43
+ };
44
+ const override = Number(env["COCKPIT_MEMORY_GUARD_LIMIT_MB"]);
45
+ if (!Number.isFinite(override) || override <= 0)
46
+ return base;
47
+ const hard = Math.floor(override);
48
+ return { hard, warning: Math.min(base.warning, hard), critical: Math.min(base.critical, hard) };
49
+ }
50
+ export function memoryGuardLimitsFor(options) {
51
+ return resolveMemoryGuardLimits(options.totalMemoryBytes ?? os.totalmem(), options.env ?? process.env);
52
+ }
53
+ /** `COCKPIT_MEMORY_GUARD=off` (or 0/false/no) at install time turns only the guard off. */
54
+ export function memoryGuardTurnedOff(env) {
55
+ return /^(off|0|false|no)$/iu.test((env?.["COCKPIT_MEMORY_GUARD"] ?? "").trim());
56
+ }
57
+ export function memoryGuardTurnedOn(env) {
58
+ return /^(on|1|true|yes)$/iu.test((env?.["COCKPIT_MEMORY_GUARD"] ?? "").trim());
59
+ }
60
+ export function memoryGuardStateFile(homeDir, name) {
61
+ return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, name);
62
+ }
63
+ /**
64
+ * The coarse label a log line carries instead of the command line. ONE table; the bash and PowerShell
65
+ * guards embed these exact patterns (POSIX ERE, JS and .NET read the same text), so a kind means the
66
+ * same thing on every host. Order matters: first match wins, "other" is the floor.
67
+ */
68
+ export const MEMORY_GUARD_KIND_PATTERNS = [
69
+ ["vitest", "vitest"],
70
+ ["next", "next[/ \\\\](dev|build|start|dist|server)|next-server"],
71
+ ["tsc", "(^|[/ \\\\])(tsc|tsserver)(\\.js)?( |$)|typescript[/\\\\]lib[/\\\\]tsc"],
72
+ ["eslint", "eslint"],
73
+ ["cockpit", "@bli-cockpit[/\\\\]cli|cockpit-cli[/\\\\]dist|[/\\\\]bin[/\\\\]cockpit( |$)"],
74
+ ];
75
+ export function classifyProcessKind(commandLine) {
76
+ for (const [kind, pattern] of MEMORY_GUARD_KIND_PATTERNS)
77
+ if (new RegExp(pattern, "u").test(commandLine))
78
+ return kind;
79
+ return "other";
80
+ }
81
+ /** First 12 hex of the command line's sha256: enough to group identical runaways, useless to read back. */
82
+ export function commandSha12(commandLine) {
83
+ return crypto.createHash("sha256").update(commandLine).digest("hex").slice(0, 12);
84
+ }
85
+ export function parseMemoryGuardLogLine(raw) {
86
+ let value;
87
+ try {
88
+ value = JSON.parse(raw);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ if (!value || typeof value !== "object")
94
+ return null;
95
+ const line = value;
96
+ const event = line["event"];
97
+ if (typeof line["ts"] !== "string" || !MEMORY_GUARD_EVENTS.includes(event))
98
+ return null;
99
+ const num = (key) => (typeof line[key] === "number" && Number.isFinite(line[key]) ? line[key] : 0);
100
+ const str = (key) => (typeof line[key] === "string" ? line[key].slice(0, 64) : "");
101
+ return {
102
+ ts: line["ts"],
103
+ event: event,
104
+ pid: num("pid"),
105
+ footprint_mb: num("footprint_mb"),
106
+ limit_mb: num("limit_mb"),
107
+ reason: str("reason"),
108
+ kind: str("kind"),
109
+ cmd_sha256: str("cmd_sha256"),
110
+ };
111
+ }
112
+ /** The last kill (any event but "started") in a log's text, newest wins. */
113
+ export function lastKillIn(logText) {
114
+ let found;
115
+ for (const raw of logText.split("\n")) {
116
+ const line = raw.trim() ? parseMemoryGuardLogLine(raw) : null;
117
+ if (line && line.event !== "started")
118
+ found = { ts: line.ts, kind: line.kind, reason: line.reason, footprint_mb: line.footprint_mb };
119
+ }
120
+ return found;
121
+ }
@@ -0,0 +1,82 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { MEMORY_GUARD_CURSOR, MEMORY_GUARD_LOG, MEMORY_GUARD_STEP, memoryGuardStateFile, parseMemoryGuardLogLine, } from "./memory-guard-contract.js";
4
+ /**
5
+ * Turns the memory guard's kills into fleet receipts (BLI-23571).
6
+ *
7
+ * The guard is a shell loop with no network; the sync tick is the courier. Each tick reads the log lines
8
+ * added since a cursor and queues one install-event receipt per kill (`step: memory_guard`,
9
+ * `status: fail`, `error_code: <reason>`, `error_detail: <the JSON metadata>`), the same outbox
10
+ * `command-guard.ts` uses for its own ceiling trips. The cursor is a timestamp plus how many lines at that
11
+ * exact second were already sent, so the log can be trimmed without re-sending and two kills in one second
12
+ * are both counted. It is written LAST: a crash between queueing and the write re-sends, never loses.
13
+ */
14
+ const TAG = "[memory-guard-forward]";
15
+ const MAX_LINES_PER_TICK = 40;
16
+ const FORWARDED_EVENTS = new Set(["killed", "kill_failed", "orphan_reaped"]);
17
+ function parseCursor(raw) {
18
+ if (!raw)
19
+ return null;
20
+ try {
21
+ const value = JSON.parse(raw);
22
+ return typeof value["ts"] === "string" && typeof value["ts_lines"] === "number" ? { ts: value["ts"], ts_lines: value["ts_lines"] } : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /** The receipt for one log line. Metadata only: the line never held a command line to begin with. */
29
+ export function memoryGuardReceipt(line) {
30
+ return {
31
+ step: MEMORY_GUARD_STEP,
32
+ status: "fail",
33
+ error_code: line.reason || line.event,
34
+ error_detail: JSON.stringify({ ts: line.ts, event: line.event, pid: line.pid, footprint_mb: line.footprint_mb, limit_mb: line.limit_mb, reason: line.reason, kind: line.kind, cmd_sha256: line.cmd_sha256 }),
35
+ at: line.ts,
36
+ };
37
+ }
38
+ /** Reads new log lines, hands their receipts to `report`, then moves the cursor. Never throws. */
39
+ export async function forwardMemoryGuardReceipts(options) {
40
+ const logPath = memoryGuardStateFile(options.homeDir, MEMORY_GUARD_LOG);
41
+ const cursorPath = memoryGuardStateFile(options.homeDir, MEMORY_GUARD_CURSOR);
42
+ const text = await readFile(logPath, "utf8").catch(() => null);
43
+ if (text === null)
44
+ return { forwarded: 0, reason: "no_log" };
45
+ const lines = text.split("\n").flatMap((raw) => (raw.trim() ? [parseMemoryGuardLogLine(raw)] : [])).filter((line) => line !== null);
46
+ const cursor = parseCursor(await readFile(cursorPath, "utf8").catch(() => null));
47
+ let skippedAtCursor = 0;
48
+ const fresh = [];
49
+ lines.forEach((line, index) => {
50
+ if (cursor && line.ts < cursor.ts)
51
+ return;
52
+ if (cursor && line.ts === cursor.ts && skippedAtCursor < cursor.ts_lines) {
53
+ skippedAtCursor += 1;
54
+ return;
55
+ }
56
+ fresh.push({ line, index });
57
+ });
58
+ if (fresh.length === 0)
59
+ return { forwarded: 0, reason: "nothing_new" };
60
+ const taken = fresh.slice(0, MAX_LINES_PER_TICK);
61
+ const events = taken.filter(({ line }) => FORWARDED_EVENTS.has(line.event)).map(({ line }) => memoryGuardReceipt(line));
62
+ try {
63
+ if (events.length > 0)
64
+ await options.report(events);
65
+ }
66
+ catch (error) {
67
+ console.error(`${TAG} receipts not queued; the cursor stays and the next tick retries`, JSON.stringify({ reason: "report_threw", count: events.length, error_name: error instanceof Error ? error.name : typeof error }));
68
+ return { forwarded: 0, reason: "report_threw" };
69
+ }
70
+ const last = taken[taken.length - 1];
71
+ const next = { ts: last.line.ts, ts_lines: lines.filter((line, index) => line.ts === last.line.ts && index <= last.index).length };
72
+ try {
73
+ await mkdir(path.dirname(cursorPath), { recursive: true });
74
+ await writeFile(`${cursorPath}.tmp`, JSON.stringify(next), "utf8");
75
+ await rename(`${cursorPath}.tmp`, cursorPath);
76
+ }
77
+ catch (error) {
78
+ console.error(`${TAG} cursor not written; the next tick re-sends these receipts`, JSON.stringify({ reason: "cursor_write_failed", error_name: error instanceof Error ? error.name : typeof error }));
79
+ }
80
+ console.error(`${TAG} forwarded`, JSON.stringify({ reason: "forwarded", receipts: events.length, lines: taken.length, remaining: fresh.length - taken.length }));
81
+ return { forwarded: events.length, reason: "forwarded" };
82
+ }
@@ -0,0 +1,128 @@
1
+ import { MEMORY_GUARD_KIND_PATTERNS, MEMORY_GUARD_LOG_KEEP_LINES, MEMORY_GUARD_LOG_LIMIT_BYTES, MEMORY_GUARD_ORPHAN_MIN_AGE_SECONDS, } from "./memory-guard-contract.js";
2
+ import { shellQuote } from "./autostart-darwin-plist.js";
3
+ /**
4
+ * The macOS memory guard (BLI-23571): a bash loop launchd keeps alive.
5
+ *
6
+ * Every 2 s it reads the kernel's memory-pressure level and a cheap `ps` RSS check. Every 10 s (every 2 s
7
+ * under critical pressure) it scans real memory FOOTPRINT with `top -o mem`, because RSS alone misses
8
+ * runaways: swapped and compressed pages leave RSS, and an 800 MB buffer showed 12 MB RSS. The limit
9
+ * tightens with pressure. Every 60 s it reaps vitest workers orphaned by a dead runner (ppid 1, older than
10
+ * 5 minutes). Only a process whose executable is `node` is ever touched; the guard never kills itself.
11
+ *
12
+ * It logs one JSON line per decision to the state dir: ids, sizes and a sha256 prefix, never a command
13
+ * line (those carry tokens). Test seams, all env: `MEMGUARD_SOURCE_ONLY=1` defines the functions and
14
+ * returns, `MEMGUARD_ONCE=1` runs one tick and exits, `MEMGUARD_NO_NOTIFY=1` skips the notification,
15
+ * `MEMGUARD_ONLY_PID=<pid>` restricts kills to one process so a test cannot shoot its own runner.
16
+ */
17
+ export function renderDarwinMemoryGuardScript(options) {
18
+ const kindChecks = MEMORY_GUARD_KIND_PATTERNS.map(([kind, pattern]) => ` re=${shellQuote(pattern)}; [[ "$1" =~ $re ]] && { echo ${kind}; return; }`);
19
+ return [
20
+ "#!/bin/bash",
21
+ "# BLI Cockpit memory guard (BLI-23571). Rendered by `cockpit autostart install`; do not edit, it is rewritten.",
22
+ "# Kills a runaway node process by memory footprint. Log: memory-guard.log in the collector state dir.",
23
+ `LIMIT_MB=${options.limits.hard}`,
24
+ `WARN_LIMIT_MB=${options.limits.warning}`,
25
+ `CRITICAL_LIMIT_MB=${options.limits.critical}`,
26
+ `ORPHAN_MIN_AGE_S=${MEMORY_GUARD_ORPHAN_MIN_AGE_SECONDS}`,
27
+ `LOG=${shellQuote(options.logPath)}`,
28
+ `LOG_LIMIT_BYTES=${MEMORY_GUARD_LOG_LIMIT_BYTES}`,
29
+ `LOG_KEEP_LINES=${MEMORY_GUARD_LOG_KEEP_LINES}`,
30
+ 'mkdir -p "$(dirname "$LOG")"',
31
+ "",
32
+ "now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }",
33
+ "",
34
+ "# event pid footprint_mb limit_mb reason kind cmd_sha256. Every value is a number or a fixed token.",
35
+ "log_json() {",
36
+ ` printf '{"ts":"%s","event":"%s","pid":%s,"footprint_mb":%s,"limit_mb":%s,"reason":"%s","kind":"%s","cmd_sha256":"%s"}\\n' \\`,
37
+ ' "$(now_iso)" "$1" "$2" "$3" "$4" "$5" "$6" "$7" >> "$LOG"',
38
+ "}",
39
+ "",
40
+ "notify() { [ \"${MEMGUARD_NO_NOTIFY:-}\" = 1 ] || osascript -e \"display notification \\\"$1\\\" with title \\\"Tower\\\"\" >/dev/null 2>&1 & }",
41
+ "",
42
+ "# The coarse label for a command line. The same table as the Windows guard and the CLI's tests.",
43
+ "classify_kind() {",
44
+ " local re",
45
+ ...kindChecks,
46
+ " echo other",
47
+ "}",
48
+ "",
49
+ "cmd_sha() { printf '%s' \"$1\" | shasum -a 256 | cut -c1-12; }",
50
+ "",
51
+ "to_mb() { # top MEM like 470M, 69G, 512K, 1024B, with an optional +/- suffix",
52
+ ' local v="${1%[+-]}" n u',
53
+ ' n="${v%[BKMGT]}"; u="${v: -1}"',
54
+ ' case "$u" in B) echo $((n / 1048576)) ;; K) echo $((n / 1024)) ;; M) echo "$n" ;; G) echo $((n * 1024)) ;; T) echo $((n * 1048576)) ;; *) echo 0 ;; esac',
55
+ "}",
56
+ "",
57
+ "kill_node() { # pid footprint_mb limit_mb reason",
58
+ ' local cmd kind sha result',
59
+ ' [ "$1" = "$$" ] && return',
60
+ ' [ -n "${MEMGUARD_ONLY_PID:-}" ] && [ "$1" != "$MEMGUARD_ONLY_PID" ] && return',
61
+ ' cmd="$(ps -o command= -p "$1" 2>/dev/null | cut -c1-2000)"',
62
+ ' kind="$(classify_kind "$cmd")"; sha="$(cmd_sha "$cmd")"',
63
+ " kill -9 \"$1\" 2>/dev/null && result=killed || result=kill_failed",
64
+ ' log_json "$result" "$1" "$2" "$3" "$4" "$kind" "$sha"',
65
+ ' if [ "$result" = killed ]; then',
66
+ ' if [ "$2" -ge 1024 ]; then notify "Tower killed a runaway node process ($(( ($2 + 512) / 1024 )) GB, $kind)"; else notify "Tower killed a runaway node process ($2 MB, $kind)"; fi',
67
+ " fi",
68
+ "}",
69
+ "",
70
+ "# 1 normal, 2 warning, 4 critical",
71
+ "pressure_level() { sysctl -n kern.memorystatus_vm_pressure_level 2>/dev/null || echo 1; }",
72
+ "",
73
+ "footprint_scan() { # limit_mb reason",
74
+ " top -l 1 -o mem -n 25 -stats pid,mem,command 2>/dev/null | awk 'NR>1 && $1 ~ /^[0-9]+$/' |",
75
+ " while read -r pid mem comm _; do",
76
+ ' case "$comm" in node) ;; *) continue ;; esac',
77
+ ' mb="$(to_mb "$mem")"',
78
+ ' [ "$mb" -gt "$1" ] 2>/dev/null && kill_node "$pid" "$mb" "$1" "$2"',
79
+ " done",
80
+ "}",
81
+ "",
82
+ "rss_scan() { # limit_mb; the cheap every-tick check on resident memory alone",
83
+ ` ps -axo pid=,rss=,comm= | awk -v lim="$(($1 * 1024))" '$3 ~ /(^|\\/)node$/ && $2 > lim {print $1, int($2/1024)}' |`,
84
+ ' while read -r pid mb; do kill_node "$pid" "$mb" "$1" rss_over_limit; done',
85
+ "}",
86
+ "",
87
+ "orphan_vitest_scan() { # vitest workers whose runner died (ppid 1) and that outlived the grace period",
88
+ " ps -axo pid=,ppid=,etime=,command= | awk '$2 == 1 && $4 ~ /(^|\\/)node$/ && $0 ~ /vitest\\/dist\\/workers\\/forks|vitest\\/dist\\/workers\\/threads|node \\(vitest/ {print $1, $3}' |",
89
+ " while read -r pid etime; do",
90
+ ` secs="$(echo "$etime" | awk -F'[-:]' '{n=NF; s=$n+($(n-1))*60; if(n>=3)s+=$(n-2)*3600; if(n==4)s+=$1*86400; print s}')"`,
91
+ ' [ "$secs" -ge "$ORPHAN_MIN_AGE_S" ] 2>/dev/null || continue',
92
+ ' [ -n "${MEMGUARD_ONLY_PID:-}" ] && [ "$pid" != "$MEMGUARD_ONLY_PID" ] && continue',
93
+ ' cmd="$(ps -o command= -p "$pid" 2>/dev/null | cut -c1-2000)"',
94
+ ' kill -9 "$pid" 2>/dev/null && log_json orphan_reaped "$pid" 0 0 orphan_vitest_worker "$(classify_kind "$cmd")" "$(cmd_sha "$cmd")"',
95
+ " done",
96
+ "}",
97
+ "",
98
+ "trim_log() { # keep the log small: past the cap, keep the newest lines",
99
+ ' local size; size="$(wc -c < "$LOG" 2>/dev/null | tr -d \' \')"',
100
+ ' [ "${size:-0}" -gt "$LOG_LIMIT_BYTES" ] 2>/dev/null || return 0',
101
+ ' tail -n "$LOG_KEEP_LINES" "$LOG" > "$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG"',
102
+ "}",
103
+ "",
104
+ '[ "${MEMGUARD_SOURCE_ONLY:-}" = 1 ] && return 0 2>/dev/null',
105
+ "",
106
+ 'log_json started "$$" 0 "$LIMIT_MB" started guard ""',
107
+ "tick=0",
108
+ "while :; do",
109
+ ' level="$(pressure_level)"',
110
+ ' if [ "$level" -ge 4 ] 2>/dev/null; then',
111
+ ' rss_scan "$CRITICAL_LIMIT_MB"',
112
+ ' footprint_scan "$CRITICAL_LIMIT_MB" pressure_critical',
113
+ ' elif [ "$level" -ge 2 ] 2>/dev/null; then',
114
+ ' rss_scan "$WARN_LIMIT_MB"',
115
+ ' [ $((tick % 5)) -eq 0 ] && footprint_scan "$WARN_LIMIT_MB" pressure_warning',
116
+ " else",
117
+ ' rss_scan "$LIMIT_MB"',
118
+ ' [ $((tick % 5)) -eq 0 ] && footprint_scan "$LIMIT_MB" footprint_over_limit',
119
+ " fi",
120
+ ' [ $((tick % 30)) -eq 0 ] && orphan_vitest_scan',
121
+ ' [ $((tick % 30)) -eq 15 ] && trim_log',
122
+ ' [ "${MEMGUARD_ONCE:-}" = 1 ] && exit 0',
123
+ " tick=$((tick + 1))",
124
+ " sleep 2",
125
+ "done",
126
+ "",
127
+ ].join("\n");
128
+ }
@@ -0,0 +1,144 @@
1
+ import { MEMORY_GUARD_KIND_PATTERNS, MEMORY_GUARD_LOG_KEEP_LINES, MEMORY_GUARD_LOG_LIMIT_BYTES, MEMORY_GUARD_ORPHAN_MIN_AGE_SECONDS, } from "./memory-guard-contract.js";
2
+ import { powershellLiteral } from "./autostart-windows-scripts.js";
3
+ /**
4
+ * The Windows memory guard (BLI-23571): a resident PowerShell loop behind the same hidden wscript launcher
5
+ * the sync job uses. Windows has no `top -o mem` footprint, so the analogue is PrivateMemorySize64 (the
6
+ * commit charge, which counts pages swapped to the pagefile). Pressure comes from free physical memory:
7
+ * under 10% free is a warning, under 5% is critical, read at most every 10 s. Only `node` processes are
8
+ * touched, never the guard's own PID. Orphaned vitest workers (parent PID no longer alive, command line
9
+ * contains `vitest`, older than 5 minutes) are reaped once a minute through Win32_Process.
10
+ *
11
+ * Same log contract as the macOS guard: one JSON line per decision, a kind label and a sha256 prefix of
12
+ * the command line, never the command line. NOT proven on a real Windows host from this repo's CI: the
13
+ * rendering is tested, the live behaviour is not.
14
+ */
15
+ export function renderWindowsMemoryGuardScript(options) {
16
+ const kindChecks = MEMORY_GUARD_KIND_PATTERNS.map(([kind, pattern]) => ` if ($Command -match ${powershellLiteral(pattern)}) { return '${kind}' }`);
17
+ return [
18
+ "$ErrorActionPreference = 'Continue'",
19
+ `$limitMb = ${options.limits.hard}`,
20
+ `$warnLimitMb = ${options.limits.warning}`,
21
+ `$criticalLimitMb = ${options.limits.critical}`,
22
+ `$orphanMinAgeSeconds = ${MEMORY_GUARD_ORPHAN_MIN_AGE_SECONDS}`,
23
+ `$logPath = ${powershellLiteral(options.logPath)}`,
24
+ `$logLimitBytes = ${MEMORY_GUARD_LOG_LIMIT_BYTES}`,
25
+ `$logKeepLines = ${MEMORY_GUARD_LOG_KEEP_LINES}`,
26
+ "# One resident copy per user session; a second instance leaves quietly.",
27
+ "$guardMutex = New-Object System.Threading.Mutex($false, 'Local\\BLICockpitMemoryGuard')",
28
+ "if (-not $guardMutex.WaitOne(0)) { exit 0 }",
29
+ "",
30
+ "function Write-GuardLog([string]$EventName, [int]$ProcessId, [int]$FootprintMb, [int]$LimitMb, [string]$Reason, [string]$Kind, [string]$Sha) {",
31
+ " $ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')",
32
+ ' $line = \'{"ts":"\' + $ts + \'","event":"\' + $EventName + \'","pid":\' + $ProcessId + \',"footprint_mb":\' + $FootprintMb + \',"limit_mb":\' + $LimitMb + \',"reason":"\' + $Reason + \'","kind":"\' + $Kind + \'","cmd_sha256":"\' + $Sha + \'"}\'',
33
+ " Add-Content -LiteralPath $logPath -Encoding UTF8 -Value $line",
34
+ "}",
35
+ "",
36
+ "# The coarse label for a command line. The same table as the macOS guard and the CLI's tests.",
37
+ "function Get-GuardKind([string]$Command) {",
38
+ ...kindChecks,
39
+ " return 'other'",
40
+ "}",
41
+ "",
42
+ "function Get-GuardSha([string]$Command) {",
43
+ " $sha = [System.Security.Cryptography.SHA256]::Create()",
44
+ " $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Command))",
45
+ " return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '').Substring(0, 12)",
46
+ "}",
47
+ "",
48
+ "function Stop-Runaway($Process, [int]$FootprintMb, [int]$LimitMb, [string]$Reason) {",
49
+ " if ($Process.Id -eq $PID) { return }",
50
+ " $command = ''",
51
+ " try { $command = [string](Get-CimInstance Win32_Process -Filter \"ProcessId=$($Process.Id)\" -ErrorAction Stop).CommandLine } catch { $command = '' } # an unreadable command line only loses the kind label (other)",
52
+ " $kind = Get-GuardKind $command",
53
+ " $sha = Get-GuardSha $command",
54
+ " try {",
55
+ " Stop-Process -Id $Process.Id -Force -ErrorAction Stop",
56
+ " Write-GuardLog 'killed' $Process.Id $FootprintMb $LimitMb $Reason $kind $sha",
57
+ " } catch {",
58
+ " Write-GuardLog 'kill_failed' $Process.Id $FootprintMb $LimitMb $Reason $kind $sha",
59
+ " }",
60
+ "}",
61
+ "",
62
+ "# 1 normal, 2 warning (<10% free physical memory), 4 critical (<5%).",
63
+ "function Get-PressureLevel {",
64
+ " try {",
65
+ " $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop",
66
+ " $freePercent = ($os.FreePhysicalMemory / $os.TotalVisibleMemorySize) * 100",
67
+ " if ($freePercent -lt 5) { return 4 }",
68
+ " if ($freePercent -lt 10) { return 2 }",
69
+ " } catch { return 1 } # pressure unreadable: judge by the normal limit",
70
+ " return 1",
71
+ "}",
72
+ "",
73
+ "function Remove-OrphanVitest {",
74
+ " $alive = @{}",
75
+ " Get-Process -ErrorAction SilentlyContinue | ForEach-Object { $alive[[int]$_.Id] = $true }",
76
+ " $now = Get-Date",
77
+ " Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" -ErrorAction SilentlyContinue | ForEach-Object {",
78
+ " if ($_.ProcessId -eq $PID) { return }",
79
+ " if (-not $_.CommandLine -or $_.CommandLine -notmatch 'vitest') { return }",
80
+ " if ($alive.ContainsKey([int]$_.ParentProcessId)) { return }",
81
+ " if (-not $_.CreationDate -or ($now - $_.CreationDate).TotalSeconds -lt $orphanMinAgeSeconds) { return }",
82
+ " try {",
83
+ " Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop",
84
+ " Write-GuardLog 'orphan_reaped' $_.ProcessId 0 0 'orphan_vitest_worker' (Get-GuardKind $_.CommandLine) (Get-GuardSha $_.CommandLine)",
85
+ " } catch {",
86
+ " Write-GuardLog 'kill_failed' $_.ProcessId 0 0 'orphan_vitest_worker' (Get-GuardKind $_.CommandLine) (Get-GuardSha $_.CommandLine)",
87
+ " }",
88
+ " }",
89
+ "}",
90
+ "",
91
+ "function Limit-GuardLog {",
92
+ " $file = Get-Item -LiteralPath $logPath -ErrorAction SilentlyContinue",
93
+ " if ($file -and $file.Length -gt $logLimitBytes) {",
94
+ " $keep = Get-Content -LiteralPath $logPath -Tail $logKeepLines",
95
+ " Set-Content -LiteralPath $logPath -Value $keep -Encoding UTF8",
96
+ " }",
97
+ "}",
98
+ "",
99
+ "New-Item -ItemType Directory -Force -Path (Split-Path -Parent $logPath) | Out-Null",
100
+ "Write-GuardLog 'started' $PID 0 $limitMb 'started' 'guard' ''",
101
+ "$level = 1",
102
+ "$lastPressure = [DateTime]::MinValue",
103
+ "$lastOrphan = Get-Date",
104
+ "while ($true) {",
105
+ " $now = Get-Date",
106
+ " if (($now - $lastPressure).TotalSeconds -ge 10) { $level = Get-PressureLevel; $lastPressure = $now }",
107
+ " $limit = $limitMb; $footprintReason = 'footprint_over_limit'",
108
+ " if ($level -ge 4) { $limit = $criticalLimitMb; $footprintReason = 'pressure_critical' }",
109
+ " elseif ($level -ge 2) { $limit = $warnLimitMb; $footprintReason = 'pressure_warning' }",
110
+ " foreach ($p in @(Get-Process -Name node -ErrorAction SilentlyContinue)) {",
111
+ " $workingMb = [int]($p.WorkingSet64 / 1MB)",
112
+ " $privateMb = [int]($p.PrivateMemorySize64 / 1MB)",
113
+ " if ($workingMb -gt $limit) { Stop-Runaway $p $workingMb $limit 'rss_over_limit' }",
114
+ " elseif ($privateMb -gt $limit) { Stop-Runaway $p $privateMb $limit $footprintReason }",
115
+ " }",
116
+ " if (($now - $lastOrphan).TotalSeconds -ge 60) { Remove-OrphanVitest; Limit-GuardLog; $lastOrphan = $now }",
117
+ " Start-Sleep -Seconds 5",
118
+ "}",
119
+ "",
120
+ ].join("\r\n");
121
+ }
122
+ /**
123
+ * Registers the guard as a logon task that restarts on failure, has no execution time limit, and starts
124
+ * immediately. Any older resident copy is stopped first so a re-install runs the new script, not the old.
125
+ */
126
+ export function renderWindowsMemoryGuardRegistration(options) {
127
+ return [
128
+ "$ErrorActionPreference = 'Stop'",
129
+ `$taskName = ${powershellLiteral(options.taskName)}`,
130
+ `$launcherPath = ${powershellLiteral(options.launcherPath)}`,
131
+ "$wscriptPath = Join-Path (Join-Path $env:SystemRoot 'System32') 'wscript.exe'",
132
+ "$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name",
133
+ "$action = New-ScheduledTaskAction -Execute $wscriptPath -Argument ('//B \"' + $launcherPath + '\"')",
134
+ "$trigger = New-ScheduledTaskTrigger -AtLogOn -User $identity",
135
+ "$principal = New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited",
136
+ "$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Seconds 0)",
137
+ "Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null",
138
+ `# A running older copy keeps the old limits and script: stop it, then start the registered one.`,
139
+ "Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue",
140
+ `Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*${options.scriptName}*' -and $_.ProcessId -ne $PID } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
141
+ "Start-ScheduledTask -TaskName $taskName",
142
+ "",
143
+ ].join("\r\n");
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.280",
3
+ "version": "0.2.282",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.87",
30
+ "@bli-cockpit/memory-mcp": "0.1.88",
31
31
  "@bli-cockpit/mcp": "0.1.171",
32
32
  "@bli-cockpit/telemetry-core": "0.1.94",
33
33
  "unpdf": "1.8.1"