@coworker-jp/aidr 0.0.2 → 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/cli.mjs +18 -4
- package/src/sudo-user.mjs +111 -0
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -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);
|
|
@@ -308,6 +309,19 @@ async function cmdInstall(opts) {
|
|
|
308
309
|
}
|
|
309
310
|
}
|
|
310
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
|
+
|
|
311
325
|
if (restartAgents.length > 0 && !opts.dryRun) {
|
|
312
326
|
const list = restartAgents.join(", ");
|
|
313
327
|
console.log("");
|
|
@@ -320,7 +334,7 @@ async function cmdInstall(opts) {
|
|
|
320
334
|
}
|
|
321
335
|
|
|
322
336
|
async function cmdUninstall(opts) {
|
|
323
|
-
const home =
|
|
337
|
+
const home = resolveCallerHome();
|
|
324
338
|
const scope = opts.scope || "user";
|
|
325
339
|
const base = scope === "user" ? home : process.cwd();
|
|
326
340
|
|
|
@@ -466,7 +480,7 @@ async function cmdUninstall(opts) {
|
|
|
466
480
|
}
|
|
467
481
|
|
|
468
482
|
async function cmdDoctor() {
|
|
469
|
-
const home =
|
|
483
|
+
const home = resolveCallerHome();
|
|
470
484
|
console.log(`home: ${home}`);
|
|
471
485
|
console.log(`node: ${process.version}`);
|
|
472
486
|
console.log(`platform: ${process.platform}/${process.arch}`);
|
|
@@ -503,7 +517,7 @@ export async function run(argv) {
|
|
|
503
517
|
program
|
|
504
518
|
.name("aidr")
|
|
505
519
|
.description("AIDR setup CLI — installs ai-scanner hooks for AI coding agents")
|
|
506
|
-
.version("0.0.
|
|
520
|
+
.version("0.0.3");
|
|
507
521
|
|
|
508
522
|
program
|
|
509
523
|
.command("install")
|
|
@@ -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
|
+
}
|