@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 +1 -1
- package/src/agents/claude.mjs +1 -1
- package/src/agents/codex.mjs +1 -1
- package/src/agents/cursor.mjs +1 -1
- package/src/agents/gemini.mjs +2 -2
- package/src/agents/windsurf.mjs +1 -1
- package/src/cli.mjs +81 -19
- package/src/fs-utils.mjs +17 -6
- package/src/scheduled.mjs +135 -106
- package/src/sudo-user.mjs +111 -0
- package/src/templates.mjs +2 -1
package/package.json
CHANGED
package/src/agents/claude.mjs
CHANGED
|
@@ -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);
|
package/src/agents/codex.mjs
CHANGED
|
@@ -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
|
package/src/agents/cursor.mjs
CHANGED
|
@@ -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 = [
|
package/src/agents/gemini.mjs
CHANGED
|
@@ -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 {
|
package/src/agents/windsurf.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
131
|
-
// `ai-scanner scan endpoint-info` periodically against an
|
|
132
|
-
// binary at
|
|
133
|
-
// binary fetch loop drops a copy there
|
|
134
|
-
// agents (claude / cursor / ...) on
|
|
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
|
-
//
|
|
256
|
-
//
|
|
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 =
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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(
|
|
45
|
+
export async function isScheduledInstalled() {
|
|
22
46
|
return (
|
|
23
|
-
await exists(
|
|
24
|
-
await exists(
|
|
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 {
|
|
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
|
-
|
|
34
|
-
|
|
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({
|
|
75
|
+
await installSystemd({ dryRun, noActivate, interval, binaryPath });
|
|
44
76
|
} else if (platform === "darwin") {
|
|
45
|
-
await installLaunchd({
|
|
77
|
+
await installLaunchd({ dryRun, noActivate, interval, binaryPath });
|
|
46
78
|
} else {
|
|
47
|
-
await
|
|
79
|
+
await installCronDir({ binaryPath, dryRun, interval });
|
|
48
80
|
}
|
|
49
81
|
}
|
|
50
82
|
|
|
51
|
-
async function
|
|
52
|
-
const
|
|
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
|
-
|
|
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:",
|
|
119
|
+
console.log("[dry-run] Would create:", SYSTEMD_SERVICE, SYSTEMD_TIMER);
|
|
79
120
|
return;
|
|
80
121
|
}
|
|
81
122
|
|
|
82
|
-
await fs.mkdir(
|
|
83
|
-
await fs.writeFile(
|
|
84
|
-
await fs.writeFile(
|
|
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", ["
|
|
90
|
-
await execFileP("systemctl", ["
|
|
91
|
-
console.log(`Scheduled scan enabled via systemd
|
|
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
|
|
94
|
-
console.warn("Timer files were written. Enable manually: systemctl
|
|
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({
|
|
100
|
-
|
|
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>${
|
|
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:",
|
|
174
|
+
console.log("[dry-run] Would create:", LAUNCHD_WRAPPER, LAUNCHD_PLIST);
|
|
129
175
|
return;
|
|
130
176
|
}
|
|
131
177
|
|
|
132
|
-
await fs.mkdir(
|
|
133
|
-
await fs.
|
|
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
|
-
|
|
138
|
-
|
|
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(
|
|
189
|
+
console.warn(`Plist written. Enable manually: sudo launchctl bootstrap system ${LAUNCHD_PLIST}`);
|
|
143
190
|
}
|
|
144
191
|
}
|
|
145
192
|
}
|
|
146
193
|
|
|
147
|
-
async function
|
|
148
|
-
|
|
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
|
|
200
|
+
console.log("[dry-run] Would create:", CRON_FILE);
|
|
152
201
|
return;
|
|
153
202
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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({
|
|
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 (
|
|
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", ["
|
|
217
|
+
try { await execFileP("systemctl", ["disable", "--now", "aidr-scheduled.timer"]); } catch { /* ignore */ }
|
|
180
218
|
}
|
|
181
|
-
for (const f of [
|
|
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(
|
|
225
|
+
if (await exists(LAUNCHD_PLIST)) {
|
|
188
226
|
if (!dryRun && !noActivate) {
|
|
189
|
-
try {
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|