// Must stay FIRST and stay an import: importing it populates // `process.env` from `/.env` during the import phase, before any // module below reads env at its own scope. A call in the body would run // after every import had already been evaluated — too late. See // server/system/loadEnv.ts. import { shadowedByServerLoad } from "./system/loadEnv.js"; // Wire @mulmoclaude/core/collection/server to this host's workspace + logger // before any module that touches collection storage loads. import "./workspace/collections/configure.js"; import "./services/google/configure.js"; import express, { Request, Response, NextFunction } from "express"; import path from "path"; import { fileURLToPath } from "url"; import agentRoutes, { startChat, spawnSystemWorker } from "./api/routes/agent.js"; import { createAccountingRouter, initAccountingEventPublisher, configureAccountingServer } from "@mulmoclaude/accounting-plugin/server"; import photoLocationsRoutes from "./api/routes/photo-locations.js"; import schedulerRoutes from "./api/routes/scheduler.js"; import sessionsRoutes, { loadAllSessions } from "./api/routes/sessions.js"; import chatIndexRoutes from "./api/routes/chat-index.js"; import feedsRoutes from "./api/routes/feeds.js"; import pluginsRoutes from "./api/routes/plugins.js"; import imageRoutes from "./api/routes/image.js"; import attachmentRoutes from "./api/routes/attachment.js"; import transcribeRoutes from "./api/routes/transcribe.js"; import presentHtmlRoutes from "./api/routes/presentHtml.js"; import shareRoutes from "./api/routes/share.js"; import remoteHostRoutes from "./api/routes/remoteHost.js"; import googleRoutes from "./api/routes/google.js"; import presentSvgRoutes from "./api/routes/presentSvg.js"; import chartRoutes from "./api/routes/chart.js"; import rolesRoutes from "./api/routes/roles.js"; import shortcutsRoutes from "./api/routes/shortcuts.js"; import dashboardRoutes from "./api/routes/dashboard.js"; import { DEFAULT_ROLE_ID } from "../src/config/roles.js"; import mulmoScriptRoutes from "./api/routes/mulmo-script.js"; import wikiRoutes from "./api/routes/wiki.js"; import wikiHistoryRoutes from "./api/routes/wiki/history.js"; import { provisionDispatcherHook } from "./workspace/hooks/provision.js"; import pdfRoutes from "./api/routes/pdf.js"; import marpThemesRoutes from "./api/routes/marp-themes.js"; import filesRoutes from "./api/routes/files.js"; import configRoutes from "./api/routes/config.js"; import configRefreshRoutes from "./api/routes/config-refresh.js"; import hookLogRoutes from "./api/routes/hookLog.js"; import mcpBrokerReadyRoutes from "./api/routes/mcpBrokerReady.js"; import skillsRoutes from "./api/routes/skills.js"; import collectionsRoutes, { makeViewActionRateLimiter } from "./api/routes/collections.js"; import collectionsRegistryRoutes from "./api/routes/collectionsRegistry.js"; import { startCollectionWatchers } from "./workspace/collections/watcher.js"; import runtimePluginRoutes from "./api/routes/runtime-plugin.js"; // Side-effect: registers the built-in "markdown" dispatch handler so the // markdown View's useRuntime().dispatch({ kind }) resolves (task #6). import "./plugins/markdown-builtin.js"; // Side-effect: registers the built-in "html" dispatch handler so the // presentHtml View's useRuntime().dispatch({ kind }) resolves (phase 2). import "./plugins/html-builtin.js"; import { loadRuntimePlugins } from "./plugins/runtime-loader.js"; import { evaluateDevPluginGate, loadDevPlugins, parseDevPluginsEnv } from "./plugins/dev-loader.js"; import { watchDevPlugins } from "./plugins/dev-watcher.js"; import { loadPresetPlugins } from "./plugins/preset-loader.js"; import { registerRuntimePlugins } from "./plugins/runtime-registry.js"; import { makePluginRuntime } from "./plugins/runtime.js"; import { MCP_PLUGIN_NAMES } from "./agent/plugin-names.js"; import { claudeCredentialsPath } from "./utils/claudeConfigPath.js"; import { setActiveBackend } from "./agent/backend/index.js"; import { fakeEchoBackend } from "./agent/backend/fake-echo.js"; import { startMacosReminderAdapter } from "./notifier/macosReminderAdapter.js"; import notifierRoutes from "./api/routes/notifier.js"; import { createShutdownRouter } from "./api/routes/shutdown.js"; import { initNotifier } from "./notifier/engine.js"; import { registerSaveAttachmentHook } from "./utils/files/attachment-store.js"; import { capturePhotoLocation } from "./workspace/photo-locations/index.js"; import { createJournalRouter } from "./api/routes/journal.js"; import { createTranslationRouter } from "./api/routes/translation.js"; import { announcePluginMetaDiagnostics } from "./plugins/diagnostics.js"; import { announceShadowedEnv, SHADOWED_ENV_KEYS_VAR } from "./system/shadowedEnv.js"; import { announceOptionalDeps } from "./system/announceOptionalDeps.js"; import { announceGeminiKey } from "./system/announceGeminiKey.js"; import { migrateLegacyBillingPresets } from "./workspace/billing-migration.js"; import { APP_VERSION } from "./system/appVersion.js"; import { createChatService } from "@mulmobridge/chat-service"; import { readSessionJsonl, readSessionMeta } from "./utils/files/session-io.js"; import { resolveBridgeSessionRole } from "./api/bridge/sessionRole.js"; import { onSessionEvent, initSessionStore } from "./events/session-store/index.js"; import { initFileChangePublisher } from "./events/file-change.js"; // Importing also binds the shared mulmoScript server ops to this host's // backend and registers the built-in "mulmoScript" dispatch handler (side // effect at module load — plans/done/feat-mulmoscript-plugin.md phase 3). import { initMulmoScriptGenerationPublisher } from "./plugins/mulmoscript-server.js"; import { initCollectionChangePublisher } from "./events/collection-change.js"; import { initPhotoLocationsChangePublisher } from "./events/photo-locations-change.js"; import { getRole, loadAllRoles } from "./workspace/roles.js"; import { discoverSkills } from "./workspace/skills/index.js"; import { WORKSPACE_PATHS } from "./workspace/paths.js"; import { resolveClientDir } from "./utils/clientDir.js"; import { serverError } from "./utils/httpError.js"; import { browserVisibleOrigin } from "./utils/forwardedOrigin.js"; import { makeUuid } from "./utils/id.js"; import { mcpToolsRouter, mcpTools, isMcpToolEnabled } from "./agent/mcp-tools/index.js"; import { preflightUserServers, logPreflightResult } from "./agent/mcpPreflight.js"; import { loadMcpConfig, loadSettings } from "./system/config.js"; import { getVoiceInputStatus, stopWhisperSidecar, warmupVoiceInput } from "./system/whisper/index.js"; import { initWorkspace, workspacePath } from "./workspace/workspace.js"; import { runMemoryMigrationOnce } from "./workspace/memory/run.js"; import { runTopicMigrationOnce } from "./workspace/memory/topic-run.js"; import { migrateCookingRecipesFromPlugin } from "./workspace/cooking-recipes/migrate.js"; import { env, isAblated, isGeminiAvailable } from "./system/env.js"; import { buildSandboxStatus } from "./api/sandboxStatus.js"; import { buildDiagnosticsMarkdown } from "./utils/diagnostics/collect.js"; import { existsSync, readFileSync } from "fs"; import { realpath as fsRealpath, stat as fsStat } from "fs/promises"; import { makeCachedRealpath, resolveArtifactRequestPath } from "./utils/files/safe.js"; import { cpus, loadavg } from "os"; import { isDockerAvailable, ensureSandboxImage } from "./system/docker.js"; import { maybeRunJournal } from "./workspace/journal/index.js"; import { backfillAllSessions } from "./workspace/chat-index/index.js"; import { feedRefreshTaskDef } from "@mulmoclaude/core/feeds/server"; import { googleCalendarSyncTaskDef } from "@mulmoclaude/core/google"; import { configureFeeds } from "./workspace/feeds/configure.js"; import { createPubSub } from "./events/pub-sub/index.js"; import { PUBSUB_CHANNELS } from "../src/config/pubsubChannels.js"; import { createTaskManager } from "./events/task-manager/index.js"; import type { ITaskManager } from "./events/task-manager/index.js"; import { initScheduler, type SystemTaskDef } from "./events/scheduler-adapter.js"; import schedulerTasksRoutes from "./api/routes/schedulerTasks.js"; import { loadSchedulerOverrides, UTC_HH_MM_RE } from "./utils/files/scheduler-overrides-io.js"; import type { IPubSub } from "./events/pub-sub/index.js"; import { connectRelay } from "./events/relay-client.js"; import { requireSameOrigin } from "./api/csrfGuard.js"; import { bearerAuth } from "./api/auth/bearerAuth.js"; import { isViewDataPath } from "./api/auth/viewToken.js"; import { deleteTokenFile, generateAndWriteToken, getCurrentToken } from "./api/auth/token.js"; import { log } from "./system/logger/index.js"; import { logBackgroundError } from "./utils/logBackgroundError.js"; import { isNonEmptyString } from "./utils/types.js"; import { collectSessionEntriesNewestFirst } from "./utils/sessionJsonl.js"; import { errorMessage } from "./utils/errors.js"; import { registerScheduledSkills } from "./workspace/skills/scheduler.js"; import { registerUserTasks } from "./workspace/skills/user-tasks.js"; import { API_ROUTES } from "../src/config/apiRoutes.js"; import { EVENT_TYPES } from "../src/types/events.js"; import { SESSION_ORIGINS } from "../src/types/session.js"; import { buildHtmlPreviewCsp } from "../src/utils/html/previewCsp.js"; import { readCspExtraSync, warnIfCspExtended } from "./utils/files/csp-io.js"; import { readAndInjectHtmlArtifact, readAndInjectHtmlFile } from "./utils/html/htmlArtifactSplicer.js"; import { resolveHtmlFileRequestPath } from "@mulmoclaude/core/files"; import { HTML_FILE_MOUNT } from "@mulmoclaude/html-plugin"; import { ONE_SECOND_MS, ONE_MINUTE_MS, ONE_HOUR_MS, STARTUP_FAILURE_FORCE_EXIT_MS, FATAL_LOG_FLUSH_MS } from "./utils/time.js"; import { isPortFree, findAvailablePort, MAX_PORT_PROBES } from "./utils/port.mjs"; import { SCHEDULE_TYPES, MISSED_RUN_POLICIES } from "@receptron/task-scheduler"; const HTML_TOKEN_PLACEHOLDER = "__MULMOCLAUDE_AUTH_TOKEN__"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const debugMode = process.argv.includes("--debug"); // Global crash diagnostics (#1364). These handlers log loudly so a // fatal failure is triagable, then EXIT — keeping the loop running // after an uncaught exception is process-unsafe per the Node docs // (invariants may already be broken). The launcher / supervisor // (Electron wrapper, systemd, etc.) is responsible for restart. // // The canonical failure this PR set out to fix — missing `claude` // on PATH crashing the server via spawn's `error` event — is now // caught at the local boundary in `server/agent/backend/claude-code.ts` // (an explicit `error` listener turns ENOENT into an AgentEvent). // These handlers are the BACKSTOP for anything we missed, not a // substitute for local error handling. (Codex review on #1364.) // // `process.exit(1)` is non-zero so supervisors that branch on exit // code treat the bounce as an error condition. process.on("uncaughtException", (err) => { log.error("uncaughtException", errorMessage(err), { stack: err instanceof Error ? err.stack : undefined, }); // Tiny grace so the log line flushes to disk before we exit. setTimeout(() => process.exit(1), FATAL_LOG_FLUSH_MS); }); process.on("unhandledRejection", (reason) => { log.error("unhandledRejection", errorMessage(reason), { stack: reason instanceof Error ? reason.stack : undefined, }); setTimeout(() => process.exit(1), FATAL_LOG_FLUSH_MS); }); // Test-seam: CI runs without a Claude CLI / API key set the // MULMOCLAUDE_FAKE_AGENT env var, which swaps in an echo-stub // backend so the chat flow still completes. Decided once at boot; // the orchestrator reads the active backend with zero per-call // overhead. Production callers never trip this branch (no runtime // import-time cost beyond the small fake-echo module itself). if (process.env.MULMOCLAUDE_FAKE_AGENT === "1") { setActiveBackend(fakeEchoBackend); log.info("agent", "MULMOCLAUDE_FAKE_AGENT=1 — active backend = fake-echo"); } initWorkspace(); warnIfCspExtended(); // Fire-and-forget memory migrations: legacy `memory.md` → atomic // (#1029), then atomic → topic-format staging (#1070). Chained so // that a fresh `memory.md` workspace lands in the topic format on // a SINGLE server start instead of needing two restarts (the topic // runner used to defer on the first start because legacy was still // in flight; now it picks up right after legacy completes). // // Both runners are idempotent: legacy no-ops when the source file // is gone, topic no-ops when the workspace already uses the topic // format or staging is already pending review. The agent can serve // traffic while the chain runs. // // `.then(noop, noop)` keeps the floating-promises rule happy // without smuggling in a `void` (banned by sonarjs/void-use). Each // runner logs its own failures; the chain's outer rejection // handler is therefore a hard backstop only. // // CLEANUP 2026-07-01: this whole chain is one-shot migration code // for #1029 + #1070. After every active workspace has flipped to // the topic format, delete the chain plus the runners under // `server/workspace/memory/` (run.ts / migrate.ts / // llm-classifier.ts / topic-run.ts / topic-migrate.ts / // topic-cluster.ts / topic-swap.ts) and the // `scripts/memory-swap-topic-staging.ts` helper. Topic-format // reading / writing (`topic-types.ts`, `topic-io.ts`, // `topic-detect.ts`) plus the topic branch in `prompt.ts` stays. const noop = (): void => {}; runMemoryMigrationOnce(workspacePath) .then(() => runTopicMigrationOnce(workspacePath)) .then(noop, noop); // Recipe-book plugin → `mc-cooking-coach` skill migration (#1286). // Boot-time idempotent copy from the plugin's `files.data` scope // (`data/plugins//recipes/`) to the canonical // `data/cooking/recipes/` path the skill drives. Sentinel-gated so // every boot after the first is a no-op. migrateCookingRecipesFromPlugin().catch((err) => { log.warn("cooking-recipes", "migration from plugin failed; falling back to original plugin path", { error: errorMessage(err), }); }); let sandboxEnabled = false; // --- Photo-EXIF capture hook (#1222 PR-A) --- // Registered at module load (NOT inside `startRuntimeServices`) // because uploads can land in the gap between `app.listen` accepting // connections and the runtime-services bootstrap finishing. The hook // itself short-circuits on non-image MIME / auto-capture opt-out, so // registering early is free for non-photo flows. (CodeRabbit review // on PR #1247.) registerSaveAttachmentHook(capturePhotoLocation); const app = express(); app.disable("x-powered-by"); // No `cors()` middleware. The Vite dev proxy forwards `/api/*` // from :5173 to :3001 server-side, and in production Express // serves the built client from the same origin, so every // legitimate request is same-origin and doesn't need CORS // headers at all. Dropping the middleware means a page at // `http://evil.example` can still send a request to // `localhost:3001` but the browser refuses to expose the // response to the calling script (no // `Access-Control-Allow-Origin` header). See // plans/done/fix-server-lockdown-cors-localhost.md for the threat // model. app.use(express.json({ limit: "50mb" })); // CSRF guard: reject state-changing requests that arrive with a // non-localhost Origin header. Allows missing Origin (server-to- // server / CLI callers) because the listener is already bound to // localhost (#148); if that ever changes, tighten this middleware // too. See plans/done/fix-server-csrf-origin-check.md. // // Custom-view data endpoints are exempt: their requests come from a // sandboxed (opaque-origin) iframe that sends `Origin: null` (which this // guard rejects), and the unguessable, slug-scoped capability token they // carry IS the anti-CSRF property an attacker page can't satisfy. They are // guarded by `requireViewToken` instead. See server/api/auth/viewToken.ts. app.use((req, res, next) => { if (isViewDataPath(req.path)) { next(); return; } requireSameOrigin(req, res, next); }); // Bearer token auth: every `/api/*` request must carry // `Authorization: Bearer ` matching the per-startup token. // Layered *on top of* CSRF guard so we catch both cross-origin // browser attacks (origin check) and local sibling processes that // bypass browser CORS (bearer check). See #272 and // plans/done/feat-bearer-token-auth.md. // // /api/files/* is exempt because // tags in rendered markdown can't attach Authorization headers. // /api/plugins/runtime/// (#1043 C-2) is exempt // for the same reason: the frontend dynamic-imports plugin assets // (`import("/api/plugins/runtime///dist/vue.js")`) and the // browser cannot attach Authorization headers to those module // requests. The pattern "4+ segments past /plugins/runtime/" only // matches asset GETs — `/plugins/runtime/list` (3 segments) and // `/plugins/runtime//dispatch` (3 segments) still require auth. // Path traversal is hardened separately by `resolveWithinRoot` in // the asset route handler. // The CSRF origin check + loopback-only binding still apply. const RUNTIME_PLUGIN_ASSET_PATH_RE = /^\/plugins\/runtime\/[^/]+\/[^/]+\//; // Generic OAuth callback receiver for runtime plugins (#1162). Same // browser-redirect-can't-carry-Authorization-header reason as the // asset path above. Trust model: registry-membership (the host's // route handler 404s an unknown :alias) plus the plugin's single-use // `state` for CSRF. const RUNTIME_PLUGIN_OAUTH_CALLBACK_RE = /^\/plugins\/runtime\/oauth-callback\/[^/]+$/; app.use("/api", (req, res, next) => { if (req.path.startsWith("/files/")) { next(); return; } // Custom-view data endpoints carry a scoped capability token, not the // global bearer (the sandboxed iframe can't read it). `requireViewToken` // guards them instead. See server/api/auth/viewToken.ts. if (isViewDataPath(req.path)) { next(); return; } if ((req.method === "GET" || req.method === "HEAD") && RUNTIME_PLUGIN_ASSET_PATH_RE.test(req.path)) { // HEAD is bypassed for the same reason as GET: the frontend // runtime-plugin loader HEAD-probes `dist/vue.js` to distinguish // "no Vue bundle (404, server-only plugin)" from real load // failures before `import()`-ing the asset (#1273 follow-up). // That probe is a raw `fetch`, not the bearer-attaching `apiGet`, // and the actual `import()` itself can't attach Authorization // either — so the auth-bypass must cover both verbs or every // runtime plugin's Vue View silently downgrades to a // definition-only entry (401 → "unexpected status" → no view). next(); return; } if (req.method === "GET" && RUNTIME_PLUGIN_OAUTH_CALLBACK_RE.test(req.path)) { next(); return; } bearerAuth(req, res, next); }); // Static mount for the canonical image storage path. Every image // generated by `saveImage()` (Gemini, canvas, image edit) lives under // `artifacts/images/YYYY/MM/.png` (#764, see // server/utils/files/image-store.ts), so an `` referring to that // shape resolves directly without going through /api/files/raw. // // Bearer auth is intentionally skipped (same reason as /api/files/*: // browser tags can't carry an Authorization header). The // requireSameOrigin guard above still applies; the listener also // stays loopback-only. // // Three-layer guard: // 1. Extension allowlist — reject anything that isn't an image, // video, or audio extension. `saveImage` currently writes `.png` // only, but Stage B (#1011) extends the markdown / wiki rewriter // to `` / `