Code

/**
 * mcp/skills/runInstaller.ts — run_installer skill
 *
 * Executes a downloaded installer file (.pkg/.dmg on macOS;
 * .msi/.exe/.msix on Windows) to (re)install a software application.
 * Use after `download_installer` (Skill #8 Step 6) when the user has
 * confirmed they want to proceed with installation.
 *
 * Privilege model
 * ---------------
 * .msi/.msix/.pkg/.dmg always write into protected system locations
 * (Program Files, HKLM, /Applications, /Library) and always need admin /
 * LocalSystem privilege. The agent runs as the standard user; G4 routes
 * these through the privileged helper daemon (Workstream B v2 +
 * fast-follow) so non-admin users complete the install end-to-end without
 * an interactive password prompt.  When the helper is unavailable
 * (HELPER_DAEMON_ENABLED=false / not installed / unreachable), the call
 * denies with helper-error / helper-unavailable / scope-boundary and the
 * agent falls back to the "ask the user to run the installer manually" path.
 *
 * .exe is different: a large share of real-world .exe installers (Zoom,
 * Slack/Squirrel, Discord, GitHub Desktop, ...) are self-contained
 * per-user bootstrappers that were never designed to run as LocalSystem —
 * they install into %APPDATA%/%LocalAppData% and need NO elevation at
 * all. Running one of these via the privileged helper installs it into
 * the SYSTEM account's own profile instead of the real user's, and the
 * installer's own "launch the app now" step then fails outright (observed:
 * "Windows cannot access the specified device, path, or file" popping a
 * dialog nobody can dismiss, hanging the run). So .exe tries locally first
 * — see runLocalExeInstall() — and only escalates to the helper if that
 * local attempt demonstrates the installer genuinely needs admin.
 *
 * Platform strategy
 * -----------------
 * macOS .pkg    `installer -pkg <path> -target /`                          (always helper-routed)
 * macOS .dmg    mount via `hdiutil`, copy .app to /Applications, eject      (always helper-routed)
 * Windows .msi  `msiexec /i <path> /qn /norestart`                         (always helper-routed)
 * Windows .exe  execFile(<path>, ["/S"]) as the current user first;        (helper-routed only on
 *               `Start-Process /S` via the helper only on demonstrated       demonstrated need)
 *               elevation need
 * Windows .msix `Add-AppxPackage -Path <path> -AllUsers` — unlike the other (always helper-routed)
 *               three types, this requires the package's signing certificate
 *               to already be trusted on the machine; an otherwise-valid
 *               package fails here if that trust hasn't been provisioned.
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/runInstaller.ts
 */
 
import * as fs         from "fs";
import * as path       from "path";
import * as os         from "os";
import { execFile }    from "child_process";
import { promisify }   from "util";
import { z }           from "zod";
 
