#!/usr/bin/env node var saveDaemonSetting = require("./daemon-settings").saveDaemonSetting; // --- Node version check --- var nodeMajor = parseInt(process.versions.node.split(".")[0], 10); if (nodeMajor < 20) { console.error("\x1b[31m[clay] Node.js 20+ is required (current: " + process.version + ")\x1b[0m"); console.error("[clay] The Claude Agent SDK 0.2.40+ requires Node 20 for Symbol.dispose support."); console.error(""); console.error(" Upgrade Node: nvm install 22 && nvm use 22"); process.exit(78); // EX_CONFIG — fatal config error, don't auto-restart } // Polyfill Symbol.dispose/asyncDispose if missing (Node 20.x may not have it) if (!Symbol.dispose) Symbol.dispose = Symbol("Symbol.dispose"); if (!Symbol.asyncDispose) Symbol.asyncDispose = Symbol("Symbol.asyncDispose"); // Increase listener limit for projects with many worktrees process.setMaxListeners(50); // Remove CLAUDECODE env var so the SDK can spawn Claude Code child processes // (prevents "cannot be launched inside another Claude Code session" error) require("./yoke/host-environment").prepare(process.env); var fs = require("fs"); var path = require("path"); var newSessionDefault = require("./new-session-default"); var { loadConfig, saveConfig, socketPath, generateSlug, syncClayrc, removeFromClayrc, writeCrashInfo, readCrashInfo, clearCrashInfo, isPidAlive, clearStaleConfig, REAL_HOME } = require("./config"); var { createIPCServer } = require("./ipc"); var { createServer, generateAuthToken } = require("./server"); var osUsersMod = require("./os-users"); var { scheduleOsUserDiagnosticsAsync } = require("./os-user-diagnostics"); var { checkAclSupport, grantProjectAccess, revokeProjectAccess, provisionAllUsers, provisionLinuxUser, grantAllUsersAccess, deactivateLinuxUser, ensureProjectsDir } = osUsersMod; var usersModule = require("./users"); var { createWorktree, isWorktree } = require("./worktree"); var mates = require("./mates"); var mateKnowledgeMigration = require("./mate-knowledge-migration"); var { isWorktreeSlug, scanAndRegisterWorktrees, rescanWorktrees, cleanupWorktreesForParent, getFilteredRemovedProjects, registerWorktreeSlug, unregisterWorktreeSlug } = require("./daemon-projects"); var daemonProjectAccess = require("./daemon-project-access"); var projectLogContext = require("./project-log-context"); var daemonVersion = require("../package.json").version; var configFile = process.env.CLAY_CONFIG || process.env.CLAUDE_RELAY_CONFIG || require("./config").configPath(); var config; try { config = JSON.parse(fs.readFileSync(configFile, "utf8")); } catch (e) { console.error("[daemon] Failed to read config:", e.message); process.exit(1); } console.log("[daemon] v" + daemonVersion + " PID " + process.pid + " config " + configFile); console.log("[daemon] Config: " + configFile); console.log("[daemon] Users: " + usersModule.USERS_FILE); if (process.env.SUDO_USER) console.log("[daemon] SUDO_USER: " + process.env.SUDO_USER); console.log("[daemon] UID: " + (typeof process.getuid === "function" ? process.getuid() : "N/A")); // --- Ensure the multi-user model (there is no single-user runtime anymore): // migrate a legacy single-user deploy, or provision a default admin on a fresh // one. One-time, idempotent, best-effort. Runs before projects/mates/sessions // load so the flip and on-disk backfill are in effect for the rest of boot. --- try { require("./migrate-single-user").ensureMultiUser(); config = loadConfig(); // refresh in-memory config to pick up the migration marker } catch (e) { console.error("[daemon] ensureMultiUser threw (continuing):", e && e.message); } config = require("./project-ownership").repair(config, usersModule.getAllUsers(), saveConfig, osUsersMod.resolveOsUserInfo); if (projectLogContext.initializeProjectKnowledge(config)) { saveConfig(config); console.log("[daemon] Assigned stable knowledge identities to existing projects"); } // --- OS users mode: check required system dependencies --- // User-switched processes inherit the account groups, like a normal login. if (config.osUsers) { var checkExec = require("child_process").execFileSync; var missing = []; try { checkExec("which", ["setfacl"], { stdio: "ignore" }); } catch (e) { missing.push("acl (setfacl)"); } try { checkExec("which", ["git"], { stdio: "ignore" }); } catch (e) { missing.push("git"); } try { checkExec("which", ["useradd"], { stdio: "ignore" }); } catch (e) { missing.push("useradd"); } if (missing.length > 0) { console.error("[daemon] OS users mode requires missing system packages: " + missing.join(", ")); console.error("[daemon] Install with: sudo apt install " + missing.map(function (m) { return m.split(" ")[0]; }).join(" ")); process.exit(78); // EX_CONFIG } console.log("[daemon] Sessions inherit each user's supplementary groups when setpriv is available."); } // --- TLS --- var tlsOptions = null; if (config.tls) { // 1. Check builtin cert (shipped with package) var builtinKeyPath = path.join(__dirname, "certs", "privkey.pem"); var builtinCertPath = path.join(__dirname, "certs", "fullchain.pem"); // 2. User cert (mkcert, etc.) var os = require("os"); var certDir = path.join(process.env.CLAY_HOME || process.env.CLAUDE_RELAY_HOME || path.join(REAL_HOME, ".clay"), "certs"); var userKeyPath = path.join(certDir, "key.pem"); var userCertPath = path.join(certDir, "cert.pem"); // Cert fetched at runtime from the clay.studio endpoint (always current), // cached by the CLI before forking us. Prefer it over the baked copy. var fetched = null; try { fetched = require("./clay-studio-cert").cachedCertFiles(); } catch (e) {} var keyPath, certPath; if (config.builtinCert !== false && fetched) { keyPath = fetched.key; certPath = fetched.cert; config.builtinCert = true; } else if (config.builtinCert !== false && fs.existsSync(builtinKeyPath) && fs.existsSync(builtinCertPath)) { keyPath = builtinKeyPath; certPath = builtinCertPath; config.builtinCert = true; } else { keyPath = userKeyPath; certPath = userCertPath; } try { tlsOptions = { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath), }; } catch (e) { console.error("[daemon] TLS cert not found, falling back to HTTP"); } } var caRoot = null; // --- Resolve LAN IP for share URL --- var os2 = require("os"); var lanIp = (function () { var ifaces = os2.networkInterfaces(); for (var addrs of Object.values(ifaces)) { for (var i = 0; i < addrs.length; i++) { if (addrs[i].family === "IPv4" && !addrs[i].internal && addrs[i].address.startsWith("100.")) return addrs[i].address; } } for (var addrs of Object.values(ifaces)) { for (var i = 0; i < addrs.length; i++) { if (addrs[i].family === "IPv4" && !addrs[i].internal) return addrs[i].address; } } return null; })(); // getFilteredRemovedProjects extracted to daemon-projects.js // --- Create multi-project server --- var listenHost = config.host || "0.0.0.0"; function rememberedProjectKnowledgeId(projectPath) { var removed = config.removedProjects || []; for (var i = removed.length - 1; i >= 0; i--) { if (removed[i].path === projectPath && projectLogContext.validId(removed[i].projectKnowledgeId)) { return removed[i].projectKnowledgeId; } } return projectLogContext.createProjectKnowledgeId(); } function projectRuntimeOptions(project) { return { projectKnowledgeId: project.projectKnowledgeId }; } function worktreeOsUserInfo(project, requestUser) { if (!config.osUsers) return null; var owner = project && project.ownerId ? usersModule.findUserById(project.ownerId) : null; var executionUser = owner && owner.linuxUser ? owner : requestUser; if (!executionUser || !executionUser.linuxUser) { throw new Error("The project owner does not have a provisioned Linux account"); } return osUsersMod.resolveOsUserInfo(executionUser.linuxUser); } function registerProjectWorktrees(project, requestUser) { try { var osUserInfo = worktreeOsUserInfo(project, requestUser); scanAndRegisterWorktrees(relay, project.path, project.slug, project.icon, project.ownerId, project.projectKnowledgeId, osUserInfo, config, function () { return config; }, worktreeOsUserInfo); } catch (e) { console.error("[daemon] Failed to scan worktrees for " + project.slug + ": " + e.message); } } var projectLifecycle = require("./daemon-project-lifecycle").attachProjectLifecycle({ getConfig: function () { return config; }, setConfig: function (next) { config = next; }, saveConfig: saveConfig, getRelay: function () { return relay; }, resolveIdentity: osUsersMod.resolveOsUserInfo, findUser: usersModule.findUserById, registerWorktrees: registerProjectWorktrees, }); var lifecycleCallbacks = require("./daemon-project-lifecycle-callbacks").attachLifecycleCallbacks({ service: projectLifecycle }); var worktreeControls = require("./daemon-worktree-controls").attachWorktreeControls({ getConfig: function () { return config; }, setConfig: function (next) { config = next; }, saveConfig: saveConfig, getRelay: function () { return relay; }, findUser: usersModule.findUserById, users: usersModule, osIdentity: worktreeOsUserInfo, syncRecent: syncClayrc, storageDir: require("./config").CONFIG_DIR, unregister: unregisterWorktreeSlug, }); var ownershipRecovery = require('./project-ownership-recovery').createRecoveryService({ getConfig: function () { return config; }, getAccounts: usersModule.getAllUsers, resolveIdentity: osUsersMod.resolveOsUserInfo, persist: saveConfig, publish: function (next, slug, ownerId) { relay.forEachProject(function (project, projectSlug) { if (projectSlug === slug || daemonProjectAccess.parentSlugFor(projectSlug) === slug) project.setProjectOwner(ownerId); }); config = next; relay.refreshProjectAccess(); } }); var relay = createServer({ ownershipRecovery: ownershipRecovery, worktreeControls: worktreeControls, projectLifecycle: projectLifecycle, tlsOptions: tlsOptions, caPath: caRoot, builtinCert: config.builtinCert || false, pinHash: config.pinHash || null, port: config.port, debug: config.debug || false, dangerouslySkipPermissions: config.dangerouslySkipPermissions || false, osUsers: config.osUsers || false, lanHost: lanIp ? lanIp + ":" + config.port : null, getRemovedProjects: function (userId) { return getFilteredRemovedProjects(config, userId); }, onAddProject: lifecycleCallbacks.onAddProject, onCreateProject: lifecycleCallbacks.onCreateProject, onCloneProject: function (cloneUrl, wsUser, callback) { wsUser = wsUser && usersModule.findUserById(wsUser.id); if (usersModule.isMultiUser() && !wsUser) { callback({ ok: false, error: "A valid account is required." }); return; } var os = require("os"); var spawn = require("child_process").spawn; var execFileSync = require("child_process").execFileSync; var baseDir; if (config.osUsers) { baseDir = "/var/clay/projects"; } else { baseDir = config.projectsDir || path.join(REAL_HOME, "clay-projects"); } // Derive slug from repo URL var repoName = cloneUrl.replace(/\.git$/, "").split("/").pop() || "project"; var slugs = config.projects.map(function (p) { return p.slug; }); var slug = generateSlug(path.join(baseDir, repoName), slugs); var targetDir; var directoryClaim; var settled = false; function finish(result) { if (settled) return; settled = true; callback(result); } function cleanup() { if (directoryClaim) { try { directoryClaim.cleanup(); } catch (error) {} } } // Clone as user to use their git credentials var spawnOpts = { cwd: baseDir }; if (config.osUsers && wsUser && wsUser.linuxUser) { try { spawnOpts.uid = parseInt(execFileSync("id", ["-u", wsUser.linuxUser], { encoding: "utf8", stdio: "pipe" }).trim(), 10); spawnOpts.gid = parseInt(execFileSync("id", ["-g", wsUser.linuxUser], { encoding: "utf8", stdio: "pipe" }).trim(), 10); spawnOpts.env = Object.assign({}, process.env, { HOME: "/home/" + wsUser.linuxUser, USER: wsUser.linuxUser }); } catch (e) {} } try { directoryClaim = require("./project-directory-claim").claim({ baseDir: baseDir, slug: slug, projects: config.projects, userId: wsUser && wsUser.id }); targetDir = directoryClaim.path; directoryClaim.assertOwned(); if (config.osUsers && wsUser && wsUser.linuxUser && spawnOpts.uid != null) fs.chownSync(targetDir, spawnOpts.uid, spawnOpts.gid); directoryClaim.assertOwned(); } catch (error) { cleanup(); finish({ ok: false, error: "Failed to prepare project directory: " + error.message }); return; } var proc; try { directoryClaim.assertOwned(); var cloneSpawn = osUsersMod.wrapSpawnAsUser("git", ["clone", cloneUrl, targetDir], spawnOpts); proc = spawn(cloneSpawn.command, cloneSpawn.args, cloneSpawn.options); } catch (error) { cleanup(); finish({ ok: false, error: "Failed to start git clone: " + error.message }); return; } var stderrBuf = ""; proc.stderr.on("data", function (chunk) { stderrBuf += chunk.toString(); }); // 5 minute timeout var cloneTimeout = setTimeout(function () { proc.kill("SIGTERM"); }, 5 * 60 * 1000); proc.on("close", function (code) { clearTimeout(cloneTimeout); if (settled) return; try { directoryClaim.assertOwned(); } catch (error) { finish({ ok: false, error: error.message }); return; } if (code !== 0) { cleanup(); var errMsg = stderrBuf.trim().split("\n").pop() || "Clone failed (exit code " + code + ")"; finish({ ok: false, error: errMsg }); return; } // chown and restrict permissions if osUsers if (config.osUsers && wsUser && wsUser.linuxUser) { try { directoryClaim.assertOwned(); fs.chmodSync(targetDir, 0o700); execFileSync("chown", ["-R", wsUser.linuxUser + ":" + wsUser.linuxUser, targetDir]); } catch (e) {} } if (usersModule.isMultiUser() && (!wsUser || !usersModule.findUserById(wsUser.id))) { finish({ ok: false, error: "The creating account is no longer available." }); return; } // Register project - creator always becomes owner // Creator always becomes owner, default private var projectEntry = { path: targetDir, slug: slug, addedAt: Date.now(), visibility: "private" }; projectEntry.projectKnowledgeId = projectLogContext.createProjectKnowledgeId(); if (wsUser && wsUser.id) { projectEntry.ownerId = wsUser.id; } try { directoryClaim.assertOwned(); config = require("./project-ownership").register(config, projectEntry, usersModule.getAllUsers(), saveConfig, osUsersMod.resolveOsUserInfo); } catch (error) { finish({ ok: false, error: "Failed to register project: " + error.message }); return; } relay.addProject(targetDir, slug, null, null, projectEntry.ownerId || null, null, projectRuntimeOptions(projectEntry)); try { syncClayrc(config.projects); } catch (e) {} console.log("[daemon] Cloned project:", slug, "→", targetDir); if (config.osUsers && wsUser && wsUser.linuxUser) { grantProjectAccess(targetDir, wsUser.linuxUser); } relay.broadcastAll({ type: "projects_updated", projects: relay.getProjects(), projectCount: config.projects.length, }); finish({ ok: true, slug: slug }); }); proc.on("error", function (err) { clearTimeout(cloneTimeout); if (settled) return; cleanup(); finish({ ok: false, error: "Failed to start git clone: " + err.message }); }); }, onRemoveProject: function (slug, userId) { if (isWorktreeSlug(slug)) return { ok: false, error: "Use the reviewed Exclude or Delete worktree control." }; return lifecycleCallbacks.onRemoveProject(slug, userId); }, onReorderProjects: function (slugs) { // Build a slug->project map from current projects var projectMap = {}; for (var j = 0; j < config.projects.length; j++) { projectMap[config.projects[j].slug] = config.projects[j]; } // Reorder based on the slugs array var reordered = []; for (var k = 0; k < slugs.length; k++) { if (projectMap[slugs[k]]) { reordered.push(projectMap[slugs[k]]); delete projectMap[slugs[k]]; } } // Append any remaining projects not in slugs (safety) var remaining = Object.keys(projectMap); for (var m = 0; m < remaining.length; m++) { reordered.push(projectMap[remaining[m]]); } config.projects = reordered; saveConfig(config); try { syncClayrc(config.projects); } catch (e) {} // Also reorder the in-memory Map so getProjects() returns the new order relay.reorderProjects(slugs); relay.broadcastAll({ type: "projects_updated", projects: relay.getProjects(), projectCount: config.projects.length, }); return { ok: true }; }, onSetProjectTitle: function (slug, newTitle) { if (isWorktreeSlug(slug)) { relay.setProjectTitle(slug, newTitle); return { ok: true }; } return lifecycleCallbacks.onSetProjectTitle(slug, newTitle); }, onSetProjectIcon: function (slug, newIcon, userId) { return worktreeControls.setIcon(slug, newIcon, userId); }, onSetProjectSessionVisibilityDefault: function (slug, visibility) { return projectLifecycle.update(slug, "session_visibility", visibility); }, onProjectOwnerChanged: function (slug, ownerId) { if (!config.osUsers) return projectLifecycle.update(slug, "owner", ownerId); if (slug.indexOf("mate-") === 0) return {error:"Mate ownership cannot be transferred."}; if (!ownerId || !usersModule.findUserById(ownerId)) return { error: "A valid owner is required." }; console.log("[daemon] onProjectOwnerChanged:", slug, "→", ownerId); var previousConfig = JSON.parse(JSON.stringify(config)); var oldOwnerId = null; var projectIdx = -1; for (var oi = 0; oi < config.projects.length; oi++) { if (config.projects[oi].slug === slug) { oldOwnerId = config.projects[oi].ownerId || null; projectIdx = oi; config.projects[oi].ownerId = ownerId; break; } } try { saveConfig(config); } catch (error) { config = previousConfig; throw error; } relay.forEachProject(function (project, projectSlug) { if (projectSlug === slug || daemonProjectAccess.parentSlugFor(projectSlug) === slug) project.setProjectOwner(ownerId); }); // OS users mode: revoke old owner ACL, grant new owner ACL if (config.osUsers && projectIdx >= 0) { var projectPath = config.projects[projectIdx].path; var allowed = config.projects[projectIdx].allowedUsers || []; var visibility = config.projects[projectIdx].visibility || "public"; // Revoke old owner (if not in allowedUsers and project is not public) if (oldOwnerId && oldOwnerId !== ownerId) { var oldOwner = usersModule.findUserById(oldOwnerId); if (oldOwner && oldOwner.linuxUser && allowed.indexOf(oldOwnerId) === -1 && visibility !== "public") { revokeProjectAccess(projectPath, oldOwner.linuxUser); } } // Grant new owner if (ownerId) { var newOwner = usersModule.findUserById(ownerId); console.log("[daemon] Owner grant ACL:", ownerId, "linuxUser:", newOwner && newOwner.linuxUser, "path:", projectPath); if (newOwner && newOwner.linuxUser) { grantProjectAccess(projectPath, newOwner.linuxUser); } } } relay.broadcastAll({ type: "projects_updated", projects: relay.getProjects(), projectCount: config.projects.length, }); if (relay.refreshProjectAccess) relay.refreshProjectAccess(); return { ok: true }; }, onGetProjectEnv: function (slug) { for (var ei = 0; ei < config.projects.length; ei++) { if (config.projects[ei].slug === slug) { return { envrc: config.projects[ei].envrc || "" }; } } return { envrc: "" }; }, onSetProjectEnv: function (slug, envrc) { return lifecycleCallbacks.onSetProjectEnv(slug, envrc); }, onGetSharedEnv: function () { return { envrc: config.sharedEnv || "" }; }, onSetSharedEnv: function (envrc) { saveDaemonSetting(config, "sharedEnv", envrc || undefined, saveConfig); return { ok: true }; }, onGetServerDefaultEffort: function () { return { effort: config.defaultEffort || null }; }, onSetServerDefaultEffort: function (effort) { if (effort) { config.defaultEffort = effort; } else { delete config.defaultEffort; } saveConfig(config); return { ok: true }; }, onGetProjectDefaultEffort: function (slug) { for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { return { effort: config.projects[i].defaultEffort || null }; } } return { effort: null }; }, onSetProjectDefaultEffort: function (slug, effort) { for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { if (effort) { config.projects[i].defaultEffort = effort; } else { delete config.projects[i].defaultEffort; } saveConfig(config); return { ok: true }; } } return { ok: false, error: "Project not found" }; }, onGetServerDefaultModel: function () { return { model: config.defaultModel || null }; }, onSetServerDefaultModel: function (model) { if (model) { config.defaultModel = model; } else { delete config.defaultModel; } saveConfig(config); return { ok: true }; }, onGetProjectDefaultModel: function (slug) { for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { return { model: config.projects[i].defaultModel || null }; } } return { model: null }; }, onSetProjectDefaultModel: function (slug, model) { for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { if (model) { config.projects[i].defaultModel = model; } else { delete config.projects[i].defaultModel; } saveConfig(config); return { ok: true }; } } return { ok: false, error: "Project not found" }; }, onGetProjectLastVendor: function (slug) { for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { return { vendor: config.projects[i].lastVendor || null }; } } return { vendor: null }; }, // The provider the project's inline New session row preselects. Stored on // the project's own config entry only. onGetProjectNewSessionDefault: function (slug) { return newSessionDefault.getNewSessionDefault(config, slug); }, onSetProjectNewSessionDefault: function (slug, preference) { return newSessionDefault.setNewSessionDefault(config, slug, preference, saveConfig); }, onSetProjectLastVendor: function (slug, vendor) { for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { if (config.projects[i].lastVendor === vendor) return { ok: true }; if (vendor) { config.projects[i].lastVendor = vendor; } else { delete config.projects[i].lastVendor; } saveConfig(config); return { ok: true }; } } return { ok: false, error: "Project not found" }; }, onGetProjectMcpServers: function (slug) { for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { return config.projects[i].enabledMcpServers || []; } } return []; }, onSetProjectMcpServers: function (slug, servers) { for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { if (servers && servers.length > 0) { config.projects[i].enabledMcpServers = servers; } else { delete config.projects[i].enabledMcpServers; } saveConfig(config); return; } } }, onGetServerDefaultMode: function () { return { mode: config.defaultMode || null }; }, onSetServerDefaultMode: function (mode) { if (mode) { config.defaultMode = mode; } else { delete config.defaultMode; } saveConfig(config); return { ok: true }; }, onGetProjectDefaultMode: function (slug) { for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { return { mode: config.projects[i].defaultMode || null }; } } return { mode: null }; }, onSetProjectDefaultMode: function (slug, mode) { for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { if (mode) { config.projects[i].defaultMode = mode; } else { delete config.projects[i].defaultMode; } saveConfig(config); return { ok: true }; } } return { ok: false, error: "Project not found" }; }, onGetAllowedOrigins: function () { return config.allowedOrigins || []; }, onSetAllowedOrigins: function (origins) { require("./network-origins").saveAllowedOrigins(config, origins, saveConfig); }, onGetDaemonConfig: function () { return { port: config.port, tls: !!tlsOptions, debug: !!config.debug, headless: !!config.headless, keepAwake: !!config.keepAwake, autoContinueOnRateLimit: !!config.autoContinueOnRateLimit, osUsers: !!config.osUsers, chatLayout: config.chatLayout || "channel", matesEnabled: config.matesEnabled === true, capsulesEnabled: config.capsulesEnabled === true, agentBehavior: require("./agent-behavior-schema").normalize(config.agentBehavior), pinEnabled: !!config.pinHash, platform: process.platform, hostname: os2.hostname(), lanIp: lanIp || null, updateChannel: config.updateChannel || "stable", imageRetentionDays: config.imageRetentionDays !== undefined ? config.imageRetentionDays : 7, }; }, onSetImageRetention: function (days) { var d = parseInt(days, 10); if (isNaN(d) || d < 0) d = 7; saveDaemonSetting(config, "imageRetentionDays", d, saveConfig); console.log("[daemon] Image retention:", d === 0 ? "forever" : d + " days"); return { ok: true, days: d }; }, onSetUpdateChannel: function (channel) { saveDaemonSetting(config, "updateChannel", channel === "beta" ? "beta" : "stable", saveConfig); console.log("[daemon] Update channel:", config.updateChannel, "(web)"); return { ok: true, updateChannel: config.updateChannel }; }, onSetPin: function (pin) { if (usersModule.isMultiUser()) return { ok: false, error: 'Server PIN is unavailable while account authentication is enabled.' }; saveDaemonSetting(config, "pinHash", pin ? generateAuthToken(pin) : null, saveConfig); relay.setAuthToken(config.pinHash); console.log("[daemon] PIN", pin ? "set" : "removed", "(web)"); return { ok: true, pinEnabled: !!config.pinHash }; }, onUpgradePin: function (newHash) { config.pinHash = newHash; relay.setAuthToken(newHash); saveConfig(config); console.log("[daemon] PIN hash auto-upgraded to scrypt"); }, onSetChatLayout: function (layout) { var val = (layout === "bubble") ? "bubble" : "channel"; config.chatLayout = val; saveConfig(config); console.log("[daemon] Chat layout:", val, "(web)"); return { ok: true, chatLayout: val }; }, onSetTerminalFont: function (family, size) { var DEFAULT_FAMILY = "'SF Mono', Menlo, Monaco, 'Courier New', monospace"; var current = config.terminalFont || { family: DEFAULT_FAMILY, size: 14 }; var nextFamily = (typeof family === "string" && family.trim()) ? family.trim().slice(0, 200) : current.family; var nextSize = current.size; if (typeof size === "number" && size >= 9 && size <= 32) { nextSize = Math.round(size); } config.terminalFont = { family: nextFamily, size: nextSize }; saveConfig(config); console.log("[daemon] Terminal font:", nextFamily, nextSize); return config.terminalFont; }, onSetMateOnboarded: function () { config.mateOnboardingShown = true; saveConfig(config); return { ok: true }; }, onSetAutoContinue: function (value) { var want = !!value; config.autoContinueOnRateLimit = want; saveConfig(config); console.log("[daemon] Auto-continue on rate limit:", want, "(web)"); return { ok: true, autoContinueOnRateLimit: want }; }, onSetMatesEnabled: function (value) { var want = !!value; config.matesEnabled = want; saveConfig(config); console.log("[daemon] Project Mate DMs:", want ? "shown" : "hidden", "(web)"); return { ok: true, matesEnabled: want }; }, onSetCapsulesEnabled: function (value) { var want = value === true; config.capsulesEnabled = want; saveConfig(config); console.log("[daemon] Experimental Capsules:", want ? "enabled" : "disabled", "(web)"); return { ok: true, capsulesEnabled: want }; }, onUpdateAgentBehavior: function (changes) { var schema = require("./agent-behavior-schema"); var checked = schema.validateChanges(changes); if (checked.error) return { error: checked.error }; var next = Object.assign({}, schema.explicit(config.agentBehavior), checked.value); var previous = config.agentBehavior; config.agentBehavior = next; try { saveConfig(config); } catch (error) { config.agentBehavior = previous; return { error: "Could not save agent behavior" }; } console.log("[daemon] Agent behavior updated (web)"); return { ok: true, policy: schema.normalize(next) }; }, onGetToolPalettes: function () { return config.toolPalettes || {}; }, onSetToolPalette: function (paletteName, order, hidden) { if (paletteName !== "session" && paletteName !== "mate") { return { error: "Unknown palette" }; } var safeOrder = Array.isArray(order) ? order.filter(function (s) { return typeof s === "string"; }) : []; var safeHidden = Array.isArray(hidden) ? hidden.filter(function (s) { return typeof s === "string"; }) : []; if (!config.toolPalettes) config.toolPalettes = {}; config.toolPalettes[paletteName] = { order: safeOrder, hidden: safeHidden }; saveConfig(config); return { ok: true, palette: paletteName, order: safeOrder, hidden: safeHidden }; }, onSetKeepAwake: function (value) { var want = !!value; saveDaemonSetting(config, "keepAwake", want, saveConfig); if (want && !caffeinateProc && process.platform === "darwin") { try { var { spawn: spawnCaff } = require("child_process"); caffeinateProc = spawnCaff("caffeinate", ["-di"], { stdio: "ignore", detached: false }); caffeinateProc.on("error", function () { caffeinateProc = null; }); } catch (e) {} } else if (!want && caffeinateProc) { try { caffeinateProc.kill(); } catch (e) {} caffeinateProc = null; } console.log("[daemon] Keep awake:", want, "(web)"); return { ok: true, keepAwake: want }; }, onShutdown: function () { console.log("[daemon] Shutdown requested via web UI"); gracefulShutdown(); }, onRestart: function () { console.log("[daemon] Restart requested via web UI"); spawnAndRestart(); }, onSetProjectVisibility: function (slug, visibility) { if (!config.osUsers) return projectLifecycle.update(slug, "visibility", visibility); if (slug.indexOf("mate-") === 0) return {error:"Mates cannot be shared."}; for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === slug) { var prevVisibility = config.projects[i].visibility || "public"; config.projects[i].visibility = visibility; saveConfig(config); console.log("[daemon] Set project visibility:", slug, "→", visibility); if (config.osUsers) { var projectPath = config.projects[i].path; var ownerId = config.projects[i].ownerId || null; // When switching to public: grant ACL to ALL clay users if (visibility === "public" && prevVisibility !== "public") { grantAllUsersAccess(projectPath, usersModule); } // When switching to private: revoke ACLs for users not in allowedUsers and not the owner if (visibility === "private" && prevVisibility !== "private") { var allowed = config.projects[i].allowedUsers || []; var allUsers = usersModule.getAllUsers(); for (var u = 0; u < allUsers.length; u++) { var usr = allUsers[u]; if (usr.role === "admin") continue; if (usr.id === ownerId) continue; if (usr.linuxUser && allowed.indexOf(usr.id) === -1) { revokeProjectAccess(projectPath, usr.linuxUser); } } } } if (relay.refreshProjectAccess) relay.refreshProjectAccess(); return { ok: true }; } } return { error: "Project not found" }; }, onSetProjectAllowedUsers: function (slug, allowedUsers) { if (!config.osUsers && !daemonProjectAccess.parentSlugFor(slug)) return projectLifecycle.update(slug, "allowed_users", allowedUsers); var accessBefore = daemonProjectAccess.getProjectAccess(config, slug, config.osUsers); var result = daemonProjectAccess.setAllowedUsers(config, slug, allowedUsers); if (result.error) return result; var parentSlug = daemonProjectAccess.parentSlugFor(slug); for (var i = 0; i < config.projects.length; i++) { if (config.projects[i].slug === (parentSlug || slug)) { var prev = accessBefore && !accessBefore.error ? (parentSlug ? accessBefore.worktreeAllowedUsers : accessBefore.allowedUsers) : []; saveConfig(config); console.log("[daemon] Set project allowed users:", slug, "→", result.allowedUsers.length, "users"); // OS users mode: sync ACLs for added/removed users // Exact-worktree grants are Clay-only and never change filesystem ACLs. if (config.osUsers && !parentSlug) { var projectPath = config.projects[i].path; // Grant access to newly added users for (var a = 0; a < allowedUsers.length; a++) { if (prev.indexOf(allowedUsers[a]) === -1) { var addedUser = usersModule.findUserById(allowedUsers[a]); if (addedUser && addedUser.linuxUser) { grantProjectAccess(projectPath, addedUser.linuxUser); } } } // Revoke access from removed users for (var r = 0; r < prev.length; r++) { if (allowedUsers.indexOf(prev[r]) === -1) { var removedUser = usersModule.findUserById(prev[r]); if (removedUser && removedUser.linuxUser) { revokeProjectAccess(projectPath, removedUser.linuxUser); } } } } if (relay.refreshProjectAccess) relay.refreshProjectAccess(); return { ok: true }; } } return { error: "Project not found" }; }, onGetProjectAccess: worktreeControls.getAccess, onUserProvisioned: function (userId, linuxUser) { // Grant ACL on all public projects to the newly provisioned user if (!config.osUsers || !linuxUser) return; for (var i = 0; i < config.projects.length; i++) { var proj = config.projects[i]; var visibility = proj.visibility || "public"; if (visibility === "public") { grantProjectAccess(proj.path, linuxUser); } } }, onUserDeleted: function (userId, linuxUser) { // Deactivate the Linux account when a Clay user is deleted if (!config.osUsers || !linuxUser) return; deactivateLinuxUser(linuxUser); }, onCreateWorktree: function (parentSlug, branchName, dirName, baseBranch, requestUser) { // Find the parent project var parent = null; for (var j = 0; j < config.projects.length; j++) { if (config.projects[j].slug === parentSlug) { parent = config.projects[j]; break; } } if (!parent) return { ok: false, error: "Parent project not found" }; if (worktreeControls.busy(parentSlug) || parent.lifecycleState) return { ok: false, error: "Worktree lifecycle operation is pending." }; var osUserInfo; try { osUserInfo = worktreeOsUserInfo(parent, requestUser); } catch (e) { return { ok: false, error: e.message }; } if (isWorktree(parent.path, osUserInfo)) return { ok: false, error: "Cannot create worktrees from a worktree project" }; var baseCommit = projectLogContext.commit(parent.path, baseBranch || "HEAD", osUserInfo); var result = createWorktree(parent.path, branchName, dirName, baseBranch, osUserInfo); if (!result.ok) return result; // Register the new worktree as ephemeral project var wtSlug = parentSlug + "--" + dirName; if (daemonProjectAccess.clearWorktreeGrant(config, wtSlug)) saveConfig(config); var context = projectLogContext.resolveWorktreeContext(result.path, parent.projectKnowledgeId, { branch: branchName, baseCommit: baseCommit, }, osUserInfo); var wtMeta = Object.assign({ parentSlug: parentSlug, external: false, projectKnowledgeId: parent.projectKnowledgeId }, context); relay.addProject(result.path, wtSlug, branchName, null, parent.ownerId, wtMeta); registerWorktreeSlug(parentSlug, wtSlug); console.log("[daemon] Created worktree:", wtSlug, "->", result.path); relay.broadcastAll({ type: "projects_updated", projects: relay.getProjects(), projectCount: config.projects.length, }); return { ok: true, slug: wtSlug, path: result.path }; }, }); // Reclaim idle out-of-process agent runtimes. Any adapter that owns a child // process may implement the optional shutdownIfIdle(ms); adapters without one // (Claude, which runs in-process or under sdk-worker) are simply skipped. // The CLAY_CODEX_* names are kept as aliases for backwards compatibility. var adapterReaperConfig = require("./yoke/host-environment").reaper(process.env); var IDLE_MS = adapterReaperConfig.idleMs; var REAP_MS = adapterReaperConfig.reapMs; var reaperHandle = setInterval(function () { if (!relay || typeof relay.forEachProject !== "function") return; relay.forEachProject(function (ctx) { if (!ctx || !ctx.adapters) return; Object.keys(ctx.adapters).forEach(function (vendor) { try { var adapter = ctx.adapters[vendor]; if (!adapter || typeof adapter.shutdownIfIdle !== "function") return; var result = adapter.shutdownIfIdle(IDLE_MS); if (result && typeof result.catch === "function") { result.catch(function (e) { console.error("[daemon] " + vendor + " idle reclaim failed:", e && e.message ? e.message : e); }); } } catch (e) {} }); }); }, REAP_MS); if (reaperHandle && typeof reaperHandle.unref === "function") { reaperHandle.unref(); } function stopCodexReaper() { if (reaperHandle) { clearInterval(reaperHandle); reaperHandle = null; } } function shutdownProjects() { stopCodexReaper(); try { var result = relay.destroyAll(); if (result && typeof result.then === "function") { return result; } return Promise.resolve(true); } catch (e) { return Promise.reject(e); } } // Worktree tracking extracted to daemon-projects.js // --- IPC server --- // Clean up stale socket/config left by a previously killed daemon var existingConfig = loadConfig(); if (existingConfig && existingConfig.pid && existingConfig.pid !== process.pid) { if (!isPidAlive(existingConfig.pid)) { console.log("[daemon] Clearing stale config from dead PID " + existingConfig.pid); clearStaleConfig(); } } var ipc = createIPCServer(socketPath(), function (msg) { console.log("[daemon] IPC:", msg.cmd); switch (msg.cmd) { case "add_project": { if (!msg.path) return { ok: false, error: "missing path" }; var absPath = path.resolve(msg.path); // Check if already registered for (var j = 0; j < config.projects.length; j++) { if (config.projects[j].path === absPath) { return { ok: true, slug: config.projects[j].slug, existing: true }; } } var slugs = config.projects.map(function (p) { return p.slug; }); var slug = generateSlug(absPath, slugs); var projectEntry = { path: absPath, slug: slug, addedAt: Date.now(), visibility: "private", ownershipRecovery: "required" }; projectEntry.projectKnowledgeId = rememberedProjectKnowledgeId(absPath); config = require("./project-ownership").register(config, projectEntry, usersModule.getAllUsers(), saveConfig, osUsersMod.resolveOsUserInfo); var registeredProject = config.projects[config.projects.length - 1]; relay.addProject(absPath, slug, null, null, registeredProject.ownerId || null, null, projectRuntimeOptions(registeredProject)); try { syncClayrc(config.projects); } catch (e) {} console.log("[daemon] Added project:", slug, "→", absPath); // Discover and register worktrees for the new project registerProjectWorktrees(projectEntry, null); relay.broadcastAll({ type: "projects_updated", projects: relay.getProjects(), projectCount: config.projects.length, }); return { ok: true, slug: slug }; } case "remove_project": { if (!msg.path && !msg.slug) return { ok: false, error: "missing path or slug" }; var target = msg.slug; if (!target) { var abs = path.resolve(msg.path); for (var k = 0; k < config.projects.length; k++) { if (config.projects[k].path === abs) { target = config.projects[k].slug; break; } } } if (!target) return { ok: false, error: "project not found" }; var removedProject = null; for (var ri = 0; ri < config.projects.length; ri++) { if (config.projects[ri].slug === target) { removedProject = config.projects[ri]; break; } } if (removedProject) { if (!config.removedProjects) config.removedProjects = []; config.removedProjects.push({ path: removedProject.path, title: removedProject.title || null, icon: removedProject.icon || null, projectKnowledgeId: removedProject.projectKnowledgeId || null, removedAt: Date.now(), }); if (config.removedProjects.length > 20) { config.removedProjects = config.removedProjects.slice(config.removedProjects.length - 20); } } relay.removeProject(target); config.projects = config.projects.filter(function (p) { return p.slug !== target; }); saveConfig(config); try { syncClayrc(config.projects); } catch (e) {} console.log("[daemon] Removed project:", target); relay.broadcastAll({ type: "projects_updated", projects: relay.getProjects(), projectCount: config.projects.length, }); return { ok: true }; } case "get_status": return { ok: true, pid: process.pid, port: config.port, tls: !!tlsOptions, keepAwake: !!config.keepAwake, osUsers: !!config.osUsers, projects: relay.getProjects(), uptime: process.uptime(), }; case "set_pin": { if (usersModule.isMultiUser()) return { ok: false, error: 'Server PIN is unavailable while account authentication is enabled.' }; config.pinHash = msg.pinHash || null; relay.setAuthToken(config.pinHash); saveConfig(config); return { ok: true }; } case "set_project_title": { if (!msg.slug) return { ok: false, error: "missing slug" }; var newTitle = msg.title || null; relay.setProjectTitle(msg.slug, newTitle); for (var ti = 0; ti < config.projects.length; ti++) { if (config.projects[ti].slug === msg.slug) { if (newTitle) { config.projects[ti].title = newTitle; } else { delete config.projects[ti].title; } break; } } saveConfig(config); try { syncClayrc(config.projects); } catch (e) {} console.log("[daemon] Project title:", msg.slug, "→", newTitle || "(default)"); relay.broadcastAll({ type: "projects_updated", projects: relay.getProjects(), projectCount: config.projects.length, }); return { ok: true }; } case "set_os_users": { var enableOsUsers = !!msg.value; if (enableOsUsers) { // Pre-flight: check if setfacl is available var aclCheck = checkAclSupport(); if (!aclCheck.available) { return { error: "acl_not_installed", installCmd: aclCheck.installCmd }; } } config.osUsers = enableOsUsers; saveConfig(config); console.log("[daemon] OS users:", enableOsUsers); // Provisioning is handled by CLI (which has terminal access for progress). // Daemon only saves the flag. On next restart, daemon will pick it up. return { ok: true }; } case "set_auto_continue": { var acWant = !!msg.value; config.autoContinueOnRateLimit = acWant; saveConfig(config); console.log("[daemon] Auto-continue on rate limit:", acWant, "(cli)"); return { ok: true, autoContinueOnRateLimit: acWant }; } case "set_keep_awake": { var want = !!msg.value; config.keepAwake = want; saveConfig(config); if (want && !caffeinateProc && process.platform === "darwin") { try { var { spawn: spawnCaff } = require("child_process"); caffeinateProc = spawnCaff("caffeinate", ["-di"], { stdio: "ignore", detached: false }); caffeinateProc.on("error", function () { caffeinateProc = null; }); } catch (e) {} } else if (!want && caffeinateProc) { try { caffeinateProc.kill(); } catch (e) {} caffeinateProc = null; } console.log("[daemon] Keep awake:", want); return { ok: true }; } case "enable_recovery": { if (!msg.urlPath || !msg.password) return { ok: false, error: "missing urlPath or password" }; relay.setRecovery(msg.urlPath, msg.password); console.log("[daemon] Admin recovery mode enabled"); return { ok: true }; } case "disable_recovery": { relay.clearRecovery(); console.log("[daemon] Admin recovery mode disabled"); return { ok: true }; } case "shutdown": console.log("[daemon] Shutdown requested via IPC"); gracefulShutdown(); return { ok: true }; case "restart": console.log("[daemon] Restart requested via IPC"); spawnAndRestart(); return { ok: true }; case "update": { if (config.headless) { console.log("[daemon] Update & restart requested via IPC — blocked (headless mode)"); return { ok: false, error: "Auto-update is disabled in headless mode" }; } console.log("[daemon] Update & restart requested via IPC"); // Dev mode (config.debug): just exit with code 120, cli.js dev watcher respawns daemon if (config.debug) { console.log("[daemon] Dev mode — restarting via dev watcher"); updateHandoff = true; setTimeout(function () { gracefulShutdown(); }, 100); return { ok: true }; } // Production: fetch latest via npx, then spawn updated daemon var { execSync: execSyncUpd, spawn: spawnUpd } = require("child_process"); var updTag = config.updateChannel === "beta" ? "beta" : "latest"; var updDaemonScript; try { // npx downloads the package and puts a bin symlink; `which` prints its path var binPath = execSyncUpd( "npx --yes --package=clay-server@" + updTag + " -- which clay-server", { stdio: ["ignore", "pipe", "pipe"], timeout: 120000, encoding: "utf8" } ).trim(); // Resolve symlink to get the actual package directory var realBin = fs.realpathSync(binPath); updDaemonScript = path.join(path.dirname(realBin), "..", "lib", "daemon.js"); updDaemonScript = path.resolve(updDaemonScript); console.log("[daemon] Resolved updated daemon:", updDaemonScript); } catch (updErr) { console.log("[daemon] npx resolve failed:", updErr.message); // Fallback: restart with current code updDaemonScript = path.join(__dirname, "daemon.js"); } // Spawn new daemon process — it will retry if port is still in use var { logPath: updLogPath, configPath: updConfigPath } = require("./config"); var updLogFd = fs.openSync(updLogPath(), "a"); var updChild = spawnUpd(process.execPath, [updDaemonScript], { detached: true, windowsHide: true, stdio: ["ignore", updLogFd, updLogFd], env: Object.assign({}, process.env, { CLAY_CONFIG: updConfigPath(), }), }); updChild.unref(); fs.closeSync(updLogFd); config.pid = updChild.pid; saveConfig(config); console.log("[daemon] Spawned new daemon (PID " + updChild.pid + "), shutting down..."); updateHandoff = true; setTimeout(function () { gracefulShutdown(); }, 100); return { ok: true }; } default: return { ok: false, error: "unknown command: " + msg.cmd }; } }); // --- Register projects --- var projects = config.projects || []; for (var i = 0; i < projects.length; i++) { var p = projects[i]; if (!projectLifecycle.canRestore(p)) { console.log("[daemon] Project removal needs recovery; runtime remains stopped:", p.slug); continue; } if (fs.existsSync(p.path)) { console.log("[daemon] Adding project:", p.slug, "→", p.path); relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId, null, projectRuntimeOptions(p)); // Discover and register worktrees for this project registerProjectWorktrees(p, null); } else { console.log("[daemon] Skipping missing project:", p.path); } } // Migrate legacy mates storage before registration mates.migrateLegacyMates(); // Import durable Mate knowledge into the Clay-wide Knowledge backend. Runs on // every start, imports nothing when there is nothing new, and never touches // the legacy files that remain the live source for existing read paths. mateKnowledgeMigration.runAtStartup({ extraRoots: (function () { var roots = []; try { if (!usersModule.isMultiUser()) return roots; var all = usersModule.getAllUsers() || []; for (var mi = 0; mi < all.length; mi++) { if (!all[mi] || !all[mi].linuxUser) continue; roots.push({ userId: all[mi].id, matesRoot: mates.resolveMatesRoot({ userId: all[mi].id, multiUser: true, linuxUser: all[mi].linuxUser }), }); } } catch (e) {} return roots; })(), }); // Register existing mates as projects if (usersModule.isMultiUser()) { // Multi-user mode: iterate over all users and load each user's mates var allUsers = usersModule.getAllUsers(); for (var ui = 0; ui < allUsers.length; ui++) { var mateCtx = mates.buildMateCtx(allUsers[ui].id); var userMates = mates.getAllMates(mateCtx); for (var mi = 0; mi < userMates.length; mi++) { var m = userMates[mi]; var mateDir = mates.getMateDir(mateCtx, m.id); var mateSlug = "mate-" + m.id; var mateName = (m.profile && m.profile.displayName) || m.name || "New Mate"; if (!m.lifecycleDeletion && fs.existsSync(mateDir)) { var mateDef = m.builtinKey ? require("./builtin-mates").getBuiltinByKey(m.builtinKey) : null; var isHost = !!(mateDef && mateDef.hostAgent); console.log("[daemon] Adding mate project:", mateSlug + (isHost ? " (host agent)" : "")); relay.addProject(mateDir, mateSlug, mateName, null, m.createdBy, null, { isMate: true, mateDisplayName: mateName, isHostAgent: isHost }); } } } } else { // Single-user mode: use null ctx var mateCtx = mates.buildMateCtx(null); var allMates = mates.getAllMates(mateCtx); for (var mi = 0; mi < allMates.length; mi++) { var m = allMates[mi]; var mateDir = mates.getMateDir(mateCtx, m.id); var mateSlug = "mate-" + m.id; var mateName = (m.profile && m.profile.displayName) || m.name || "New Mate"; if (!m.lifecycleDeletion && fs.existsSync(mateDir)) { var mateDef2 = m.builtinKey ? require("./builtin-mates").getBuiltinByKey(m.builtinKey) : null; var isHost2 = !!(mateDef2 && mateDef2.hostAgent); console.log("[daemon] Adding mate project:", mateSlug + (isHost2 ? " (host agent)" : "")); relay.addProject(mateDir, mateSlug, mateName, null, m.createdBy, null, { isMate: true, mateDisplayName: mateName, isHostAgent: isHost2 }); } } } // Sync ~/.clayrc on startup try { syncClayrc(config.projects); } catch (e) {} // --- Cleanup stale SDK warmup CLI session files --- // // On adapter init the SDK is given a dummy "hi" prompt to discover models, // skills, and capabilities. Even though we abort before any response // arrives, the SDK creates a real CLI session jsonl on disk. Going forward // we delete those inline (see lib/yoke/adapters/claude.js and // claude-worker.js), but installs from before that fix can have a long // tail of "hi"-only sessions polluting the Resume CLI session dialog. // // On every daemon startup we scan REAL_HOME/.claude/projects/*/ and unlink // files that match a strict warmup signature so legitimate "hi" sessions // from real users are never touched. All checks must pass: // // 1. file size < 64 KB (real sessions grow past this fast; // threshold is generous because warmup files include the SDK's // skill_listing / deferred_tools attachments which can run into the // tens of KB on its own) // 2. mtime older than 60 seconds (avoid racing an in-flight warmup) // 3. exactly one real user prompt equal to literal "hi" (the automatic // interruption record written when the probe is aborted is ignored) // 4. zero assistant messages (real users virtually always get a reply) // // Multi-user OS-isolated installs have per-user ~/.claude trees that the // daemon process can't traverse; the inline cleanup in claude-worker.js // covers those. function cleanupSdkWarmupSessions() { return require("./yoke/maintenance").cleanup(REAL_HOME); } // --- Start listening (with retry for port-in-use during update handoff) --- var listenRetries = 0; var MAX_LISTEN_RETRIES = 15; function startListening() { // One-shot cleanup of stale SDK warmup CLI session files from previous // daemon runs (best-effort, never fatal). try { cleanupSdkWarmupSessions(); } catch (e) {} relay.server.listen(config.port, listenHost, function () { var protocol = tlsOptions ? "https" : "http"; console.log("[daemon] Listening on " + protocol + "://" + listenHost + ":" + config.port); console.log("[daemon] PID:", process.pid); console.log("[daemon] Projects:", config.projects.length); // Update PID in config config.pid = process.pid; saveConfig(config); // Auto-provision Linux accounts on startup if OS users mode is enabled. // ACLs are NOT re-applied on every startup (too slow with recursive setfacl). // ACLs are set when: projects are added, users are added, or visibility changes. if (config.osUsers) { setTimeout(function () { try { ensureProjectsDir(); } catch (e) {} try { var provResult = provisionAllUsers(usersModule); if (provResult.provisioned.length > 0) { console.log("[daemon] Auto-provisioned " + provResult.provisioned.length + " Linux account(s) on startup"); // Only set ACLs for newly provisioned users (not all users on all projects) for (var pi = 0; pi < config.projects.length; pi++) { var proj = config.projects[pi]; if ((proj.visibility || "public") === "public") { for (var ni = 0; ni < provResult.provisioned.length; ni++) { try { grantProjectAccess(proj.path, provResult.provisioned[ni].linuxUser); } catch (e) {} } } } } if (provResult.errors.length > 0) { console.error("[daemon] Failed to provision " + provResult.errors.length + " account(s)"); } } catch (provErr) { console.error("[daemon] Startup provisioning error:", provErr.message); } console.log("[daemon] Startup OS users check complete."); // Diagnostics run after provisioning, never affect provisioning or serving. scheduleOsUserDiagnosticsAsync({ getUsers: function() { return usersModule.getAllUsers(); }, getProjects: function() { return config.projects; }, }); }, 100); } // Check for crash info from a previous crash and notify clients var crashInfo = readCrashInfo(); if (crashInfo) { console.log("[daemon] Recovered from crash at", new Date(crashInfo.time).toISOString()); console.log("[daemon] Crash reason:", crashInfo.reason); // Delay notification so clients have time to reconnect setTimeout(function () { relay.broadcastAll({ type: "toast", level: "warn", message: "Server recovered from a crash and was automatically restarted.", detail: crashInfo.reason || null, }); }, 3000); clearCrashInfo(); } }); } relay.server.on("error", function (err) { if (err.code === "EADDRINUSE" && listenRetries < MAX_LISTEN_RETRIES) { listenRetries++; console.log("[daemon] Port " + config.port + " in use, retrying (" + listenRetries + "/" + MAX_LISTEN_RETRIES + ")..."); setTimeout(startListening, 1000); return; } console.error("[daemon] Server error:", err.message); writeCrashInfo({ reason: "Server error: " + err.message, pid: process.pid, time: Date.now(), }); process.exit(1); }); startListening(); // --- HTTP onboarding server (only when TLS is active) --- if (relay.onboardingServer) { var onboardingPort = config.port + 1; relay.onboardingServer.on("error", function (err) { console.error("[daemon] Onboarding HTTP server error:", err.message); }); relay.onboardingServer.listen(onboardingPort, listenHost, function () { console.log("[daemon] Onboarding HTTP on http://" + listenHost + ":" + onboardingPort); }); } // --- Caffeinate (macOS) --- var caffeinateProc = null; if (config.keepAwake && process.platform === "darwin") { try { var { spawn } = require("child_process"); caffeinateProc = spawn("caffeinate", ["-di"], { stdio: "ignore", detached: false }); caffeinateProc.on("error", function () { caffeinateProc = null; }); } catch (e) {} } // --- Spawn new daemon and graceful restart --- function spawnAndRestart() { try { updateHandoff = true; ipc.close(); shutdownProjects().then(function () { if (relay.onboardingServer) relay.onboardingServer.close(); // Close the server first so the port is released before spawning the new daemon relay.server.close(function () { try { var { spawn: spawnRestart } = require("child_process"); var { logPath: restartLogPath, configPath: restartConfigPath } = require("./config"); var daemonScript = path.join(__dirname, "daemon.js"); var logFd = fs.openSync(restartLogPath(), "a"); var child = spawnRestart(process.execPath, [daemonScript], { detached: true, windowsHide: true, stdio: ["ignore", logFd, logFd], env: Object.assign({}, process.env, { CLAY_CONFIG: restartConfigPath(), }), }); child.unref(); fs.closeSync(logFd); config.pid = child.pid; saveConfig(config); console.log("[daemon] Spawned new daemon (PID " + child.pid + "), exiting."); process.exit(120); } catch (e) { console.error("[daemon] Restart failed:", e.message); process.exit(1); } }); }).catch(function (e) { console.error("[daemon] Restart shutdown failed:", e && e.message ? e.message : e); if (relay.onboardingServer) relay.onboardingServer.close(); relay.server.close(function () { process.exit(1); }); }); // Force exit after 10 seconds if server.close hangs setTimeout(function () { console.error("[daemon] Forced exit after timeout during restart"); process.exit(1); }, 10000); } catch (e) { console.error("[daemon] Restart failed:", e.message); relay.broadcastAll({ type: "toast", level: "error", message: "Restart failed: " + e.message }); relay.broadcastAll({ type: "restart_server_result", ok: false, error: e.message }); } } // --- Graceful shutdown --- var updateHandoff = false; // true when shutting down for update (new daemon already spawned) var shutdownStarted = false; function gracefulShutdown() { try { console.log("[daemon] Shutting down..."); } catch (e) {} if (shutdownStarted) return; shutdownStarted = true; var exitCode = updateHandoff ? 120 : 0; // 120 = update handoff, don't auto-restart if (caffeinateProc) { try { caffeinateProc.kill(); } catch (e) {} } ipc.close(); // Remove PID from config (skip if update handoff — new daemon PID is already saved) if (!updateHandoff) { try { var c = loadConfig(); if (c && c.pid === process.pid) { delete c.pid; saveConfig(c); } } catch (e) {} } shutdownProjects().then(function () { if (relay.onboardingServer) { relay.onboardingServer.close(); } relay.server.close(function () { try { console.log("[daemon] Server closed"); } catch (e) {} process.exit(exitCode); }); }).catch(function (e) { console.error("[daemon] Shutdown cleanup failed:", e && e.message ? e.message : e); if (relay.onboardingServer) { relay.onboardingServer.close(); } relay.server.close(function () { process.exit(exitCode); }); }); // Force exit after 10 seconds setTimeout(function () { try { console.error("[daemon] Forced exit after timeout"); } catch (e) {} process.exit(1); }, 10000); } process.on("SIGTERM", gracefulShutdown); process.on("SIGINT", gracefulShutdown); // Last-resort cleanup: kill caffeinate if process exits without graceful shutdown process.on("exit", function () { if (caffeinateProc) { try { caffeinateProc.kill(); } catch (e) {} } }); // Windows emits SIGHUP when console window closes if (process.platform === "win32") { process.on("SIGHUP", gracefulShutdown); } process.on("uncaughtException", function (err) { var errMsg = err ? (err.message || String(err)) : ""; var isAbort = errMsg.indexOf("Operation aborted") !== -1 || errMsg.indexOf("AbortError") !== -1 || (err && err.name === "AbortError"); if (isAbort) { // A single session's SDK write was aborted (e.g. stream closed before // write completed). This is recoverable, so do NOT tear down the whole // daemon and kill every other session. try { console.error("[daemon] Suppressed AbortError (single-session failure):", errMsg); } catch (e) {} return; } // EIO/EPIPE on stdout/stderr when parent process (dev mode CLI) dies. // Not fatal for the daemon itself. var isIOError = errMsg.indexOf("EIO") !== -1 || errMsg.indexOf("EPIPE") !== -1; if (isIOError) { return; } console.error("[daemon] Uncaught exception:", err); writeCrashInfo({ reason: err ? (err.stack || err.message || String(err)) : "unknown", pid: process.pid, time: Date.now(), }); gracefulShutdown(); }); process.on("unhandledRejection", function (reason) { var errMsg = reason ? (reason.message || String(reason)) : ""; var isAbort = errMsg.indexOf("Operation aborted") !== -1 || errMsg.indexOf("AbortError") !== -1 || (reason && reason.name === "AbortError"); if (isAbort) { console.error("[daemon] Suppressed unhandled rejection (AbortError):", errMsg); return; } console.error("[daemon] Unhandled rejection:", reason); });