@coworker-jp/aidr 0.1.10 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/scheduled.mjs CHANGED
@@ -2,13 +2,20 @@ import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import { execFile } from "child_process";
4
4
  import { promisify } from "util";
5
- import { resolveInvokerUser } from "./invoker-resolve.mjs";
6
5
 
7
6
  const execFileP = promisify(execFile);
8
7
 
9
- // System-wide install paths (root-only). The schedule entry runs as root so
10
- // endpoint-info can collect host-wide inventory (process list, SUID, /etc/*).
11
- // All paths are owned by root:root; the access-key env file is mode 0600.
8
+ // System-wide install paths (root-only), shared with the legacy
9
+ // coworker-sentinel cleanup (sentinel.mjs). Historically this module wrote a
10
+ // cron.d / launchd schedule entry (`aidr install --scheduled`) that ran
11
+ // `ai-scanner scan endpoint-info` periodically; that mechanism is retired —
12
+ // endpoint-info collection now runs from the coworker-sentinel daemon's
13
+ // internal timer (installed via the OS-native installers, not aidr).
14
+ // What remains here is:
15
+ // - the path constants (still the canonical legacy system layout)
16
+ // - the shared log / logrotate cleanup helpers used by sentinel.mjs
17
+ // - cleanupLegacyScheduled(), which removes leftover schedule entries
18
+ // written by older aidr versions.
12
19
  //
13
20
  // `AIDR_SCHEDULED_ROOT` env (smoke / unit test only) prefixes every path so
14
21
  // they land under a tmpdir like `/tmp/test123/opt/coworker/aidr/bin/...`
@@ -33,19 +40,21 @@ const SYSTEM_ENV_DIR = IS_WIN
33
40
  : ROOT_PREFIX + "/etc/aidr";
34
41
  export const SYSTEM_ENV_FILE = path.join(SYSTEM_ENV_DIR, "aidr.env");
35
42
 
36
- // macOS launchd (LaunchDaemons run as root, available pre-login)
43
+ // Legacy macOS launchd entry (LaunchDaemons ran as root, pre-login) —
44
+ // written by `aidr install --scheduled` before the sentinel-daemon scheduler.
45
+ // Kept only so cleanupLegacyScheduled can find and remove old installs.
37
46
  export const LAUNCHD_PLIST = ROOT_PREFIX + "/Library/LaunchDaemons/jp.coworker.aidr.scheduled.plist";
47
+ export const LAUNCHD_LABEL = "jp.coworker.aidr.scheduled";
38
48
  export const LAUNCHD_WRAPPER = path.join(SYSTEM_BIN_DIR, "aidr-scheduled-wrapper.sh");
39
49
 
40
- // Linux: cron.d entry, runs as root every N hours.
50
+ // Legacy Linux cron.d entry (same retirement).
41
51
  export const CRON_FILE = ROOT_PREFIX + "/etc/cron.d/aidr-scheduled";
42
52
 
43
- // Shared world-readable system log directory. Both aidr-scheduled and
44
- // coworker-sentinel daemons write here so non-root users can `tail` errors
45
- // without sudo (memory: feedback_server_failures_never_burden_client.md).
46
- // Mode 0755 root:root, files mode 0644. launchd's `StandardOutPath` (macOS)
47
- // redirects daemon stderr/stdout here; for Linux cron, the entry uses a
48
- // literal `>> ... 2>&1` redirect.
53
+ // Shared world-readable system log directory. The coworker-sentinel daemon
54
+ // writes here so non-root users can `tail` errors without sudo (memory:
55
+ // feedback_server_failures_never_burden_client.md). Mode 0755 root:root,
56
+ // files mode 0644. SCHEDULED_LOG_FILE is the legacy aidr-scheduled log,
57
+ // kept only so cleanupLegacyScheduled can remove it.
49
58
  export const SYSTEM_LOG_DIR = IS_WIN
50
59
  ? path.join(WIN_PROGRAMDATA, "coworker", "logs")
51
60
  : ROOT_PREFIX + "/var/log/coworker";
@@ -63,26 +72,9 @@ async function exists(p) {
63
72
  }
64
73
 