const execFileAsync = promisify(execFile);
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "run_installer",
  description:
    "Runs a downloaded installer file (.pkg/.dmg on macOS; .msi/.exe/.msix " +
    "on Windows) to (re)install a software application.  Use after " +
    "download_installer when the user has confirmed they want to " +
    "proceed with installation.  Requires admin privileges, which the " +
    "privileged helper daemon supplies for non-admin users.",
  riskLevel:       "high",
  destructive:     true,
  requiresConsent: true,
  supportsDryRun:  true,
  affectedScope:   ["system"],
  auditRequired:   true,
  // Default G4 step timeout (60s) is routinely too short for a real
  // install — observed timing out on Zoom/Teams/Slack reinstalls in the
  // field. The underlying helper daemon already budgets up to 600s per
  // command (see run_installer.rs PER_COMMAND_TIMEOUT); this is the outer
  // G4 ceiling, forwarded to the helper CLI roundtrip too (see
  // runViaHelper in execution.ts) so both layers agree.
  timeoutMs:       120_000,
  escalationHint:  {
    darwin:
      "sudo installer -pkg <path>.pkg -target /  # for .pkg; .dmg requires hdiutil mount + cp + eject",
    win32:
      "msiexec /i <path>.msi /qn /norestart  # for .msi; .exe varies per vendor (try /S for silent install); " +
      "Add-AppxPackage -Path <path>.msix -AllUsers  # for .msix — requires the package's signing certificate " +
      "to already be trusted on the machine",
  },
  outputKeys: ["installerPath","installerType","dryRun","plannedCommand","exitCode","durationMs","message"],
  schema: {
    // snake_case keys: this tool routes its real run through the privileged
    // helper, whose `struct Params` is snake_case (`installer_path`,
    // `installer_type`) per the documented wire contract (HELPER-IPC-PROTOCOL.md
    // / HELPER-HANDLERS.md). G4 forwards executor params verbatim, so the schema
    // keys MUST match the helper field names exactly.
    installer_path: z
      .string()
      .min(1)
      .describe(
        "Absolute path to the installer file on disk.  Typically the " +
        "filePath returned by a prior download_installer call.",
      ),
    installer_type: z
      .enum(["pkg", "dmg", "msi", "exe", "msix"])
      .nullable().optional()
      .describe(
        "Installer type.  When omitted, auto-detected from the file " +
        "extension.  Must match the platform (pkg/dmg → macOS; " +
        "msi/exe/msix → Windows).",
      ),
    dryRun: z
      .boolean()
      .nullable().optional()
      .describe(
        "If true, validate the installer + show what would run, but do " +
        "not execute.  Default: true (G4 dry-run-first policy).",
      ),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
interface RunInstallerResult {
  installerPath: string;
  installerType: "pkg" | "dmg" | "msi" | "exe" | "msix";
  dryRun:        boolean;
  /** The exact command string the helper / sudo would execute.  Echoed
   *  in dry-run mode so the consent gate can show it to the user. */
  plannedCommand: string;
  /** Set after a real run; absent in dry-run mode. */
  exitCode?:     number;
  /** Set after a real run; absent in dry-run mode. */
  durationMs?:   number;
  message:       string;
}
 
// -- Helpers ------------------------------------------------------------------
 
function detectInstallerType(installerPath: string): "pkg" | "dmg" | "msi" | "exe" | "msix" {
  const ext = path.extname(installerPath).toLowerCase().replace(/^\./, "");
  if (ext === "pkg" || ext === "dmg" || ext === "msi" || ext === "exe" || ext === "msix") {
    return ext;
  }
  throw new Error(
    `Cannot detect installer type from extension '.${ext}' — supply installerType explicitly`,
  );
}
 
function plannedCommandFor(
  type: "pkg" | "dmg" | "msi" | "exe" | "msix",
  installerPath: string,
): string {
  switch (type) {
    case "pkg":
      return `installer -pkg "${installerPath}" -target /`;
    case "dmg":
      return `hdiutil attach "${installerPath}" -nobrowse -plist  →  cp -Rf <mount>/<app>.app /Applications/  →  hdiutil detach <mount> -force`;
    case "msi":
      return `msiexec /i "${installerPath}" /qn /norestart`;
    case "exe":
      return `Start-Process -FilePath "${installerPath}" -ArgumentList /S -Wait -PassThru`;
    case "msix":
      return `Add-AppxPackage -Path "${installerPath}" -AllUsers`;
  }
}
 
// -- Local (non-elevated) .exe install path -----------------------------------
 
/**
 * Thrown when a local, non-elevated install attempt demonstrates that the
 * installer genuinely needs administrator privileges, so the G4 execution
 * guard knows to retry via the privileged helper instead of surfacing this
 * as a normal tool failure.
 *
 * Classified by `.name`, not `instanceof`: this module is compiled into
 * dist/skills/ and loaded via require() at runtime, separately from the
 * main Electron bundle that contains G4 — two independently compiled
 * module graphs never share a class reference, so an instanceof check
 * across that boundary would never match even for the "same" class.
 */
export class NeedsElevationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "NeedsElevationError";
  }
}
 
