@coworker-jp/aidr 0.1.2 → 0.1.4
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/package.json +1 -1
- package/src/cli.mjs +1 -1
- package/src/invoker-resolve.mjs +63 -0
- package/src/scheduled.mjs +36 -92
- package/src/sentinel.mjs +2 -1
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Best-effort invoker username resolution at install time.
|
|
2
|
+
//
|
|
3
|
+
// `aidr install --scheduled` / `--with-sentinel` writes the unix username
|
|
4
|
+
// into `/etc/aidr/aidr.env` as `AI_SCANNER_INVOKER_USER`. The Rust daemon
|
|
5
|
+
// reads it back to resolve git email / Apple ID / $HOME of the actual
|
|
6
|
+
// installer (root daemons have no useful identity of their own).
|
|
7
|
+
//
|
|
8
|
+
// In the auto-sudo path the CLI re-execs itself via `sudo -E`, which sets
|
|
9
|
+
// SUDO_USER. But several real-world contexts arrive here as bare root
|
|
10
|
+
// without SUDO_USER:
|
|
11
|
+
// - `ssh root@host` then `npx ... install --scheduled` (no sudo prefix)
|
|
12
|
+
// - container / CI where the user is already root
|
|
13
|
+
// - sudo policy that strips SUDO_USER
|
|
14
|
+
//
|
|
15
|
+
// We walk a fallback chain so the resulting daemon snapshot reports the
|
|
16
|
+
// real user instead of `actor_id=unknown`. Each step is best-effort and
|
|
17
|
+
// never throws — we return `null` when no signal is available, which is
|
|
18
|
+
// the honest outcome for genuinely identity-less contexts (CI runners).
|
|
19
|
+
|
|
20
|
+
import { execSync } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
function tryExec(cmd) {
|
|
23
|
+
try {
|
|
24
|
+
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isUsable(name) {
|
|
31
|
+
return name && name !== "root";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveInvokerUser() {
|
|
35
|
+
if (process.env.SUDO_USER) return process.env.SUDO_USER;
|
|
36
|
+
|
|
37
|
+
// `who am i` reads utmp, so it returns the actual login user even after
|
|
38
|
+
// `sudo -i` / `su -`. Empty under non-tty / no utmp.
|
|
39
|
+
const whoUser = tryExec("who am i").split(/\s+/)[0];
|
|
40
|
+
if (isUsable(whoUser)) return whoUser;
|
|
41
|
+
|
|
42
|
+
// POSIX `logname` gives controlling-tty owner. Slightly different code
|
|
43
|
+
// path from `who am i` — kept as a separate fallback because they fail
|
|
44
|
+
// independently on minimal images.
|
|
45
|
+
const logname = tryExec("logname");
|
|
46
|
+
if (isUsable(logname)) return logname;
|
|
47
|
+
|
|
48
|
+
// systemd Linux: `loginctl list-sessions` columns are: SESSION UID USER ...
|
|
49
|
+
const loginctl = tryExec("loginctl list-sessions --no-legend");
|
|
50
|
+
for (const line of loginctl.split("\n")) {
|
|
51
|
+
const fields = line.trim().split(/\s+/);
|
|
52
|
+
if (fields.length >= 3 && isUsable(fields[2])) return fields[2];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Env fallbacks (root-filtered) so a user-shell install without sudo at
|
|
56
|
+
// all (rare for --scheduled, possible for plain --agent without root)
|
|
57
|
+
// still captures the obvious answer.
|
|
58
|
+
for (const env of ["LOGNAME", "USER"]) {
|
|
59
|
+
if (isUsable(process.env[env])) return process.env[env];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
package/src/scheduled.mjs
CHANGED
|
@@ -2,6 +2,7 @@ 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";
|
|
5
6
|
|
|
6
7
|
const execFileP = promisify(execFile);
|
|
7
8
|
|
|
@@ -21,24 +22,19 @@ export const SYSTEM_USR_LOCAL_BIN = ROOT_PREFIX + "/usr/local/bin/ai-scanner";
|
|
|
21
22
|
const SYSTEM_ENV_DIR = ROOT_PREFIX + "/etc/aidr";
|
|
22
23
|
export const SYSTEM_ENV_FILE = path.join(SYSTEM_ENV_DIR, "aidr.env");
|
|
23
24
|
|
|
24
|
-
// Linux systemd
|
|
25
|
-
const SYSTEMD_DIR = ROOT_PREFIX + "/etc/systemd/system";
|
|
26
|
-
export const SYSTEMD_SERVICE = path.join(SYSTEMD_DIR, "aidr-scheduled.service");
|
|
27
|
-
export const SYSTEMD_TIMER = path.join(SYSTEMD_DIR, "aidr-scheduled.timer");
|
|
28
|
-
|
|
29
25
|
// macOS launchd (LaunchDaemons run as root, available pre-login)
|
|
30
26
|
export const LAUNCHD_PLIST = ROOT_PREFIX + "/Library/LaunchDaemons/jp.coworker.aidr.scheduled.plist";
|
|
31
27
|
export const LAUNCHD_WRAPPER = path.join(SYSTEM_BIN_DIR, "aidr-scheduled-wrapper.sh");
|
|
32
28
|
|
|
33
|
-
// Linux cron
|
|
29
|
+
// Linux: cron.d entry, runs as root every N hours.
|
|
34
30
|
export const CRON_FILE = ROOT_PREFIX + "/etc/cron.d/aidr-scheduled";
|
|
35
31
|
|
|
36
32
|
// Shared world-readable system log directory. Both aidr-scheduled and
|
|
37
33
|
// coworker-sentinel daemons write here so non-root users can `tail` errors
|
|
38
34
|
// without sudo (memory: feedback_server_failures_never_burden_client.md).
|
|
39
|
-
// Mode 0755 root:root, files mode 0644.
|
|
40
|
-
//
|
|
41
|
-
//
|
|
35
|
+
// Mode 0755 root:root, files mode 0644. launchd's `StandardOutPath` (macOS)
|
|
36
|
+
// redirects daemon stderr/stdout here; for Linux cron, the entry uses a
|
|
37
|
+
// literal `>> ... 2>&1` redirect.
|
|
42
38
|
export const SYSTEM_LOG_DIR = ROOT_PREFIX + "/var/log/coworker";
|
|
43
39
|
export const SCHEDULED_LOG_FILE = path.join(SYSTEM_LOG_DIR, "scheduled.log");
|
|
44
40
|
export const SENTINEL_LOG_FILE = path.join(SYSTEM_LOG_DIR, "sentinel.log");
|
|
@@ -133,11 +129,7 @@ export async function removeSystemLog(logFile, { dryRun = false } = {}) {
|
|
|
133
129
|
}
|
|
134
130
|
|
|
135
131
|
export async function isScheduledInstalled() {
|
|
136
|
-
return (
|
|
137
|
-
await exists(SYSTEMD_TIMER) ||
|
|
138
|
-
await exists(LAUNCHD_PLIST) ||
|
|
139
|
-
await exists(CRON_FILE)
|
|
140
|
-
);
|
|
132
|
+
return (await exists(LAUNCHD_PLIST)) || (await exists(CRON_FILE));
|
|
141
133
|
}
|
|
142
134
|
|
|
143
135
|
export async function installScheduled(opts) {
|
|
@@ -161,15 +153,12 @@ export async function installScheduled(opts) {
|
|
|
161
153
|
await writeEnvFile(accessKey, dryRun);
|
|
162
154
|
|
|
163
155
|
// Create the world-readable log dir + touch scheduled.log + drop the
|
|
164
|
-
// shared logrotate config. Both run before the
|
|
165
|
-
//
|
|
156
|
+
// shared logrotate config. Both run before the launchd / cron entry so
|
|
157
|
+
// the daemon's first invocation has a target to append to.
|
|
166
158
|
await ensureSystemLogDir(SCHEDULED_LOG_FILE, { dryRun });
|
|
167
159
|
await writeLogrotateConfig({ dryRun });
|
|
168
160
|
|
|
169
|
-
|
|
170
|
-
if (platform === "linux") {
|
|
171
|
-
await installSystemd({ dryRun, noActivate, interval, binaryPath });
|
|
172
|
-
} else if (platform === "darwin") {
|
|
161
|
+
if (process.platform === "darwin") {
|
|
173
162
|
await installLaunchd({ dryRun, noActivate, interval, binaryPath });
|
|
174
163
|
} else {
|
|
175
164
|
await installCronDir({ binaryPath, dryRun, interval });
|
|
@@ -177,14 +166,14 @@ export async function installScheduled(opts) {
|
|
|
177
166
|
}
|
|
178
167
|
|
|
179
168
|
async function writeEnvFile(accessKey, dryRun) {
|
|
180
|
-
// Capture
|
|
181
|
-
// `
|
|
182
|
-
// identity (Apple ID / git email / username)
|
|
183
|
-
// `sudo -u <invoker>` so it reads the invoker's
|
|
184
|
-
// instead of root's empty ones. Pre-resolving here
|
|
185
|
-
// layer because the Apple ID needs to be re-read in the
|
|
186
|
-
// context at runtime anyway (root has no MobileMeAccounts
|
|
187
|
-
const invoker =
|
|
169
|
+
// Capture the invoker username (SUDO_USER first, then `who am i` /
|
|
170
|
+
// `logname` / `loginctl` / $LOGNAME / $USER fallbacks) — the Rust
|
|
171
|
+
// binary resolves the full identity (Apple ID / git email / username)
|
|
172
|
+
// at scan time using `sudo -u <invoker>` so it reads the invoker's
|
|
173
|
+
// plist / gitconfig instead of root's empty ones. Pre-resolving here
|
|
174
|
+
// is the wrong layer because the Apple ID needs to be re-read in the
|
|
175
|
+
// right user context at runtime anyway (root has no MobileMeAccounts).
|
|
176
|
+
const invoker = resolveInvokerUser();
|
|
188
177
|
let envContent = `AI_SCANNER_ACCESS_KEY=${accessKey}\n`;
|
|
189
178
|
if (invoker) envContent += `AI_SCANNER_INVOKER_USER=${invoker}\n`;
|
|
190
179
|
if (dryRun) {
|
|
@@ -196,57 +185,6 @@ async function writeEnvFile(accessKey, dryRun) {
|
|
|
196
185
|
await fs.writeFile(SYSTEM_ENV_FILE, envContent, { mode: 0o600 });
|
|
197
186
|
}
|
|
198
187
|
|
|
199
|
-
async function installSystemd({ binaryPath, dryRun, noActivate, interval = 4 }) {
|
|
200
|
-
// StandardOutput=append: requires systemd 240+ (released 2018). Modern
|
|
201
|
-
// distros (RHEL 9 / Ubuntu 20.04+ / Debian 11+) all support it. Older
|
|
202
|
-
// RHEL 8 (systemd 239) operators must redirect manually or upgrade.
|
|
203
|
-
const serviceContent = `[Unit]
|
|
204
|
-
Description=AIDR scheduled endpoint-info scan
|
|
205
|
-
After=network-online.target
|
|
206
|
-
Wants=network-online.target
|
|
207
|
-
|
|
208
|
-
[Service]
|
|
209
|
-
Type=oneshot
|
|
210
|
-
User=root
|
|
211
|
-
EnvironmentFile=${SYSTEM_ENV_FILE}
|
|
212
|
-
ExecStart=${binaryPath} scan endpoint-info
|
|
213
|
-
StandardOutput=append:${SCHEDULED_LOG_FILE}
|
|
214
|
-
StandardError=append:${SCHEDULED_LOG_FILE}
|
|
215
|
-
`;
|
|
216
|
-
|
|
217
|
-
const timerContent = `[Unit]
|
|
218
|
-
Description=AIDR scheduled endpoint-info scan timer
|
|
219
|
-
|
|
220
|
-
[Timer]
|
|
221
|
-
OnBootSec=5min
|
|
222
|
-
OnUnitActiveSec=${interval}h
|
|
223
|
-
Persistent=true
|
|
224
|
-
|
|
225
|
-
[Install]
|
|
226
|
-
WantedBy=timers.target
|
|
227
|
-
`;
|
|
228
|
-
|
|
229
|
-
if (dryRun) {
|
|
230
|
-
console.log("[dry-run] Would create:", SYSTEMD_SERVICE, SYSTEMD_TIMER);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
await fs.mkdir(path.dirname(SYSTEMD_SERVICE), { recursive: true });
|
|
235
|
-
await fs.writeFile(SYSTEMD_SERVICE, serviceContent, { mode: 0o644 });
|
|
236
|
-
await fs.writeFile(SYSTEMD_TIMER, timerContent, { mode: 0o644 });
|
|
237
|
-
|
|
238
|
-
if (!noActivate) {
|
|
239
|
-
try {
|
|
240
|
-
await execFileP("systemctl", ["daemon-reload"]);
|
|
241
|
-
await execFileP("systemctl", ["enable", "--now", "aidr-scheduled.timer"]);
|
|
242
|
-
console.log(`Scheduled scan enabled via system systemd timer (every ${interval}h)`);
|
|
243
|
-
} catch (e) {
|
|
244
|
-
console.warn("Warning: systemctl failed:", e.message);
|
|
245
|
-
console.warn("Timer files were written. Enable manually: sudo systemctl enable --now aidr-scheduled.timer");
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
188
|
export async function installLaunchd({ binaryPath, dryRun, noActivate, interval = 4 }) {
|
|
251
189
|
// launchd does not support EnvironmentFile, so we use a tiny wrapper that
|
|
252
190
|
// sources /etc/aidr/aidr.env and execs the binary. Plist itself stays 0644
|
|
@@ -317,7 +255,7 @@ async function installCronDir({ binaryPath, dryRun, interval = 4 }) {
|
|
|
317
255
|
// /etc/cron.d entries run as the user named in the line. We embed the env
|
|
318
256
|
// file source via a small inline script. stdout + stderr are appended to
|
|
319
257
|
// the world-readable log so non-root users can read recent runs without
|
|
320
|
-
// sudo
|
|
258
|
+
// sudo.
|
|
321
259
|
const entry = `0 */${interval} * * * root . ${SYSTEM_ENV_FILE} && ${binaryPath} scan endpoint-info >> ${SCHEDULED_LOG_FILE} 2>&1 # aidr-scheduled
|
|
322
260
|
`;
|
|
323
261
|
if (dryRun) {
|
|
@@ -327,6 +265,22 @@ async function installCronDir({ binaryPath, dryRun, interval = 4 }) {
|
|
|
327
265
|
await fs.mkdir(path.dirname(CRON_FILE), { recursive: true });
|
|
328
266
|
await fs.writeFile(CRON_FILE, entry, { mode: 0o644 });
|
|
329
267
|
console.log(`Scheduled scan added to ${CRON_FILE} (every ${interval}h)`);
|
|
268
|
+
|
|
269
|
+
// Warn (don't fail) when no cron daemon is detected. The file is valid;
|
|
270
|
+
// the user just needs to install one (`apt install cron`, `dnf install
|
|
271
|
+
// cronie`, etc.) before the next scheduled hour for it to fire.
|
|
272
|
+
if (!await hasCronDaemon()) {
|
|
273
|
+
console.warn(
|
|
274
|
+
"WARNING: cron daemon not detected. Install one (`apt install cron` / `dnf install cronie` / `apk add dcron`) so /etc/cron.d/aidr-scheduled actually fires."
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function hasCronDaemon() {
|
|
280
|
+
for (const candidate of ["/usr/sbin/cron", "/usr/sbin/crond", "/sbin/cron", "/sbin/crond"]) {
|
|
281
|
+
if (await exists(candidate)) return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
330
284
|
}
|
|
331
285
|
|
|
332
286
|
export async function uninstallScheduled({ dryRun = false, noActivate = false } = {}) {
|
|
@@ -336,16 +290,6 @@ export async function uninstallScheduled({ dryRun = false, noActivate = false }
|
|
|
336
290
|
throw new Error("uninstallScheduled requires root privileges");
|
|
337
291
|
}
|
|
338
292
|
|
|
339
|
-
if (await exists(SYSTEMD_TIMER)) {
|
|
340
|
-
if (!dryRun && !noActivate) {
|
|
341
|
-
try { await execFileP("systemctl", ["disable", "--now", "aidr-scheduled.timer"]); } catch { /* ignore */ }
|
|
342
|
-
}
|
|
343
|
-
for (const f of [SYSTEMD_SERVICE, SYSTEMD_TIMER]) {
|
|
344
|
-
if (!dryRun) { try { await fs.unlink(f); } catch { /* ignore */ } }
|
|
345
|
-
else { console.log("[dry-run] Would remove:", f); }
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
293
|
if (await exists(LAUNCHD_PLIST)) {
|
|
350
294
|
if (!dryRun && !noActivate) {
|
|
351
295
|
try { await execFileP("launchctl", ["bootout", "system", LAUNCHD_PLIST]); } catch { /* ignore */ }
|
|
@@ -363,8 +307,8 @@ export async function uninstallScheduled({ dryRun = false, noActivate = false }
|
|
|
363
307
|
else { console.log("[dry-run] Would remove:", CRON_FILE); }
|
|
364
308
|
}
|
|
365
309
|
|
|
366
|
-
// Remove the env file last so an aborted uninstall
|
|
367
|
-
//
|
|
310
|
+
// Remove the env file last so an aborted uninstall doesn't strand a
|
|
311
|
+
// schedule entry that still references a missing env file.
|
|
368
312
|
if (await exists(SYSTEM_ENV_FILE)) {
|
|
369
313
|
if (!dryRun) { try { await fs.unlink(SYSTEM_ENV_FILE); } catch { /* ignore */ } }
|
|
370
314
|
else { console.log("[dry-run] Would remove:", SYSTEM_ENV_FILE); }
|
package/src/sentinel.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import fs from "node:fs/promises";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { execFile, spawnSync } from "node:child_process";
|
|
16
|
+
import { resolveInvokerUser } from "./invoker-resolve.mjs";
|
|
16
17
|
import { promisify } from "node:util";
|
|
17
18
|
import { fetchSentinel, detectPlatform } from "./binary-fetcher.mjs";
|
|
18
19
|
import {
|
|
@@ -155,7 +156,7 @@ export async function installSentinel(opts = {}) {
|
|
|
155
156
|
appended += `\nAI_SCANNER_ACCESS_KEY=${accessKey}\n`;
|
|
156
157
|
}
|
|
157
158
|
if (!content.includes("AI_SCANNER_INVOKER_USER")) {
|
|
158
|
-
const invoker =
|
|
159
|
+
const invoker = resolveInvokerUser();
|
|
159
160
|
if (invoker) {
|
|
160
161
|
appended += `AI_SCANNER_INVOKER_USER=${invoker}\n`;
|
|
161
162
|
}
|