/** * Per-user DSH supervisor: a resident main DSH plus an **on-demand** watchdog. * * The watchdog is not spawned at launch; it is pulled up once when the main * crashes (to repair) or when a post-restart command must be executed. This * keeps the steady-state footprint at one process per active user while still * providing crash repair + command handoff. The watchdog's agent-level * repair/session-resume is harness-internal and deferred to real-harness * integration (see docs/blueprint.md §4). * @module dshs/supervisor/orchestrator */ import { execFileSync, spawn, type ChildProcess, type StdioOptions } from 'node:child_process' import { randomUUID } from 'node:crypto' import { appendFileSync, chmodSync, chownSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from 'node:fs' import { join } from 'node:path' import type { ServerConfig } from '../config.js' import { handoffPath, homeRoot, userRoot, workspaceRoot } from '../fs/workspace.js' import { breakerActive, breakerCooldownMs, breakerUntil, decideCrashAction, openBreaker, pruneHistory, type BreakerPolicy, type BreakerState, type CrashPolicyConfig, } from './crash-policy.js' import { createPortGuard, type PortGuard } from './firewall.js' import { findFreePort, scrubEnv } from './spawn.js' import { AlreadyRunningError, CrashBreakerOpenError, type Endpoint, type Instance, type InstanceRole, type InstanceStatus, type Spawner, type UserStatus, } from './spawner.js' // Re-exported so existing importers (routes) keep resolving from this module. export { AlreadyRunningError, CrashBreakerOpenError, type Instance, type InstanceRole, type InstanceStatus, type UserStatus, } /** Task given to the one-shot headless watchdog so it doesn't error on a * missing task; executes any post-restart command from the handoff path. */ const WATCHDOG_TASK = 'Read DSHS_HANDOFF_PATH. If it contains a JSON {"command": ...}, run that command. Then exit.' /* ───────────────────────────────────────────────────────────────────────────── * 档案 81 · R1-④:**实例内存配额按「已启用插件集合」推导**(原先写死 384M) * * 为什么改(2026-09-13 事故,实测): * guest 启用 `dsh-univer-office` 后,其 gateway **单进程 RSS ≈390 MB**,而实例 cgroup 上限 * 写死 384M ⇒ 实例起来即被 **cgroup OOM kill(exitCode 137)** ⇒ 页面「恢复 → 起来 → 又被杀」 * 死循环(每 ~35s 一次 GET /)。**根因不是恢复逻辑,而是配额与插件集合脱钩。** * * 规则:`基准 + Σ(插件内存预估)`,再加下限/上限封顶;V8 老生代上限跟随配额(留 96M 余量)。 * · 预估表口径承接档案 75/67(插件评估的「资源成本」维度); * · 未识别的插件按 0 计(保守:宁可少给,也不虚高); * · 上限 1024M 是单实例硬顶(宿主 1870M,并发数由 maxIdleInstances + available 决定)。 * 回滚:把下方 PLUGIN_MEM_MB 清空即可退回「MIN_MEM_MB」单一档。 * ──────────────────────────────────────────────────────────────────────────── */ const PLUGIN_MEM_MB: Record = { 'dsh-univer-office': 512, // 实测其 gateway 单进程 ≈390 MB(2026-09-13 14:44 384→512:544 仍欠 ~50-120 MiB,实测 gateway 起不来;512 ⇒ 配额 672) 'dsh-plugin-mcn-suite': 128, // 7 插件整合包(含 xlsx 等重型依赖) } const BASE_MEM_MB = 160 // dsh 基座(实测稳态 106~117、峰值约 145) const MIN_MEM_MB = 384 // 不低于原写死值(等于零回归) const MAX_MEM_MB = 1024 const HEAP_HEADROOM_MB = 96 // 配额里留给非堆部分(native/栈/共享) /** 读 profile 的 bundles,推导该实例应有的 cgroup 上限(MB)。读不到就用下限。 */ function instanceMemMb(profileDir: string): number { try { const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh?: { profile?: { bundles?: string[] } } } const bundles = pkg.dsh?.profile?.bundles ?? [] let mb = BASE_MEM_MB for (const b of bundles) mb += PLUGIN_MEM_MB[b] ?? 0 return Math.min(Math.max(mb, MIN_MEM_MB), MAX_MEM_MB) } catch { return MIN_MEM_MB } } /** V8 老生代上限跟随配额(不再写死 160)。 */ function heapMbFor(memMb: number): number { return Math.max(128, Math.min(256, memMb - HEAP_HEADROOM_MB)) } /** * 把「堆上限」从既有 NODE_OPTIONS 里摘掉,换成按配额推导的值。 * * 为什么不让 env 决定堆:`DSH_INSTANCE_NODE_OPTIONS` 曾在平台进程 env 里被设成 * `--max-old-space-size=160`(档案 74 的下调),而该值**不在** systemd 的 manager env / unit 里 * (2026-09-13 实测:`systemctl unset-environment` 后进程 env 里仍有)⇒ 靠改配置改不动它。 * 规则改为:**堆由代码按配额推导**(与 MemoryMax 同一口径,不可能再对不上); * env 仍然有效,但只承载**其它**选项(如 `--trace-gc`)。要强行指定堆,用 `DSHS_HEAP_MB_OVERRIDE`。 */ function withHeap(base: string | undefined, memMb: number): string { const override = Number(process.env.DSHS_HEAP_MB_OVERRIDE ?? '') const mb = Number.isFinite(override) && override > 0 ? override : heapMbFor(memMb) const rest = (base ?? '') .split(/\s+/) .filter((t) => t !== '' && !t.startsWith('--max-old-space-size')) rest.push(`--max-old-space-size=${mb}`) return rest.join(' ') } /** * Local backend: owns the lifecycle of per-user DSH process pairs via * child_process. State is in-memory. Implements {@link Spawner}. */ export class LocalSpawner implements Spawner { private readonly mains = new Map() private readonly watchdogs = new Map() private readonly children = new Map() private readonly restartTimers = new Map() /** 熔断窗口内的自动重启时间戳(档案 20)。 */ private readonly crashHistory = new Map() /** 自上次稳定运行以来的连续重启次数(驱动指数退避,档案 20)。 */ private readonly crashStreak = new Map() /** 稳定判定定时器:连续运行达 `crashStableMs` 即视为已恢复(档案 20)。 */ private readonly stableTimers = new Map() /** * 档案 78:熔断冷却状态(**跨轮存活** —— `resetCrashState()` 刻意不清它)。 * 冷却期内拒绝**隐式**启动;冷却过后只给一次干净预算。key = userId。 */ private readonly breaker = new Map() /** Last activity per user (ms epoch) — fed by proxied traffic / enter. */ private readonly lastActive = new Map() private readonly reapTimer: NodeJS.Timeout | undefined private readonly portGuard: PortGuard | undefined constructor( private readonly config: ServerConfig, /** Resolve the user's own API key (decrypted); null = user has none. */ private readonly resolveApiKey: (userId: string) => Promise, /** Resolve the user's assigned Linux uid (falls back to hash when unset). */ private readonly resolveUid: (userId: string) => Promise, ) { this.portGuard = createPortGuard(config.portGuard) // 档案 30:portal 启动即清掉遗留实例 scope(重启后无法接管)。 this.cleanAllStaleScopes() // Local-mode idle reap: periodically stop mains that are idle past the TTL, // then cap the resident count (LRU by last activity). Only armed when at // least one of the two rules is enabled. The timer is unref'd so it never // keeps the process alive by itself. if ( config.idleReapIntervalSeconds > 0 && (config.instanceIdleTtlSeconds > 0 || config.maxIdleInstances > 0) ) { this.reapTimer = setInterval(() => void this.reapOnce(), config.idleReapIntervalSeconds * 1000) this.reapTimer.unref() } } /** Record activity so a warm instance is not treated as idle. */ touch(userId: string): void { this.lastActive.set(userId, Date.now()) } /** Idle-reap pass: ① stop mains idle past the TTL; ② cap resident count by * least-recent activity. Only stops processes — never touches user data. */ private async reapOnce(): Promise { const now = Date.now() const ttlMs = this.config.instanceIdleTtlSeconds * 1000 const max = this.config.maxIdleInstances const reaped = new Set() const reaperLog = (userId: string, reason: string): void => { const idleSecs = Math.round((now - (this.lastActive.get(userId) ?? now)) / 1000) process.stderr.write(`[idle-reap] stop ${userId} (${reason}; idle ${idleSecs}s)\n`) } if (ttlMs > 0) { for (const userId of [...this.mains.keys()]) { const last = this.lastActive.get(userId) ?? now if (now - last > ttlMs) { reaped.add(userId) reaperLog(userId, `idle>${this.config.instanceIdleTtlSeconds}s`) await this.stop(userId) } } } if (max > 0) { const running = [...this.mains.keys()].filter((userId) => !reaped.has(userId)) if (running.length > max) { const excess = running .sort((a, b) => (this.lastActive.get(a) ?? 0) - (this.lastActive.get(b) ?? 0)) .slice(0, running.length - max) for (const userId of excess) { reaperLog(userId, `cap>${max} lru`) await this.stop(userId) } } } } /** Spawn the resident main DSH for a user (watchdog is pulled up on demand). */ async launch( userId: string, folder: string, patch?: string, opts?: { force?: boolean }, ): Promise { if (this.mains.has(userId)) throw new AlreadyRunningError(userId) // 档案 78:冷却期内拒绝**隐式**启动(`/api/dsh/enter` 与「用户选文件夹」都会走到这里)。 // 原实现里 circuit-open 会 resetCrashState() 清空预算 ⇒ 只要有人(用户 F5、注入脚本自愈、 // 脚本直铺)不断重试,崩溃循环就能无限重复。`force: true` 供**平台自身**的显式操作绕开。 if (opts?.force === true) this.clearBreaker(userId, 'forced-launch') else this.assertBreakerClosed(userId) // 显式启动 = 新一轮:清空崩溃计数(用户重新进入后应拿到完整重试预算,档案 20)。 this.resetCrashState(userId) // 档案 18 v3 收尾:启动前补齐"受限目录选择器"(幂等;首登 profile 尚未创建时会自行跳过)。 this.ensurePickerProfile(userId) this.touch(userId) const instance = await this.spawnInstance(userId, 'main', folder, patch) // 等待 dsh web 打印 launch token,保证返回的打开 URL 可直接通过浏览器认证 await this.waitForLaunchToken(instance) return instance } /** * 档案 18 v3 收尾(第二层自愈):确保该用户的 profile 具备"受限目录选择器" * (平台段 + 插件包)—— 复用平台脚本 `ensure-workspace-picker.cjs --user-id `(幂等)。 * 异步 fire-and-forget:不阻塞启动;失败只记日志(第一层由 provisioning 钩子兜底)。 * 覆盖场景:profile 被清空/重建、手工删掉平台段、插件被卸载。 */ private ensurePickerProfile(userId: string): void { const script = process.env.DSH_PICKER_ENSURE_SCRIPT ?? join(process.cwd(), 'poc', 'workspace-scoped-picker', 'ensure-workspace-picker.cjs') if (!existsSync(script)) return try { const child = spawn('node', [script, '--user-id', userId], { detached: true, stdio: 'ignore' }) child.on('error', (err) => { process.stderr.write(`[picker-ensure] spawn failed: ${err.message}\n`) }) child.unref() } catch (err) { process.stderr.write( `[picker-ensure] failed for ${userId}: ${err instanceof Error ? err.message : String(err)}\n`, ) } } /** Stop the current main (clean) and respawn it with the same folder/patch. */ async restartMain(userId: string): Promise { const current = this.mains.get(userId) if (current === undefined) return undefined // 人工/接口触发的重启不是崩溃:重置计数,避免占用熔断预算(档案 20)。 this.resetCrashState(userId) this.killInstance(userId, current) this.touch(userId) const instance = await this.spawnInstance(userId, 'main', current.folder, current.patch) await this.waitForLaunchToken(instance) return instance } /** * 档案 34:重启实例并**探活**(功能插件启用后的可用性判定)。 * * `restartMain` 内部已等 launch token(20s),但它吞掉了结论;这里叠加一个稳定窗口 * 并给出显式判定,供调用方决定「保留该插件」还是「回滚 + 标记为不兼容」。 * 返回 `ok:false` 时,调用方必须回滚——否则插件会把实例拖进崩溃循环(档案 25 教训)。 * 人工/接口触发的重启不计入熔断预算(`restartMain` 内已 resetCrashState)。 */ /** * 档案 34:重启实例并**探活**(功能插件启用后的可用性判定)。 * * 判定链:先确保有实例(无则按 /api/dsh/enter 同路径 launch 一个——否则 restartMain 返回 * undefined,会把无辜插件误判为不兼容)→ 重启 → **轮询**到 launch token 且状态 running → * 再过稳定窗口确认没闪崩。任一步失败即返回 ok:false 并给出真实原因(例如 dsh 的 * `duplicate loader entry id`),调用方据此回滚并标记该插件。 * * 预算:launch/restartMain 内部各等 20s,这里再给 `budgetMs`(默认 60s)轮询——冷启动较慢的 * 插件不该被误判(实测 2.5s 固定等待会把好插件判死)。 */ async restartAndProbe(userId: string, budgetMs = 60000): Promise<{ ok: boolean; reason: string }> { if (this.mains.get(userId) === undefined) { try { // 档案 78:插件启用/隔离是平台自身的显式操作 → force 绕开熔断冷却 // (否则「插件把实例搞崩 → 熔断 → 想自动禁用该插件」会被自己的冷却挡住)。 await this.launch(userId, workspaceRoot(userRoot(this.config.dataRoot, userId)), undefined, { force: true, }) } catch (err) { return { ok: false, reason: `实例启动异常:${err instanceof Error ? err.message : String(err)}` } } } else { const inst = await this.restartMain(userId) if (inst === undefined) return { ok: false, reason: "实例未启动" } await this.spawnWatchdog(userId) } const deadline = Date.now() + budgetMs let lastReason = "" while (Date.now() < deadline) { const m = this.mains.get(userId) if (m === undefined) return { ok: false, reason: "重启后实例消失" } if (m.status === "crashed" || m.status === "stopped") return { ok: false, reason: m.lastError ?? "实例启动后崩溃" } if (m.launchToken !== undefined) { // 稳定窗口:拿到 token 后立刻崩的插件也算失败(避免"闪一下就死"被当作成功)。 await new Promise((resolve) => setTimeout(resolve, 2500)) const a = this.mains.get(userId) if (a === undefined) return { ok: false, reason: "重启后实例消失" } if (a.status === "crashed" || a.status === "stopped") { lastReason = a.lastError ?? "实例启动后崩溃"; continue } if (a.launchToken !== undefined) return { ok: true, reason: "" } } await new Promise((resolve) => setTimeout(resolve, 500)) } return { ok: false, reason: lastReason !== "" ? lastReason : "实例启动超时(未产出 launch token)" } } /** Restart every running main so a swapped global API key takes effect. */ async restartAllMains(): Promise { for (const userId of [...this.mains.keys()]) { try { await this.restartMain(userId) } catch (err) { // One user's spawn failure must not abort the broadcast restart. console.error(`restartAllMains: restart ${userId} failed`, err) } } } /** Spawn a one-shot watchdog for the user's current main (repair / execute). */ async spawnWatchdog(userId: string): Promise { if (!this.config.enablePatch) return undefined // watchdog needs the runtime patch if (this.watchdogs.has(userId)) return this.watchdogs.get(userId) const main = this.mains.get(userId) if (main === undefined) return undefined return await this.spawnInstance(userId, 'watchdog', main.folder, main.patch) } /** Current main + watchdog for a user. */ async status(userId: string): Promise { return { main: this.mains.get(userId), watchdog: this.watchdogs.get(userId) } } /** Endpoint the proxy forwards to (local → the running main's loopback port). */ async endpointFor(userId: string): Promise { const port = this.mains.get(userId)?.port return port === undefined ? undefined : { host: '127.0.0.1', port } } /** 等待该用户 main 实例打印 launch token(本地模式启动完成信号),供 enter 复用分支 * 在返回打开 URL 前调用;无实例/已崩溃/已停则立即返回。 */ async waitForLaunchTokenForUser(userId: string, timeoutMs = 20000): Promise { const main = this.mains.get(userId) if (main === undefined) return await this.waitForLaunchToken(main, timeoutMs) } /** Stop both processes for a user (cancelling any pending restart). */ async stop(userId: string): Promise { const timer = this.restartTimers.get(userId) if (timer !== undefined) { clearTimeout(timer) this.restartTimers.delete(userId) } const main = this.mains.get(userId) const watchdog = this.watchdogs.get(userId) if (main !== undefined) this.killInstance(userId, main) if (watchdog !== undefined) this.killInstance(userId, watchdog) this.mains.delete(userId) this.watchdogs.delete(userId) this.lastActive.delete(userId) this.resetCrashState(userId) } /** Stop every tracked process on shutdown. */ async teardown(): Promise { if (this.reapTimer !== undefined) clearInterval(this.reapTimer) for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId) } /** No-op: local mode has no sidecar — the control plane owns the volume. */ async ensureFileService(_userId: string): Promise {} private handoffPath(userId: string): string { return handoffPath(userRoot(this.config.dataRoot, userId)) } /** * 预置默认工作区(幂等,仅在工作区列表为空时写入): * 把 `/ws` 以短标题「我的工作区」写进 `/storages/workspace.json`, * 使新用户/清空后无需手动选择工作区即可开始会话;已有工作区时**不覆盖**用户现状。 * 失败不阻断启动(只记日志)。 */ private async seedDefaultWorkspace(userId: string): Promise { const root = userRoot(this.config.dataRoot, userId) const dir = join(homeRoot(root), 'storages') const file = join(dir, 'workspace.json') try { mkdirSync(dir, { recursive: true }) let existing: { global?: { workspaceIds?: unknown } } | undefined try { existing = JSON.parse(readFileSync(file, 'utf8')) as { global?: { workspaceIds?: unknown } } } catch { existing = undefined } const ids = existing?.global?.workspaceIds if (Array.isArray(ids) && ids.length > 0) return // 已有工作区 → 尊重用户现状 const ws = workspaceRoot(root) const id = randomUUID() const now = new Date().toISOString() const seed = { unit: { name: 'workspace', version: 2 }, global: { initialized: true, workspaceIds: [id], archivedSessionIds: [] }, tables: { workspaces: { [id]: { path: ws, title: '我的工作区', sessionIds: [], createdAt: now, updatedAt: now }, }, }, } writeFileSync(file, JSON.stringify(seed, null, 2) + '\n') const uid = await this.resolveUid(userId) chownSync(file, uid, uid) // 实例以该 uid 运行,需可读写 chownSync(dir, uid, uid) process.stderr.write(`[seed-workspace] ${userId} → ${ws}\n`) } catch (err) { process.stderr.write( `[seed-workspace] failed for ${userId}: ${err instanceof Error ? err.message : String(err)}\n`, ) } } /** Materialize the rendered patch so the dsh CLI can `--patch ` it. * Per role, so a watchdog spawning alongside its main never races the file. */ private writePatch(userId: string, role: InstanceRole, patch: string): string { const dir = join(userRoot(this.config.dataRoot, userId), 'patches') mkdirSync(dir, { recursive: true }) const path = join(dir, `${role}.yml`) writeFileSync(path, patch) return path } private async baseEnv(userId: string): Promise> { // 档案 81 · R1-④:堆上限跟随「已启用插件集合」推导出的配额(不再写死 160) const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) const root = userRoot(this.config.dataRoot, userId) const home = homeRoot(root) const workspace = workspaceRoot(root) const apiKey = await this.resolveApiKey(userId) return { ...scrubEnv(process.env), // HOME drives the child's directory picker default (homedir()); point it // at the user's workspace so their folders show, not DSH's internal home. HOME: workspace, // DSH's own state (profiles/sessions/credentials) stays in `home`. DSH_HOME: home, // Shared read-only skill directory (dsh-skill-filesystem bundled layer, // rank 600). Present only when configured. ...(this.config.bundledSkillDir !== '' ? { DSH_BUNDLED_SKILL_DIR: this.config.bundledSkillDir } : {}), // 档案 33:复用官方权限逻辑,只改「默认档位」。dsh-base 的 cordis.patch.yml 读该 env: // sandbox mode = DSH_PERMISSION_MODE;approval policy = (mode === 'danger-full-access' ? 'never' : 'ask') // 本机内核 5.10 无 Landlock、bwrap 后端在平台容器内探测失败 → 内置默认 workspace-write // 会 fail-closed 拒绝执行任何 shell(P0)。改 danger-full-access = 不在实例内再叠加沙箱, // 隔离交由平台容器(bwrap + uid + cgroup)负责,且不再逐条弹审批确认。 // 用户仍可在实例「设置 → 权限」里用官方 UI 自行切回。运维可用同名 env 覆盖。 DSH_PERMISSION_MODE: process.env.DSH_PERMISSION_MODE ?? 'danger-full-access', // 档案 44:**冻结基础运行时版本**,禁止用户升级 Python / pip / node 造成版本漂移。 // 已经天然成立的护栏:`/usr` 是 ro-bind → 实例内**改不了** `/usr/local/dsh-runtime/**` // (pip 默认 install 直接被 Read-only 挡)、`npm/pnpm -g` 也失败、`$HOME/.local/bin` // 不在实例 PATH 里(PATH 固定为 /usr/local/bin:/usr/bin:/bin)。 // 唯一剩下的缝是 **Python 的 user-site**:一旦 pip 创建 `$HOME/.local/lib/python3.12/ // site-packages`,它就会进入 sys.path 且**排在平台 site-packages 之前** → 用户装的同名包 // 会盖住平台包(2026-09-11 实测:`import packaging` 拿到用户版 26.3)。故: // · PYTHONNOUSERSITE=1 → user-site 不进 sys.path(平台包永不被盖) // · PYTHONUSERBASE=/usr/local/dsh-runtime/.no-user-install → `pip install --user` // 尝试写在只读位 → **明确报权限错**(而不是"装上了却不生效") // 引导:需要额外依赖 → `pip install --target /.pylibs` + PYTHONPATH,或自建 venv(不改平台)。 PYTHONNOUSERSITE: '1', PYTHONUSERBASE: '/usr/local/dsh-runtime/.no-user-install', // 档案 58(2026-09-12):给实例内所有 node 进程设「内存上限 + 编译缓存」。 // // 为什么必须设 --max-old-space-size:实测一个刚起的实例私有内存 106~117 MiB、 // 峰值 VmHWM 206~209 MiB,而 **V8 的默认堆上限是按宿主物理内存推算的** // (本机 1870 MiB → heap_size_limit **960 MiB**),它**感知不到 cgroup 的 MemoryMax**。 // 后果是内存真涨起来时先撞**内核 SIGKILL**(无优雅退出、无 stderr、日志查不到死因), // 而不是 V8 自己触发 GC 并抛 JS 异常。设 256 MiB 的依据:dsh 实测老生代仅 27~30 MiB, // 三倍余量足够;作用是把「失控上限」从 960 压到 256,让 V8 先 GC、不够就抛可诊断的 JS 错。 // 该 env 会被实例内子进程继承(用户自己跑 node 也受限),用户可 `NODE_OPTIONS= node …` 覆盖。 // 运维可用 DSH_INSTANCE_NODE_OPTIONS 整体覆盖(与 DSH_PERMISSION_MODE 同风格)。 // // 为什么设 NODE_COMPILE_CACHE(Node 22+ 官方特性;本机 v22.23.2 实测 // `module.enableCompileCache` 为函数、可用):dsh 是 **223 个包 / 717 个 JS 文件 / // 21.2 MB 源码**的大单体,实测私有内存里 **88 MiB 是 [anon] 段** —— 主要就是 V8 // 为这些文件即时生成的机器码(code range)。给出缓存目录后编译产物落盘复用, // 冷启动不必每次重编译,直接削启动开销与峰值。 // 目录选 /home/.node-compile-cache:不放 ws(会被 ws-cleanup 按平台产物清理)、 // 不放 /tmp(实例的 /tmp 是私有的,每次重建会丢)。 // 档案 81 · R1-④:堆上限跟随配额(160 是档案 74 的旧下调值,已不再写死) NODE_OPTIONS: withHeap(process.env.DSH_INSTANCE_NODE_OPTIONS, memMb), NODE_COMPILE_CACHE: join(home, '.node-compile-cache'), // 档案 76:dsh-univer-office 的 bundled Gateway 默认监听 TCP loopback,但本平台实例的 uid // 被 nft 拒绝连 127.0.0.0/8(**连它自己 spawn 的 gateway 也连不上**)→ 该插件改用 unix // socket。由平台 env 控制:**不设 = 插件保持原 TCP 行为**(回滚只需删掉这个 env)。 ...(process.env.DSH_INSTANCE_UNIVER_SOCKET === undefined ? {} : { UNIVER_DSH_GATEWAY_SOCKET: process.env.DSH_INSTANCE_UNIVER_SOCKET }), // Each user's own key; omit entirely when unset so the harness reports // "no key" instead of a header-hostile value. ...(apiKey !== null ? { DEEPSEEK_API_KEY: apiKey } : {}), } } private async spawnInstance(userId: string, role: InstanceRole, folder: string, patch?: string): Promise { const isMain = role === 'main' const port = isMain ? await findFreePort() : undefined const instance: Instance = { id: randomUUID(), userId, role, folder, port, status: 'starting', patch, } const map = role === 'main' ? this.mains : this.watchdogs map.set(userId, instance) // 注:曾在此预置默认工作区(seedDefaultWorkspace),但实测 dsh 启动时会按其注册表 // 不变量(order 与 table 一致性)把外部写入的条目清理掉 → 该做法无效,已移除调用 // (方法保留但不再使用,见 2026-09-11 记录)。工作区改由用户经官方 picker 自建。 const [command = 'dsh', ...args] = this.config.dshCommand const launchArgs = ['--profile', role === 'main' ? 'web' : 'headless'] // --patch is a launcher flag and must precede any app/inner args (--host/--port // for web, the task string for headless): dsh forwards the first unrecognized // token onward, so a trailing --patch reaches the app as an unknown option. // Off by default so the child boots even on older dsh versions. if (this.config.enablePatch && patch !== undefined) { launchArgs.push('--patch', this.writePatch(userId, role, patch)) } if (role === 'main') { launchArgs.push('--host', '127.0.0.1', '--port', String(port)) } else { launchArgs.push(WATCHDOG_TASK) } // 档案 81 · R1-④:本实例的内存配额由「已启用插件集合」推导(算一次,传给 spawnAsUser 用) const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) const env: Record = { ...(await this.baseEnv(userId)), DSHS_ROLE: role, DSHS_HANDOFF_PATH: this.handoffPath(userId), // both roles: main writes, watchdog reads } if (isMain) { env.DSHS_PORT = String(port) } // 档案 30:单实例保证升到 OS 层 —— spawn 前清掉该 uid 名下未纳管的残留 scope, // 防止 portal 重启后旧 scope 变孤儿、与新实例并存(共享 profile → 会话/settings 冲突)。 if (this.config.isolationMode === 'account') { this.cleanStaleScopes(await this.resolveUid(userId)) } const { child, unit } = await this.spawnAsUser(userId, command, [...args, ...launchArgs], { cwd: folder, env }, role) if (unit !== undefined) instance.unit = unit this.trackChild(userId, instance, child) return instance } /** Spawn the child inside a bwrap sandbox (mount/pid isolation) with setpriv * uid isolation, under a systemd-run scope that enforces cgroup resource * limits. 只读挂载 /usr /lib64 /etc,只暴露用户根目录 + 每用户独立 /tmp, * 实例读不到宿主其他路径(含其他用户、DB、凭据)。返回 child + scope 名。 */ private async spawnAsUser( userId: string, command: string, args: string[], options: { cwd: string; env: Record }, role: InstanceRole = 'main', ): Promise<{ child: ChildProcess; unit?: string }> { const stdio: StdioOptions = ['ignore', 'pipe', 'pipe'] if (this.config.isolationMode !== 'account') { return { child: spawn(command, args, { ...options, stdio }) } } const uid = await this.resolveUid(userId) const root = userRoot(this.config.dataRoot, userId) // 每用户独立 tmp(1777):bwrap 的 --tmpfs 权限是 755,dsh spill-local 需 mkdtemp /tmp const tmpDir = join(root, 'tmp') mkdirSync(tmpDir, { recursive: true }) chmodSync(tmpDir, 0o1777) // mkdirSync 的 mode 受 umask 掩码,显式 chmod 保证 1777 const bwrapArgs = [ '--ro-bind', '/usr', '/usr', '--ro-bind', '/lib64', '/lib64', // 2026-09-11 修复(实例内 AI 能力核查):合成根缺 /bin /sbin /lib 会导致 // dsh 的沙箱后端探针 execvp 失败(dsh-sandbox-local 在 Linux 优先用 bwrap, // Landlock 需内核>=5.13,al8 为 4.19 故不可用)→ SANDBOX_UNAVAILABLE → // 实例内 bash 工具被整体拒绝("refusing to run the command unconfined")。 // 用符号链接补齐标准布局(不扩大可见面:仅 /bin /sbin /lib 三个链接)。 '--symlink', 'usr/bin', '/bin', '--symlink', 'usr/sbin', '/sbin', '--symlink', 'usr/lib', '/lib', // 2026-09-11(档案 39 · 用户要求「实例只能读到自己那份」): // /etc 由「整体只读挂载」收窄为「空 tmpfs + 文件白名单」。 // // 动机:整体挂载会让每个实例读走 /etc 下 576 个 others-readable 文件,其中包含 // **平台情报**——/etc/systemd/system/dsh-*.{service,path}(4)、 // /etc/nftables-dsh-egress.nft(出网护栏规则全文)、/etc/cron.d/dsh-*(2)、 // /etc/letsencrypt/renewal/*.conf。等于把平台架构与护栏策略交给每个租户。 // (凭据类 dshs.env / shadow / gshadow / sudoers 本就 600/0000,读不到, // 本次不涉及;/usr 经审计无任何凭据。) // // 白名单 = 运行时实测必需的最小集(档案 39 §验证记录): // node / dsh --version / os.userInfo / DNS / node-TLS / curl 200 全通, // `dsh --profile web --dump-config` 收窄前后 **544 行 / 170 个 @deepseek-ai 插件逐字一致**。 // 效果:/etc 可见项 222 → 12,可读文件 576 → 26,平台情报 4/1/2/8 → 0/0/0/0。 // 注意:symlink 必须绑其 **realpath 目标到 symlink 原路径**,否则实例内是断链 //(/etc/nsswitch.conf → /etc/authselect/nsswitch.conf、/etc/localtime → /usr/share/zoneinfo/…)。 '--tmpfs', '/etc', ...(() => { const allow = [ '/etc/ld.so.cache', // 动态链接器缓存(node 启动必需) '/etc/ld.so.conf', '/etc/passwd', // os.userInfo() / uid→name '/etc/group', '/etc/nsswitch.conf', // DNS 解析(symlink) '/etc/hosts', '/etc/resolv.conf', '/etc/host.conf', '/etc/services', '/etc/localtime', // 时区(symlink) '/etc/os-release', '/etc/machine-id', '/etc/pki/tls/certs', // CA bundle(curl / 系统 TLS) '/etc/pki/ca-trust', // 2026-09-11 修正(档案 39 补丁):**符号链接枢纽目录,必须整体挂**。 // 漏掉它的后果(实测):`/usr/bin/python3 -> /etc/alternatives/python3 -> /usr/bin/python3.6`, // 中间一跳在 /etc → 沙箱内断链 → **21 个命令静默失效**:python3 / python / pip3 / pip-3 / // pydoc3 / python3-config / pyvenv-3 / easy_install-3 / unversioned-python / ld(→ld.bfd) / // pax / print-* (lp,lpr,lpq,lprm,lpstat,cancel) / ifup / ifdown / lpc。 // 其中 `python3`、`pip3`(装技能依赖)、`ld`(node-gyp 编译原生模块)都是我方场景的关键命令。 // 该目录 33 项**全部指向 /usr 下的程序/man**(已只读挂载)→ **不含任何凭据或平台情报**, // 挂它不扩大实质可见面,只是让 /usr 里已有程序的软链重新生效。 // ⚠️ 通用教训:`/etc` 白名单化时,必须用探测器找出**所有穿过 /etc 的符号链接**,逐个覆盖: // find /usr/bin /usr/sbin /usr/libexec /usr/local/bin -maxdepth 1 | while read f; do // [ -L "$f" ] || continue; c=$f; n=0 // while [ -L "$c" ] && [ $n -lt 10 ]; do t=$(readlink "$c") // case $t in /*) c=$t;; *) c=$(dirname "$c")/$t;; esac // case $c in /etc/*) echo "$f -> $c";; esac; n=$((n+1)); done // done | sort -u '/etc/alternatives', // 2026-09-11 修正(同 alternatives 一类,**第二次踩**):**`/etc/ssl` 必须挂**。 // 漏掉它的后果(guest 会话实证):宿主上 `/etc/ssl/certs/ca-bundle.crt` 存在, // 但实例内 `/etc/ssl` 不存在 → **Python(走 FHS 默认 CA 路径)found 不到 CA** → // `URLError(SSLCertVerificationError: self-signed certificate in certificate chain)`。 // curl 之所以一直没事,是因为它读 `/etc/pki/tls/certs/ca-bundle.crt`(也在白名单里)。 // `/etc/ssl` 只含 CA 证书(公开信息),**不含凭据/平台情报**,挂它不扩大实质可见面。 // ⚠️ 判定准则再强调:**除"叶子文件"外,还要覆盖"整个目录类枢纽"** // (`/etc/alternatives` 是软链枢纽、`/etc/ssl` 是 CA 根目录),否则会静默打断一整类工具。 '/etc/ssl', ] const out: string[] = [] for (const p of allow) { let src = p try { src = realpathSync(p) // symlink → 解析到真实文件 } catch { continue // 该机器上不存在则跳过(--ro-bind-try 语义) } out.push('--ro-bind-try', src, p) } return out })(), '--dev', '/dev', '--proc', '/proc', '--bind', tmpDir, '/tmp', '--bind', root, root, // 2026-09-11(档案 38 P0 修复,档案 40 收尾):**共享技能层必须真的挂进命名空间**。 // `@deepseek-ai/dsh-skill-filesystem` 是在**实例内** `resolve(config.bundledSkillDir ?? // process.env.DSH_BUNDLED_SKILL_DIR)` 之后**直接读盘**(lib/index.js:84,181),不是编排器 // 推数据 → 光注入 env 没用,目录必须可见。此前只注入了 env 未挂载 → 档案 10/11 的共享 // 技能层「投了也发现不了」(实例内 ls = No such file or directory)。 // // 权限**不扩大**(2026-09-11 以平台原样参数 + 本行实测): // · mountinfo = `…/bundled-skills → 同名 ro,nosuid,nodev` → **精确叶子路径 + 只读** // · `ls …/users/` **只列用户自己那一个**;读 DB = No such file;`touch` 挂载点 = Read-only // · 对照组(不加本行)里父目录同样存在(bwrap 为用户 root 合成的),**不是本行新暴露的** // ⛔ 绝不可写成挂父目录(`--ro-bind `)= 一次性把全部用户 home + // DB + 凭据放进每个实例。改完必须逐字复核 args 并重跑可见面实测。 // 必须放在 '--bind root root' **之后**(bwrap 后写覆盖前写)。 ...(this.config.bundledSkillDir !== '' ? (['--ro-bind-try', this.config.bundledSkillDir, this.config.bundledSkillDir] as string[]) : []), // 2026-09-11(档案 18 v3 收尾 / 档案 17 §P1):平台策略文件在实例内**只读**。 // 威胁模型:用户可把 /home/profiles/web 加为工作区,随后用 bash 直接改写 // cordis.patch.yml(去掉平台段 → 恢复全盘 picker)或 package.json(挂任意 bundle)→ // 属"绕过平台策略"(跨租户仍不成立,uid/bwrap 隔离不变)。 // 只读三个文件;**不动 cordis.yml**——实测 dsh 启动时会写它(01:16:22),ro 会导致启动异常。 // 注意:必须放在 '--bind root root' **之后**(bwrap 后写覆盖前写)。 ...(() => { const profileDir = join(homeRoot(root), 'profiles', role === 'main' ? 'web' : 'headless') const protectedFiles = ['cordis.patch.yml', 'package.json', 'pnpm-lock.yaml'] const out: string[] = [] for (const name of protectedFiles) { const file = join(profileDir, name) out.push('--ro-bind-try', file, file) } return out })(), '--unshare-pid', '--chdir', options.cwd, '--', 'setpriv', '--reuid', String(uid), '--regid', String(uid), '--clear-groups', command, ...args, ] const unit = `dsh-${uid}-${randomUUID().slice(0, 8)}` // 档案 81 · R1-④:cgroup 上限由「已启用插件集合」推导(原先写死 384M;见文件头注释的事故) const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) // 档案 58(2026-09-12):MemoryMax 512M → 384M。 // 依据(全部来自实测,见 scripts/probe-instance-mem.cjs): // · dsh 自身私有内存稳态 106~117 MiB、峰值约 145 MiB(= VmHWM 206 减去共享的 62 MiB // node 运行时页);两个不同用户冷启动同量 → 这是**基座成本**而非用户行为。 // · 384 - 145 = **239 MiB 留给用户任务**(python / ffmpeg / pip 编译原生模块), // 覆盖绝大多数场景;而同样的重任务在 512 下也大概率超限,故不是本次新引入的风险。 // 作用范围要看清:MemoryMax 是**上限而非预留**(systemd 不预扣),所以它**不决定并发数** // (并发由宿主 2 核 / 1870 MiB 与实时 available 决定);降它的真实收益是 // **收窄单实例失控(内存泄漏/重任务)时的破坏半径**,给同宿主其他实例留活路。 // 若实例被 cgroup OOM kill:该实例的 scope 会非 0 退出 → 编排器按崩溃退避重启(档案 20), // 并写结构化日志;只影响该租户,不波及其他实例(uid + cgroup 隔离)。 // 回滚:把 384M 改回 512M,npm run build,drain + 重启 dshs。 // 配套:baseEnv 里的 NODE_OPTIONS=--max-old-space-size=256 才是**主动**让 V8 提前 GC // 的那一层;本行是兜底硬限。两者一起看才有意义。 const sdArgs = [ '--scope', '--unit', unit, '-p', 'MemoryMax=' + memMb + 'M', // 档案 81 R1-④:按插件集合推导 '-p', 'CPUQuota=150%', '-p', 'TasksMax=128', '--', 'bwrap', ...bwrapArgs, ] return { child: spawn('systemd-run', sdArgs, { ...options, stdio }), unit: `${unit}.scope` } } /** 等待 dsh web 在 stdout 打印 launch token(最多 timeoutMs),用于拼装可直达的打开 URL。 */ private async waitForLaunchToken(instance: Instance, timeoutMs = 20000): Promise { const deadline = Date.now() + timeoutMs while (instance.launchToken === undefined && Date.now() < deadline) { if (instance.status === 'crashed' || instance.status === 'stopped') return await new Promise((resolve) => setTimeout(resolve, 150)) } } private trackChild(userId: string, instance: Instance, child: ChildProcess): void { this.children.set(instance.id, child) child.on('spawn', () => { instance.status = 'running' instance.pid = child.pid ?? undefined // 稳定判定(档案 20):连续运行达 crashStableMs 视为已恢复 → 重置退避步数。 if (instance.role === 'main') this.armStableReset(userId, instance) if (instance.role === 'main' && instance.port !== undefined && this.portGuard !== undefined) { try { this.portGuard.install(instance.port) } catch (error) { // Fail closed: without the port guard a co-tenant could reach this // DSH's loopback RPC directly. Kill the just-spawned child and mark // the instance crashed rather than serve unguarded. instance.status = 'crashed' instance.lastError = error instanceof Error ? error.message : String(error) child.kill('SIGKILL') } } }) child.stdout?.on('data', (chunk: Buffer) => { const text = chunk.toString() process.stdout.write(text) if (instance.role === 'main' && instance.launchToken === undefined) { const m = /dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=([A-Za-z0-9_-]+)/.exec(text) if (m !== null && m[1] !== undefined) instance.launchToken = m[1] } }) let stderrTail = '' child.stderr?.on('data', (chunk: Buffer) => { stderrTail = (stderrTail + chunk.toString()).slice(-2048) // Also surface the child's stderr on the orchestrator's own stderr so a // boot crash is visible in journald instead of only in lastError. process.stderr.write(`[dsh-child ${instance.role}] ${chunk.toString()}`) }) child.on('error', (err) => { instance.status = 'crashed' instance.lastError = err.message this.children.delete(instance.id) }) child.on('exit', (code) => { instance.exitCode = code ?? undefined this.children.delete(instance.id) // Release the loopback port guard as soon as the main's process is gone // (explicit stop, restart, and crash all funnel through this handler). if (instance.role === 'main' && instance.port !== undefined) { this.portGuard?.remove(instance.port) } const map = instance.role === 'main' ? this.mains : this.watchdogs // Only act if this instance is still the current one (avoid a stale // exit handler touching a freshly restarted instance). if (map.get(userId)?.id !== instance.id) return if (instance.status === 'stopped') { map.delete(userId) return } // A one-shot watchdog that finished cleanly is not restarted. if (instance.role === 'watchdog' && code === 0) { instance.status = 'stopped' map.delete(userId) return } instance.status = 'crashed' instance.lastError = stderrTail.slice(-500) || undefined if (instance.role === 'main') { void this.spawnWatchdog(userId) this.scheduleCrashRestart(userId, instance) } else { map.delete(userId) } }) } /** 崩溃策略参数(均来自 ServerConfig,可用 env 覆盖)。 */ private crashPolicy(): CrashPolicyConfig { return { baseDelayMs: this.config.restartBackoffMs, maxDelayMs: this.config.restartBackoffMaxMs, windowMs: this.config.crashWindowMs, maxRestartsInWindow: this.config.crashMaxRestarts, stableResetMs: this.config.crashStableMs, } } /** 档案 78:熔断冷却参数(均来自 ServerConfig,可用 env 覆盖)。 */ private breakerPolicy(): BreakerPolicy { return { baseCooldownMs: this.config.crashBreakerCooldownMs, maxCooldownMs: this.config.crashBreakerMaxCooldownMs, } } /** 结构化自愈日志(stderr → journald,可 grep `event=instance-restart`)。 */ private crashLog(payload: Record): void { process.stderr.write(`[crash-restart] ${JSON.stringify(payload)}\n`) } /** * 档案 78:熔断**告警**(原来只写一行 stderr,等于没有告警)。 * * 双通道:① stderr → journald(`journalctl -u dshs | grep crash-breaker`); * ② 追加 `/var/log/dsh-crash-breaker.log`(与其它巡检日志同习惯,便于定时巡检 / `tail`)。 * **只告警、不处置** —— 要不要人工介入由人判断;平台只保证「有痕迹、可 grep、冷却有界」。 * 写文件失败不影响主流程(best-effort)。 */ private alertBreaker(payload: Record): void { process.stderr.write(`[crash-breaker] ${JSON.stringify(payload)}\n`) const logPath = process.env.DSH_CRASH_BREAKER_LOG ?? '/var/log/dsh-crash-breaker.log' try { appendFileSync(logPath, `${new Date().toISOString()} ${JSON.stringify(payload)}\n`) } catch { /* 无权限 / 路径不存在不致命:stderr 那条仍在 */ } } /** * 档案 78:冷却期内拒绝隐式启动;冷却已过则清掉熔断态、放行一次干净尝试。 * * **刻意不放进 `resetCrashState()`** —— 那个函数被显式启动调用,而熔断态必须 * **跨轮存活**,否则又回到「熔断即清预算 ⇒ 无限重来」。 */ private assertBreakerClosed(userId: string): void { const b = this.breaker.get(userId) if (b === undefined) return const cfg = this.breakerPolicy() const now = Date.now() if (breakerActive(b, now, cfg)) { const until = breakerUntil(b, cfg) this.crashLog({ event: 'crash-breaker-reject', userId, opens: b.opens, cooldownUntil: until, retryAfterMs: until - now, }) throw new CrashBreakerOpenError(userId, until, b.opens) } this.breaker.delete(userId) this.crashLog({ event: 'crash-breaker-cooldown-expired', userId, opens: b.opens }) } /** 清掉熔断态(平台自身显式操作:插件启用/隔离、admin 重启)。 */ private clearBreaker(userId: string, reason: string): void { if (this.breaker.delete(userId)) { this.crashLog({ event: 'crash-breaker-cleared', userId, reason }) } } /** 档案 78 观测面:当前用户的熔断状态(无则 null)。 */ breakerInfo(userId: string): { opens: number; openedAt: number; cooldownUntil: number } | null { const b = this.breaker.get(userId) if (b === undefined) return null return { opens: b.opens, openedAt: b.openedAt, cooldownUntil: breakerUntil(b, this.breakerPolicy()) } } /** 清空某用户的崩溃计数(显式启动 / 人工重启 / 停机时调用)。 */ private resetCrashState(userId: string): void { this.crashHistory.delete(userId) this.crashStreak.delete(userId) const stable = this.stableTimers.get(userId) if (stable !== undefined) { clearTimeout(stable) this.stableTimers.delete(userId) } } /** main 连续运行达 crashStableMs 即视为恢复:重置退避步数(窗口历史保留)。 */ private armStableReset(userId: string, instance: Instance): void { const previous = this.stableTimers.get(userId) if (previous !== undefined) clearTimeout(previous) const timer = setTimeout(() => { this.stableTimers.delete(userId) if (this.mains.get(userId)?.id !== instance.id) return // 已被新实例取代 if (instance.status !== 'running') return if (this.crashStreak.delete(userId)) { this.crashLog({ event: 'instance-stable', userId, stableMs: this.config.crashStableMs }) } }, this.config.crashStableMs) timer.unref() this.stableTimers.set(userId, timer) } /** * 插件加载失败自愈(2026-09-12 新增 · anysearch 事故修复)。 * * 背景:门户「功能管理」启用插件走 `restartAndProbe` —— 探活失败会 `restoreProfile` + * 逐个隔离 + 自动禁用不兼容插件(档案 34)。但**不经过门户的路径**(`ensure-*.cjs` * 脚本直铺、手工改 profile)没有这一层;某个插件若与当前 dsh 版本不兼容(典型报错 * `failed to import loader entry ()`),实例会每次启动即崩,而 * crash-restart 只止损不禁用 → 用户陷入「进不去」的死循环。 * * 本方法补上这一层:从 `lastError` 解析出肇事的插件包名,把它从 profile 的 * `package.json` bundles 摘掉,并清掉 `cordis.patch.yml` 里引用该包的条目 * (否则 patch 中指向它的字段会报 `CONFIGURED_MISSING` 之类)。**只动 profile 的 * 文本文件,不碰 node_modules**(重装很快,回滚 node_modules 反而易碎)。 * * @returns 摘掉的包名;未命中或修复失败返回 `undefined` */ private tryHealPluginFailure(userId: string, lastError: string | undefined): string | undefined { if (lastError === undefined || lastError === '') return undefined const m = /failed to import loader entry \S+ \(([^)]+)\)/.exec(lastError) if (m === null) return undefined const pkg = m[1] // 防御:只接受合法包名形状,避免把异常文本当路径用。 if (!/^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i.test(pkg)) return undefined const dir = join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web') try { const pkgPath = join(dir, 'package.json') if (!existsSync(pkgPath)) return undefined const parsed = JSON.parse(readFileSync(pkgPath, 'utf8')) as { dsh?: { profile?: { bundles?: string[] } } } const bundles = parsed.dsh?.profile?.bundles if (!Array.isArray(bundles) || !bundles.includes(pkg)) return undefined parsed.dsh!.profile!.bundles = bundles.filter((b) => b !== pkg) writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + '\n') // patch 里引用该包的条目一并清掉:删 `name: ""` 行 + 紧邻在它上面的 `- id: …` 行。 const patchPath = join(dir, 'cordis.patch.yml') if (existsSync(patchPath)) { const lines = readFileSync(patchPath, 'utf8').split('\n') const out: string[] = [] for (const line of lines) { const t = line.trim() const quotes = `name: "${pkg}"` const single = `name: '${pkg}'` if (t === quotes || t === single || t === `name: ${pkg}`) { const prev = out[out.length - 1] if (prev !== undefined && /^\s*-\s*id:/.test(prev)) out.pop() continue } out.push(line) } writeFileSync(patchPath, out.join('\n')) } return pkg } catch (err) { process.stderr.write( `[plugin-heal] ${userId} ${pkg} 自愈失败:${err instanceof Error ? err.message : String(err)}\n`, ) return undefined } } /** * 崩溃后的自动重启决策(档案 20 · 方案 A): * 指数退避(base → max)+ 窗口熔断(窗口内超过上限则停止自动重启并标记 failed)。 */ private scheduleCrashRestart(userId: string, instance: Instance): void { const cfg = this.crashPolicy() const now = Date.now() // ── 插件加载失败自愈(2026-09-12 新增 · anysearch 事故修复)───────────── // 走门户启用时,探活失败会回滚 profile 并自动禁用不兼容插件(档案 34)。但 // **不经过门户的路径**(`ensure-*.cjs` 直铺、手工改 profile)没有这层保护:某个插件 // 若与当前 dsh 版本不兼容,实例会每次启动即崩,而 crash-restart 只止损不禁用 // → 用户陷入「进不去」的死循环(2026-09-12 admin 实例实测)。 // 这里在重启决策**之前**先尝试摘掉肇事插件;摘掉成功就清空崩溃计数,给一次干净重启。 const healedPlugin = this.tryHealPluginFailure(userId, instance.lastError) if (healedPlugin !== undefined) { this.crashLog({ event: 'plugin-auto-disabled', userId, plugin: healedPlugin, reason: 'plugin load failure' }) this.resetCrashState(userId) } const history = this.crashHistory.get(userId) ?? [] const streak = this.crashStreak.get(userId) ?? 0 const decision = decideCrashAction(history, streak, now, cfg) instance.restarts = (instance.restarts ?? 0) + 1 instance.lastCrashedAt = now if (decision.action === 'circuit-open') { // 熔断:停止自动重启,标记 failed;同时移除条目让用户重新 enter 时可重新 launch。 // 档案 78 修正:**不再「白送」满额预算** —— 记一次**跨轮存活**的熔断态,冷却期内 // 拒绝隐式启动(`assertBreakerClosed`)。原实现在这里 resetCrashState() 把窗口历史 // 一并清空 ⇒ 下一次 enter 又是满额预算 ⇒ 崩溃循环可以无限重来。 instance.status = 'failed' this.mains.delete(userId) this.resetCrashState(userId) const bstate = openBreaker(this.breaker.get(userId), now) this.breaker.set(userId, bstate) const bcfg = this.breakerPolicy() const payload = { event: 'crash-loop-circuit-open', userId, opens: bstate.opens, cooldownMs: breakerCooldownMs(bstate.opens, bcfg), cooldownUntil: breakerUntil(bstate, bcfg), windowMs: cfg.windowMs, restartsInWindow: decision.windowRestarts, maxRestartsInWindow: cfg.maxRestartsInWindow, restarts: instance.restarts, lastError: instance.lastError?.slice(0, 200), } this.crashLog(payload) this.alertBreaker(payload) return } const nextHistory = pruneHistory(history, now, cfg.windowMs) nextHistory.push(now) this.crashHistory.set(userId, nextHistory) this.crashStreak.set(userId, streak + 1) this.crashLog({ event: 'instance-restart', userId, attempt: decision.attempt, delayMs: decision.delayMs, restartsInWindow: decision.windowRestarts, restarts: instance.restarts, exitCode: instance.exitCode, lastError: instance.lastError?.slice(0, 200), }) const timer = setTimeout(() => { this.restartTimers.delete(userId) void this.spawnInstance(userId, instance.role, instance.folder, instance.patch) }, decision.delayMs) timer.unref() this.restartTimers.set(userId, timer) } /** 档案 30:清掉指定 uid 名下的残留 systemd scope(孤儿)。严格前缀匹配,不误伤门户自身。 */ private cleanStaleScopes(uid: number): void { this.stopScopesByPrefix(`dsh-${uid}-`) } /** 档案 30:portal 启动时清一次 —— 编排器重启后无法接管已有 scope,统一清掉防孤儿。 */ private cleanAllStaleScopes(): void { this.stopScopesByPrefix('dsh-', /^dsh-\d+-[0-9a-f]+\.scope$/) } /** 停止匹配前缀(可选正则)的 systemd scope;list/stop 失败一律静默跳过。 */ private stopScopesByPrefix(prefix: string, re?: RegExp): void { try { const out = execFileSync('systemctl', ['list-units', '--type=scope', '--no-legend', '--plain'], { encoding: 'utf8', timeout: 10000 }) for (const line of out.split('\n')) { const name = line.trim().split(/\s+/)[0] if (name === undefined || name === '') continue if (!name.startsWith(prefix) || !name.endsWith('.scope')) continue if (re !== undefined && !re.test(name)) continue try { execFileSync('systemctl', ['stop', name], { timeout: 10000 }) } catch { /* ignore */ } } } catch { /* list-units 失败:跳过 */ } } private killInstance(userId: string, instance: Instance): void { instance.status = 'stopped' // bwrap 沙箱模式下,scope 由 systemd 管理:systemctl stop 才能正确终止整个 // scope(含 bwrap + dsh),child.kill 只杀 systemd-run 进程、不杀 scope 内进程。 if (instance.unit !== undefined) { try { execFileSync('systemctl', ['stop', instance.unit], { timeout: 10000 }) } catch { // systemctl stop 失败时走 child.kill 兜底 } } const child = this.children.get(instance.id) if (child === undefined) return child.kill('SIGTERM') const killer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') }, 5000) killer.unref() } }