/**
 * Best-effort signal that a failed spawn was rejected specifically because
 * the target .exe requires elevation, as opposed to any other install
 * failure (bad download, wrong architecture, corrupt installer, the
 * installer ran but exited non-zero for its own reasons). A failed
 * CreateProcess call (couldn't start at all) surfaces to Node as an errno
 * string code (EACCES/EPERM) rather than a numeric exit code, which is
 * the generic "no permission to do this" signal Node uses across
 * platforms — a nonzero numeric `.code` means the process DID start and
 * exited on its own, which is a real install failure, not an elevation
 * issue, and must not be swallowed into a helper retry.
 *
 * NOT yet verified against a real elevation-required installer on Windows
 * (this is Windows-only runtime behaviour and development happens on
 * macOS) — if this under- or over-triggers in the field, tighten or
 * loosen this pattern rather than the call site's escalation logic.
 */
function looksLikeElevationRequired(err: unknown): boolean {
  const e = err as (NodeJS.ErrnoException & { message?: string }) | undefined;
  if (!e) return false;
  if (e.code === "EACCES" || e.code === "EPERM") return true;
  return /elevation|ERROR_ELEVATION_REQUIRED|\b740\b/i.test(e.message ?? "");
}
 
/**
 * Runs a Windows .exe installer directly via child_process.execFile — NOT
 * PowerShell's Start-Process — as the current, non-elevated process.
 *
 * Deliberately bypasses PowerShell/ShellExecute: ShellExecute honours a
 * target's "requireAdministrator" manifest by popping an interactive UAC
 * consent prompt, which would hang indefinitely here (there is nobody to
 * click it in an automation context — this is exactly how the Zoom
 * install hung when run as SYSTEM). execFile calls CreateProcess
 * directly, which has no such manifest-driven auto-elevation behaviour: a
 * binary that truly requires admin fails the call outright and
 * synchronously — precisely the signal looksLikeElevationRequired() is
 * watching for. A binary that does NOT require admin (the common case for
 * consumer installers) just runs, as the real interactive user, into
 * their own profile — sidestepping the SYSTEM-profile mismatch entirely.
 *
 * Returns the exit code on success (always 0 — execFile only resolves
 * when the child exits 0). Throws NeedsElevationError when the spawn
 * itself was rejected for a permission reason; throws a plain Error for
 * any other failure (real install failure — must not retry via helper).
 */