65
74
  /**
66
- * Create SYSTEM_LOG_DIR (mode 0755 root:root) and touch the given daemon's
67
- * log file (mode 0644) if it doesn't already exist. Idempotent — both
68
- * installScheduled and installSentinel call this so the daemon's first run
69
- * has a writable target even before any line is logged.
70
- */
71
- export async function ensureSystemLogDir(logFile, { dryRun = false } = {}) {
72
- if (dryRun) {
73
- console.log("[dry-run] Would create:", SYSTEM_LOG_DIR, "(mode 0755) and touch", logFile, "(mode 0644)");
74
- return;
75
- }
76
- await fs.mkdir(SYSTEM_LOG_DIR, { recursive: true, mode: 0o755 });
77
- if (!(await exists(logFile))) {
78
- const fd = await fs.open(logFile, "w", 0o644);
79
- await fd.close();
80
- }
81
- }
82
-
83
- /**
84
- * Drop /etc/logrotate.d/coworker covering both daemons. Idempotent: same
85
- * content overwritten on every install. The logrotate command itself may
75
+ * Drop /etc/logrotate.d/coworker for the sentinel daemon log. Idempotent:
76
+ * used by cleanupLegacyScheduled to strip the retired scheduled.log stanza
77
+ * from configs written by older aidr versions. The logrotate command itself may
86
78
  * not be present on minimal containers; the config file is harmless when
87
79
  * no logrotate is installed (it simply isn't read). Operators on systems
88
80
  * without logrotate should configure their own rotation.
@@ -91,17 +83,7 @@ export async function writeLogrotateConfig({ dryRun = false } = {}) {
91
83
  // logrotate is a Unix tool; Windows has no equivalent here (the service can
92
84
  // size-rotate its own audit log). No-op on Windows.
93
85
  if (process.platform === "win32") return;
94
- const body = `# Generated by aidr install. Covers both aidr-scheduled (weekly) and
95
- # coworker-sentinel (daily, more chatty under load).
96
- /var/log/coworker/scheduled.log {
97
- weekly
98
- rotate 4
99
- compress
100
- missingok
101
- notifempty
102
- create 0644 root root
103
- }
104
-
86
+ const body = `# Generated by aidr install. Covers the coworker-sentinel daemon log.
105
87
  /var/log/coworker/sentinel.log {
106
88
  daily
107
89
  rotate 7
@@ -122,8 +104,8 @@ export async function writeLogrotateConfig({ dryRun = false } = {}) {
122
104
  /**
123
105
  * Remove the given daemon's log file. If both daemon log files are gone
124
106
  * afterwards, also rmdir SYSTEM_LOG_DIR and remove the logrotate config.
125
- * Used by uninstallScheduled and uninstallSentinel; the order doesn't
126
- * matter (whichever daemon uninstalls last wins the cleanup).
107
+ * Used by cleanupLegacyScheduled and uninstallSentinel; the order doesn't
108
+ * matter (whichever cleanup runs last wins the dir/logrotate removal).
127
109
  */
