@coworker-jp/aidr 0.0.1 → 0.0.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coworker-jp/aidr",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,7 @@ export async function install(installBase, accessKey, opts = {}) {
42
42
  ];
43
43
  const settingsPath = path.join(base, "settings.json");
44
44
  const [settingsRes, ...scriptResults] = await Promise.all([
45
- writeJsonMerge(settingsPath, buildClaudeSettings(accessKey, scope), mergeClaudeSettings, { dryRun }),
45
+ writeJsonMerge(settingsPath, buildClaudeSettings(accessKey, scope), mergeClaudeSettings, { dryRun, backup: scope !== "project" }),
46
46
  ...scriptEntries.map(([p, c, mode]) => writeFileSafe(p, c, { dryRun, force: true, backup: false, mode })),
47
47
  ]);
48
48
  const scriptBackups = scriptResults.map((r) => r && r.backupPath).filter(Boolean);
@@ -59,7 +59,7 @@ export async function install(installBase, accessKey, opts = {}) {
59
59
  path.join(base, "hooks.json"),
60
60
  buildCodexHooksJson(scope, home),
61
61
  mergeCodexHooksJson,
62
- { dryRun, mode: 0o644 },
62
+ { dryRun, mode: 0o644, backup: scope !== "project" },
63
63
  );
64
64
 
65
65
  // 2) Merge config.toml to enable the feature flag. Backs up pre-merge TOML
@@ -47,7 +47,7 @@ export async function install(installBase, accessKey, opts = {}) {
47
47
  path.join(base, "hooks.json"),
48
48
  buildCursorHooksJson(scope, home),
49
49
  mergeCursorHooksJson,
50
- { dryRun, mode: 0o644 },
50
+ { dryRun, mode: 0o644, backup: scope !== "project" },
51
51
  );
52
52
 