async function runLocalExeInstall(
  installerPath: string,
  signal?:       AbortSignal,
): Promise<number> {
  try {
    await execFileAsync(installerPath, ["/S"], { signal });
    return 0;
  } catch (err) {
    if (looksLikeElevationRequired(err)) {
      throw new NeedsElevationError(
        `"${installerPath}" appears to require administrator privileges ` +
        `(local non-elevated launch failed: ${(err as Error).message})`,
      );
    }
    throw new Error(`Local install failed: ${(err as Error).message}`);
  }
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run(
  {
    installer_path: installerPath,
    installer_type: installerType,
    dryRun = true,
  }: {
    installer_path:  string;
    installer_type?: "pkg" | "dmg" | "msi" | "exe" | "msix";
    dryRun?:         boolean;
  },
  ctx?: { signal?: AbortSignal },
): Promise<RunInstallerResult> {
  // Resolve the installer type (explicit or auto-detected).
  const resolvedType = installerType ?? detectInstallerType(installerPath);
 
  // Cross-platform sanity: the agent-side schema validation already
  // accepts any of the five; here we surface a clear error if the user
  // is on the wrong platform for the chosen type before calling the
  // helper (which would also reject, but with a less friendly message).
  const platform = os.platform();
  const macosTypes = ["pkg", "dmg"] as const;
  const winTypes   = ["msi", "exe", "msix"] as const;
  if (platform === "darwin" && (winTypes as readonly string[]).includes(resolvedType)) {
    throw new Error(
      `Installer type '${resolvedType}' is for Windows; this device is macOS`,
    );
  }
  if (platform === "win32" && (macosTypes as readonly string[]).includes(resolvedType)) {
    throw new Error(
      `Installer type '${resolvedType}' is for macOS; this device is Windows`,
    );
  }
 
  // Path must exist and be a regular file before we even attempt to
  // route the call to the helper.  The helper validates again on its
  // side — this is for fast user-feedback in dry-run mode.
  if (!path.isAbsolute(installerPath)) {
    throw new Error(`installerPath must be absolute, got: ${installerPath}`);
  }
  let stat: fs.Stats;
  try {
    stat = fs.statSync(installerPath);
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    throw new Error(`installerPath not readable: ${msg}`);
  }
  if (!stat.isFile()) {
    throw new Error(`installerPath does not point to a regular file: ${installerPath}`);
  }
 
  const planned = plannedCommandFor(resolvedType, installerPath);
 
  const isLocalFirstExe = resolvedType === "exe" && platform === "win32";
 
  if (dryRun) {
    // Dry-run: do not invoke the helper.  Return the planned command
    // for the G4 consent gate to display.
    return {
      installerPath,
      installerType: resolvedType,
      dryRun:        true,
      plannedCommand: planned,
      message: isLocalFirstExe
        ? `Would run: ${planned}\n\n` +
          `Most .exe installers (Zoom, Slack, Discord, etc.) are per-user ` +
          `and don't need admin — this will first be attempted directly as ` +
          `the current user.  Only if that demonstrates the installer genuinely ` +
          `needs elevation will the agent retry it through the privileged helper ` +
          `daemon (when available).`
        : `Would run: ${planned}\n\n` +
          `On confirmation, the agent will route this through the privileged ` +
          `helper daemon (when available) so non-admin users complete the ` +
          `install end-to-end.  When the helper is unavailable, the call ` +
          `denies with helper-error / helper-unavailable / scope-boundary ` +
          `and the user must run the installer manually.`,
    };
  }
 
  // .exe: try running it directly, as the current (non-elevated) user,
  // before ever involving the privileged helper — see runLocalExeInstall's
  // doc comment for why (per-user installers, SYSTEM-profile mismatch).
  // Throwing NeedsElevationError here is a signal G4's executeStep acts
  // on to retry via the helper; any other error is a real install failure
  // and propagates normally.
  if (isLocalFirstExe) {
    const started  = Date.now();
    const exitCode = await runLocalExeInstall(installerPath, ctx?.signal);
    return {
      installerPath,
      installerType:  resolvedType,
      dryRun:         false,
      plannedCommand: planned,
      exitCode,
      durationMs:     Date.now() - started,
      message:        `Installed successfully, no elevation required: ${planned}`,
    };
  }
 
  // Real run for .msi/.msix/.pkg/.dmg: G4's scope-boundary check routes
  // this op through the helper daemon automatically because
  // affectedScope: ["system"] + helper allowlist contains
  // "run_installer".  The agent-side tool does NOT shell out to
  // `msiexec` / `installer` directly — that would bypass the
  // helper-routing pipeline and fail for non-admin users.  Instead, we
  // throw a sentinel error here that the G4 layer intercepts and
  // replaces with the helper-routed call.
  //
  // In practice, when the agent runtime invokes this tool with
  // dryRun=false, it does so through the G4 execute step, which has
  // already chosen "route via helper" for this tool.  The helper
  // returns { installer_path, installer_type, success, exit_code,
  // duration_ms }; the runtime maps that into RunInstallerResult.
  throw new Error(
    "run_installer is helper-routed; the agent runtime should call the " +
    "helper bridge directly rather than this tool's local run().  " +
    "Reaching this code means the routing layer didn't intercept the call.",
  );
}
 
// -- Smoke test ---------------------------------------------------------------
 
if (false) {
  run({ installer_path: "/tmp/example.pkg" })
    .then(r => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}