alvin-bot 4.26.0 → 5.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/CHANGELOG.md +115 -0
- package/README.md +98 -14
- package/bin/cli.js +95 -9
- package/dist/handlers/commands.js +26 -0
- package/dist/index.js +20 -0
- package/dist/services/permissions-wizard.js +291 -0
- package/dist/services/self-diagnosis.js +272 -0
- package/dist/services/sudo.js +66 -6
- package/dist/services/trends.js +309 -0
- package/package.json +3 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissions Wizard (5.1.0).
|
|
3
|
+
*
|
|
4
|
+
* Reality check up front: on macOS, TCC (Transparency, Consent, Control)
|
|
5
|
+
* permissions are architecturally NOT grantable by an app — no API, no
|
|
6
|
+
* AppleScript, no sudo trick, no signed-entitlement bypass for a normal
|
|
7
|
+
* Node CLI. The user has to toggle the switches in System Settings.
|
|
8
|
+
*
|
|
9
|
+
* What this wizard DOES do — make the toggling experience painless:
|
|
10
|
+
*
|
|
11
|
+
* 1. Detect every permission's current state
|
|
12
|
+
* 2. For each missing one: open the EXACT right Settings pane
|
|
13
|
+
* 3. Wait for the user to toggle (poll detect every 2 s)
|
|
14
|
+
* 4. Verify and move to the next
|
|
15
|
+
* 5. End with a clear summary of granted / skipped / still-missing
|
|
16
|
+
*
|
|
17
|
+
* Covered permissions (macOS):
|
|
18
|
+
* - sudo password (not TCC; stored in Keychain so brew/apt commands
|
|
19
|
+
* don't re-prompt — separate from TCC but bundled here for
|
|
20
|
+
* "one upfront onboarding" UX)
|
|
21
|
+
* - Full Disk Access (TCC — for protected dirs like ~/Library/Mail,
|
|
22
|
+
* ~/Documents when needed by skills/codex)
|
|
23
|
+
* - Automation (TCC — for osascript driving Apple Mail/Notes/Calendar)
|
|
24
|
+
* - Accessibility (TCC — for cliclick mouse/keyboard automation)
|
|
25
|
+
*
|
|
26
|
+
* On Linux: only sudo applies. The wizard reports a no-op for TCC steps.
|
|
27
|
+
*
|
|
28
|
+
* Opt-out: set ALVIN_DISABLE_SELF_PRESERVATION=true to silence everything,
|
|
29
|
+
* but this wizard never runs unless explicitly invoked — no startup hook.
|
|
30
|
+
*/
|
|
31
|
+
import { execSync } from "child_process";
|
|
32
|
+
import os from "os";
|
|
33
|
+
import { getSudoStatus, openSystemSettings, storePassword, verifyPassword, } from "./sudo.js";
|
|
34
|
+
const PLATFORM = os.platform();
|
|
35
|
+
export const PERMISSIONS = [
|
|
36
|
+
{
|
|
37
|
+
id: "sudo",
|
|
38
|
+
name: "Sudo / Admin Access",
|
|
39
|
+
why: "Lets Alvin run admin commands (brew install, apt-get, etc.) without re-prompting every time.",
|
|
40
|
+
platforms: ["darwin", "linux"],
|
|
41
|
+
detect: async (status) => {
|
|
42
|
+
const s = status ?? (await getSudoStatus());
|
|
43
|
+
if (!s.configured)
|
|
44
|
+
return { state: "missing", detail: "password not stored" };
|
|
45
|
+
if (!s.verified)
|
|
46
|
+
return { state: "missing", detail: "stored but doesn't authenticate" };
|
|
47
|
+
return { state: "granted" };
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "full-disk-access",
|
|
52
|
+
name: "Full Disk Access",
|
|
53
|
+
why: "Lets `node` (and anything it spawns — codex, file-skills, document tools) read protected directories like ~/Library/Mail, ~/Documents when skills need them.",
|
|
54
|
+
platforms: ["darwin"],
|
|
55
|
+
detect: async (status) => {
|
|
56
|
+
const s = status ?? (await getSudoStatus());
|
|
57
|
+
if (PLATFORM !== "darwin")
|
|
58
|
+
return { state: "n/a" };
|
|
59
|
+
return { state: s.permissions.fullDiskAccess ? "granted" : "missing" };
|
|
60
|
+
},
|
|
61
|
+
openPane: () => openSystemSettings("full-disk-access"),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "automation",
|
|
65
|
+
name: "Automation (Apple Events)",
|
|
66
|
+
why: "Lets osascript drive Apple Mail, Apple Notes, Calendar, Finder — used by the email-send, apple-notes, and several productivity skills.",
|
|
67
|
+
platforms: ["darwin"],
|
|
68
|
+
detect: async (status) => {
|
|
69
|
+
const s = status ?? (await getSudoStatus());
|
|
70
|
+
if (PLATFORM !== "darwin")
|
|
71
|
+
return { state: "n/a" };
|
|
72
|
+
const a = s.permissions.automation;
|
|
73
|
+
if (a === null || a === undefined)
|
|
74
|
+
return { state: "n/a" };
|
|
75
|
+
return { state: a ? "granted" : "missing" };
|
|
76
|
+
},
|
|
77
|
+
openPane: () => openSystemSettings("automation"),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "accessibility",
|
|
81
|
+
name: "Accessibility",
|
|
82
|
+
why: "Lets cliclick simulate mouse + keyboard input — used by some browser-automation and UI-testing skills.",
|
|
83
|
+
platforms: ["darwin"],
|
|
84
|
+
detect: async (status) => {
|
|
85
|
+
const s = status ?? (await getSudoStatus());
|
|
86
|
+
if (PLATFORM !== "darwin")
|
|
87
|
+
return { state: "n/a" };
|
|
88
|
+
if (s.accessibilityDetail === "cliclick-missing") {
|
|
89
|
+
return { state: "tool-missing", detail: "cliclick not installed (brew install cliclick)" };
|
|
90
|
+
}
|
|
91
|
+
return { state: s.permissions.accessibility ? "granted" : "missing" };
|
|
92
|
+
},
|
|
93
|
+
openPane: () => openSystemSettings("accessibility"),
|
|
94
|
+
prereq: "brew install cliclick",
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
/**
|
|
98
|
+
* Read-only — return the current state of every applicable permission.
|
|
99
|
+
* Used by `permissions status` and by the doctor command.
|
|
100
|
+
*/
|
|
101
|
+
export async function readPermissionsSnapshot() {
|
|
102
|
+
const status = await getSudoStatus();
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const perm of PERMISSIONS) {
|
|
105
|
+
if (!perm.platforms.includes(PLATFORM)) {
|
|
106
|
+
out.push({ permission: perm, state: "n/a", detail: `not applicable on ${PLATFORM}` });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const r = await perm.detect(status);
|
|
110
|
+
out.push({ permission: perm, ...r });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Wait up to `timeoutMs` for a permission to flip from missing→granted.
|
|
116
|
+
* Polls detect() every 2 seconds. Used after openPane() to give the
|
|
117
|
+
* user time to toggle the switch.
|
|
118
|
+
*/
|
|
119
|
+
async function waitForGrant(perm, timeoutMs) {
|
|
120
|
+
const deadline = Date.now() + timeoutMs;
|
|
121
|
+
while (Date.now() < deadline) {
|
|
122
|
+
const r = await perm.detect();
|
|
123
|
+
if (r.state === "granted")
|
|
124
|
+
return true;
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Run the full guided permissions wizard. Sequential per permission.
|
|
131
|
+
*
|
|
132
|
+
* Behaviour per permission:
|
|
133
|
+
* - already granted → skip silently (one-liner)
|
|
134
|
+
* - tool-missing → tell user the prereq, offer to skip
|
|
135
|
+
* - missing (TCC) → open Settings pane, wait up to 60 s, verify
|
|
136
|
+
* - missing (sudo) → prompt for password, store in Keychain, verify
|
|
137
|
+
*
|
|
138
|
+
* The user can decline any step. Nothing is required for the bot to keep
|
|
139
|
+
* working — degraded skills just won't work until the relevant permission
|
|
140
|
+
* is granted.
|
|
141
|
+
*/
|
|
142
|
+
export async function runPermissionsWizard(cb) {
|
|
143
|
+
const result = { granted: [], skipped: [], stillMissing: [], toolMissing: [] };
|
|
144
|
+
cb.print("\n🛡️ Mac Permissions Wizard");
|
|
145
|
+
cb.print("─────────────────────────────────────────────────────────");
|
|
146
|
+
cb.print("macOS doesn't let any app grant TCC permissions on its own —");
|
|
147
|
+
cb.print("we'll walk you through each toggle. Decline any step you want.");
|
|
148
|
+
cb.print("");
|
|
149
|
+
// First read snapshot once so we don't re-probe constantly
|
|
150
|
+
const initial = await readPermissionsSnapshot();
|
|
151
|
+
cb.print("Current state:");
|
|
152
|
+
for (const snap of initial) {
|
|
153
|
+
const icon = snap.state === "granted" ? "✓" : snap.state === "n/a" ? "·" : "✗";
|
|
154
|
+
cb.print(` ${icon} ${snap.permission.name.padEnd(28)} ${snap.state}${snap.detail ? " (" + snap.detail + ")" : ""}`);
|
|
155
|
+
}
|
|
156
|
+
cb.print("");
|
|
157
|
+
// ── Sudo step (always first; not TCC, just Keychain) ────────────────
|
|
158
|
+
const sudoSnap = initial.find((s) => s.permission.id === "sudo");
|
|
159
|
+
if (sudoSnap && sudoSnap.state !== "granted" && sudoSnap.permission.platforms.includes(PLATFORM)) {
|
|
160
|
+
cb.print(`\n[1/4] ${sudoSnap.permission.name}`);
|
|
161
|
+
cb.print(` ${sudoSnap.permission.why}`);
|
|
162
|
+
cb.print(` Status: ${sudoSnap.state}${sudoSnap.detail ? " (" + sudoSnap.detail + ")" : ""}`);
|
|
163
|
+
const proceed = await cb.confirm(" Set up now?");
|
|
164
|
+
if (!proceed) {
|
|
165
|
+
result.skipped.push("sudo");
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const pw = await cb.promptPassword(" Your macOS account password (stored in Keychain): ");
|
|
169
|
+
if (!pw) {
|
|
170
|
+
result.skipped.push("sudo");
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const stored = storePassword(pw);
|
|
174
|
+
if (!stored.ok) {
|
|
175
|
+
cb.print(` ❌ Could not store: ${stored.error}`);
|
|
176
|
+
result.stillMissing.push("sudo");
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const verify = await verifyPassword();
|
|
180
|
+
if (verify.ok) {
|
|
181
|
+
cb.print(` ✓ Stored in ${stored.method} and verified.`);
|
|
182
|
+
result.granted.push("sudo");
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
cb.print(` ❌ Wrong password — Keychain entry removed.`);
|
|
186
|
+
// storePassword stored it; verifyPassword found it wrong.
|
|
187
|
+
// We should revoke to avoid leaving a bogus pw lying around.
|
|
188
|
+
try {
|
|
189
|
+
const { revokePassword } = await import("./sudo.js");
|
|
190
|
+
revokePassword();
|
|
191
|
+
}
|
|
192
|
+
catch { }
|
|
193
|
+
result.stillMissing.push("sudo");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else if (sudoSnap?.state === "granted") {
|
|
200
|
+
cb.print(`[1/4] ${sudoSnap.permission.name} ✓ already configured`);
|
|
201
|
+
result.granted.push("sudo");
|
|
202
|
+
}
|
|
203
|
+
// ── TCC permissions (Mac only) ──────────────────────────────────────
|
|
204
|
+
if (PLATFORM !== "darwin") {
|
|
205
|
+
cb.print("\nNon-macOS platform — TCC permissions are macOS-only. Skipping.");
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
let i = 2;
|
|
209
|
+
for (const id of ["full-disk-access", "automation", "accessibility"]) {
|
|
210
|
+
const perm = PERMISSIONS.find((p) => p.id === id);
|
|
211
|
+
const snap = initial.find((s) => s.permission.id === id);
|
|
212
|
+
cb.print(`\n[${i}/4] ${perm.name}`);
|
|
213
|
+
cb.print(` ${perm.why}`);
|
|
214
|
+
cb.print(` Status: ${snap.state}${snap.detail ? " (" + snap.detail + ")" : ""}`);
|
|
215
|
+
i++;
|
|
216
|
+
if (snap.state === "granted") {
|
|
217
|
+
cb.print(` ✓ Already granted.`);
|
|
218
|
+
result.granted.push(id);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (snap.state === "tool-missing") {
|
|
222
|
+
cb.print(` ⚠ Prerequisite missing: ${perm.prereq || "(no install hint)"}`);
|
|
223
|
+
const install = await cb.confirm(" Install the prerequisite now?");
|
|
224
|
+
if (install && perm.prereq) {
|
|
225
|
+
try {
|
|
226
|
+
execSync(perm.prereq, { stdio: "inherit", timeout: 120_000 });
|
|
227
|
+
cb.print(` ✓ Installed.`);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
cb.print(` ❌ Install failed.`);
|
|
231
|
+
result.toolMissing.push(id);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
result.toolMissing.push(id);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Open the Settings pane and wait for toggle
|
|
241
|
+
if (!perm.openPane) {
|
|
242
|
+
cb.print(` (no automatic pane-open for this permission)`);
|
|
243
|
+
result.stillMissing.push(id);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const proceed = await cb.confirm(" Open System Settings and grant now?");
|
|
247
|
+
if (!proceed) {
|
|
248
|
+
result.skipped.push(id);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
cb.print(` → Opening System Settings…`);
|
|
252
|
+
perm.openPane();
|
|
253
|
+
cb.print(` → Waiting up to 60 s for you to toggle the switch (polls every 2 s)…`);
|
|
254
|
+
cb.print(` → For Full Disk Access: add the actual node binary —`);
|
|
255
|
+
cb.print(` ${getNodeBinaryHint()}`);
|
|
256
|
+
const granted = await waitForGrant(perm, 60_000);
|
|
257
|
+
if (granted) {
|
|
258
|
+
cb.print(` ✓ Granted.`);
|
|
259
|
+
result.granted.push(id);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
cb.print(` ⏱ Timeout — you can run the wizard again any time.`);
|
|
263
|
+
result.stillMissing.push(id);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
function getNodeBinaryHint() {
|
|
269
|
+
try {
|
|
270
|
+
const path = execSync(`readlink -f "$(command -v node)"`, { encoding: "utf-8", timeout: 1000 }).trim();
|
|
271
|
+
return path || "(could not resolve node binary)";
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return "(run: readlink -f \"$(command -v node)\")";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Format a snapshot list as a compact human-readable block.
|
|
279
|
+
* Used by both `permissions status` and the doctor command.
|
|
280
|
+
*/
|
|
281
|
+
export function formatPermissionsSnapshot(snaps) {
|
|
282
|
+
const lines = [];
|
|
283
|
+
for (const snap of snaps) {
|
|
284
|
+
const icon = snap.state === "granted" ? "✓" :
|
|
285
|
+
snap.state === "tool-missing" ? "⚠" :
|
|
286
|
+
snap.state === "n/a" ? "·" : "✗";
|
|
287
|
+
lines.push(` ${icon} ${snap.permission.name.padEnd(28)} ${snap.state}` +
|
|
288
|
+
(snap.detail ? ` — ${snap.detail}` : ""));
|
|
289
|
+
}
|
|
290
|
+
return lines.join("\n");
|
|
291
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI-driven Self-Diagnosis (Self-Preservation Phase 2, feature 3I).
|
|
3
|
+
*
|
|
4
|
+
* Trigger model — IMPORTANT:
|
|
5
|
+
*
|
|
6
|
+
* 3I does NOT run at watchdog-brake time. The bot is mid-exit then;
|
|
7
|
+
* spawning a fresh process just to make one AI call would be heavy
|
|
8
|
+
* and racy. Instead, 3I runs at the next successful bot start:
|
|
9
|
+
* it scans ~/.alvin-bot/diagnostics/ for forensic bundles that don't
|
|
10
|
+
* yet have a sidecar .analysis.md file, runs AI analysis on each,
|
|
11
|
+
* writes the sidecar, and delivers a Telegram summary via 1D.
|
|
12
|
+
*
|
|
13
|
+
* User-visible consequence: when the bot recovers from a brake, the
|
|
14
|
+
* first thing it does after Pre-Flight is analyze why it crashed —
|
|
15
|
+
* the operator gets the diagnosis on their phone within ~30 s of
|
|
16
|
+
* the bot coming back up.
|
|
17
|
+
*
|
|
18
|
+
* Provider-agnostic — uses the active Provider's query() async generator,
|
|
19
|
+
* works for claude-sdk / codex-cli / groq / gemini / openai / offline-gemma4.
|
|
20
|
+
* The prompt is deliberately tight (~250 tokens base + bundle content,
|
|
21
|
+
* truncated to ~12 KB) so even small-context models can handle it.
|
|
22
|
+
*
|
|
23
|
+
* Output shape — we force a structured plain-text response (no JSON;
|
|
24
|
+
* JSON parsing reliability is unever across providers, especially with
|
|
25
|
+
* smaller models). The 5-line format is hard to mess up:
|
|
26
|
+
*
|
|
27
|
+
* HYPOTHESIS: ...
|
|
28
|
+
* ROOT_CAUSE_CATEGORY: ...
|
|
29
|
+
* REMEDIATION: ...
|
|
30
|
+
* CONFIDENCE: HIGH|MEDIUM|LOW
|
|
31
|
+
* EXPLANATION: ...
|
|
32
|
+
*
|
|
33
|
+
* Privacy: forensic bundles are already curated by 2F to exclude
|
|
34
|
+
* secrets (BOT_TOKEN, API keys); only whitelisted non-secret env vars
|
|
35
|
+
* are included. So the AI request contains: bot version, logs, env
|
|
36
|
+
* keys (non-secret), tool inventory, disk state. No tokens leave the
|
|
37
|
+
* machine.
|
|
38
|
+
*
|
|
39
|
+
* Auto-remediation policy (v1, intentionally conservative):
|
|
40
|
+
* - We NEVER auto-apply any remediation. The AI's REMEDIATION line
|
|
41
|
+
* is shown to the operator as a suggestion only.
|
|
42
|
+
* - Operator runs it manually if it looks right.
|
|
43
|
+
* - This will likely relax in a future release once we build
|
|
44
|
+
* confidence in the AI's track record per remediation category.
|
|
45
|
+
*
|
|
46
|
+
* Opt-out:
|
|
47
|
+
* ALVIN_DISABLE_SELF_DIAGNOSIS=true → skip 3I specifically
|
|
48
|
+
* ALVIN_DISABLE_SELF_PRESERVATION=true → skip ALL Phase-1/2
|
|
49
|
+
*/
|
|
50
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync } from "fs";
|
|
51
|
+
import { join } from "path";
|
|
52
|
+
import { homedir } from "os";
|
|
53
|
+
import { emitCritical } from "./critical-notify.js";
|
|
54
|
+
const PROMPT_TEMPLATE = `You are an SRE assistant. An Alvin Bot instance hit a critical failure.
|
|
55
|
+
Below is the forensic dump. Read it, then respond in EXACTLY this 5-line format —
|
|
56
|
+
no markdown, no commentary, no extra lines:
|
|
57
|
+
|
|
58
|
+
HYPOTHESIS: <one short sentence: what likely went wrong>
|
|
59
|
+
ROOT_CAUSE_CATEGORY: <pick ONE: config-error | resource-exhaustion | external-failure | code-bug | environment-conflict | unknown>
|
|
60
|
+
REMEDIATION: <one shell command the operator can run, OR "no automated action available">
|
|
61
|
+
CONFIDENCE: <HIGH | MEDIUM | LOW>
|
|
62
|
+
EXPLANATION: <2-4 sentences explaining your reasoning, plain text>
|
|
63
|
+
|
|
64
|
+
--- FORENSIC DUMP ---
|
|
65
|
+
{BUNDLE_CONTENT}
|
|
66
|
+
--- END OF DUMP ---`;
|
|
67
|
+
function isDisabled() {
|
|
68
|
+
return (process.env.ALVIN_DISABLE_SELF_DIAGNOSIS === "true" ||
|
|
69
|
+
process.env.ALVIN_DISABLE_SELF_PRESERVATION === "true");
|
|
70
|
+
}
|
|
71
|
+
function parseAIResponse(text) {
|
|
72
|
+
const get = (key) => {
|
|
73
|
+
// ^KEY: ... up to next \n^KEY:|end. Multiline-safe.
|
|
74
|
+
const re = new RegExp(`^${key}:\\s*([\\s\\S]*?)(?=^(?:HYPOTHESIS|ROOT_CAUSE_CATEGORY|REMEDIATION|CONFIDENCE|EXPLANATION):|$)`, "m");
|
|
75
|
+
const m = text.match(re);
|
|
76
|
+
return m ? m[1].trim() : "";
|
|
77
|
+
};
|
|
78
|
+
const conf = get("CONFIDENCE").split(/\s+/)[0]?.toUpperCase() || "";
|
|
79
|
+
return {
|
|
80
|
+
hypothesis: get("HYPOTHESIS") || "(no hypothesis returned)",
|
|
81
|
+
rootCauseCategory: get("ROOT_CAUSE_CATEGORY") || "unknown",
|
|
82
|
+
remediation: get("REMEDIATION") || "(no remediation)",
|
|
83
|
+
confidence: (["HIGH", "MEDIUM", "LOW"].includes(conf) ? conf : "UNKNOWN"),
|
|
84
|
+
explanation: get("EXPLANATION") || "(no explanation)",
|
|
85
|
+
raw: text,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Truncate the forensic bundle to fit within small-context windows.
|
|
90
|
+
* Keep the first 2 KB (event detail, process state, env) and last 8 KB
|
|
91
|
+
* (recent logs, where the actual error usually surfaces).
|
|
92
|
+
*/
|
|
93
|
+
function truncateBundle(text, maxChars = 12_000) {
|
|
94
|
+
if (text.length <= maxChars)
|
|
95
|
+
return text;
|
|
96
|
+
const head = text.slice(0, 2000);
|
|
97
|
+
const tail = text.slice(-(maxChars - 2000 - 50));
|
|
98
|
+
return `${head}\n\n[... ${text.length - maxChars} chars elided ...]\n\n${tail}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Run AI analysis on a single forensic bundle. Returns null on opt-out,
|
|
102
|
+
* unparseable response, or provider failure. Side-effects: writes
|
|
103
|
+
* `<bundlePath>.analysis.md` sidecar with the formatted result.
|
|
104
|
+
*/
|
|
105
|
+
export async function analyzeBundle(bundlePath, registry, opts = {}) {
|
|
106
|
+
if (isDisabled())
|
|
107
|
+
return null;
|
|
108
|
+
if (!existsSync(bundlePath))
|
|
109
|
+
return null;
|
|
110
|
+
let provider, activeKey = "(unknown)";
|
|
111
|
+
try {
|
|
112
|
+
provider = registry.getActive();
|
|
113
|
+
activeKey = registry.getActiveKey();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (!provider)
|
|
119
|
+
return null;
|
|
120
|
+
const t0 = Date.now();
|
|
121
|
+
const bundleRaw = readFileSync(bundlePath, "utf-8");
|
|
122
|
+
const bundleClipped = truncateBundle(bundleRaw);
|
|
123
|
+
const prompt = PROMPT_TEMPLATE.replace("{BUNDLE_CONTENT}", bundleClipped);
|
|
124
|
+
// Hard timeout — protects against a hung provider call wedging
|
|
125
|
+
// the bot startup forever. Stream may be aborted mid-flight.
|
|
126
|
+
const timeoutMs = opts.timeoutMs ?? 120_000;
|
|
127
|
+
const abortController = new AbortController();
|
|
128
|
+
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
|
129
|
+
let fullText = "";
|
|
130
|
+
try {
|
|
131
|
+
for await (const chunk of provider.query({
|
|
132
|
+
prompt,
|
|
133
|
+
systemPrompt: "You are a precise SRE assistant. Reply ONLY in the requested format.",
|
|
134
|
+
abortSignal: abortController.signal,
|
|
135
|
+
})) {
|
|
136
|
+
if (chunk.type === "text") {
|
|
137
|
+
if (chunk.delta)
|
|
138
|
+
fullText += chunk.delta;
|
|
139
|
+
else if (chunk.text)
|
|
140
|
+
fullText = chunk.text;
|
|
141
|
+
}
|
|
142
|
+
else if (chunk.type === "error") {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
else if (chunk.type === "done") {
|
|
147
|
+
if (chunk.text)
|
|
148
|
+
fullText = chunk.text;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
if (!fullText.trim())
|
|
159
|
+
return null;
|
|
160
|
+
const parsed = parseAIResponse(fullText);
|
|
161
|
+
const result = {
|
|
162
|
+
...parsed,
|
|
163
|
+
durationMs: Date.now() - t0,
|
|
164
|
+
provider: activeKey,
|
|
165
|
+
};
|
|
166
|
+
// Write sidecar — overwrites if user re-runs analysis
|
|
167
|
+
try {
|
|
168
|
+
const sidecarPath = bundlePath.replace(/\.md$/, ".analysis.md");
|
|
169
|
+
writeFileSync(sidecarPath, formatAnalysis(result, bundlePath), { mode: 0o600 });
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Sidecar write failed — non-fatal, we still return the result
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
function formatAnalysis(r, bundlePath) {
|
|
177
|
+
return [
|
|
178
|
+
`# AI Self-Diagnosis`,
|
|
179
|
+
``,
|
|
180
|
+
`**Bundle:** \`${bundlePath}\``,
|
|
181
|
+
`**Generated:** ${new Date().toISOString()}`,
|
|
182
|
+
`**Provider:** ${r.provider}`,
|
|
183
|
+
`**Duration:** ${r.durationMs} ms`,
|
|
184
|
+
``,
|
|
185
|
+
`## Hypothesis`,
|
|
186
|
+
r.hypothesis,
|
|
187
|
+
``,
|
|
188
|
+
`## Root Cause Category`,
|
|
189
|
+
`\`${r.rootCauseCategory}\``,
|
|
190
|
+
``,
|
|
191
|
+
`## Suggested Remediation`,
|
|
192
|
+
`\`\`\`bash`,
|
|
193
|
+
r.remediation,
|
|
194
|
+
`\`\`\``,
|
|
195
|
+
`> **Note:** the bot does NOT auto-apply this. Run it yourself only if it makes sense.`,
|
|
196
|
+
``,
|
|
197
|
+
`## Confidence`,
|
|
198
|
+
`**${r.confidence}**`,
|
|
199
|
+
``,
|
|
200
|
+
`## Explanation`,
|
|
201
|
+
r.explanation,
|
|
202
|
+
``,
|
|
203
|
+
`---`,
|
|
204
|
+
``,
|
|
205
|
+
`### Raw AI response`,
|
|
206
|
+
`\`\`\``,
|
|
207
|
+
r.raw,
|
|
208
|
+
`\`\`\``,
|
|
209
|
+
``,
|
|
210
|
+
].join("\n");
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Find diagnostic bundles in ~/.alvin-bot/diagnostics/ that do NOT yet
|
|
214
|
+
* have a sidecar .analysis.md. Used by the startup scanner.
|
|
215
|
+
*/
|
|
216
|
+
function findUnanalyzedBundles() {
|
|
217
|
+
const dir = join(homedir(), ".alvin-bot", "diagnostics");
|
|
218
|
+
if (!existsSync(dir))
|
|
219
|
+
return [];
|
|
220
|
+
try {
|
|
221
|
+
return readdirSync(dir)
|
|
222
|
+
.filter((f) => f.endsWith(".md") && !f.endsWith(".analysis.md"))
|
|
223
|
+
.filter((f) => !existsSync(join(dir, f.replace(/\.md$/, ".analysis.md"))))
|
|
224
|
+
.map((f) => join(dir, f));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Scan for unanalyzed bundles and run AI analysis on each. Designed
|
|
232
|
+
* to be called once at bot startup, in the background (non-blocking).
|
|
233
|
+
*
|
|
234
|
+
* Delivers one Telegram DM per analyzed bundle via the 1D channel,
|
|
235
|
+
* with the structured findings. Auto-deduplicates: a bundle with an
|
|
236
|
+
* existing sidecar is skipped.
|
|
237
|
+
*/
|
|
238
|
+
export async function runStartupAnalyzer(registry) {
|
|
239
|
+
if (isDisabled())
|
|
240
|
+
return;
|
|
241
|
+
if (!registry)
|
|
242
|
+
return;
|
|
243
|
+
const bundles = findUnanalyzedBundles();
|
|
244
|
+
if (bundles.length === 0)
|
|
245
|
+
return;
|
|
246
|
+
// Process oldest first (FIFO so the operator sees them in order)
|
|
247
|
+
bundles.sort();
|
|
248
|
+
console.log(`🧠 Self-diagnosis: ${bundles.length} unanalyzed bundle(s) found — analyzing...`);
|
|
249
|
+
for (const bundlePath of bundles) {
|
|
250
|
+
try {
|
|
251
|
+
const result = await analyzeBundle(bundlePath, registry);
|
|
252
|
+
if (!result) {
|
|
253
|
+
console.warn(` ⚠ ${bundlePath}: analysis returned no result (provider error or opt-out)`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
console.log(` ✓ ${bundlePath.split("/").pop()} → ${result.rootCauseCategory} (${result.confidence}, ${result.durationMs}ms via ${result.provider})`);
|
|
257
|
+
// Deliver via 1D — severity warn (informational), not critical
|
|
258
|
+
emitCritical({
|
|
259
|
+
category: "custom",
|
|
260
|
+
severity: "warn",
|
|
261
|
+
title: `AI diagnosis ready — ${result.rootCauseCategory} (${result.confidence} confidence)`,
|
|
262
|
+
detail: `Hypothesis: ${result.hypothesis}\n\n` +
|
|
263
|
+
`Explanation: ${result.explanation}\n\n` +
|
|
264
|
+
`Full analysis: ${bundlePath.replace(/\.md$/, ".analysis.md")}`,
|
|
265
|
+
suggestedAction: result.remediation,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.warn(` ⚠ ${bundlePath}: analyzer threw — ${err instanceof Error ? err.message : String(err)}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
package/dist/services/sudo.js
CHANGED
|
@@ -218,7 +218,47 @@ export function openSystemSettings(pane) {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
/**
|
|
221
|
-
*
|
|
221
|
+
* Detect Automation permission on macOS (TCC).
|
|
222
|
+
*
|
|
223
|
+
* Method: try to send a benign AppleEvent to System Events. If TCC has
|
|
224
|
+
* NOT granted the calling binary (typically `node` under launchd)
|
|
225
|
+
* permission to control System Events, osascript fails with one of:
|
|
226
|
+
*
|
|
227
|
+
* - error 1743 ("Not authorized to send Apple events to ...")
|
|
228
|
+
* - "(-1743)" in stderr
|
|
229
|
+
* - "errAEEventNotPermitted"
|
|
230
|
+
*
|
|
231
|
+
* If TCC grants it, osascript returns a small integer (process count).
|
|
232
|
+
* Either way, we don't actually care about the count — only that the
|
|
233
|
+
* call succeeded.
|
|
234
|
+
*
|
|
235
|
+
* Returns:
|
|
236
|
+
* true — Automation granted
|
|
237
|
+
* false — Automation denied (or never asked, which appears identical)
|
|
238
|
+
* null — non-macOS (no TCC)
|
|
239
|
+
*/
|
|
240
|
+
function detectAutomation() {
|
|
241
|
+
if (PLATFORM !== "darwin")
|
|
242
|
+
return null;
|
|
243
|
+
try {
|
|
244
|
+
execSync(`osascript -e 'tell application "System Events" to count of processes' 2>&1`, { stdio: "pipe", timeout: 3000 });
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
249
|
+
// Either way, treat as "not granted". The error path is fast; we
|
|
250
|
+
// don't want to wedge startup on this probe.
|
|
251
|
+
if (msg.includes("1743") || /[Nn]ot authoriz/i.test(msg) || /errAEEventNotPermitted/.test(msg)) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
// Other error (e.g. osascript itself missing — very unlikely on macOS)
|
|
255
|
+
// — treat as unknown but bias false to surface the issue.
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Get comprehensive sudo + permissions status. Used by both the
|
|
261
|
+
* `/sudo` command and the new permissions wizard.
|
|
222
262
|
*/
|
|
223
263
|
export async function getSudoStatus() {
|
|
224
264
|
const configured = retrievePassword() !== null;
|
|
@@ -230,15 +270,33 @@ export async function getSudoStatus() {
|
|
|
230
270
|
}
|
|
231
271
|
// Check macOS permissions
|
|
232
272
|
let accessibility = null;
|
|
273
|
+
let accessibilityDetail = "n/a";
|
|
233
274
|
let fullDiskAccess = null;
|
|
275
|
+
let automation = null;
|
|
234
276
|
if (PLATFORM === "darwin") {
|
|
277
|
+
// Accessibility — distinguish "denied" from "tool not installed".
|
|
278
|
+
// cliclick is the standard probe; if it's missing the test is
|
|
279
|
+
// inconclusive but we can tell the wizard exactly what to do.
|
|
280
|
+
let cliclickPresent = false;
|
|
235
281
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
accessibility = true;
|
|
282
|
+
execSync("command -v cliclick", { stdio: "pipe", timeout: 1000 });
|
|
283
|
+
cliclickPresent = true;
|
|
239
284
|
}
|
|
240
|
-
catch {
|
|
285
|
+
catch { }
|
|
286
|
+
if (!cliclickPresent) {
|
|
241
287
|
accessibility = false;
|
|
288
|
+
accessibilityDetail = "cliclick-missing";
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
try {
|
|
292
|
+
execSync("cliclick p:.", { stdio: "pipe", timeout: 3000 });
|
|
293
|
+
accessibility = true;
|
|
294
|
+
accessibilityDetail = "granted";
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
accessibility = false;
|
|
298
|
+
accessibilityDetail = "denied";
|
|
299
|
+
}
|
|
242
300
|
}
|
|
243
301
|
try {
|
|
244
302
|
// Check Full Disk Access (try to read a protected file)
|
|
@@ -248,6 +306,7 @@ export async function getSudoStatus() {
|
|
|
248
306
|
catch {
|
|
249
307
|
fullDiskAccess = false;
|
|
250
308
|
}
|
|
309
|
+
automation = detectAutomation();
|
|
251
310
|
}
|
|
252
311
|
return {
|
|
253
312
|
configured,
|
|
@@ -255,7 +314,8 @@ export async function getSudoStatus() {
|
|
|
255
314
|
verified,
|
|
256
315
|
platform: PLATFORM,
|
|
257
316
|
user,
|
|
258
|
-
permissions: { accessibility, fullDiskAccess },
|
|
317
|
+
permissions: { accessibility, fullDiskAccess, automation },
|
|
318
|
+
accessibilityDetail,
|
|
259
319
|
};
|
|
260
320
|
}
|
|
261
321
|
/**
|