impel-cli 0.15.3 → 0.16.1

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.
@@ -0,0 +1,648 @@
1
+ // Typed local tool registry for install recovery.
2
+ //
3
+ // The recovery model never receives shell access: it selects one of these
4
+ // tools by name and the CLI runs the corresponding reviewed implementation.
5
+ // Every result is redacted and bounded before it can reach the model.
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+
11
+ import {
12
+ environmentValue,
13
+ findNativeBinary,
14
+ nativeCommandInvocation,
15
+ nativeSpawnInvocation,
16
+ } from "../nativeProcess.js";
17
+ import { IMPEL_CLI_PROFILES_DIR, tenantCliProfilePaths } from "../cliProfiles.js";
18
+ import { CONFIG_DIR } from "../config.js";
19
+ import { redactInstallRecoveryText } from "./redact.js";
20
+
21
+ const MAX_OUTPUT_CHARS = 8_000;
22
+ const BINARY_TOOLS = ["claude", "codex", "npm", "node", "powershell"];
23
+
24
+ /**
25
+ * Risk classes decide who has to approve a call:
26
+ * read — no approval; inspects state only.
27
+ * safe — mutates only Impel-owned state or retries an idempotent step;
28
+ * pre-approved by an explicit `--repair`, otherwise confirmed.
29
+ * system — runs a vendor installer or edits the user PATH; always requires
30
+ * an interactive confirmation.
31
+ */
32
+ export const INSTALL_RECOVERY_TOOLS = Object.freeze({
33
+ inspect_runtime: {
34
+ risk: "read",
35
+ description:
36
+ "Report bounded runtime facts: OS, architecture, Node.js version, PATH entry count, and TTY state. Call this first when the failure cause is unclear.",
37
+ schema: {},
38
+ },
39
+ inspect_path: {
40
+ risk: "read",
41
+ description:
42
+ "List sanitized PATH entries and whether each directory exists. Use when a tool installed correctly but is not discoverable.",
43
+ schema: {},
44
+ },
45
+ inspect_processes: {
46
+ risk: "read",
47
+ description:
48
+ "List running processes relevant to installers (claude, codex, node, npm, powershell, installers). Use for EBUSY/EPERM/locked-file failures.",
49
+ schema: {},
50
+ },
51
+ check_binary: {
52
+ risk: "read",
53
+ description:
54
+ "Locate one executable and run its bounded `--version` check. Reports the discovered path and version output.",
55
+ schema: {
56
+ tool: { type: "string", enum: BINARY_TOOLS, required: true },
57
+ },
58
+ },
59
+ read_profile: {
60
+ risk: "read",
61
+ description:
62
+ "Read a bounded, sanitized copy of one Impel-owned profile file (Claude settings.json or Codex config.toml). Use to diagnose profile parse or configuration errors.",
63
+ schema: {
64
+ profile: { type: "string", enum: ["claude_settings", "codex_config"], required: true },
65
+ },
66
+ },
67
+ probe_gateway: {
68
+ risk: "read",
69
+ description:
70
+ "Run a synthetic authenticated probe against the Impel gateway. Reports reachable/rejected status; never sends user content.",
71
+ schema: {},
72
+ },
73
+ check_auth_helper: {
74
+ risk: "read",
75
+ description:
76
+ "Run the tenant's Impel-owned app token helper with a bounded timeout and classify the result (works, missing, stale interpreter/CLI path, or failing). Use when managed apps report auth/credential failures.",
77
+ schema: {},
78
+ },
79
+ check_health: {
80
+ risk: "read",
81
+ description:
82
+ "Run every goal health check for this recovery session and report pass/fail per goal. The installation counts as fixed only when all goals pass. Call this after any repair.",
83
+ schema: {},
84
+ },
85
+ repair_impel_profile: {
86
+ risk: "safe",
87
+ description:
88
+ "Atomically regenerate the Impel-managed CLI profiles (Claude settings + Codex config) for the selected tenant. Touches only Impel-owned files.",
89
+ schema: {},
90
+ },
91
+ cleanup_impel_staging: {
92
+ risk: "safe",
93
+ description:
94
+ "Remove validated stale Impel-owned staging directories (interrupted installs). Only exact known staging roots are eligible; no paths can be supplied.",
95
+ schema: {},
96
+ },
97
+ retry_step: {
98
+ risk: "safe",
99
+ description:
100
+ "Re-run one idempotent installer step that failed (for example the npm self-update or the gateway verification). Use after fixing the underlying cause.",
101
+ schema: {
102
+ step: { type: "string", required: true, maxLength: 128 },
103
+ },
104
+ },
105
+ install_vendor_cli: {
106
+ risk: "system",
107
+ description:
108
+ "Run the official vendor installer for one missing CLI (Windows only). Uses the vendor's checksum-verifying native installer; requires the user's confirmation.",
109
+ schema: {
110
+ tool: { type: "string", enum: ["claude", "codex"], required: true },
111
+ },
112
+ },
113
+ install_vendor_app: {
114
+ risk: "system",
115
+ description:
116
+ "Install or rebuild the isolated Impel desktop app profile(s) using the signed vendor app. Requires the user's confirmation.",
117
+ schema: {
118
+ target: { type: "string", enum: ["claude", "codex", "all"], required: true },
119
+ },
120
+ },
121
+ repair_user_path: {
122
+ risk: "system",
123
+ description:
124
+ "Add one verified vendor install directory to the user PATH (Windows only). The directory comes from local discovery, never from you. Requires the user's confirmation.",
125
+ schema: {
126
+ tool: { type: "string", enum: ["claude", "codex"], required: true },
127
+ },
128
+ },
129
+ report_outcome: {
130
+ risk: "read",
131
+ description:
132
+ "Finish the recovery session. Use status \"fixed\" only after check_health reports every goal passing; otherwise use \"blocked\" and give the user one concrete manual step.",
133
+ schema: {
134
+ status: { type: "string", enum: ["fixed", "blocked"], required: true },
135
+ summary: { type: "string", required: true, maxLength: 2_000 },
136
+ user_action: { type: "string", maxLength: 2_000 },
137
+ },
138
+ },
139
+ });
140
+
141
+ export function installRecoveryToolDefinitions() {
142
+ return Object.entries(INSTALL_RECOVERY_TOOLS).map(([name, tool]) => ({
143
+ name,
144
+ description: tool.description,
145
+ input_schema: {
146
+ type: "object",
147
+ properties: Object.fromEntries(
148
+ Object.entries(tool.schema).map(([key, spec]) => [
149
+ key,
150
+ { type: spec.type, ...(spec.enum ? { enum: spec.enum } : {}) },
151
+ ])
152
+ ),
153
+ required: Object.entries(tool.schema)
154
+ .filter(([, spec]) => spec.required)
155
+ .map(([key]) => key),
156
+ additionalProperties: false,
157
+ },
158
+ }));
159
+ }
160
+
161
+ /** Validate a model-proposed call. Returns normalized input or null. */
162
+ export function validateInstallRecoveryToolCall(name, input) {
163
+ const tool = INSTALL_RECOVERY_TOOLS[name];
164
+ if (!tool) return null;
165
+ const value = input && typeof input === "object" && !Array.isArray(input) ? input : {};
166
+ const keys = Object.keys(tool.schema);
167
+ if (!Object.keys(value).every((key) => keys.includes(key))) return null;
168
+ const normalized = {};
169
+ for (const key of keys) {
170
+ const spec = tool.schema[key];
171
+ const supplied = value[key];
172
+ if (supplied === undefined || supplied === null) {
173
+ if (spec.required) return null;
174
+ continue;
175
+ }
176
+ if (typeof supplied !== "string") return null;
177
+ if (spec.enum && !spec.enum.includes(supplied)) return null;
178
+ if (supplied.length > (spec.maxLength || 512)) return null;
179
+ normalized[key] = supplied;
180
+ }
181
+ return normalized;
182
+ }
183
+
184
+ export function installRecoveryToolRisk(name) {
185
+ return INSTALL_RECOVERY_TOOLS[name]?.risk || null;
186
+ }
187
+
188
+ function result(outcome, summary, details = {}) {
189
+ return {
190
+ outcome,
191
+ summary: redactInstallRecoveryText(summary).slice(0, 2_000),
192
+ ...(details.output
193
+ ? { output: redactInstallRecoveryText(details.output).slice(0, MAX_OUTPUT_CHARS) }
194
+ : {}),
195
+ ...(Number.isInteger(details.exitCode) ? { exitCode: details.exitCode } : {}),
196
+ };
197
+ }
198
+
199
+ function runBounded(io, command, args, context, options = {}) {
200
+ return io.spawnSync(command, args, {
201
+ encoding: "utf8",
202
+ env: context.environment,
203
+ stdio: ["ignore", "pipe", "pipe"],
204
+ timeout: options.timeout || 15_000,
205
+ windowsHide: true,
206
+ ...(options.windowsVerbatimArguments !== undefined
207
+ ? { windowsVerbatimArguments: options.windowsVerbatimArguments }
208
+ : {}),
209
+ });
210
+ }
211
+
212
+ function inspectRuntime(context) {
213
+ const pathEntries = String(environmentValue(context.environment, "PATH") || "")
214
+ .split(context.platform === "win32" ? ";" : path.delimiter)
215
+ .filter(Boolean);
216
+ return result("succeeded", "Collected bounded runtime metadata.", {
217
+ output: JSON.stringify({
218
+ platform: context.platform,
219
+ architecture: process.arch,
220
+ nodeVersion: process.version,
221
+ pathEntries: pathEntries.length,
222
+ tty: Boolean(context.isTTY),
223
+ tenantSelected: Boolean(context.tenantId),
224
+ }),
225
+ });
226
+ }
227
+
228
+ function inspectPath(context) {
229
+ const entries = String(environmentValue(context.environment, "PATH") || "")
230
+ .split(context.platform === "win32" ? ";" : path.delimiter)
231
+ .filter(Boolean)
232
+ .slice(0, 64)
233
+ .map((entry) => {
234
+ let exists = false;
235
+ try {
236
+ exists = fs.statSync(entry).isDirectory();
237
+ } catch {
238
+ exists = false;
239
+ }
240
+ return `${exists ? "ok " : "missing "}${entry}`;
241
+ });
242
+ return result("succeeded", `PATH has ${entries.length} inspected entries.`, {
243
+ output: entries.join("\n"),
244
+ });
245
+ }
246
+
247
+ function inspectProcesses(context, io) {
248
+ const invocation = context.platform === "win32"
249
+ ? { command: "tasklist.exe", args: ["/fo", "csv", "/nh"] }
250
+ : { command: "ps", args: ["-Ao", "comm="] };
251
+ const run = runBounded(io, invocation.command, invocation.args, context, { timeout: 10_000 });
252
+ if (run?.error || run?.status !== 0) {
253
+ return result("failed", "Could not inspect running installer processes.", {
254
+ exitCode: run?.status,
255
+ output: run?.error?.message || run?.stderr,
256
+ });
257
+ }
258
+ const relevant = String(run.stdout || "")
259
+ .split(/\r?\n/gu)
260
+ .filter((line) => /claude|chatgpt|codex|impel|node|npm|powershell|msiexec|installer/iu.test(line))
261
+ .slice(0, 40)
262
+ .join("\n");
263
+ return result(
264
+ "succeeded",
265
+ relevant ? "Found potentially relevant running processes." : "No relevant running process was found.",
266
+ { output: relevant || "none" }
267
+ );
268
+ }
269
+
270
+ function checkBinary(input, context, io) {
271
+ const binary = io.findNativeBinary(input.tool, context.environment, context.platform);
272
+ if (!binary) {
273
+ return result("failed", `${input.tool} is not installed or not discoverable on PATH or in known install locations.`);
274
+ }
275
+ const invocation = nativeSpawnInvocation(binary, ["--version"], context.environment, context.platform);
276
+ const run = runBounded(io, invocation.command, invocation.args, context, {
277
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
278
+ });
279
+ return run?.status === 0 && !run?.error
280
+ ? result("succeeded", `${input.tool} passed its bounded version check.`, {
281
+ output: `path: ${binary}\nversion: ${String(run.stdout || "").trim()}`,
282
+ })
283
+ : result("failed", `${input.tool} was found but failed its bounded version check.`, {
284
+ exitCode: run?.status,
285
+ output: `path: ${binary}\n${run?.error?.message || run?.stderr || ""}`,
286
+ });
287
+ }
288
+
289
+ function readProfile(input, context) {
290
+ if (!context.tenantId) return result("failed", "No tenant is selected; profiles have not been created yet.");
291
+ const paths = tenantCliProfilePaths(context.tenantId);
292
+ const filePath = input.profile === "claude_settings"
293
+ ? path.join(paths.claudeConfigDir, "settings.json")
294
+ : path.join(paths.codexHome, "config.toml");
295
+ let raw;
296
+ try {
297
+ raw = fs.readFileSync(filePath, "utf8");
298
+ } catch (error) {
299
+ return result("failed", `The profile file could not be read (${error?.code || error?.message}).`);
300
+ }
301
+ return result("succeeded", `Read the Impel-owned ${input.profile} profile.`, {
302
+ output: raw.slice(0, 4_000),
303
+ });
304
+ }
305
+
306
+ function checkAuthHelper(context, io) {
307
+ if (!context.tenantId) {
308
+ return result("failed", "No tenant is selected; the app token helper has not been created yet.");
309
+ }
310
+ if (context.platform === "win32") {
311
+ return result("failed", "Windows managed apps do not use a shell token helper; check the CLI profiles instead.");
312
+ }
313
+ const helperPath = path.join(
314
+ CONFIG_DIR,
315
+ "apps",
316
+ "tenants",
317
+ context.tenantId,
318
+ "bin",
319
+ "token"
320
+ );
321
+ if (!fs.existsSync(helperPath)) {
322
+ return result("failed", "The tenant app token helper is missing; repair_impel_profile regenerates it.");
323
+ }
324
+ const run = io.spawnSync(helperPath, [], {
325
+ encoding: "utf8",
326
+ env: context.environment,
327
+ stdio: ["ignore", "pipe", "pipe"],
328
+ timeout: 20_000,
329
+ windowsHide: true,
330
+ });
331
+ if (run?.error) {
332
+ return result("failed", `The app token helper could not be executed (${run.error.code || run.error.message}).`);
333
+ }
334
+ if (run.status === 0 && /impel_/u.test(String(run.stdout || ""))) {
335
+ return result("succeeded", "The app token helper runs and emits a credential.");
336
+ }
337
+ const stderr = String(run.stderr || "");
338
+ const classified = /Cannot find module|MODULE_NOT_FOUND/iu.test(stderr)
339
+ ? "its embedded impel-cli path no longer exists"
340
+ : /No such file or directory|not found|no usable node/iu.test(stderr)
341
+ ? "its embedded node runtime path no longer exists"
342
+ : /SyntaxError/iu.test(stderr)
343
+ ? "it points at an incompatible impel-cli checkout"
344
+ : "it exited without emitting a credential";
345
+ return result(
346
+ "failed",
347
+ `The app token helper is broken: ${classified}. repair_impel_profile rewrites it with current paths.`,
348
+ { exitCode: run.status, output: stderr.slice(0, 2_000) }
349
+ );
350
+ }
351
+
352
+ async function probeGateway(context) {
353
+ if (typeof context.probeGateway !== "function") {
354
+ return result("failed", "Gateway verification is unavailable in this recovery context.");
355
+ }
356
+ const probe = await context.probeGateway();
357
+ if (probe?.reachable && !probe?.rejected) {
358
+ return result("succeeded", `Gateway responded with HTTP ${probe.status}.`);
359
+ }
360
+ return result(
361
+ "failed",
362
+ probe?.rejected
363
+ ? `Gateway rejected the credential with HTTP ${probe.status}. A fresh PAT from the Impel gateway settings page usually fixes this.`
364
+ : `Gateway could not be reached: ${probe?.error || "unknown error"}.`
365
+ );
366
+ }
367
+
368
+ async function checkHealth(context) {
369
+ if (typeof context.runGoals !== "function") {
370
+ return result("failed", "No goal health checks were configured for this session.");
371
+ }
372
+ const report = await context.runGoals();
373
+ const lines = report.results.map(
374
+ (goal) => `${goal.ok ? "pass" : "FAIL"} ${goal.id}: ${goal.description}${goal.detail ? ` — ${goal.detail}` : ""}`
375
+ );
376
+ return result(
377
+ report.passed ? "succeeded" : "failed",
378
+ report.passed
379
+ ? "All goal health checks pass."
380
+ : `${report.results.filter((goal) => !goal.ok).length} goal health check(s) still failing.`,
381
+ { output: lines.join("\n") || "no goals configured" }
382
+ );
383
+ }
384
+
385
+ async function repairImpelProfile(context) {
386
+ if (typeof context.repairProfiles !== "function") {
387
+ return result("failed", "Impel profile repair is unavailable in this recovery context.");
388
+ }
389
+ const outcome = await context.repairProfiles();
390
+ return outcome === false
391
+ ? result("failed", "The Impel profile could not be repaired.")
392
+ : result("succeeded", "The Impel-managed profiles were regenerated.");
393
+ }
394
+
395
+ const STAGING_ENTRY_RE = /\.(?:tmp|previous)-\d+$/u;
396
+ const STAGING_SCAN_DEPTH = 6;
397
+ const STAGING_SCAN_LIMIT = 40;
398
+
399
+ function managedStagingPath(candidate, roots) {
400
+ const resolved = path.resolve(candidate);
401
+ return roots.some((root) => {
402
+ const managedRoot = `${path.resolve(root)}${path.sep}`;
403
+ return resolved.startsWith(managedRoot) && STAGING_ENTRY_RE.test(path.basename(resolved));
404
+ });
405
+ }
406
+
407
+ /** Impel-owned roots that installer staging artifacts can appear under. */
408
+ export function impelManagedStagingRoots(environment = process.env) {
409
+ const roots = [
410
+ path.join(CONFIG_DIR, "apps"),
411
+ IMPEL_CLI_PROFILES_DIR,
412
+ path.join(CONFIG_DIR, "vendor"),
413
+ ];
414
+ const appHome = environmentValue(environment, "IMPEL_APP_HOME");
415
+ if (appHome) roots.push(appHome);
416
+ return roots;
417
+ }
418
+
419
+ /** Bounded walk of the managed roots for `*.tmp-<pid>` / `*.previous-<pid>` leftovers. */
420
+ export function discoverImpelStagingPaths(roots, io = { readdirSync: fs.readdirSync }) {
421
+ const found = [];
422
+ const walk = (directory, depth) => {
423
+ if (depth > STAGING_SCAN_DEPTH || found.length >= STAGING_SCAN_LIMIT) return;
424
+ let entries;
425
+ try {
426
+ entries = io.readdirSync(directory, { withFileTypes: true });
427
+ } catch {
428
+ return;
429
+ }
430
+ for (const entry of entries) {
431
+ if (found.length >= STAGING_SCAN_LIMIT) return;
432
+ const full = path.join(directory, entry.name);
433
+ if (STAGING_ENTRY_RE.test(entry.name)) {
434
+ found.push(full);
435
+ continue;
436
+ }
437
+ if (entry.isDirectory() && !entry.isSymbolicLink()) walk(full, depth + 1);
438
+ }
439
+ };
440
+ for (const root of roots) walk(root, 0);
441
+ return found;
442
+ }
443
+
444
+ function cleanupStaging(context, io) {
445
+ const roots = context.managedRoots || impelManagedStagingRoots(context.environment);
446
+ const candidates = context.stagingPaths
447
+ || discoverImpelStagingPaths(roots, { readdirSync: io.readdirSync || fs.readdirSync });
448
+ const removable = candidates.filter((candidate) => managedStagingPath(candidate, roots));
449
+ for (const candidate of removable) {
450
+ io.rmSync(candidate, { recursive: true, force: true });
451
+ }
452
+ return result(
453
+ "succeeded",
454
+ removable.length
455
+ ? `Removed ${removable.length} stale Impel-owned staging path(s).`
456
+ : "No validated stale Impel staging path was present.",
457
+ removable.length ? { output: removable.join("\n") } : {}
458
+ );
459
+ }
460
+
461
+ async function retryStep(input, context) {
462
+ if (typeof context.retryStep !== "function") {
463
+ return result("failed", "The failed step cannot be retried in this process.");
464
+ }
465
+ const outcome = await context.retryStep(input.step);
466
+ return outcome === false
467
+ ? result("failed", "The step failed again.")
468
+ : result("succeeded", "The step completed on retry.");
469
+ }
470
+
471
+ async function installVendorCli(input, context) {
472
+ if (context.platform !== "win32" || typeof context.installVendorClis !== "function") {
473
+ return result("failed", "Automated vendor CLI installation is unavailable on this platform.");
474
+ }
475
+ const outcome = await context.installVendorClis(input.tool);
476
+ return outcome?.binaries?.[input.tool]
477
+ ? result("succeeded", `${input.tool} installed and passed verification.`)
478
+ : result("failed", `${input.tool} installation did not reach a verified state.`);
479
+ }
480
+
481
+ async function installVendorApp(input, context) {
482
+ if (typeof context.installVendorApp !== "function") {
483
+ return result("failed", "Vendor app installation is unavailable in this recovery context.");
484
+ }
485
+ const installed = await context.installVendorApp(input.target);
486
+ return installed === false
487
+ ? result("failed", `The ${input.target} vendor app did not install cleanly.`)
488
+ : result("succeeded", `The ${input.target} vendor app installation completed.`);
489
+ }
490
+
491
+ function repairUserPath(input, context, io) {
492
+ if (context.platform !== "win32") {
493
+ return result("failed", "User PATH repair is currently available only on Windows.");
494
+ }
495
+ const binary = io.findNativeBinary(input.tool, context.environment, context.platform);
496
+ if (!binary || !path.win32.isAbsolute(binary)) {
497
+ return result("failed", `${input.tool} has no trusted absolute install path to add.`);
498
+ }
499
+ const verification = nativeSpawnInvocation(binary, ["--version"], context.environment, "win32");
500
+ const verified = runBounded(io, verification.command, verification.args, context, {
501
+ windowsVerbatimArguments: verification.windowsVerbatimArguments,
502
+ });
503
+ if (verified?.status !== 0 || verified?.error) {
504
+ return result("failed", `${input.tool}'s discovered install path did not pass its bounded version check.`, {
505
+ exitCode: verified?.status,
506
+ output: verified?.error?.message || verified?.stderr,
507
+ });
508
+ }
509
+ const directory = path.win32.dirname(binary);
510
+ const currentPath = String(environmentValue(context.environment, "PATH") || "");
511
+ const entries = currentPath.split(";").filter(Boolean);
512
+ if (!entries.some((entry) => entry.toLowerCase() === directory.toLowerCase())) {
513
+ context.environment.PATH = [directory, currentPath].filter(Boolean).join(";");
514
+ }
515
+ const powershell = nativeCommandInvocation(
516
+ "powershell",
517
+ [
518
+ "-NoLogo",
519
+ "-NoProfile",
520
+ "-NonInteractive",
521
+ "-ExecutionPolicy",
522
+ "Bypass",
523
+ "-Command",
524
+ "$d=$env:IMPEL_RECOVERY_PATH; $p=[Environment]::GetEnvironmentVariable('Path','User'); $a=@($p -split ';' | Where-Object { $_ }); if (-not ($a | Where-Object { $_ -ieq $d })) { [Environment]::SetEnvironmentVariable('Path',(($a + $d) -join ';'),'User') }",
525
+ ],
526
+ context.environment,
527
+ "win32"
528
+ );
529
+ const run = io.spawnSync(powershell.command, powershell.args, {
530
+ encoding: "utf8",
531
+ env: { ...context.environment, IMPEL_RECOVERY_PATH: directory },
532
+ stdio: ["ignore", "pipe", "pipe"],
533
+ timeout: 15_000,
534
+ windowsHide: true,
535
+ windowsVerbatimArguments: powershell.windowsVerbatimArguments,
536
+ });
537
+ return run?.status === 0 && !run?.error
538
+ ? result("succeeded", `${input.tool}'s trusted install directory was added to the user PATH.`)
539
+ : result("failed", `Could not add ${input.tool}'s install directory to the user PATH.`, {
540
+ exitCode: run?.status,
541
+ output: run?.error?.message || run?.stderr,
542
+ });
543
+ }
544
+
545
+ /**
546
+ * Execute one validated tool call. `context.confirmed` must already reflect
547
+ * the approval decision for mutating tools; unconfirmed mutations return a
548
+ * "declined" result instead of running.
549
+ */
550
+ export async function executeInstallRecoveryTool(name, rawInput, context = {}, dependencies = {}) {
551
+ const input = validateInstallRecoveryToolCall(name, rawInput);
552
+ if (input === null) {
553
+ return result("failed", "Refused an unknown or malformed recovery tool call.");
554
+ }
555
+ const risk = installRecoveryToolRisk(name);
556
+ if (risk !== "read" && context.confirmed !== true) {
557
+ return result("declined", "The local mutation was not approved on this machine.");
558
+ }
559
+ const resolvedContext = {
560
+ platform: process.platform,
561
+ environment: process.env,
562
+ isTTY: process.stdin.isTTY,
563
+ ...context,
564
+ };
565
+ const io = { spawnSync, findNativeBinary, rmSync: fs.rmSync, ...dependencies };
566
+ switch (name) {
567
+ case "inspect_runtime": return inspectRuntime(resolvedContext);
568
+ case "inspect_path": return inspectPath(resolvedContext);
569
+ case "inspect_processes": return inspectProcesses(resolvedContext, io);
570
+ case "check_binary": return checkBinary(input, resolvedContext, io);
571
+ case "read_profile": return readProfile(input, resolvedContext);
572
+ case "check_auth_helper": return checkAuthHelper(resolvedContext, io);
573
+ case "probe_gateway": return probeGateway(resolvedContext);
574
+ case "check_health": return checkHealth(resolvedContext);
575
+ case "repair_impel_profile": return repairImpelProfile(resolvedContext);
576
+ case "cleanup_impel_staging": return cleanupStaging(resolvedContext, io);
577
+ case "retry_step": return retryStep(input, resolvedContext);
578
+ case "install_vendor_cli": return installVendorCli(input, resolvedContext);
579
+ case "install_vendor_app": return installVendorApp(input, resolvedContext);
580
+ case "repair_user_path": return repairUserPath(input, resolvedContext, io);
581
+ case "report_outcome":
582
+ return result("succeeded", "Outcome recorded.");
583
+ default:
584
+ return result("failed", "Refused an unregistered recovery tool.");
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Known-fingerprint repairs that run before any inference. Each entry is a
590
+ * tool call the engine executes under the normal approval rules.
591
+ */
592
+ export function deterministicInstallRecoveryPlan(failure, platform = process.platform) {
593
+ const text = `${failure.step} ${failure.errorCode || ""} ${failure.message}`.toLowerCase();
594
+ const plan = [];
595
+ if (/profile|config\.toml|settings\.json/u.test(text)) {
596
+ plan.push({
597
+ tool: "repair_impel_profile",
598
+ input: {},
599
+ reason: "The failure mentions an Impel-managed profile file; regenerate it.",
600
+ });
601
+ }
602
+ if (/staging|\.tmp-|incomplete app/u.test(text)) {
603
+ plan.push({
604
+ tool: "cleanup_impel_staging",
605
+ input: {},
606
+ reason: "The failure mentions stale staging state from an interrupted install.",
607
+ });
608
+ }
609
+ if (platform === "win32" && /installer completed.*not.*verif|not discoverable|stale path/u.test(text)) {
610
+ const tool = /claude/u.test(text) ? "claude" : /codex/u.test(text) ? "codex" : null;
611
+ if (tool) {
612
+ plan.push({
613
+ tool: "repair_user_path",
614
+ input: { tool },
615
+ reason: "The installer finished but the command is not on PATH; add the verified install directory.",
616
+ });
617
+ }
618
+ }
619
+ if (/busy|eperm|ebusy|locked/u.test(text)) {
620
+ plan.push({
621
+ tool: "inspect_processes",
622
+ input: {},
623
+ reason: "The failure looks like a file lock; find the blocking process.",
624
+ });
625
+ }
626
+ if (/npm install|self-update|update failed/u.test(text)) {
627
+ plan.push({
628
+ tool: "retry_step",
629
+ input: { step: failure.step },
630
+ reason: "Transient npm/network failures often succeed on a clean retry.",
631
+ });
632
+ }
633
+ if (/provider auth command|cannot find module|module_not_found|token helper/u.test(text)) {
634
+ plan.push(
635
+ {
636
+ tool: "check_auth_helper",
637
+ input: {},
638
+ reason: "The failure mentions the app auth helper; classify how it is broken.",
639
+ },
640
+ {
641
+ tool: "repair_impel_profile",
642
+ input: {},
643
+ reason: "Regenerating Impel-owned profiles rewrites the token helper with current paths.",
644
+ }
645
+ );
646
+ }
647
+ return plan;
648
+ }