53
53
  const entries = [
@@ -15,14 +15,14 @@ export const meta = {
15
15
  };
16
16
 
17
17
  export async function install(home, accessKey, opts = {}) {
18
- const { dryRun = false } = opts;
18
+ const { dryRun = false, scope = "project" } = opts;
19
19
  const base = meta.configDir(home);
20
20
  const hooks = path.join(base, "hooks");
21
21
  const binary = path.join(home, meta.agentDir, "bin", "ai-scanner");
22
22
  const settingsPath = path.join(base, "settings.json");
23
23
  const preHookPath = path.join(hooks, "aidr-pre.sh");
24
24
  const [settingsRes, hookRes] = await Promise.all([
25
- writeJsonMerge(settingsPath, buildGeminiSettings(accessKey, binary), mergeGeminiSettings, { dryRun }),
25
+ writeJsonMerge(settingsPath, buildGeminiSettings(accessKey, binary), mergeGeminiSettings, { dryRun, backup: scope !== "project" }),
26
26
  writeFileSafe(preHookPath, T.getGeminiPreHookSh(binary), { dryRun, force: true, mode: 0o755 }),
27
27
  ]);
28
28
  return {
@@ -44,7 +44,7 @@ export async function install(installBase, accessKey, opts = {}) {
44
44
  path.join(base, "hooks.json"),
45
45
  buildWindsurfHooksJson(scope, home),
46
46
  mergeWindsurfHooksJson,
47
- { dryRun, mode: 0o644 },
47
+ { dryRun, mode: 0o644, backup: scope !== "project" },
48
48
  );
49
49
 
50
50
  const entries = [
package/src/cli.mjs CHANGED
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import os from "node:os";
3
3
  import fs from "node:fs/promises";
4
4
  import readline from "node:readline";
5
- import { execFile } from "node:child_process";
5
+ import { execFile, spawnSync } from "node:child_process";
6
6
  import { promisify } from "node:util";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { Command } from "commander";
@@ -13,6 +13,7 @@ import { verifyKey, maskKey } from "./verify.mjs";
13
13
  import { fetchBinary, fetchOpengrep } from "./binary-fetcher.mjs";
14
14
  import { detectInstalled } from "./detect.mjs";
15
15
  import { installBrowserExtension } from "./browser-extension.mjs";
16
+ import { resolveCallerHome, isRunningUnderSudo, chownToCaller } from "./sudo-user.mjs";
16
17
 
17
18
  // Package identity — read at module load from the bundled package.json.
18
19
  // The PROD publish workflow renames the package to `@coworker-jp/aidr`;
@@ -83,7 +84,7 @@ async function cmdInstall(opts) {
83
84
  });
84
85
  }
85
86
 
86
- const home = process.env.HOME || os.homedir();
87
+ const home = resolveCallerHome();
87
88
  const scope = opts.scope || "user";
88
89
  const base = scope === "user" ? home : process.cwd();
89
90
  validateKey(opts.key);
@@ -127,11 +128,12 @@ async function cmdInstall(opts) {
127
128
  opts.scheduled = true;
128
129
  }
129
130
 
130
- // --scheduled installs a cron / systemd / launchd entry that runs
131
- // `ai-scanner scan endpoint-info` periodically against an agent-independent
132
- // binary at ~/.aidr/bin/ai-scanner. Auto-add the standalone install so the
133
- // binary fetch loop drops a copy there even when the user only listed AI
134
- // agents (claude / cursor / ...) on the command line.
131
+ // --scheduled installs a system-wide cron / systemd / launchd entry that
132
+ // runs `ai-scanner scan endpoint-info` periodically as root, against an
133
+ // agent-independent binary at /opt/coworker/aidr/bin/ai-scanner. Auto-add
134
+ // the standalone install so the binary fetch loop drops a copy there
135
+ // even when the user only listed AI agents (claude / cursor / ...) on
136
+ // the command line.
135
137
  if (opts.scheduled && !agents.includes("standalone")) {
136
138
  agents.push("standalone");
137
139
  }
@@ -142,6 +144,29 @@ async function cmdInstall(opts) {
142
144
  process.exit(1);
143
145
  }
144
146
 
147
+ // --scheduled の system path 書き込みは root 必須。
148
+ // - TTY あり (対話 shell): `sudo -E` で自分自身を re-exec、password prompt が出る
149
+ // - TTY なし (CI / pipe): sudo は対話的に password を聞けないので fail-fast 案内
150
+ // smoke / unit test では `AI_SCANNER_FAKE_ROOT=1` を設定してこのチェックを skip 可能。
151
+ if (opts.scheduled && !opts.dryRun
152
+ && process.getuid && process.getuid() !== 0
153
+ && process.env.AI_SCANNER_FAKE_ROOT !== "1") {
154
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
155
+ if (interactive) {
156
+ console.error("--scheduled requires root. Re-running via sudo (you may be prompted for your password)...");
157
+ const result = spawnSync(
158
+ "sudo",
159
+ ["-E", process.execPath, ...process.argv.slice(1)],
160
+ { stdio: "inherit" }
161
+ );
162
+ process.exit(result.status ?? 1);
163
+ }
164
+ console.error("Error: --scheduled requires root privileges (writes /opt/coworker/aidr, /etc/systemd/system, /etc/aidr).");
165
+ console.error("Re-run with sudo (non-interactive shells cannot prompt for password):");
166
+ console.error(` sudo -E npx @coworker-jp/aidr install --scheduled --key <ak_xxx>`);
167
+ process.exit(2);
168
+ }
169
+
145
170
  // Confirmation for existing shared settings.json (claude / gemini).
146
171
  // --yes bypasses; --dry-run doesn't write so skip; otherwise prompt on TTY,
147
172
  // error if non-interactive (safer than silently merging in CI).
@@ -171,9 +196,16 @@ async function cmdInstall(opts) {
171
196
  }
172
197
 
173
198
  if (!opts.skipBinary && !opts.dryRun) {
199
+ const { SYSTEM_BIN_DIR } = await import("./scheduled.mjs");
174
200
  const agentBinDirs = [];
175
201
  for (const name of agents) {
176
202
  const mod = await loadAgent(name);
203
+ // --scheduled 時の standalone は system path (/opt/coworker/aidr/bin)
204
+ // に置く。それ以外は従来通り per-user の ~/.<agent>/bin。
205
+ if (name === "standalone" && opts.scheduled) {
206
+ agentBinDirs.push(SYSTEM_BIN_DIR);
207
+ continue;
208
+ }
177
209
  const agentDir = mod.meta.agentDir;
178
210
  if (!agentDir) continue;
179
211
  agentBinDirs.push(path.join(home, agentDir, "bin"));
@@ -252,16 +284,11 @@ async function cmdInstall(opts) {
252
284
 
253
285
  if (opts.scheduled && !opts.dryRun) {
254
286
  const { installScheduled } = await import("./scheduled.mjs");
255
- // Always point the cron / systemd / launchd entry at the standalone
256
- // binary (~/.aidr/bin/ai-scanner). It is agent-independent, self-updates
257
- // on every endpoint-info run, and survives an individual agent's removal.
258
- const standalone = await loadAgent("standalone");
259
- const binaryPath = path.join(home, standalone.meta.agentDir, "bin", "ai-scanner");
287
+ // Schedule entries always point at /opt/coworker/aidr/bin/ai-scanner
288
+ // (system-wide, root-owned). The path is hard-coded inside scheduled.mjs.
260
289
  try {
261
290
  await installScheduled({
262
- home,
263
291
  accessKey: opts.key,
264
- binaryPath,
265
292
  dryRun: opts.dryRun,
266
293
  interval: intervalHours,
267
294
  });
@@ -282,6 +309,19 @@ async function cmdInstall(opts) {
282
309
  }
283
310
  }
284
311
 
312
+ // Auto-sudo re-exec wrote per-agent files as root into the original user's
313
+ // home / cwd. Hand them back so the user can manage them without sudo on
314
+ // subsequent runs. System-wide writes under /opt + /etc are intentionally
315
+ // left root-owned by skipping anything outside `base`.
316
+ if (isRunningUnderSudo() && !opts.dryRun) {
317
+ for (const r of installResults) {
318
+ const mod = await loadAgent(r.name);
319
+ const agentDir = mod.meta.agentDir;
320
+ if (!agentDir) continue;
321
+ await chownToCaller(path.join(base, agentDir));
322
+ }
323
+ }
324
+
285
325
  if (restartAgents.length > 0 && !opts.dryRun) {
286
326
  const list = restartAgents.join(", ");
287
327
  console.log("");
@@ -294,7 +334,7 @@ async function cmdInstall(opts) {
294
334
  }
295
335
 
296
336
  async function cmdUninstall(opts) {
297
- const home = process.env.HOME || os.homedir();
337
+ const home = resolveCallerHome();
298
338
  const scope = opts.scope || "user";
299
339
  const base = scope === "user" ? home : process.cwd();
300
340
 
@@ -409,16 +449,38 @@ async function cmdUninstall(opts) {
409
449
  }
410
450
  }));
411
451
 
452
+ // Schedule entries live in system paths (/etc, /opt, /Library/LaunchDaemons)
453
+ // and need root to remove. Mirror cmdInstall's auto-sudo: TTY なら sudo に
454
+ // re-exec、non-TTY は guidance を出して per-user 部分だけ撤去で完了。
412
455
  try {
413
- const { uninstallScheduled } = await import("./scheduled.mjs");
414
- await uninstallScheduled({ home, dryRun: opts.dryRun });
456
+ const { uninstallScheduled, isScheduledInstalled } = await import("./scheduled.mjs");
457
+ if (await isScheduledInstalled()) {
458
+ const isRoot = process.env.AI_SCANNER_FAKE_ROOT === "1"
459
+ || (process.getuid && process.getuid() === 0);
460
+ if (!isRoot) {
461
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
462
+ if (interactive) {
463
+ console.error("Scheduled entry detected. Re-running via sudo to remove (you may be prompted for your password)...");
464
+ const result = spawnSync(
465
+ "sudo",
466
+ ["-E", process.execPath, ...process.argv.slice(1)],
467
+ { stdio: "inherit" }
468
+ );
469
+ process.exit(result.status ?? 0);
470
+ }
471
+ console.error("Note: scheduled entry detected but uninstall needs root. Re-run:");
472
+ console.error(` sudo -E npx @coworker-jp/aidr uninstall`);
473
+ } else {
474
+ await uninstallScheduled({ dryRun: opts.dryRun });
475
+ }
476
+ }
415
477
  } catch (e) {
416
478
  console.error(`scheduled uninstall warning: ${e.message}`);
417
479
  }
418
480
  }
419
481
 
420
482
  async function cmdDoctor() {
421
- const home = process.env.HOME || os.homedir();
483
+ const home = resolveCallerHome();
422
484
  console.log(`home: ${home}`);
423
485
  console.log(`node: ${process.version}`);
424
486
  console.log(`platform: ${process.platform}/${process.arch}`);
@@ -455,7 +517,7 @@ export async function run(argv) {
455
517
  program
456
518
  .name("aidr")
457
519
  .description("AIDR setup CLI — installs ai-scanner hooks for AI coding agents")
458
- .version("0.0.1");
520
+ .version("0.0.3");
459
521
 
460
522
  program
461
523
  .command("install")
package/src/fs-utils.mjs CHANGED
@@ -127,6 +127,10 @@ export async function removeIfExists(p) {
127
127
  export async function writeJsonMerge(target, ourObject, mergeFn, {
128
128
  dryRun = false,
129
129
  mode = 0o644,
130
+ // `backup: false` で書き込み前バックアップを skip。リポジトリ git 配下に
131
+ // 入る project scope では、git 自体が history を持つので `<target>.backup-<ts>`
132
+ // が単に noise を増やす。user scope (~/.<agent>/...) では既定 true のまま残す。
133
+ backup = true,
130
134
  stdout = process.stdout,
131
135
  stderr = process.stderr,
132
136
  } = {}) {
@@ -160,16 +164,23 @@ export async function writeJsonMerge(target, ourObject, mergeFn, {
160
164
  return { written: false, path: target, backupPath: null, unchanged: true };
161
165
  }
162
166
 
163
- if (stat && !dryRun) {
167
+ if (stat && backup && !dryRun) {
164
168
  backupPath = backupPathFor(target);
165
169
  await fsp.copyFile(target, backupPath);
166
170
  }
167
171
  if (stat) {
168
- stderr.write(
169
- `WARNING: ${target} already exists. ` +
170
- `${dryRun ? "(dry-run: would back up)" : `Backed up to ${backupPath}.`}\n` +
171
- `Merging ai-scanner env/hooks; your other settings are preserved.\n`
172
- );
172
+ if (backup) {
173
+ stderr.write(
174
+ `WARNING: ${target} already exists. ` +
175
+ `${dryRun ? "(dry-run: would back up)" : `Backed up to ${backupPath}.`}\n` +
176
+ `Merging ai-scanner env/hooks; your other settings are preserved.\n`
177
+ );
178
+ } else {
179
+ stderr.write(
180
+ `Updating ${target} (no backup; project scope is git-tracked).\n` +
181
+ `Merging ai-scanner env/hooks; your other settings are preserved.\n`
182
+ );
183
+ }
173
184
  }
174
185
 
175
186
  if (dryRun) {
package/src/scheduled.mjs CHANGED
@@ -1,62 +1,105 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
- import os from "os";
4
3
  import { execFile } from "child_process";
5
4
  import { promisify } from "util";
6
5
 
7
6
  const execFileP = promisify(execFile);
8
7
 
9
- function systemdDir(home) {
10
- return path.join(home, ".config", "systemd", "user");
8
+ // System-wide install paths (root-only). The schedule entry runs as root so
9
+ // endpoint-info can collect host-wide inventory (process list, SUID, /etc/*).
10
+ // All paths are owned by root:root; the access-key env file is mode 0600.
11
+ //
12
+ // `AIDR_SCHEDULED_ROOT` env (smoke / unit test only) prefixes every path so
13
+ // they land under a tmpdir like `/tmp/test123/opt/coworker/aidr/bin/...`
14
+ // without ever touching real /etc or /opt. Production should never set this.
15
+ const ROOT_PREFIX = process.env.AIDR_SCHEDULED_ROOT || "";
16
+
17
+ export const SYSTEM_BIN_DIR = ROOT_PREFIX + "/opt/coworker/aidr/bin";
18
+ export const SYSTEM_BIN = path.join(SYSTEM_BIN_DIR, "ai-scanner");
19
+ export const SYSTEM_OPENGREP = path.join(SYSTEM_BIN_DIR, "opengrep");
20
+ export const SYSTEM_USR_LOCAL_BIN = ROOT_PREFIX + "/usr/local/bin/ai-scanner";
21
+ const SYSTEM_ENV_DIR = ROOT_PREFIX + "/etc/aidr";
22
+ export const SYSTEM_ENV_FILE = path.join(SYSTEM_ENV_DIR, "aidr.env");
23
+
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
+ // macOS launchd (LaunchDaemons run as root, available pre-login)
30
+ export const LAUNCHD_PLIST = ROOT_PREFIX + "/Library/LaunchDaemons/jp.coworker.aidr.scheduled.plist";
31
+ export const LAUNCHD_WRAPPER = path.join(SYSTEM_BIN_DIR, "aidr-scheduled-wrapper.sh");
32
+
33
+ // Linux cron fallback (system cron, runs as root)
34
+ export const CRON_FILE = ROOT_PREFIX + "/etc/cron.d/aidr-scheduled";
35
+
36
+ function isRoot() {
37
+ if (process.env.AI_SCANNER_FAKE_ROOT === "1") return true;
38
+ return Boolean(process.getuid && process.getuid() === 0);
11
39
  }
12
- function systemdService(home) { return path.join(systemdDir(home), "aidr-scheduled.service"); }
13
- function systemdTimer(home) { return path.join(systemdDir(home), "aidr-scheduled.timer"); }
14
- function systemdEnv(home) { return path.join(systemdDir(home), "aidr-scheduled.env"); }
15
- function launchdPlist(home) { return path.join(home, "Library", "LaunchAgents", "jp.coworker.aidr.scheduled.plist"); }
16
40
 
17
41
  async function exists(p) {
18
42
  try { await fs.access(p); return true; } catch { return false; }
19
43
  }
20
44
 
21
- export async function isScheduledInstalled(home) {
45
+ export async function isScheduledInstalled() {
22
46
  return (
23
- await exists(systemdTimer(home)) ||
24
- await exists(launchdPlist(home))
47
+ await exists(SYSTEMD_TIMER) ||
48
+ await exists(LAUNCHD_PLIST) ||
49
+ await exists(CRON_FILE)
25
50
  );
26
51
  }
27
52
 
28
53
  export async function installScheduled(opts) {
29
- const { home = os.homedir(), accessKey, binaryPath,
30
- dryRun = false, interval = 4 } = opts;
54
+ const { accessKey, dryRun = false, interval = 4 } = opts;
31
55
  const noActivate = opts.noActivate || process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE === "1";
32
56
 
33
- // Schedule entry is agent-independent (cron points at ~/.aidr/bin/ai-scanner
34
- // and the binary self-updates on each run). If a previous schedule entry
35
- // exists, replace it unconditionally — the only thing that could differ is
36
- // the binary path or interval, both of which the new install owns.
37
- if (await isScheduledInstalled(home)) {
38
- await uninstallScheduled({ home, dryRun, noActivate });
57
+ if (!isRoot()) {
58
+ throw new Error("installScheduled requires root privileges (system paths under /etc and /opt)");
39
59
  }
40
60
 
61
+ // Always use the canonical system binary path; the binary is placed there by
62
+ // the standalone agent's binary fetch step before this function runs.
63
+ const binaryPath = SYSTEM_BIN;
64
+
65
+ // Replace any prior schedule entry. Avoids duplicate ExecStart / interval drift.
66
+ if (await isScheduledInstalled()) {
67
+ await uninstallScheduled({ dryRun, noActivate });
68
+ }
69
+
70
+ // Write the shared env file first; both Linux and macOS paths reference it.
71
+ await writeEnvFile(accessKey, dryRun);
72
+
41
73
  const platform = process.platform;
42
74
  if (platform === "linux") {
43
- await installSystemd({ home, accessKey, binaryPath, dryRun, noActivate, interval });
75
+ await installSystemd({ dryRun, noActivate, interval, binaryPath });
44
76
  } else if (platform === "darwin") {
45
- await installLaunchd({ home, accessKey, binaryPath, dryRun, noActivate, interval });
77
+ await installLaunchd({ dryRun, noActivate, interval, binaryPath });
46
78
  } else {
47
- await installCrontab({ binaryPath, dryRun, interval });
79
+ await installCronDir({ binaryPath, dryRun, interval });
48
80
  }
49
81
  }
50
82
 
51
- async function installSystemd({ home, accessKey, binaryPath, dryRun, noActivate, interval = 4 }) {
52
- const dir = systemdDir(home);
83
+ async function writeEnvFile(accessKey, dryRun) {
84
+ const envContent = `AI_SCANNER_ACCESS_KEY=${accessKey}\n`;
85
+ if (dryRun) {
86
+ console.log("[dry-run] Would create:", SYSTEM_ENV_FILE, "(mode 0600)");
87
+ return;
88
+ }
89
+ await fs.mkdir(SYSTEM_ENV_DIR, { recursive: true, mode: 0o755 });
90
+ await fs.writeFile(SYSTEM_ENV_FILE, envContent, { mode: 0o600 });
91
+ }
53
92
 
93
+ async function installSystemd({ binaryPath, dryRun, noActivate, interval = 4 }) {
54
94
  const serviceContent = `[Unit]
55
95
  Description=AIDR scheduled endpoint-info scan
96
+ After=network-online.target
97
+ Wants=network-online.target
56
98
 
57
99
  [Service]
58
100
  Type=oneshot
59
- EnvironmentFile=${systemdEnv(home)}
101
+ User=root
102
+ EnvironmentFile=${SYSTEM_ENV_FILE}
60
103
  ExecStart=${binaryPath} scan endpoint-info
61
104
  `;
62
105
 
@@ -72,32 +115,40 @@ Persistent=true
72
115
  WantedBy=timers.target
73
116
  `;
74
117
 
75
- const envContent = `AI_SCANNER_ACCESS_KEY=${accessKey}\n`;
76
-
77
118
  if (dryRun) {
78
- console.log("[dry-run] Would create:", systemdService(home), systemdTimer(home), systemdEnv(home));
119
+ console.log("[dry-run] Would create:", SYSTEMD_SERVICE, SYSTEMD_TIMER);
79
120
  return;
80
121
  }
81
122
 
82
- await fs.mkdir(dir, { recursive: true });
83
- await fs.writeFile(systemdService(home), serviceContent, { mode: 0o644 });
84
- await fs.writeFile(systemdTimer(home), timerContent, { mode: 0o644 });
85
- await fs.writeFile(systemdEnv(home), envContent, { mode: 0o600 });
123
+ await fs.mkdir(path.dirname(SYSTEMD_SERVICE), { recursive: true });
124
+ await fs.writeFile(SYSTEMD_SERVICE, serviceContent, { mode: 0o644 });
125
+ await fs.writeFile(SYSTEMD_TIMER, timerContent, { mode: 0o644 });
86
126
 
87
127
  if (!noActivate) {
88
128
  try {
89
- await execFileP("systemctl", ["--user", "daemon-reload"]);
90
- await execFileP("systemctl", ["--user", "enable", "--now", "aidr-scheduled.timer"]);
91
- console.log(`Scheduled scan enabled via systemd user timer (every ${interval}h)`);
129
+ await execFileP("systemctl", ["daemon-reload"]);
130
+ await execFileP("systemctl", ["enable", "--now", "aidr-scheduled.timer"]);
131
+ console.log(`Scheduled scan enabled via system systemd timer (every ${interval}h)`);
92
132
  } catch (e) {
93
- console.warn("Warning: systemctl --user failed:", e.message);
94
- console.warn("Timer files were written. Enable manually: systemctl --user enable --now aidr-scheduled.timer");
133
+ console.warn("Warning: systemctl failed:", e.message);
134
+ console.warn("Timer files were written. Enable manually: sudo systemctl enable --now aidr-scheduled.timer");
95
135
  }
96
136
  }
97
137
  }
98
138
 
99
- async function installLaunchd({ home, accessKey, binaryPath, dryRun, noActivate, interval = 4 }) {
100
- const dir = path.join(home, "Library", "LaunchAgents");
139
+ async function installLaunchd({ binaryPath, dryRun, noActivate, interval = 4 }) {
140
+ // launchd does not support EnvironmentFile, so we use a tiny wrapper that
141
+ // sources /etc/aidr/aidr.env and execs the binary. Plist itself stays 0644
142
+ // (launchctl bootstrap requires it readable); the secret lives in the env
143
+ // file at 0600.
144
+ const wrapperContent = `#!/bin/bash
145
+ # Generated by aidr install --scheduled. Sources the system env file and
146
+ # execs ai-scanner so the access key is not embedded in the LaunchDaemon plist.
147
+ set -a
148
+ . ${SYSTEM_ENV_FILE}
149
+ set +a
150
+ exec ${binaryPath} scan endpoint-info
151
+ `;
101
152
 
102
153
  const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
103
154
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -105,17 +156,12 @@ async function installLaunchd({ home, accessKey, binaryPath, dryRun, noActivate,
105
156
  <dict>
106
157
  <key>Label</key>
107
158
  <string>jp.coworker.aidr.scheduled</string>
159
+ <key>UserName</key>
160
+ <string>root</string>
108
161
  <key>ProgramArguments</key>
109
162
  <array>
110
- <string>${binaryPath}</string>
111
- <string>scan</string>
112
- <string>endpoint-info</string>
163
+ <string>${LAUNCHD_WRAPPER}</string>
113
164
  </array>
114
- <key>EnvironmentVariables</key>
115
- <dict>
116
- <key>AI_SCANNER_ACCESS_KEY</key>
117
- <string>${accessKey}</string>
118
- </dict>
119
165
  <key>StartInterval</key>
120
166
  <integer>${interval * 3600}</integer>
121
167
  <key>RunAtLoad</key>
@@ -125,95 +171,78 @@ async function installLaunchd({ home, accessKey, binaryPath, dryRun, noActivate,
125
171
  `;
126
172
 
127
173
  if (dryRun) {
128
- console.log("[dry-run] Would create:", launchdPlist(home));
174
+ console.log("[dry-run] Would create:", LAUNCHD_WRAPPER, LAUNCHD_PLIST);
129
175
  return;
130
176
  }
131
177
 
132
- await fs.mkdir(dir, { recursive: true });
133
- await fs.writeFile(launchdPlist(home), plistContent, { mode: 0o644 });
178
+ await fs.mkdir(path.dirname(LAUNCHD_WRAPPER), { recursive: true });
179
+ await fs.mkdir(path.dirname(LAUNCHD_PLIST), { recursive: true });
180
+ await fs.writeFile(LAUNCHD_WRAPPER, wrapperContent, { mode: 0o755 });
181
+ await fs.writeFile(LAUNCHD_PLIST, plistContent, { mode: 0o644 });
134
182
 
135
183
  if (!noActivate) {
136
184
  try {
137
- const uid = process.getuid?.() ?? (await execFileP("id", ["-u"])).stdout.trim();
138
- await execFileP("launchctl", ["bootstrap", `gui/${uid}`, launchdPlist(home)]);
139
- console.log(`Scheduled scan enabled via launchd (every ${interval}h)`);
185
+ await execFileP("launchctl", ["bootstrap", "system", LAUNCHD_PLIST]);
186
+ console.log(`Scheduled scan enabled via system LaunchDaemon (every ${interval}h)`);
140
187
  } catch (e) {
141
188
  console.warn("Warning: launchctl bootstrap failed:", e.message);
142
- console.warn("Plist written. Enable manually: launchctl load ~/Library/LaunchAgents/jp.coworker.aidr.scheduled.plist");
189
+ console.warn(`Plist written. Enable manually: sudo launchctl bootstrap system ${LAUNCHD_PLIST}`);
143
190
  }
144
191
  }
145
192
  }
146
193
 
147
- async function installCrontab({ binaryPath, dryRun, interval = 4 }) {
148
- const entry = `0 */${interval} * * * ${binaryPath} scan endpoint-info 2>/dev/null # aidr-scheduled\n`;
149
-
194
+ async function installCronDir({ binaryPath, dryRun, interval = 4 }) {
195
+ // /etc/cron.d entries run as the user named in the line. We embed the env
196
+ // file source via a small inline script.
197
+ const entry = `0 */${interval} * * * root . ${SYSTEM_ENV_FILE} && ${binaryPath} scan endpoint-info 2>/dev/null # aidr-scheduled
198
+ `;
150
199
  if (dryRun) {
151
- console.log("[dry-run] Would add crontab entry:", entry.trim());
200
+ console.log("[dry-run] Would create:", CRON_FILE);
152
201
  return;
153
202
  }
154
-
155
- let existing = "";
156
- try {
157
- const { stdout } = await execFileP("crontab", ["-l"]);
158
- existing = stdout;
159
- } catch { /* no crontab yet */ }
160
-
161
- if (existing.includes("aidr-scheduled")) return;
162
-
163
- const newCrontab = existing.endsWith("\n") || existing === ""
164
- ? existing + entry
165
- : existing + "\n" + entry;
166
-
167
- const child = execFile("crontab", ["-"]);
168
- child.stdin.write(newCrontab);
169
- child.stdin.end();
170
- await new Promise((resolve, reject) => child.on("close", c => c === 0 ? resolve() : reject(new Error(`crontab exit ${c}`))));
171
- console.log(`Scheduled scan added to user crontab (every ${interval}h)`);
203
+ await fs.mkdir(path.dirname(CRON_FILE), { recursive: true });
204
+ await fs.writeFile(CRON_FILE, entry, { mode: 0o644 });
205
+ console.log(`Scheduled scan added to ${CRON_FILE} (every ${interval}h)`);
172
206
  }
173
207
 
174
- export async function uninstallScheduled({ home = os.homedir(), dryRun = false, noActivate = false } = {}) {
208
+ export async function uninstallScheduled({ dryRun = false, noActivate = false } = {}) {
175
209
  noActivate = noActivate || process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE === "1";
176
210
 
177
- if (await exists(systemdTimer(home))) {
211
+ if (!isRoot()) {
212
+ throw new Error("uninstallScheduled requires root privileges");
213
+ }
214
+
215
+ if (await exists(SYSTEMD_TIMER)) {
178
216
  if (!dryRun && !noActivate) {
179
- try { await execFileP("systemctl", ["--user", "disable", "--now", "aidr-scheduled.timer"]); } catch { /* ignore */ }
217
+ try { await execFileP("systemctl", ["disable", "--now", "aidr-scheduled.timer"]); } catch { /* ignore */ }
180
218
  }
181
- for (const f of [systemdService(home), systemdTimer(home), systemdEnv(home)]) {
219
+ for (const f of [SYSTEMD_SERVICE, SYSTEMD_TIMER]) {
182
220
  if (!dryRun) { try { await fs.unlink(f); } catch { /* ignore */ } }
183
221
  else { console.log("[dry-run] Would remove:", f); }
184
222
  }
185
223
  }
186
224
 
187
- if (await exists(launchdPlist(home))) {
225
+ if (await exists(LAUNCHD_PLIST)) {
188
226
  if (!dryRun && !noActivate) {
189
- try {
190
- const uid = process.getuid?.() ?? (await execFileP("id", ["-u"])).stdout.trim();
191
- await execFileP("launchctl", ["bootout", `gui/${uid}`, launchdPlist(home)]);
192
- } catch { /* ignore */ }
227
+ try { await execFileP("launchctl", ["bootout", "system", LAUNCHD_PLIST]); } catch { /* ignore */ }
228
+ }
229
+ if (!dryRun) {
230
+ try { await fs.unlink(LAUNCHD_PLIST); } catch { /* ignore */ }
231
+ try { await fs.unlink(LAUNCHD_WRAPPER); } catch { /* ignore */ }
232
+ } else {
233
+ console.log("[dry-run] Would remove:", LAUNCHD_PLIST, LAUNCHD_WRAPPER);
193
234
  }
194
- if (!dryRun) { try { await fs.unlink(launchdPlist(home)); } catch { /* ignore */ } }
195
- else { console.log("[dry-run] Would remove:", launchdPlist(home)); }
196
235
  }
197
236
 
198
- try {
199
- const { stdout } = await execFileP("crontab", ["-l"]);
200
- const filtered = stdout.split("\n").filter(l => !l.includes("aidr-scheduled")).join("\n");
201
- if (filtered !== stdout) {
202
- if (!dryRun) {
203
- const child = execFile("crontab", ["-"]);
204
- child.stdin.write(filtered + (filtered.endsWith("\n") ? "" : "\n"));
205
- child.stdin.end();
206
- await new Promise(r => child.on("close", r));
207
- } else {
208
- console.log("[dry-run] Would remove aidr-scheduled from crontab");
209
- }
210
- }
211
- } catch { /* no crontab */ }
212
- }
237
+ if (await exists(CRON_FILE)) {
238
+ if (!dryRun) { try { await fs.unlink(CRON_FILE); } catch { /* ignore */ } }
239
+ else { console.log("[dry-run] Would remove:", CRON_FILE); }
240
+ }
213
241
 
214
- function promptUser(question) {
215
- return new Promise(resolve => {
216
- process.stdout.write(question);
217
- process.stdin.once("data", d => resolve(d.toString().trim()));
218
- });
242
+ // Remove the env file last so an aborted uninstall (mid-systemctl) doesn't
243
+ // strand a service file that still references a missing env file.
244
+ if (await exists(SYSTEM_ENV_FILE)) {
245
+ if (!dryRun) { try { await fs.unlink(SYSTEM_ENV_FILE); } catch { /* ignore */ } }
246
+ else { console.log("[dry-run] Would remove:", SYSTEM_ENV_FILE); }
247
+ }
219
248
  }
@@ -0,0 +1,111 @@
1
+ // SUDO_USER-aware HOME resolution and ownership helpers.
2
+ //
3
+ // `aidr install --scheduled` auto-escalates via `sudo -E` (cli.mjs).
4
+ // After sudo, the new node process runs as root with HOME reset to /root by
5
+ // the default `set_home` policy. Without correction, every path derived from
6
+ // HOME (e.g. ~/.claude/settings.json under `--scope user`) would land in
7
+ // /root instead of the original user's home, AND every file we write would
8
+ // be root-owned even though the user expects to manage them.
9
+ //
10
+ // We restore the caller's identity via the SUDO_USER / SUDO_UID / SUDO_GID
11
+ // vars sudo always sets, and chown the user-scope writes back to that user
12
+ // when the install completes.
13
+
14
+ import fs from "node:fs";
15
+ import fsp from "node:fs/promises";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ /**
21
+ * Look up `username`'s home directory.
22
+ * 1. /etc/passwd (Linux always; macOS for system users only)
23
+ * 2. macOS dscl (regular macOS users live in Open Directory)
24
+ * Returns null if not found.
25
+ *
26
+ * Test override: AIDR_PASSWD_FILE points at an alternate passwd-format file.
27
+ */
28
+ export function lookupUserHome(username) {
29
+ if (!username) return null;
30
+ const passwdPath = process.env.AIDR_PASSWD_FILE || "/etc/passwd";
31
+ try {
32
+ const passwd = fs.readFileSync(passwdPath, "utf8");
33
+ for (const line of passwd.split("\n")) {
34
+ const f = line.split(":");
35
+ if (f[0] === username && f[5]) return f[5];
36
+ }
37
+ } catch { /* fall through */ }
38
+
39
+ if (process.platform === "darwin" && !process.env.AIDR_PASSWD_FILE) {
40
+ try {
41
+ const out = execFileSync(
42
+ "dscl", [".", "-read", `/Users/${username}`, "NFSHomeDirectory"],
43
+ { encoding: "utf8", timeout: 2000 }
44
+ );
45
+ const m = out.match(/NFSHomeDirectory:\s*(.+)/);
46
+ if (m) return m[1].trim();
47
+ } catch { /* fall through */ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Resolve the calling user's home.
54
+ * - If running as root with SUDO_USER set, use SUDO_USER's home (auto-sudo case).
55
+ * - Otherwise fall back to $HOME / os.homedir().
56
+ *
57
+ * Test override: AIDR_FORCE_HOME bypasses everything.
58
+ */
59
+ export function resolveCallerHome() {
60
+ if (process.env.AIDR_FORCE_HOME) return process.env.AIDR_FORCE_HOME;
61
+ if (isRunningUnderSudo()) {
62
+ const sudoHome = lookupUserHome(process.env.SUDO_USER);
63
+ if (sudoHome) return sudoHome;
64
+ }
65
+ return process.env.HOME || os.homedir();
66
+ }
67
+
68
+ /**
69
+ * True when we're running as root *and* SUDO_USER is set — i.e. the auto-sudo
70
+ * re-exec path. Plain `su` or login-as-root sets neither, and is treated as
71
+ * intentional root usage (no chown back).
72
+ */
73
+ export function isRunningUnderSudo() {
74
+ if (process.platform === "win32") return false;
75
+ if (!process.getuid || process.getuid() !== 0) return false;
76
+ return Boolean(process.env.SUDO_USER);
77
+ }
78
+
79
+ /**
80
+ * SUDO_UID / SUDO_GID parsed as integers, or null when not under sudo.
81
+ */
82
+ export function callerIds() {
83
+ if (!isRunningUnderSudo()) return null;
84
+ const uid = parseInt(process.env.SUDO_UID || "", 10);
85
+ const gid = parseInt(process.env.SUDO_GID || "", 10);
86
+ if (Number.isInteger(uid) && Number.isInteger(gid)) return { uid, gid };
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Recursively chown a path tree to the caller (SUDO_USER) when running under
92
+ * auto-sudo. No-op when not under sudo or on Windows. Errors per-entry are
93
+ * swallowed — worst case the user fixes ownership manually with a single
94
+ * `chown -R`. Symlinks are chowned via lchown (don't follow).
95
+ */
96
+ export async function chownToCaller(targetPath) {
97
+ const ids = callerIds();
98
+ if (!ids) return;
99
+ await chownTree(targetPath, ids.uid, ids.gid);
100
+ }
101
+
102
+ async function chownTree(p, uid, gid) {
103
+ let st;
104
+ try { st = await fsp.lstat(p); } catch { return; }
105
+ try { await fsp.lchown(p, uid, gid); } catch { /* ignore */ }
106
+ if (st.isDirectory()) {
107
+ let entries;
108
+ try { entries = await fsp.readdir(p); } catch { return; }
109
+ await Promise.all(entries.map((e) => chownTree(path.join(p, e), uid, gid)));
110
+ }
111
+ }
package/src/templates.mjs CHANGED
@@ -300,7 +300,8 @@ export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detectio
300
300
 
301
301
  ${SCAN_HELPERS}
302
302
 
303
- jq -r '.tool_response // ""' | run_scanner scan websearch`;
303
+ jq -r '.tool_response // ""' | run_scanner scan websearch
304
+ `;
304
305
 
305
306
  export const LOG_WEB_URLS_SH = `#!/bin/bash
306
307
  # Log URLs from WebSearch and WebFetch tool calls