Files
ClaudeMailbox/node/src/autostart/windows.ts
Mika Kuns 42237149a1
Some checks failed
CI (Node) / build-test (push) Successful in 9s
Release (Node) / release (push) Failing after 1s
Release / release (push) Successful in 7s
feat: stdio MCP wrapper + Windows Run-key autostart fallback (v1.0.1)
Two production-readiness fixes so colleagues can install cleanly:

1. Plugin's MCP server now spawns `claude-mailbox mcp-stdio`, a small
   stdio MCP wrapper that proxies tool calls to the daemon's REST API.
   Claude Code does not support env-var substitution in HTTP MCP `url`
   fields (issue #46889), so the wrapper is the only way to make the
   daemon URL configurable per machine via CLAUDE_MAILBOX_URL.

2. Windows `install-autostart` now falls back from `schtasks /Create`
   to an HKCU\Software\Microsoft\Windows\CurrentVersion\Run entry
   when Group Policy blocks the Scheduled Task path. Both modes are
   per-user, no admin, persist across logoffs. The chosen mode is
   recorded in ~/.claude-mailbox/autostart-mode so status/start/stop/
   uninstall-autostart pick the right cleanup path.

Also bumps the npm version to 1.0.1 to align with the published 1.0.0
plus this patch.
2026-05-19 13:43:55 +02:00

354 lines
10 KiB
TypeScript

import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
import { userConfigPath } from "../config.js";
function markerPath(): string {
return join(dirname(userConfigPath()), MARKER_FILE);
}
function readActiveMode(): "task" | "run-key" | null {
const path = markerPath();
if (!existsSync(path)) return null;
const raw = readFileSync(path, "utf8").trim();
if (raw === "task" || raw === "run-key") return raw;
return null;
}
function writeActiveMode(mode: "task" | "run-key"): void {
const path = markerPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, mode, "utf8");
}
function clearActiveMode(): void {
const path = markerPath();
if (existsSync(path)) rmSync(path, { force: true });
}
function isAccessDenied(stderr: string): boolean {
return /access is denied|0x80070005/i.test(stderr);
}
const TASK_NAME = "ClaudeMailbox";
const SERVICE_NAME = "ClaudeMailbox";
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
const RUN_VALUE = "ClaudeMailbox";
const MARKER_FILE = "autostart-mode";
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
const path = userConfigPath();
if (!existsSync(path)) {
mkdirSync(join(path, ".."), { recursive: true });
const seed: Record<string, unknown> = {};
if (opts.port !== undefined) seed.port = opts.port;
if (opts.bind !== undefined) seed.bind = opts.bind;
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
}
return path;
}
function buildServeCommand(): { node: string; script: string; configPath: string } {
const { node, script } = cliEntry();
return { node, script, configPath: userConfigPath() };
}
function buildServeCommandString(configPath: string): string {
const { node, script } = buildServeCommand();
return `"${node}" "${script}" serve --config "${configPath}"`;
}
function tryScheduledTaskInstall(opts: AutostartInstallOpts): { ok: boolean; stderr: string } {
const configPath = ensureConfigSeeded(opts);
const tr = buildServeCommandString(configPath);
const r = run("schtasks.exe", [
"/Create",
"/SC",
"ONLOGON",
"/TN",
TASK_NAME,
"/TR",
tr,
"/RL",
"LIMITED",
"/F",
]);
if (r.status !== 0) return { ok: false, stderr: r.stderr || r.stdout };
run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
return { ok: true, stderr: "" };
}
function runKeyInstall(opts: AutostartInstallOpts): void {
const configPath = ensureConfigSeeded(opts);
const cmd = buildServeCommandString(configPath);
const r = run("reg.exe", [
"add",
RUN_KEY,
"/v",
RUN_VALUE,
"/t",
"REG_SZ",
"/d",
cmd,
"/f",
]);
if (r.status !== 0) {
throw new Error(`reg add (HKCU Run) failed (exit ${r.status}): ${r.stderr || r.stdout}`);
}
spawnRunKeyDaemon(configPath);
}
function spawnRunKeyDaemon(configPath: string): void {
if (runKeyDaemonRunning()) return;
const { node, script } = buildServeCommand();
const ps = `Start-Process -WindowStyle Hidden -FilePath "${node}" -ArgumentList @('"${script}"','serve','--config','"${configPath}"')`;
run("powershell.exe", ["-NoProfile", "-Command", ps]);
}
function runKeyDaemonRunning(): boolean {
const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*claude-mailbox*serve*' } | Select-Object -First 1 -ExpandProperty ProcessId`;
const r = run("powershell.exe", ["-NoProfile", "-Command", ps]);
return r.status === 0 && r.stdout.trim().length > 0;
}
function killRunKeyDaemon(): void {
const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*claude-mailbox*serve*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`;
run("powershell.exe", ["-NoProfile", "-Command", ps]);
}
function runKeyUninstall(): void {
const r = run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
if (r.status !== 0 && !/unable to find/i.test(r.stderr)) {
throw new Error(`reg delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
}
killRunKeyDaemon();
}
function scheduledTaskInstall(opts: AutostartInstallOpts): void {
const attempt = tryScheduledTaskInstall(opts);
if (attempt.ok) {
writeActiveMode("task");
return;
}
if (isAccessDenied(attempt.stderr)) {
console.warn(
"schtasks /Create denied by Windows policy — falling back to HKCU Run-key autostart (per-user, no admin).",
);
runKeyInstall(opts);
writeActiveMode("run-key");
return;
}
throw new Error(`schtasks /Create failed: ${attempt.stderr}`);
}
function scheduledTaskUninstall(purge: boolean): void {
const mode = readActiveMode();
if (mode === "run-key") {
runKeyUninstall();
clearActiveMode();
if (purge) purgeData();
return;
}
// Default to task uninstall, also clean up Run-key in case of mixed state
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
const r = run("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"]);
if (r.status !== 0 && !/cannot find/i.test(r.stderr) && !/does not exist/i.test(r.stderr)) {
// Fall through — try Run-key cleanup anyway
}
// Best-effort Run-key cleanup
run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]);
killRunKeyDaemon();
clearActiveMode();
if (purge) purgeData();
}
function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
const mode = readActiveMode();
if (mode === "run-key") {
const r = run("reg.exe", ["query", RUN_KEY, "/v", RUN_VALUE]);
if (r.status !== 0) return "NotInstalled";
return runKeyDaemonRunning() ? "Running" : "Stopped";
}
const r = run("schtasks.exe", ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]);
if (r.status !== 0) {
if (/cannot find/i.test(r.stderr) || /does not exist/i.test(r.stderr)) {
// Maybe a Run-key install happened without a marker (legacy / manual). Check reg.
const reg = run("reg.exe", ["query", RUN_KEY, "/v", RUN_VALUE]);
if (reg.status === 0) return runKeyDaemonRunning() ? "Running" : "Stopped";
return "NotInstalled";
}
return "Stopped";
}
if (/Status:\s*Running/i.test(r.stdout)) return "Running";
return "Stopped";
}
function scheduledTaskRun(): void {
const mode = readActiveMode();
if (mode === "run-key") {
const cfgPath = userConfigPath();
spawnRunKeyDaemon(cfgPath);
return;
}
const r = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
if (r.status !== 0) throw new Error(`schtasks /Run failed: ${r.stderr || r.stdout}`);
}
function scheduledTaskEnd(): void {
const mode = readActiveMode();
if (mode === "run-key") {
killRunKeyDaemon();
return;
}
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
}
interface NodeWindowsService {
on(event: "install" | "uninstall" | "alreadyinstalled" | "start" | "stop", cb: () => void): void;
install(): void;
uninstall(): void;
start(): void;
stop(): void;
exists?: boolean;
}
interface NodeWindowsModule {
Service: new (opts: {
name: string;
description?: string;
script: string;
nodeOptions?: string[];
workingDirectory?: string;
}) => NodeWindowsService;
}
async function loadNodeWindows(): Promise<NodeWindowsModule> {
try {
return (await import("node-windows")) as unknown as NodeWindowsModule;
} catch (err) {
throw new Error(
"node-windows is not installed. Install it with `npm i -g node-windows` or use the default Scheduled Task autostart instead.",
);
}
}
function isAdministrator(): boolean {
const r = run("net.exe", ["session"]);
return r.status === 0;
}
async function serviceInstall(opts: AutostartInstallOpts): Promise<void> {
if (!isAdministrator()) {
throw new Error("install-autostart --service requires an Administrator shell.");
}
ensureConfigSeeded(opts);
const { script, configPath } = buildServeCommand();
const nw = await loadNodeWindows();
await new Promise<void>((resolveFn, rejectFn) => {
const svc = new nw.Service({
name: SERVICE_NAME,
description: "ClaudeMailbox MCP mail daemon for parallel Claude session coordination.",
script,
nodeOptions: [],
});
svc.on("install", () => {
svc.start();
resolveFn();
});
svc.on("alreadyinstalled", () => resolveFn());
try {
svc.install();
} catch (e) {
rejectFn(e);
}
void configPath;
});
}
async function serviceUninstall(purge: boolean): Promise<void> {
if (!isAdministrator()) {
throw new Error("uninstall-autostart --service requires an Administrator shell.");
}
const { script } = buildServeCommand();
const nw = await loadNodeWindows();
await new Promise<void>((resolveFn, rejectFn) => {
const svc = new nw.Service({ name: SERVICE_NAME, script });
svc.on("uninstall", () => resolveFn());
try {
svc.uninstall();
} catch (e) {
rejectFn(e);
}
});
if (purge) purgeData();
}
function serviceStatus(): "Running" | "Stopped" | "NotInstalled" {
const r = run("sc.exe", ["query", SERVICE_NAME]);
if (r.status !== 0) return "NotInstalled";
if (/STATE\s*:\s*\d+\s+RUNNING/i.test(r.stdout)) return "Running";
return "Stopped";
}
function serviceStart(): void {
const r = run("sc.exe", ["start", SERVICE_NAME]);
if (r.status !== 0) throw new Error(`sc start failed: ${r.stderr || r.stdout}`);
}
function serviceStop(): void {
const r = run("sc.exe", ["stop", SERVICE_NAME]);
if (r.status !== 0 && !/has not been started/i.test(r.stdout)) {
throw new Error(`sc stop failed: ${r.stderr || r.stdout}`);
}
}
function purgeData(): void {
const cfg = userConfigPath();
if (existsSync(cfg)) {
try {
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
void parsed;
} catch {
// ignore
}
}
}
export function windowsManager(mode: "default" | "service"): AutostartManager {
if (mode === "service") {
return {
mode,
install: serviceInstall,
uninstall: serviceUninstall,
async start() {
serviceStart();
},
async stop() {
serviceStop();
},
async status() {
return serviceStatus();
},
};
}
return {
mode,
async install(opts) {
scheduledTaskInstall(opts);
},
async uninstall(purge) {
scheduledTaskUninstall(purge);
},
async start() {
scheduledTaskRun();
},
async stop() {
scheduledTaskEnd();
},
async status() {
return scheduledTaskStatus();
},
};
}