@bli-cockpit/cli 0.2.281 → 0.2.283

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
  *
@@ -55,6 +55,7 @@ const NOTES_ACTIONS = new Set([
55
55
  "bot-upcoming",
56
56
  "record",
57
57
  "app",
58
+ "review",
58
59
  ]);
59
60
  /** Actions whose first positional is the note it acts on. */
60
61
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
@@ -110,6 +111,9 @@ const NOTES_ALLOWED_FLAGS = [
110
111
  // transcript was stored cut short. A FLAG, not a verb: it is the same sync
111
112
  // door with a different request.
112
113
  "--repair-truncated",
114
+ // BLI-22350. `review <note-id> --submit` reads `{ raw_hash, answers }` from stdin; `--accept-all` sends the defaults.
115
+ "--submit",
116
+ "--accept-all",
113
117
  ];
114
118
  const NOTES_VALUE_FLAGS = [
115
119
  "--home",
@@ -150,6 +154,7 @@ export function parseNotesArgs(args) {
150
154
  throw new Error("notes rename needs --name <name>.");
151
155
  const { to, clearShelf } = readNotesShelfTarget(action, values);
152
156
  const repairTruncated = readRepairTruncated(action, values);
157
+ const review = readReviewRequest(action, values, rest);
153
158
  const { limit, month, offset } = readNotesWindow(action, values);
154
159
  return {
155
160
  kind: "notes",
@@ -184,6 +189,7 @@ export function parseNotesArgs(args) {
184
189
  ...(month ? { month } : {}),
185
190
  ...(offset === undefined ? {} : { offset }),
186
191
  ...(repairTruncated ? { repairTruncated: true } : {}),
192
+ ...review,
187
193
  yes,
188
194
  json,
189
195
  };
@@ -193,7 +199,7 @@ function readNotesAction(first) {
193
199
  // almost always wants — same reasoning as bare `cockpit` running convergence.
194
200
  const action = (first === undefined ? "list" : first);
195
201
  if (!NOTES_ACTIONS.has(action)) {
196
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, or transcription-stats (and record start|stop|status|cancel, app install|uninstall|status).`);
202
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, transcription-stats, or review (and record start|stop|status|cancel, app install|uninstall|status).`);
197
203
  }
198
204
  return action;
199
205
  }
@@ -220,6 +226,23 @@ function readAppRequest(rest) {
220
226
  throw new Error(`notes app ${verb} does not take "${rest[1]}".`);
221
227
  return { appAction: verb };
222
228
  }
229
+ /** BLI-22350: `review [<note-id>] [--submit|--accept-all]`. Bare lists mine; an id shows one; a flag answers it. */
230
+ function readReviewRequest(action, values, rest) {
231
+ const flag = values.booleans.has("--submit") ? "--submit" : values.booleans.has("--accept-all") ? "--accept-all" : null;
232
+ if (action !== "review") {
233
+ if (flag)
234
+ throw new Error(`${flag} belongs to \`cockpit notes review <note-id> ${flag}\`.`);
235
+ return {};
236
+ }
237
+ if (rest.length > 1)
238
+ throw new Error(`notes review takes one note id, not ${rest.length}.`);
239
+ const noteId = optionalNonEmpty(rest[0]);
240
+ if (values.booleans.has("--submit") && values.booleans.has("--accept-all"))
241
+ throw new Error("notes review takes --submit or --accept-all, not both.");
242
+ if (flag && !noteId)
243
+ throw new Error(`notes review ${flag} needs a note id: cockpit notes review <note-id> ${flag}.`);
244
+ return { ...(noteId ? { noteId } : {}), ...(flag === "--submit" ? { submit: true } : {}), ...(flag === "--accept-all" ? { acceptAll: true } : {}) };
245
+ }
223
246
  function readRepairTruncated(action, values) {
224
247
  const repairTruncated = values.booleans.has("--repair-truncated");
225
248
  if (repairTruncated && action !== "sync") {
@@ -328,6 +351,7 @@ function readNotesPaths(action, rest) {
328
351
  const takesNoPositional = action !== "paste"
329
352
  && action !== "record"
330
353
  && action !== "app"
354
+ && action !== "review"
331
355
  && action !== "connect"
332
356
  && action !== "sync"
333
357
  && action !== "sync-status"
@@ -3,7 +3,7 @@ export const NOTES_HELP = [
3
3
  [
4
4
  "notes",
5
5
  [
6
- "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel|app install|uninstall|status] [flags]",
6
+ "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel|app install|uninstall|status|review [<note-id>] [--submit|--accept-all]] [flags]",
7
7
  "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 1 GB. Over 4 MB (a whole meeting) the file goes straight to storage and is transcribed in the background; --wait then polls for up to 4 hours and prints progress, otherwise for at most 330 seconds.",
8
8
  "",
9
9
  "record start|stop [<session-id>]|status|cancel — record a meeting in a room from this machine's microphone (Apple Silicon macOS and Windows; Linux says it is not supported). On Windows the microphone needs Settings > Privacy & security > Microphone on, including Let desktop apps access your microphone. Nothing leaves the machine while recording; the recorder stops by itself after 8 hours (under the 1 GB upload limit). `stop` uploads the whole original recording, silence included, and Tower writes a note with Speaker 1, Speaker 2... lines, or says no speech was detected (add --wait to watch the transcription). `cancel` stops and KEEPS the file; `status` lists what is recording and every session waiting for upload (`needs_upload`); `stop <session-id>` uploads a cancelled, crashed or auto-stopped session. The BLI Notetaker app uploads waiting sessions by itself. The recording also stays on this machine.",
@@ -42,6 +42,7 @@ export const NOTES_HELP = [
42
42
  "bot-upcoming [--json] — your own Google Meet meetings today that the bot will join, with claim state. Decline or mark private to opt out.",
43
43
  "granola-status [--limit <n>] [--json] — the Granola cron's own last several runs (ticket 10718): reason, counts, model, spend and which lookback it used. Granola runs on a shared server-side key, not a connected account, so this is its own read rather than `accounts`.",
44
44
  "transcription-stats [--month YYYY-MM] [--json] — what meeting transcription cost and did (BLI-18003): minutes, dollars (list and real), requests, failures and fallbacks per provider for the UTC month (default: this one), the spend against the monthly cap and how close the 80% message and the hold are, how many meetings are waiting for budget, which provider keys the deploy holds (by name), and how old the numbers are. Switch providers or move the cap with `cockpit settings switches set transcription.provider|transcription.fallback_chain|transcription.monthly_cap_usd <value>`.",
45
+ "review [<note-id>] [--submit|--accept-all] [--json] — the meeting reviews waiting for you (BLI-22350). A recorded meeting stays with its attendees until one of them clears one review screen: who's who, lines the raw transcript may have put under the wrong speaker, unclear words, spots where the clean-up may have changed the meaning, sensitive moments. Bare `review` lists yours (waiting, being applied, cleared in the last 7 days). `review <note-id>` shows one in that order with every default answer; --json prints the raw read (raw_hash, items, defaults). `--accept-all` submits every default (names as guessed, lines kept, best-guess readings, the words as said where the meaning may have changed, sensitive moments to attendees only) and prints what it accepted. `--submit` reads { \"raw_hash\": ..., \"answers\": [...] } from stdin: edit the defaults from --json and pipe them in. A refusal says to reload when the review changed (stale_raw_hash, stale_guess) or names what is wrong with the answers. Same door as the web screen (/meeting-notes/<note-id>/review) and the notes_review_* MCP tools.",
45
46
  "A large note is read by a model on the server and can take a couple of minutes; the terminal says so before it waits.",
46
47
  "--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
47
48
  "Sharing and moving need a signed-in session the database can see. If this deployment cannot mint one, they are refused as needs_signed_in_session rather than done with no permission check — and reads say when they came back narrower than the browser's.",