@coworker-jp/aidr 0.0.8 → 0.1.0

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.8",
3
+ "version": "0.1.0",
4
4
  "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -589,7 +589,7 @@ export async function run(argv) {
589
589
  program
590
590
  .name("aidr")
591
591
  .description("AIDR setup CLI — installs ai-scanner hooks for AI coding agents")
592
- .version("0.0.6");
592
+ .version("0.1.0");
593
593
 
594
594
  program
595
595
  .command("install")
package/src/scheduled.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
- import { execFile } from "child_process";
3
+ import { execFile, spawnSync } from "child_process";
4
4
  import { promisify } from "util";
5
5
 
6
6
  const execFileP = promisify(execFile);
@@ -177,15 +177,82 @@ export async function installScheduled(opts) {
177
177
  }
178
178
 
179
179
  async function writeEnvFile(accessKey, dryRun) {
180
- const envContent = `AI_SCANNER_ACCESS_KEY=${accessKey}\n`;
180
+ // Capture the install-time actor identity here (NOT at scheduled
181
+ // run-time) because the scheduler runs as root with no Apple ID, no
182
+ // git config, and $USER="root". Without this, the Rust scanner would
183
+ // emit snapshots labelled "unknown" (or worse, fall through to
184
+ // customer_email — the bug this whole helper exists to fix).
185
+ const actorId = resolveInstallerActorId();
186
+ let envContent = `AI_SCANNER_ACCESS_KEY=${accessKey}\n`;
187
+ if (actorId) envContent += `AI_SCANNER_ACTOR_ID=${actorId}\n`;
181
188
  if (dryRun) {
182
189
  console.log("[dry-run] Would create:", SYSTEM_ENV_FILE, "(mode 0600)");
190
+ if (actorId) console.log("[dry-run] actor_id captured:", actorId);
183
191
  return;
184
192
  }
185
193
  await fs.mkdir(SYSTEM_ENV_DIR, { recursive: true, mode: 0o755 });
186
194
  await fs.writeFile(SYSTEM_ENV_FILE, envContent, { mode: 0o600 });
187
195
  }
188
196
 
197
+ /**
198
+ * Resolve the **pre-sudo user's** identity for embedding in
199
+ * AI_SCANNER_ACTOR_ID at install time. Order, per user spec:
200
+ * 1. SUDO_USER's git config user.email (run as that user — root has
201
+ * its own (usually empty) git config that we must not return)
202
+ * 2. macOS: SUDO_USER's Apple ID (`defaults read MobileMeAccounts`)
203
+ * 3. SUDO_USER itself (the Unix username)
204
+ *
205
+ * Returns `null` when none of the above succeed (e.g. install was run
206
+ * as bare root with no SUDO_USER). The caller writes nothing in that
207
+ * case; the Rust scanner then surfaces "unknown" instead of mislabeling
208
+ * the snapshot with the user-portal login email.
209
+ *
210
+ * Test override: AIDR_INSTALLER_ACTOR_ID env bypasses every detection
211
+ * step (used by Node unit tests so they don't have to mock spawnSync).
212
+ */
213
+ export function resolveInstallerActorId() {
214
+ if (process.env.AIDR_INSTALLER_ACTOR_ID) {
215
+ return process.env.AIDR_INSTALLER_ACTOR_ID || null;
216
+ }
217
+ const sudoUser = process.env.SUDO_USER;
218
+ if (!sudoUser) return null;
219
+
220
+ // 1. git config user.email — run AS the SUDO_USER so we read their
221
+ // ~/.gitconfig, not /root/.gitconfig (which is normally empty).
222
+ // --global is intentional: per-repo configs aren't relevant for
223
+ // a host-level identity. timeout caps a hung git invocation.
224
+ const gitRes = spawnSync(
225
+ "sudo",
226
+ ["-u", sudoUser, "git", "config", "--global", "--get", "user.email"],
227
+ { encoding: "utf8", timeout: 5000 },
228
+ );
229
+ if (gitRes.status === 0) {
230
+ const email = (gitRes.stdout || "").trim();
231
+ if (email) return email;
232
+ }
233
+
234
+ // 2. macOS Apple ID. Mirrors the Rust binary's parse_apple_id: read
235
+ // the first AccountID line from MobileMeAccounts. Must run as the
236
+ // SUDO_USER — root has no MobileMeAccounts plist of its own.
237
+ if (process.platform === "darwin") {
238
+ const dRes = spawnSync(
239
+ "sudo",
240
+ ["-u", sudoUser, "defaults", "read", "MobileMeAccounts"],
241
+ { encoding: "utf8", timeout: 5000 },
242
+ );
243
+ if (dRes.status === 0) {
244
+ const m = (dRes.stdout || "").match(/AccountID\s*=\s*"([^"]+)"/);
245
+ const id = m && m[1] ? m[1].trim() : "";
246
+ if (id) return id;
247
+ }
248
+ }
249
+
250
+ // 3. Username fallback. Better than "unknown" because at least it
251
+ // ties the snapshot to a Unix account on the host, but it loses
252
+ // the cross-host identity that an email gives us.
253
+ return sudoUser;
254
+ }
255
+
189
256
  async function installSystemd({ binaryPath, dryRun, noActivate, interval = 4 }) {
190
257
  // StandardOutput=append: requires systemd 240+ (released 2018). Modern
191
258
  // distros (RHEL 9 / Ubuntu 20.04+ / Debian 11+) all support it. Older
@@ -237,7 +304,7 @@ WantedBy=timers.target
237
304
  }