128
110
  export async function removeSystemLog(logFile, { dryRun = false } = {}) {
129
111
  if (await exists(logFile)) {
@@ -144,196 +126,75 @@ export async function removeSystemLog(logFile, { dryRun = false } = {}) {
144
126
  }
145
127
  }
146
128
 
129
+ /**
130
+ * Detect leftovers of the retired `aidr install --scheduled` mechanism
131
+ * (cron.d entry / launchd plist / launchd wrapper) from older aidr versions.
132
+ */
147
133
  export async function isScheduledInstalled() {
148
- return (await exists(LAUNCHD_PLIST)) || (await exists(CRON_FILE));
134
+ return (await exists(LAUNCHD_PLIST))
135
+ || (await exists(CRON_FILE))
136
+ || (await exists(LAUNCHD_WRAPPER));
149
137
  }
150
138
 
151
- export async function installScheduled(opts) {
152
- const { accessKey, dryRun = false, interval = 4 } = opts;
153
- const noActivate = opts.noActivate || process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE === "1";
154
-
155
- if (!isRoot()) {
156
- throw new Error("installScheduled requires root privileges (system paths under /etc and /opt)");
157
- }
158
-
159
- // Always use the canonical system binary path; the binary is placed there by
160
- // the standalone agent's binary fetch step before this function runs.
161
- const binaryPath = SYSTEM_BIN;
162
-
163
- // Replace any prior schedule entry. Avoids duplicate ExecStart / interval drift.
164
- if (await isScheduledInstalled()) {
165
- await uninstallScheduled({ dryRun, noActivate });
166
- }
167
-
168
- // Write the shared env file first; both Linux and macOS paths reference it.
169
- await writeEnvFile(accessKey, dryRun);
170
-
171
- // Create the world-readable log dir + touch scheduled.log + drop the
172
- // shared logrotate config. Both run before the launchd / cron entry so
173
- // the daemon's first invocation has a target to append to.
174
- await ensureSystemLogDir(SCHEDULED_LOG_FILE, { dryRun });
175
- await writeLogrotateConfig({ dryRun });
176
-
177
- if (process.platform === "darwin") {
178
- await installLaunchd({ dryRun, noActivate, interval, binaryPath });
179
- } else {
180
- await installCronDir({ binaryPath, dryRun, interval });
181
- }
182
- }
183
-
184
- async function writeEnvFile(accessKey, dryRun) {
185
- // Capture the invoker username (SUDO_USER first, then `who am i` /
186
- // `logname` / `loginctl` / $LOGNAME / $USER fallbacks) — the Rust
187
- // binary resolves the full identity (Apple ID / git email / username)
188
- // at scan time using `sudo -u <invoker>` so it reads the invoker's
189
- // plist / gitconfig instead of root's empty ones. Pre-resolving here
190
- // is the wrong layer because the Apple ID needs to be re-read in the
191
- // right user context at runtime anyway (root has no MobileMeAccounts).
192
- const invoker = resolveInvokerUser();
193
- let envContent = `AI_SCANNER_ACCESS_KEY=${accessKey}\n`;
194
- if (invoker) envContent += `AI_SCANNER_INVOKER_USER=${invoker}\n`;
195
- if (dryRun) {
196
- console.log("[dry-run] Would create:", SYSTEM_ENV_FILE, "(mode 0600)");
197
- if (invoker) console.log("[dry-run] invoker captured:", invoker);
198
- return;
199
- }
200
- await fs.mkdir(SYSTEM_ENV_DIR, { recursive: true, mode: 0o755 });
201
- await fs.writeFile(SYSTEM_ENV_FILE, envContent, { mode: 0o600 });
202
- }
203
-
204
- export async function installLaunchd({ binaryPath, dryRun, noActivate, interval = 4 }) {
205
- // launchd does not support EnvironmentFile, so we use a tiny wrapper that
206
- // sources /etc/aidr/aidr.env and execs the binary. Plist itself stays 0644
207
- // (launchctl bootstrap requires it readable); the secret lives in the env
208
- // file at 0600.
209
- const wrapperContent = `#!/bin/bash
210
- # Generated by aidr install --scheduled. Sources the system env file and
211
- # execs ai-scanner so the access key is not embedded in the LaunchDaemon plist.
212
- set -a
213
- . ${SYSTEM_ENV_FILE}
214
- set +a
215
- # launchd starts daemons with a narrow default PATH (/usr/bin:/bin:/usr/sbin:/sbin)
216
- # that omits Homebrew. Without this, scan endpoint-info silently misses brew
217
- # packages because Command::new("brew") spawn fails — gem still works because
218
- # /usr/bin/gem is on the default PATH. Both Apple Silicon (/opt/homebrew/bin)
219
- # and Intel (/usr/local/bin) prefixes are safe to prepend on either arch since
220
- # missing dirs in PATH are simply ignored at lookup time.
221
- export PATH="/opt/homebrew/bin:/usr/local/bin:/opt/homebrew/sbin:/usr/local/sbin:\${PATH:-/usr/bin:/bin:/usr/sbin:/sbin}"
222
- exec ${binaryPath} scan endpoint-info
223
- `;
224
-
225
- const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
226
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
227
- <plist version="1.0">
228
- <dict>
229
- <key>Label</key>
230
- <string>jp.coworker.aidr.scheduled</string>
231
- <key>UserName</key>
232
- <string>root</string>
233
- <key>ProgramArguments</key>
234
- <array>
235
- <string>${LAUNCHD_WRAPPER}</string>
236
- </array>
237
- <key>StartInterval</key>
238
- <integer>${interval * 3600}</integer>
239
- <key>RunAtLoad</key>
240
- <false/>
241
- <key>StandardOutPath</key>
242
- <string>${SCHEDULED_LOG_FILE}</string>
243
- <key>StandardErrorPath</key>
244
- <string>${SCHEDULED_LOG_FILE}</string>
245
- </dict>
246
- </plist>
247
- `;
248
-
249
- if (dryRun) {
250
- console.log("[dry-run] Would create:", LAUNCHD_WRAPPER, LAUNCHD_PLIST);
251
- return;
252
- }
253
-
254
- await fs.mkdir(path.dirname(LAUNCHD_WRAPPER), { recursive: true });
255
- await fs.mkdir(path.dirname(LAUNCHD_PLIST), { recursive: true });
256
- await fs.writeFile(LAUNCHD_WRAPPER, wrapperContent, { mode: 0o755 });
257
- await fs.writeFile(LAUNCHD_PLIST, plistContent, { mode: 0o644 });
258
-
259
- if (!noActivate) {
260
- try {
261
- await execFileP("launchctl", ["bootstrap", "system", LAUNCHD_PLIST]);
262
- console.log(`Scheduled scan enabled via system LaunchDaemon (every ${interval}h)`);
263
- } catch (e) {
264
- console.warn("Warning: launchctl bootstrap failed:", e.message);
265
- console.warn(`Plist written. Enable manually: sudo launchctl bootstrap system ${LAUNCHD_PLIST}`);
266
- }
267
- }
268
- }
269
-
270
- async function installCronDir({ binaryPath, dryRun, interval = 4 }) {
271
- // The aidr.env file is plain `KEY=VALUE` lines (no `export` prefix —
272
- // systemd's EnvironmentFile= used by the sentinel service requires that
273
- // form). Plain `. file` sets shell-local variables that don't propagate
274
- // to the spawned ai-scanner process; `set -a` flips on auto-export so
275
- // every assignment becomes an environment variable. Same pattern as the
276
- // launchd wrapper on macOS.
277
- const entry = `0 */${interval} * * * root set -a; . ${SYSTEM_ENV_FILE}; set +a; ${binaryPath} scan endpoint-info >> ${SCHEDULED_LOG_FILE} 2>&1
278
- `;
279
- if (dryRun) {
280
- console.log("[dry-run] Would create:", CRON_FILE);
281
- return;
282
- }
283
- await fs.mkdir(path.dirname(CRON_FILE), { recursive: true });
284
- await fs.writeFile(CRON_FILE, entry, { mode: 0o644 });
285
- console.log(`Scheduled scan added to ${CRON_FILE} (every ${interval}h)`);
286
-
287
- // Warn (don't fail) when no cron daemon is detected. The file is valid;
288
- // the user just needs to install one (`apt install cron`, `dnf install
289
- // cronie`, etc.) before the next scheduled hour for it to fire.
290
- if (!await hasCronDaemon()) {
291
- console.warn(
292
- "WARNING: cron daemon not detected. Install one (`apt install cron` / `dnf install cronie` / `apk add dcron`) so /etc/cron.d/aidr-scheduled actually fires."
293
- );
294
- }
295
- }
296
-
297
- async function hasCronDaemon() {
298
- for (const candidate of ["/usr/sbin/cron", "/usr/sbin/crond", "/sbin/cron", "/sbin/crond"]) {
299
- if (await exists(candidate)) return true;
300
- }
301
- return false;
302
- }
303
-
304
- export async function uninstallScheduled({ dryRun = false, noActivate = false } = {}) {
305
- noActivate = noActivate || process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE === "1";
306
-
139
+ /**
140
+ * Remove every artifact the retired `aidr install --scheduled` flow wrote:
141
+ * - /etc/cron.d/aidr-scheduled (Linux)
142
+ * - /Library/LaunchDaemons/jp.coworker.aidr.scheduled.plist (macOS,
143
+ * with a best-effort `launchctl bootout system/<label>` first)
144
+ * - the launchd wrapper script in SYSTEM_BIN_DIR
145
+ * - /var/log/coworker/scheduled.log (+ the log dir / logrotate config if
146
+ * no daemon log remains — see removeSystemLog)
147
+ * - the stale scheduled.log stanza in /etc/logrotate.d/coworker when the
148
+ * config survives because sentinel is still installed
149
+ *
150
+ * MUST NOT touch /etc/aidr/aidr.env: the coworker-sentinel daemon reads its
151
+ * access key from that shared env file.
152
+ *
153
+ * Idempotent; safe to call when nothing is installed.
154
+ */
155
+ export async function cleanupLegacyScheduled({ dryRun = false } = {}) {
307
156
  if (!isRoot()) {
308
- throw new Error("uninstallScheduled requires root privileges");
157
+ throw new Error("cleanupLegacyScheduled requires root privileges (system paths under /etc and /opt)");
309
158
  }
159
+ const noActivate = process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE === "1";
310
160
 
311
161
  if (await exists(LAUNCHD_PLIST)) {
312
- if (!dryRun && !noActivate) {
313
- try { await execFileP("launchctl", ["bootout", "system", LAUNCHD_PLIST]); } catch { /* ignore */ }
314
- }
315
- if (!dryRun) {
316
- try { await fs.unlink(LAUNCHD_PLIST); } catch { /* ignore */ }
317
- try { await fs.unlink(LAUNCHD_WRAPPER); } catch { /* ignore */ }
162
+ if (dryRun) {
163
+ console.log("[dry-run] Would remove:", LAUNCHD_PLIST);
318
164
  } else {
319
- console.log("[dry-run] Would remove:", LAUNCHD_PLIST, LAUNCHD_WRAPPER);
165
+ if (!noActivate) {
166
+ // Best-effort: unloads the running LaunchDaemon so the entry stops
167
+ // firing immediately. Fails harmlessly when it was never bootstrapped
168
+ // (or off-macOS in tests).
169
+ try { await execFileP("launchctl", ["bootout", `system/${LAUNCHD_LABEL}`]); } catch { /* ignore */ }
170
+ }
171
+ try { await fs.unlink(LAUNCHD_PLIST); } catch { /* ignore */ }
320
172
  }
321
173
  }
322
174
 
323
- if (await exists(CRON_FILE)) {
324
- if (!dryRun) { try { await fs.unlink(CRON_FILE); } catch { /* ignore */ } }
325
- else { console.log("[dry-run] Would remove:", CRON_FILE); }
175
+ for (const f of [LAUNCHD_WRAPPER, CRON_FILE]) {
176
+ if (await exists(f)) {
177
+ if (dryRun) { console.log("[dry-run] Would remove:", f); }
178
+ else { try { await fs.unlink(f); } catch { /* ignore */ } }
179
+ }
326
180
  }
327
181
 
328
- // Remove the env file last so an aborted uninstall doesn't strand a
329
- // schedule entry that still references a missing env file.
330
- if (await exists(SYSTEM_ENV_FILE)) {
331
- if (!dryRun) { try { await fs.unlink(SYSTEM_ENV_FILE); } catch { /* ignore */ } }
332
- else { console.log("[dry-run] Would remove:", SYSTEM_ENV_FILE); }
333
- }
182
+ // NOTE: SYSTEM_ENV_FILE (/etc/aidr/aidr.env) is deliberately NOT removed —
183
+ // the coworker-sentinel daemon reads AI_SCANNER_ACCESS_KEY /
184
+ // AI_SCANNER_INVOKER_USER from it.
334
185
 
335
- // Drop the scheduled log file. removeSystemLog also rmdirs SYSTEM_LOG_DIR
336
- // and deletes /etc/logrotate.d/coworker if no daemon log files remain
337
- // (i.e. sentinel is also uninstalled).
186
+ // Drop the legacy scheduled log file. removeSystemLog also rmdirs
187
+ // SYSTEM_LOG_DIR and deletes /etc/logrotate.d/coworker when no daemon log
188
+ // remains (i.e. sentinel is not installed either).
338
189
  await removeSystemLog(SCHEDULED_LOG_FILE, { dryRun });
190
+
191
+ // When the logrotate config survives (sentinel still installed), older
192
+ // installs left a stale scheduled.log stanza in it — rewrite with the
193
+ // current sentinel-only content.
194
+ if (!dryRun && (await exists(LOGROTATE_FILE))) {
195
+ const body = await fs.readFile(LOGROTATE_FILE, "utf8").catch(() => "");
196
+ if (body.includes("scheduled.log")) {
197
+ await writeLogrotateConfig({ dryRun });
198
+ }
199
+ }
339
200
  }