238
305
  }
239
306
 
240
- async function installLaunchd({ binaryPath, dryRun, noActivate, interval = 4 }) {
307
+ export async function installLaunchd({ binaryPath, dryRun, noActivate, interval = 4 }) {
241
308
  // launchd does not support EnvironmentFile, so we use a tiny wrapper that
242
309
  // sources /etc/aidr/aidr.env and execs the binary. Plist itself stays 0644
243
310
  // (launchctl bootstrap requires it readable); the secret lives in the env
@@ -248,6 +315,13 @@ async function installLaunchd({ binaryPath, dryRun, noActivate, interval = 4 })
248
315
  set -a
249
316
  . ${SYSTEM_ENV_FILE}
250
317
  set +a
318
+ # launchd starts daemons with a narrow default PATH (/usr/bin:/bin:/usr/sbin:/sbin)
319
+ # that omits Homebrew. Without this, scan endpoint-info silently misses brew
320
+ # packages because Command::new("brew") spawn fails — gem still works because
321
+ # /usr/bin/gem is on the default PATH. Both Apple Silicon (/opt/homebrew/bin)
322
+ # and Intel (/usr/local/bin) prefixes are safe to prepend on either arch since
323
+ # missing dirs in PATH are simply ignored at lookup time.
324
+ export PATH="/opt/homebrew/bin:/usr/local/bin:/opt/homebrew/sbin:/usr/local/sbin:\${PATH:-/usr/bin:/bin:/usr/sbin:/sbin}"
251
325
  exec ${binaryPath} scan endpoint-info
252
326
  `;
253
327
 
package/src/sentinel.mjs CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  ensureSystemLogDir,
23
23
  writeLogrotateConfig,
24
24
  removeSystemLog,
25
+ resolveInstallerActorId,
25
26
  } from "./scheduled.mjs";
26
27
 
27
28
  const execFileP = promisify(execFile);
@@ -138,13 +139,30 @@ export async function installSentinel(opts = {}) {
138
139
  await ensureSystemLogDir(SENTINEL_LOG_FILE, { dryRun });
139
140
  await writeLogrotateConfig({ dryRun });
140
141
 
141
- // 3. Write env file (append AI_SCANNER_ACCESS_KEY if missing)
142
+ // 3. Write env file (append AI_SCANNER_ACCESS_KEY + AI_SCANNER_ACTOR_ID
143
+ // if missing). When --with-sentinel runs alongside --scheduled,
144
+ // scheduled.mjs's writeEnvFile has already written both lines and
145
+ // we no-op. When --with-sentinel runs standalone (no --scheduled),
146
+ // we are the only writer of aidr.env and must capture the
147
+ // pre-sudo SUDO_USER identity ourselves; otherwise the daemon
148
+ // would report `unknown` actor (or worse, the customer_email
149
+ // fallback before that branch was removed).
142
150
  if (accessKey && !dryRun) {
143
151
  let content = "";
144
152
  try { content = await fs.readFile(SYSTEM_ENV_FILE, "utf8"); } catch { /* first run */ }
153
+ let appended = "";
145
154
  if (!content.includes("AI_SCANNER_ACCESS_KEY")) {
155
+ appended += `\nAI_SCANNER_ACCESS_KEY=${accessKey}\n`;
156
+ }
157
+ if (!content.includes("AI_SCANNER_ACTOR_ID")) {
158
+ const actorId = resolveInstallerActorId();
159
+ if (actorId) {
160
+ appended += `AI_SCANNER_ACTOR_ID=${actorId}\n`;
161
+ }
162
+ }
163
+ if (appended) {
146
164
  await fs.mkdir(path.dirname(SYSTEM_ENV_FILE), { recursive: true });
147
- await fs.appendFile(SYSTEM_ENV_FILE, `\nAI_SCANNER_ACCESS_KEY=${accessKey}\n`);
165
+ await fs.appendFile(SYSTEM_ENV_FILE, appended);
148
166
  await fs.chmod(SYSTEM_ENV_FILE, 0o600);
149
167
  }
150
168
  }